diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index aa1787b..31d0807 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -29,11 +29,12 @@ updates:
- "PhoenixmlDb.*"
# Test tooling churns often and rarely interestingly. One PR, not six.
+ # Microsoft.NET.Test.Sdk and coverlet.collector were dropped with the move to
+ # Microsoft.Testing.Platform; the MTP packages themselves arrive transitively through
+ # xunit.v3, so "xunit*" is the whole surface Dependabot can see here.
test-tooling:
patterns:
- "xunit*"
- - "Microsoft.NET.Test.Sdk"
- - "coverlet*"
ignore:
# FluentAssertions 7.x and 8.x moved to the paid Xceed licence. docmd's core is
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1dfcfac..d3a058c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,39 @@ stylesheet writes against is not stable until `1.0`.
## [Unreleased]
+Test infrastructure only. **No product change**: nothing under `src/` was touched, and no shipped
+package reference moved.
+
+### Changed
+
+- **xunit.v3 3.2.2 → 4.0.1**, which runs on Microsoft.Testing.Platform and drops VSTest. The
+ test platform moved with it; `dotnet test docmd.slnx` and the existing `--filter` expressions
+ are unchanged.
+
+ The opt-in is `"test": { "runner": "Microsoft.Testing.Platform" }` in `global.json`. The
+ property the error message points at, `TestingPlatformDotnetTestSupport`, is the pre-.NET-10
+ bridge and cannot help: MTP 2.4.0 raises the error from inside that bridge's own target,
+ guarded on the SDK major version alone.
+
+ Three VSTest-only packages were removed rather than bumped, because under MTP they do nothing:
+ `Microsoft.NET.Test.Sdk`, `xunit.runner.visualstudio` and `coverlet.collector`. Test projects
+ gained `Exe`, which `Microsoft.NET.Test.Sdk` used to supply.
+
+ Verified: 363 tests — 362 pass, 1 skip (the corpus audit) — which is the pre-migration count
+ of 362 plus the one performance test the old filter had excluded; `--locked-mode` restore
+ clean; the performance gate still runs in isolation.
+
+ A run that executes no tests now exits 8 rather than passing, so a filter that matches nothing
+ fails CI instead of going green. Full reasoning in
+ [`docs/deferred-work.md`](docs/deferred-work.md).
+
+### Fixed
+
+- `src/Docmd.Cli/packages.lock.json` recorded `Ooxml.Md.Core` at `0.2.3` after the 0.2.4 release.
+ Regenerating the lock files corrected it. Worth knowing that `--locked-mode` does not catch
+ this: it validates package versions, not the project-reference versions in the same file, so
+ that field goes stale on every release and nothing complains.
+
## [0.2.4] — 2026-09-29
Dependencies. **No behaviour change**: output is byte-identical across the 13-document corpus.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1969ef4..14774cf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -57,6 +57,32 @@ deliberately not: `fixtures/real/sample.docx` is a genuine third-party-written d
`word-noise` carries Word's own mess — `w:proofErr`, rsids, bookmarks between runs, words split
mid-word at spell-check boundaries. Those two find bugs hand-written XML never does.
+### The test platform
+
+Tests run on **Microsoft.Testing.Platform**, not VSTest. `dotnet test docmd.slnx` is unchanged,
+and so is `--filter "FullyQualifiedName~Something"` — xunit.v3 4.x accepts VSTest filter syntax.
+Three things are worth knowing:
+
+- **Each test project is an executable.** `OutputType` is `Exe`, set centrally in
+ `Directory.Build.props` for any project whose name ends in `.Tests`, and you can run one
+ without `dotnet test`:
+
+ ```console
+ $ ./tests/Docmd.Word.Tests/bin/Release/net10.0/Docmd.Word.Tests
+ $ ./tests/Docmd.Word.Tests/bin/Release/net10.0/Docmd.Word.Tests -class Docmd.Word.Tests.TransformScalingTests
+ ```
+
+ Run that way it is xunit's own in-process console runner, whose options are single-dash and
+ different (`-class`, `-method`, `-trait`; `--help` lists them). The MTP options that
+ `dotnet test` accepts, `--filter` included, are rejected there with `unknown option`.
+- **A run that executes no tests fails**, with exit code 8. Under VSTest a filter that matched
+ nothing was a silent pass, which is the vacuous pass above with CI's name on it.
+- **A VSTest filter cannot be combined** with xunit's own `--filter-class` / `--filter-method` /
+ `--filter-query` forms. Pick one style per invocation.
+
+The opt-in is `"test": { "runner": "Microsoft.Testing.Platform" }` in `global.json`. Without it
+the SDK runs the VSTest command, which on .NET 10 fails the build rather than falling back.
+
### Running the corpus audit
Against your own documents:
diff --git a/Directory.Build.props b/Directory.Build.props
index 71f7966..5916770 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -37,6 +37,20 @@
https://github.com/phoenixmldb/docmd
true
+
+
+ Exe
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9352051..28f87df 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,10 +7,22 @@
-
-
-
-
+
+
diff --git a/docs/deferred-work.md b/docs/deferred-work.md
index e244f03..aa7d312 100644
--- a/docs/deferred-work.md
+++ b/docs/deferred-work.md
@@ -60,11 +60,12 @@ already dead, while the anchor text survives. Recorded so the corpus audit can r
- `docmd audit`, `-r`/`--recursive`, `--review`, `--style-map`, `--strict`, `--report` and
`--revisions` all currently fail cleanly as not-yet-supported.
-## xunit v3 4.0 needs a test-platform migration, not a version bump
+## xunit v3 4.0 needed a test-platform migration, not a version bump
-**Status:** open, deliberately deferred 2026-09-10. Pinned at xunit.v3 3.2.2.
+**Status:** closed 2026-09-29. On xunit.v3 4.0.1, running on Microsoft.Testing.Platform 2.4.0.
-xunit.v3 4.0.0 drops VSTest support on the .NET 10 SDK and requires Microsoft.Testing.Platform:
+xunit.v3 4.0 drops VSTest on the .NET 10 SDK. The error names a property that sounds like the
+fix and is not:
```
error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform
@@ -72,19 +73,39 @@ on .NET 10 SDK and later. If you use dotnet test, you should opt-in to the new d
test experience.
```
-Setting `TestingPlatformDotnetTestSupport` is not sufficient on its own; the `dotnet test` CLI
-needs its own opt-in as well. Beyond that, MTP changes filter syntax, and `--filter
-"FullyQualifiedName~X"` appears in `.github/workflows/ci.yml` (which runs the performance gate as
-its own step), in `CONTRIBUTING.md` twice, and in the corpus-audit instructions. All of them
-change together or none do.
-
-Deferred because it is orthogonal to shipping, and a hurried test-infrastructure migration
-immediately before a first release is a poor trade: the thing that tells you whether the release
-is sound is the last thing to rebuild in a hurry. Nothing about 3.2.2 is broken.
-
-**Closes with:** `dotnet.config` selecting the MTP runner, the property above, the filter
-expressions rewritten in all four places, and a CI run proving the performance gate still runs
-in isolation.
+`TestingPlatformDotnetTestSupport=true` is the pre-.NET-10 bridge: it redirects the VSTest
+target into `InvokeTestingPlatform`. MTP 2.4.0 removed that escape hatch, and the removal is
+what produces the error above. `Microsoft.Testing.Platform.MSBuild.targets` raises it from
+`_MTPBeforeVSTest`, guarded on nothing but the SDK major version and a variable named
+`_SupportsGlobalJsonTestRunner` — so the property cannot suppress it, and the refusal is
+conditioned on a *better* opt-in being available. The SDK agrees: `dotnet test --help` on
+10.0.401 says to opt in "via global.json".
+
+**What it actually took**, which was less than this entry predicted:
+
+- `global.json` gains `"test": { "runner": "Microsoft.Testing.Platform" }`. Not `dotnet.config`,
+ which this entry guessed at; that is a different mechanism and not what this SDK reads.
+- Three packages removed rather than bumped, all VSTest-only and inert under MTP:
+ `Microsoft.NET.Test.Sdk`, `xunit.runner.visualstudio`, `coverlet.collector`. Reasoning is in
+ `Directory.Packages.props`. Coverage, if anyone ever wants a number, is `coverlet.mtp` or
+ `Microsoft.Testing.Extensions.CodeCoverage`; nothing here has asked for one.
+- `Exe` for test projects, which `Microsoft.NET.Test.Sdk` used to supply
+ implicitly. It is in `Directory.Build.props` conditioned on the project name, because
+ `IsTestProject` is set long after `.props` is evaluated and a condition on it never matches.
+- **The filter rewrite was unnecessary.** This entry expected `--filter
+ "FullyQualifiedName~X"` to change in four places. xunit.v3 4.x ships `--filter` accepting
+ VSTest syntax, so CI, `CONTRIBUTING.md` and the corpus-audit instructions are unchanged and
+ still correct. The restriction is that a VSTest filter cannot be combined with xunit's own
+ `--filter-class` / `--filter-query` forms, which nothing here does.
+
+**What we gained rather than paid for:** a run that executes no tests now exits 8 instead of
+passing. A filter typo used to be a silent green — measured, not assumed:
+`--filter "FullyQualifiedName~NoSuchTestNameAtAll"` exits 8, and the real suite exits 0.
+
+Verified on 2026-09-29, against this commit: 363 tests — 362 pass, 1 skip (the corpus audit),
+which is the pre-migration count of 362 plus the one performance test the old filter excluded.
+`--locked-mode` restore clean; the performance gate still runs alone. The count is recorded as a
+dated measurement rather than as a standing fact; the suite is the fact.
## Formats
diff --git a/global.json b/global.json
index 01cb658..a0703e8 100644
--- a/global.json
+++ b/global.json
@@ -2,5 +2,8 @@
"sdk": {
"version": "10.0.400",
"rollForward": "latestFeature"
+ },
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
}
}
diff --git a/src/Docmd.Cli/packages.lock.json b/src/Docmd.Cli/packages.lock.json
index 0ee553b..208dfae 100644
--- a/src/Docmd.Cli/packages.lock.json
+++ b/src/Docmd.Cli/packages.lock.json
@@ -61,7 +61,7 @@
"docmd.word": {
"type": "Project",
"dependencies": {
- "Ooxml.Md.Core": "[0.2.3, )",
+ "Ooxml.Md.Core": "[0.2.4, )",
"PhoenixmlDb.Xslt": "[2.4.1, )"
}
},
diff --git a/tests/Docmd.Word.Tests/Docmd.Word.Tests.csproj b/tests/Docmd.Word.Tests/Docmd.Word.Tests.csproj
index ec56fa5..1947013 100644
--- a/tests/Docmd.Word.Tests/Docmd.Word.Tests.csproj
+++ b/tests/Docmd.Word.Tests/Docmd.Word.Tests.csproj
@@ -7,10 +7,7 @@
false
-
-
-