Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ jobs:
8.0.x
9.0.x
10.0.x
11.0.x
11.0.100-preview.6.26359.118
runCdk: false
secrets: inherit
30 changes: 30 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,36 @@
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
</PropertyGroup>

<!-- MSBuild auto-imports process environment variables as property values (case-insensitively)
whenever no project-file assignment has already set that property - CI's shared devops-
templates PR-build workflow sets a GITHUB_ENV variable named "outputPath" (intended only for
its own later Blazor/Lambda publish steps, which read it explicitly via an "output" CLI flag),
but that variable stays set as a process env var for every subsequent step in the same job,
including the plain "dotnet build"/"dotnet test" steps this repo's own PR build actually runs.
Left unset, that env var satisfies MSBuild's standard `OutputPath`
property (same name, case-insensitive) for every project the SDK builds - collapsing every
project's per-TFM output into one shared directory across the entire solution instead of each
project's own bin/<config>/<tfm>/. Verified by reproducing locally: `outputPath=<path> dotnet
build Compono.slnx` puts every project (libraries and test projects alike) into
<path>/<tfm>/ together. That shared directory is exactly the kind of setup where two
projects' concurrent MSBuild copy-to-output steps can race on the same shared runtime
dependency file - the actual cause of the intermittent "Could not load ...
Microsoft.Testing.Extensions.MSBuild ... cannot find the file" CI flake (a different TFM each
run, never reproducible via a single local build - exactly what a race looks like), not a
missing/unpinned package reference. Explicitly reasserting OutputPath here, unconditionally,
restores the SDK's own default per-project/per-TFM computation regardless of what an external
CI environment happens to leak in - the same value
Microsoft.NET.DefaultOutputPaths.targets computes when OutputPath was never externally set.
Resetting to empty (not to an explicit bin\$(Configuration)\$(TargetFramework)\ value) is
deliberate - a multi-targeted project's inner per-TargetFramework build unconditionally
appends $(TargetFramework)\ onto whatever OutputPath already resolves to, so hardcoding that
same segment here would double it (bin\Release\net10.0\net10.0\, verified while testing this
fix). Resetting to empty instead makes the SDK's own conditional default-path computation
fire again, exactly as if the leaked env var had never been set. -->
<PropertyGroup>
<OutputPath></OutputPath>
</PropertyGroup>

<!-- Tests never get packed - only src/ packages are consumer-facing. -->
<PropertyGroup Condition="'$(IsTestProject)' == 'true'">
<IsPackable>false</IsPackable>
Expand Down
13 changes: 13 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@
a directly-referenced/newer Microsoft.Testing.Platform and throws TypeLoadException
(IDataConsumer) at test-host startup. -->
<PackageVersion Include="Microsoft.Testing.Platform" Version="2.3.3" />
<!-- Ships Microsoft.Testing.Extensions.MSBuild.dll, the `dotnet test`-via-MTP entry point's own
runtime dependency. TestingPlatformDotnetTestSupport=true is supposed to auto-add this
package via the .NET SDK's own targets, but under Central Package Management with no pinned
PackageVersion for it, that implicit reference resolved inconsistently across a solution-wide
parallel multi-TFM build - observed in CI as a TFM-inconsistent FileNotFoundException for
this exact assembly at test-host startup (never reproducible via a single-project local
build/publish, since there's no parallel-TFM race to hit). Compono.XunitV3.Tests never hit
this because it explicitly references Microsoft.Testing.Platform (pinned above) via
test/Directory.Build.targets' shared xUnit-only ItemGroup; Compono.TUnit.Tests/
Compono.TUnit.SampleTests had no equivalent explicit, CPM-pinned reference at all. An
explicit PackageReference (added to both projects) makes restore resolve it deterministically
instead of depending on the SDK's own auto-injection timing. -->
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.3.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
Expand Down
64 changes: 43 additions & 21 deletions docs/packages/compono-tunit.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ composer's own `Create<T>()`).

## What it gives you (today)

This is the first, method-parameter-only slice of `Compono.TUnit` β€” see
PLAN-0040 Phase 0/1 have shipped β€” see
Comment thread
ncipollina marked this conversation as resolved.
[ADR-0040](../adr/0040-compono-tunit-package-design.md) for the full design
and [PLAN-0040](../plans/0040-compono-tunit-package-design.md) for what
ships in which phase.
and [PLAN-0040](../plans/0040-compono-tunit-package-design.md) for phase
status.

- **`[Compose]`** β€” every method parameter is composed:

Expand All @@ -47,10 +47,35 @@ ships in which phase.
property (`TestContext.Current.Metadata.TestDetails.CustomProperties`),
and a *composition* failure's message includes the seed that produced it.

`[Compose<TProfile>]` and `[Compose<TProfile, TConfig>]` β€” profile
selection and profile configuration arguments, matching
[`Compono.XunitV3`](compono-xunitv3.md#profile-configuration-arguments)'s
own shape β€” are not part of this first slice; see PLAN-0040's later phases.
- **`[Compose<TProfile>]`** β€” applies a fixed, default-constructed
profile to the row's `Composer`, matching
[`Compono.XunitV3`](compono-xunitv3.md)'s own `ComposeAttribute<TProfile>`
exactly:

```csharp
[Test]
[Compose<NSubstituteTestProfile>]
public async Task Saves_order([Shared] IOrderRepository repository, CreateOrderHandler handler, PlaceOrder command)
{
await handler.Handle(command);
await repository.Received(1).SaveAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
}
```

- **`[Compose<TProfile, TConfig>]`** β€” profile selection and profile
configuration arguments, matching
[`Compono.XunitV3`](compono-xunitv3.md#profile-configuration-arguments)'s
own shape exactly, including its once-per-attribute-instance reflection
bound (`ConfigProfileBinder`, mirrored into `Compono.TUnit.Binding`).

## Hard constraint: one Compose-family attribute per method

`[Compose]`, `[Compose<TProfile>]`, and `[Compose<TProfile, TConfig>]` are
all `ComposeAttribute` subclasses. `[AttributeUsage(AllowMultiple = false)]`
is enforced per exact attribute type by the compiler, not across the
family β€” stacking two *different* Compose-family attributes on one method
compiles, but `BindingPlan.ValidateSignature` rejects it at
data-generation time with a clear `CompositionException`.

## Native AOT

Expand All @@ -63,6 +88,16 @@ own shape β€” are not part of this first slice; see PLAN-0040's later phases.
the real `ComposeAttribute.GetDataRowsAsync` through both a custom composed
type and a provider-resolved leaf type.

`[Compose<TProfile, TConfig>]`'s `ConfigProfileBinder` needed its own
separate AOT gate (ADR-0041 Amendment 1) β€” `ConstructorInfo.Invoke`-based
construction on a closed generic type argument is **not** safe by default
under trimming; the trimmer strips a type's public constructors unless
something tells it they're reachable. `ConfigProfileBinder` and
`ComposeAttribute<TProfile, TConfig>` carry
`[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]`
annotations end to end to fix this, verified by the same AOT smoke test
exercising `[Compose<TProfile, TConfig>]` alongside the plain form.

## Disposal

TUnit disposes a `[Compose]`-composed **root** method argument itself,
Expand All @@ -79,21 +114,8 @@ section for the full reasoning.

## What it deliberately doesn't do

- **Stacking distinct Compose-family attributes on one method is not
currently detected or rejected.** `Compono.XunitV3`'s equivalent throws a
clear `CompositionException` for this shape
([`Compono.XunitV3`](compono-xunitv3.md#what-it-deliberately-doesnt-do)) -
`Compono.TUnit` does not do the same validation yet: `BindingPlan.Build`
only ever sees TUnit's own `MethodMetadata`, which doesn't expose the
method's attribute list the way a raw `MethodInfo` does, so this is a
known v1 gap, tracked in
[PLAN-0040](../plans/0040-compono-tunit-package-design.md)'s Phase 1
checklist ("Stacked Compose-family attribute validation"), not a
guarantee. Don't stack Compose-family attributes on one
TUnit test method - the result is undefined, not a documented failure
mode.
- **No fixture object** β€” configuration lives in a profile, applied per
test method, not a shared mutable object (once profile support ships).
test method, not a shared mutable object.

## Next

Expand Down
90 changes: 81 additions & 9 deletions docs/plans/0040-compono-tunit-package-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,13 +416,13 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule.

### Phase 1: Profile variants, their own tests and docs

**Status:** Not Started
**Status:** Done

- [ ] `ComposeAttribute<TProfile> : ComposeAttribute` β€” `new()`-constrained
- [x] `ComposeAttribute<TProfile> : ComposeAttribute` β€” `new()`-constrained
profile type parameter, mirroring `Compono.XunitV3`'s
`ComposeAttribute<TProfile>` exactly (method-level only, matching
that package's own original scope decision).
- [ ] `ComposeAttribute<TProfile, TConfig> : ComposeAttribute` β€” profile
- [x] `ComposeAttribute<TProfile, TConfig> : ComposeAttribute` β€” profile
built from attribute-constructor-supplied config args, mirroring
`Compono.XunitV3`'s `ComposeAttribute<TProfile, TConfig>`
(ADR-0036) exactly, including its once-per-attribute-instance
Expand All @@ -444,7 +444,7 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule.
independent constructor and storage, duplicated from
`Compono.XunitV3`'s exact shape, not shared with the other generic
form.
- [ ] Stacked Compose-family attribute validation: reject a test method
- [x] Stacked Compose-family attribute validation: reject a test method
carrying more than one of `[Compose]`/`[Compose<TProfile>]`/
`[Compose<TProfile, TConfig>]` β€” `AllowMultiple = false` is enforced
per exact attribute type by the compiler, not across the family, so
Expand All @@ -462,7 +462,7 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule.
requirement. Without it, TUnit runs both attributes' data sources
independently and produces duplicate/conflicting rows despite
ADR-0040 promising full `Compono.XunitV3` parity.
- [ ] `test/Compono.TUnit.Tests`: profile-binding unit/integration
- [x] `test/Compono.TUnit.Tests`: profile-binding unit/integration
coverage (`ComposeAttribute<TProfile>`, `ComposeAttribute<TProfile,
TConfig>` config binding) plus inline-values-combined-with-a-profile
coverage (Phase 0 already covers inline values alone; this phase
Expand All @@ -473,20 +473,20 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule.
(`[Compose]` + `[Compose<TProfile>]`, `[Compose<TProfile>]` +
`[Compose<TProfile, TConfig>]`, etc.) β€” the case Phase 0 alone can't
exercise, since it needs a second Compose-family type to exist.
- [ ] The full Goal-section scenario, run for real under TUnit: `[Shared]
- [x] The full Goal-section scenario, run for real under TUnit: `[Shared]
IOrderRepository` composed via `[Compose<NSubstituteTestProfile>]`,
`UseNSubstitute()` wired through the profile, `repository` reused
inside `handler`'s own composed constructor parameter β€” the
`Compono.XunitV3.SampleTests.NSubstituteTests.Saves_order` scenario,
reproduced under TUnit for real (this needs `Compono.NSubstitute` as
an additional test dependency, matching how the xUnit v3 sample
project references it).
- [ ] Extend `docs/packages/compono-tunit.md` with the profile-attribute
- [x] Extend `docs/packages/compono-tunit.md` with the profile-attribute
sections (`[Compose<TProfile>]`/`[Compose<TProfile, TConfig>]`,
inline values).
- [ ] Extend `skills/compono/references/tunit.md` with profile-attribute
- [x] Extend `skills/compono/references/tunit.md` with profile-attribute
guidance, matching `xunit-v3.md`'s equivalent sections.
- [ ] **Native AOT gate on `ConfigProfileBinder` β€” a release requirement,
- [x] **Native AOT gate on `ConfigProfileBinder` β€” a release requirement,
not optional polish (ADR-0041 Amendment 1).** `[Compose<TProfile,
TConfig>]`'s own `ConfigProfileBinder` needs the identical AOT
analysis ADR-0041 already performed for row-binding dispatch:
Expand Down Expand Up @@ -838,3 +838,75 @@ Then the two tasks this phase's own text had left blocked on that merge:

Both tasks this phase's text had left open are now done - Phase 0 is
complete pending PR review/merge.

**Phase 1 implementation (2026-08-12)**: `ComposeAttribute<TProfile>` and
`ComposeAttribute<TProfile, TConfig>` (+ `ConfigProfileBinder`) both ported
byte-for-byte from `Compono.XunitV3`, adapted to `Compono.TUnit`'s base
class shape. Stacked-attribute rejection added to
`BindingPlan.ValidateSignature`, resolving the method's real `MethodInfo`
via a parameter's `ReflectionInfo.Member` (or a fallback lookup for a
zero-parameter method - see the PR #76 Codex review note below for why
that fallback isn't a plain `Type.GetMethod(name, Type.EmptyTypes)` call)
and counting `ComposeAttribute`-derived attributes on it.

**PR #76 Codex review, round 1 (2026-08-12)**: 2 findings, both confirmed
real, fixed in `e36facd`:
- The zero-parameter `ResolveMethodInfo` fallback originally used
`Type.GetMethod(name, Type.EmptyTypes)`, which matches by parameter
*types* only, not generic arity - a class declaring both a
zero-parameter `Run()` and a zero-parameter-but-generic `Run<T>()`
threw `AmbiguousMatchException` instead of reaching the existing
generic-method `CompositionException`, crashing `BindingPlan.Build`
entirely for that shape. Fixed by filtering `GetMethods()` on name,
zero declared parameters, *and* `testInformation.GenericTypeCount`
together. Added `AmbiguousZeroParameterMethod()`/
`AmbiguousZeroParameterMethod<T>()` fixtures and two regression tests.
- `src/Compono.TUnit/Compono.TUnit.csproj`'s NuGet description still said
the profile variants "ship in a later phase" despite this same PR
shipping them - restored to describe the full family.

**PR #76 Codex review, round 2 (2026-08-12)**: 1 finding, confirmed real,
fixed in the same commit as this note - a code comment on
`ValidateSignature` (and this Notes entry, above) still described the
*original*, buggy `Type.EmptyTypes`-only reasoning after the arity-aware
fix replaced it, contradicting the actual implementation and risking
someone "simplifying" `ResolveMethodInfo` back to the broken version on a
future read. Both corrected to point at `ResolveMethodInfo`'s real,
three-part filter.

**Native AOT gate on `ConfigProfileBinder` (ADR-0041 Amendment 1) found a
real gap, not a formality.** Extending the Phase 0 AOT smoke test to also
exercise `[Compose<TProfile, TConfig>]` failed at runtime on first try:
`CompositionException: 'ProfileConfig' must have exactly one public
constructor to be used as profile configuration, but has 0` β€” the trimmer
strips a closed generic type argument's public constructors by default
unless something tells it they're reachable; "`ConstructorInfo.Invoke` on
an already-known/non-generic `Type` is likely lower-risk than
`MakeGenericMethod`" (this plan's own original hedge) was directionally
right but not sufficient on its own. Fixed with
`[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]`
annotations on `ConfigProfileBinder`'s `Type`/generic-type-parameter
inputs and on `ComposeAttribute<TProfile, TConfig>`'s own `TProfile`/
`TConfig` type parameters β€” re-running the same `dotnet publish -c Release
-p:PublishAot=true -r osx-arm64 --self-contained true` + run confirmed
both `[Compose]` and `[Compose<TProfile, TConfig>]` now pass, with zero
trim warnings from `Compono.TUnit`'s own code (`-p:TrimmerSingleWarn=false`
still shows only the same two pre-existing harness-only `IL2072` warnings
from Phase 0).

The Goal-section scenario now runs for real:
`test/Compono.TUnit.SampleTests/NSubstituteTests.cs` mirrors
`Compono.XunitV3.SampleTests/NSubstituteTests.cs` exactly, added
`Compono.NSubstitute` to that project's local-feed pack chain (relies on
`PackageReference`'s default transitive-dependency flow for `NSubstitute`
itself, same as the xUnit v3 sibling - no explicit `NSubstitute`
`PackageReference` needed). Passed under a real TUnit runner across all
four TFMs.

Docs (`docs/packages/compono-tunit.md`,
`skills/compono/references/tunit.md`) updated in the same change - the
former "not part of this slice"/"stacking is undefined" language replaced
with the shipped shape and the real stacked-attribute rejection behavior.

Phase 1 is complete - every task checked off, full solution build/test
green.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ public class ComposeAttribute : TUnit.Core.UntypedDataSourceGeneratorAttribute,

Inheritance [System\.Object](https://learn.microsoft.com/en-us/dotnet/api/system.object 'System\.Object') β†’ [System\.Attribute](https://learn.microsoft.com/en-us/dotnet/api/system.attribute 'System\.Attribute') β†’ `TUnit.Core.AsyncUntypedDataSourceGeneratorAttribute` β†’ `TUnit.Core.UntypedDataSourceGeneratorAttribute` β†’ ComposeAttribute

Derived
↳ [ComposeAttribute&lt;TProfile,TConfig&gt;](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md 'Compono\.TUnit\.ComposeAttribute\<TProfile,TConfig\>')
↳ [ComposeAttribute&lt;TProfile&gt;](Compono.TUnit.ComposeAttribute_TProfile_.md 'Compono\.TUnit\.ComposeAttribute\<TProfile\>')

Implements `TUnit.Core.Interfaces.ITestDiscoveryEventReceiver`, `TUnit.Core.Interfaces.IEventReceiver`

### Remarks
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#### [Compono\.TUnit](index.md 'index')
### [Compono\.TUnit](Compono.TUnit.md 'Compono\.TUnit').[ComposeAttribute&lt;TProfile,TConfig&gt;](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md 'Compono\.TUnit\.ComposeAttribute\<TProfile,TConfig\>')

## ComposeAttribute\(object\[\]\) Constructor

Creates a [ComposeAttribute&lt;TProfile,TConfig&gt;](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md 'Compono\.TUnit\.ComposeAttribute\<TProfile,TConfig\>')\.

```csharp
public ComposeAttribute(params object?[] configArguments);
```
#### Parameters

<a name='Compono.TUnit.ComposeAttribute_TProfile,TConfig_.ComposeAttribute(object[]).configArguments'></a>

`configArguments` [System\.Object](https://learn.microsoft.com/en-us/dotnet/api/system.object 'System\.Object')[\[\]](https://learn.microsoft.com/en-us/dotnet/api/system.array 'System\.Array')

Profile configuration arguments, bound positionally to [TConfig](Compono.TUnit.ComposeAttribute_TProfile,TConfig_.md#Compono.TUnit.ComposeAttribute_TProfile,TConfig_.TConfig 'Compono\.TUnit\.ComposeAttribute\<TProfile,TConfig\>\.TConfig')'s single
public constructor \- an entirely separate binding target from this attribute family's ordinary
inline values; every test method parameter is composed in full regardless of what's supplied
here\. See the type\-level remarks for why each argument should use the strongest attribute\-legal
type available rather than a bare string\.
Loading