diff --git a/.claude/docs/workflows.md b/.claude/docs/workflows.md
index 652a6696262..c04a467bc38 100644
--- a/.claude/docs/workflows.md
+++ b/.claude/docs/workflows.md
@@ -11,11 +11,15 @@ dotnet build TUnit.Dev.slnx --no-restore -graphBuild:True
Use `TUnit.slnx` for the full solution, including Roslyn version variants. Run `dotnet test` from the relevant test project directory. Core generator snapshots live in `tests/TUnit.Core.SourceGenerator.Tests`; public API snapshots live in `tests/TUnit.PublicAPI`.
+Test projects target net8.0, net9.0 and net10.0; only installed runtimes can run, so pass `--framework net10.0` (or another installed one) when the machine lacks the others. The PublicAPI and generator snapshot projects add a net472 leg on Windows only; off Windows, `.Net4_7` snapshot updates come from the `ReceivedSnapshots-windows-latest` CI artifact.
+
+Scripts in `scripts/` need PowerShell 7. On Linux and macOS, quote any argument containing `*` when calling a native command from PowerShell: unquoted arguments are glob-expanded, so a treenode filter becomes a list of file paths.
+
For the intentionally failing test application, always select the class or method under test:
```sh
cd tests/TUnit.TestProject
-dotnet test --treenode-filter "/*/*/ClassName/*"
+dotnet test --framework net10.0 --treenode-filter "/*/*/ClassName/*"
```
A single-method filter has the form `/Assembly/Namespace/ClassName/TestMethodName`. Run one filter per command; do not join paths with `|`.
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 6ba768a7dae..db8a9f8b835 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -8,9 +8,9 @@
"ms-dotnettools.csdevkit"
],
"settings": {
- "dotnet.defaultSolution": "TUnit.sln"
+ "dotnet.defaultSolution": "TUnit.Dev.slnx"
}
}
},
- "postCreateCommand": "dotnet restore && dotnet build"
+ "postCreateCommand": "bash .devcontainer/install-runtimes.sh && dotnet build TUnit.Dev.slnx"
}
diff --git a/.devcontainer/install-runtimes.sh b/.devcontainer/install-runtimes.sh
new file mode 100755
index 00000000000..af24163c675
--- /dev/null
+++ b/.devcontainer/install-runtimes.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# The SDK image ships a single runtime, but the test projects target net8.0, net9.0 and
+# net10.0 (eng/TestProject.props). Install every runtime they need next to the image's SDK.
+set -euo pipefail
+
+dotnet_root="$(dirname "$(readlink -f "$(command -v dotnet)")")"
+installer="$(mktemp)"
+trap 'rm -f "$installer"' EXIT
+
+curl -sSL https://dot.net/v1/dotnet-install.sh -o "$installer"
+
+for channel in 8.0 9.0 10.0; do
+ for runtime in dotnet aspnetcore; do
+ bash "$installer" --channel "$channel" --runtime "$runtime" --install-dir "$dotnet_root" --no-path
+ done
+done
+
+dotnet --list-runtimes
diff --git a/.gitattributes b/.gitattributes
index 91e319e9a89..7a5ed3eb03d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -35,7 +35,12 @@
###############################################################################
*.bat text eol=crlf
*.cmd text eol=crlf
-*.ps1 text eol=crlf
+
+###############################################################################
+# PowerShell - always LF: pwsh reads LF on every OS (Windows PowerShell too), and
+# a CRLF checkout breaks the #!/usr/bin/env pwsh shebang on Linux and macOS.
+###############################################################################
+*.ps1 text eol=lf
###############################################################################
# Verify snapshots - LF for consistent cross-platform diffs
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 930ad9cdc10..68ecd4e371f 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -108,13 +108,38 @@ If want to provide sample code for complicated or useful different test suite se
## Building TUnit Locally
+TUnit builds and tests on Windows, Linux and macOS.
+
+### Prerequisites
+
+- **.NET SDK** matching [`global.json`](../global.json).
+- **.NET 8, 9 and 10 runtimes.** The test projects target all three, and a target framework whose
+ runtime is missing fails with "You must install or update .NET". Either run a single framework
+ (`--framework net10.0`, see below) or install the others, including the ASP.NET Core runtime, with
+ [dotnet-install](https://learn.microsoft.com/dotnet/core/tools/dotnet-install-script)
+ (`--channel 8.0 --runtime dotnet`, `--channel 8.0 --runtime aspnetcore`, and so on). Homebrew's
+ `dotnet` formula ships one runtime only.
+- **PowerShell 7** (`pwsh`) for the scripts in `scripts/`. On Linux and macOS they run directly, e.g.
+ `./scripts/run-reflection-tests.ps1`, or as `pwsh scripts/run-reflection-tests.ps1`.
+- **Docker** for the ASP.NET Core and Aspire tests.
+- **A Native AOT toolchain** for AOT publishing: the Visual Studio "Desktop development with C++"
+ workload on Windows, the Xcode Command Line Tools on macOS, and `clang` plus the zlib development
+ package on Linux. If .NET itself was installed with Homebrew, its AOT runtime links against
+ Homebrew's OpenSSL and Brotli:
+ `export LIBRARY_PATH=/opt/homebrew/opt/openssl@3/lib:/opt/homebrew/opt/brotli/lib`.
+
+Any of Visual Studio, Rider, or VS Code with C# Dev Kit works; the repository's VS Code settings and
+the dev container open `TUnit.Dev.slnx`.
+
+### Solutions
+
TUnit ships several solution files. Pick the right one for your task — the
full `TUnit.slnx` is large enough that a cold build can take 20+ minutes,
which is rarely what contributors actually need:
| Solution | Projects | When to use |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
-| `TUnit.Dev.slnx` | 44 | **Recommended for contributors.** Drops the per-Roslyn-version generator variants and example projects. Open this in Visual Studio / Rider for day-to-day work. |
+| `TUnit.Dev.slnx` | 44 | **Recommended for contributors.** Drops the per-Roslyn-version generator variants and example projects. Open this in your IDE for day-to-day work. |
| `TUnit.CI.slnx` | 73 | What CI builds. Use only when you're verifying CI-specific changes. |
| `TUnit.slnx` | 96 | Full shipping graph including all per-Roslyn-version analyzer/generator variants. Slow; rarely needed locally. |
@@ -132,7 +157,7 @@ $env:MSBUILDUSESERVER = '1' # PowerShell
Source-generated files are not written to disk by default (saves significant
disk I/O on every build). To dump them under each test project's
-`SourceGeneratedViewer\` folder, build with:
+`SourceGeneratedViewer/` folder, build with:
```bash
dotnet build -p:EmitCompilerGeneratedFiles=true
@@ -153,11 +178,15 @@ TUnit supports both source-generated and reflection-based test discovery. **All
If your changes affect the source generator output or public APIs:
-1. Run the relevant tests: `dotnet test tests/TUnit.Core.SourceGenerator.Tests` or `dotnet test tests/TUnit.PublicAPI`
+1. Run the relevant tests: `dotnet test --project tests/TUnit.Core.SourceGenerator.Tests --framework net10.0` or `dotnet test --project tests/TUnit.PublicAPI --framework net10.0` (repeat for each framework you have installed)
2. Review any `.received.txt` files generated
3. If the changes are intentional, rename them to `.verified.txt`
4. Commit the `.verified.txt` files with your changes
+The `.Net4_7` snapshots come from a .NET Framework leg that only builds on Windows. On Linux and macOS,
+push your change and take the `*.received.*` files from the `ReceivedSnapshots-windows-latest` artifact
+of the failed CI run.
+
#### Performance Considerations
TUnit is designed to handle millions of tests. When contributing:
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 386f539e12b..907bbd8586c 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -181,6 +181,19 @@ jobs:
name: TestingPlatformDiagnosticLogs${{matrix.os}}
path: '**/log_*.diag'
+ # Snapshot mismatches leave *.received.txt next to the *.verified.txt they failed against.
+ # The .Net4_7 snapshots can only be produced on Windows, so contributors on Linux/macOS
+ # take them from this artifact.
+ - name: Upload Received Snapshots
+ uses: actions/upload-artifact@v7.0.1
+ if: failure()
+ with:
+ name: ReceivedSnapshots-${{matrix.os}}
+ path: |
+ **/*.received.*
+ **/*.received/**
+ if-no-files-found: ignore
+
- name: Upload Hang Dumps
uses: actions/upload-artifact@v7.0.1
if: always()
diff --git a/.gitignore b/.gitignore
index 7eb8ef22b87..a94f1be893f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,9 @@
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
+# macOS Finder metadata
+.DS_Store
+
# Mono auto generated files
mono_crash.*
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 00000000000..af886f9b89b
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,5 @@
+{
+ "recommendations": [
+ "ms-dotnettools.csdevkit"
+ ]
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 179219e2150..38e8420a760 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -8,10 +8,11 @@
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
- "preLaunchTask": "build",
+ "preLaunchTask": "build TUnit.TestProject",
// If you have changed target frameworks, make sure to update the program path.
- "program": "${workspaceFolder}/tests/TUnit.TestProject/bin/Debug/net8.0/TUnit.TestProject.dll",
- "args": [],
+ "program": "${workspaceFolder}/tests/TUnit.TestProject/bin/Debug/net10.0/TUnit.TestProject.dll",
+ // TUnit.TestProject contains intentionally failing tests: always run a targeted filter.
+ "args": ["--treenode-filter", "${input:treenodeFilter}"],
"cwd": "${workspaceFolder}/tests/TUnit.TestProject",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "internalConsole",
@@ -22,5 +23,13 @@
"type": "coreclr",
"request": "attach"
}
+ ],
+ "inputs": [
+ {
+ "id": "treenodeFilter",
+ "type": "promptString",
+ "description": "Tree-node filter, e.g. /*/*/ClassName/*",
+ "default": "/*/*/ClassName/*"
+ }
]
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000000..2448ab43cb1
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,4 @@
+{
+ // The repository root holds three solutions; C# Dev Kit needs to be told which to load.
+ "dotnet.defaultSolution": "TUnit.Dev.slnx"
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 93ce27de3b0..88b8349a671 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -7,19 +7,22 @@
"type": "process",
"args": [
"build",
- "${workspaceFolder}/TUnit.slnx",
+ "${workspaceFolder}/TUnit.Dev.slnx",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
- "label": "publish",
+ // TUnit.Dev.slnx does not include TUnit.TestProject, so the debug launch builds it directly.
+ "label": "build TUnit.TestProject",
"command": "dotnet",
"type": "process",
"args": [
- "publish",
- "${workspaceFolder}/TUnit.slnx",
+ "build",
+ "${workspaceFolder}/tests/TUnit.TestProject",
+ "--framework",
+ "net10.0",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
@@ -33,7 +36,9 @@
"watch",
"run",
"--project",
- "${workspaceFolder}/TUnit.slnx"
+ "${workspaceFolder}/tests/TUnit.UnitTests",
+ "--framework",
+ "net10.0"
],
"problemMatcher": "$msCompile"
}
diff --git a/Directory.Build.props b/Directory.Build.props
index 93c84077101..cd755a67bae 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,6 +8,13 @@
net10.0
+
+
+ net472;
+
+
netstandard2.0
$(TargetFramework)
diff --git a/benchmarks/TUnit.Performance.Tests/run-benchmarks.ps1 b/benchmarks/TUnit.Performance.Tests/run-benchmarks.ps1
old mode 100644
new mode 100755
diff --git a/benchmarks/TUnit.PerformanceBenchmarks/Profiles/profile-cpu.ps1 b/benchmarks/TUnit.PerformanceBenchmarks/Profiles/profile-cpu.ps1
index 8a5ccc9db55..6c3ea7b8d92 100644
--- a/benchmarks/TUnit.PerformanceBenchmarks/Profiles/profile-cpu.ps1
+++ b/benchmarks/TUnit.PerformanceBenchmarks/Profiles/profile-cpu.ps1
@@ -3,7 +3,7 @@
param(
[int]$Scale = 1000,
- [string]$OutputDir = ".\Results"
+ [string]$OutputDir = "Results"
)
$ErrorActionPreference = "Stop"
@@ -17,7 +17,7 @@ try {
# Regenerate tests for the specified scale
Write-Host "Generating tests for scale: $Scale" -ForegroundColor Cyan
- & pwsh -ExecutionPolicy Bypass -File "$projectDir\generate-tests.ps1" -Scale $Scale
+ & pwsh -ExecutionPolicy Bypass -File (Join-Path $projectDir "generate-tests.ps1") -Scale $Scale
# Build the project
Write-Host "Building project..." -ForegroundColor Cyan
@@ -25,7 +25,9 @@ try {
# Generate timestamp for output files
$timestamp = Get-Date -Format "yyyy-MM-dd_HHmmss"
- $traceFile = "$OutputDir\cpu-trace-$Scale-$timestamp.nettrace"
+ # Join-Path, not "\": dotnet-trace is a native command, so PowerShell does not translate the
+ # separator for it on Linux/macOS.
+ $traceFile = Join-Path $OutputDir "cpu-trace-$Scale-$timestamp.nettrace"
Write-Host "Starting CPU profile capture..." -ForegroundColor Cyan
Write-Host "Trace will be saved to: $traceFile" -ForegroundColor Yellow
diff --git a/benchmarks/TUnit.PerformanceBenchmarks/Profiles/profile-gc.ps1 b/benchmarks/TUnit.PerformanceBenchmarks/Profiles/profile-gc.ps1
index 3eb04203e0b..8507b8d28ab 100644
--- a/benchmarks/TUnit.PerformanceBenchmarks/Profiles/profile-gc.ps1
+++ b/benchmarks/TUnit.PerformanceBenchmarks/Profiles/profile-gc.ps1
@@ -3,7 +3,7 @@
param(
[int]$Scale = 1000,
- [string]$OutputDir = ".\Results"
+ [string]$OutputDir = "Results"
)
$ErrorActionPreference = "Stop"
@@ -17,7 +17,7 @@ try {
# Regenerate tests for the specified scale
Write-Host "Generating tests for scale: $Scale" -ForegroundColor Cyan
- & pwsh -ExecutionPolicy Bypass -File "$projectDir\generate-tests.ps1" -Scale $Scale
+ & pwsh -ExecutionPolicy Bypass -File (Join-Path $projectDir "generate-tests.ps1") -Scale $Scale
# Build the project
Write-Host "Building project..." -ForegroundColor Cyan
@@ -25,7 +25,9 @@ try {
# Generate timestamp for output files
$timestamp = Get-Date -Format "yyyy-MM-dd_HHmmss"
- $traceFile = "$OutputDir\gc-trace-$Scale-$timestamp.nettrace"
+ # Join-Path, not "\": dotnet-trace is a native command, so PowerShell does not translate the
+ # separator for it on Linux/macOS.
+ $traceFile = Join-Path $OutputDir "gc-trace-$Scale-$timestamp.nettrace"
Write-Host "Starting GC/allocation profile capture..." -ForegroundColor Cyan
Write-Host "Trace will be saved to: $traceFile" -ForegroundColor Yellow
diff --git a/benchmarks/TUnit.PerformanceBenchmarks/Profiles/run-baseline.ps1 b/benchmarks/TUnit.PerformanceBenchmarks/Profiles/run-baseline.ps1
index aeb92f7c7a8..3953e78a110 100644
--- a/benchmarks/TUnit.PerformanceBenchmarks/Profiles/run-baseline.ps1
+++ b/benchmarks/TUnit.PerformanceBenchmarks/Profiles/run-baseline.ps1
@@ -2,7 +2,7 @@
# Usage: .\run-baseline.ps1
param(
- [string]$OutputDir = ".\Results"
+ [string]$OutputDir = "Results"
)
$ErrorActionPreference = "Stop"
@@ -11,7 +11,18 @@ $projectDir = Split-Path -Parent $scriptDir
$scales = @(100, 500, 1000, 5000, 10000)
$timestamp = Get-Date -Format "yyyy-MM-dd_HHmmss"
-$resultsFile = "$OutputDir\baseline-$timestamp.md"
+$resultsFile = Join-Path $OutputDir "baseline-$timestamp.md"
+
+# Get-CimInstance is Windows-only; ask each platform in its own way.
+function Get-CpuName {
+ try {
+ if ($IsMacOS) { return (sysctl -n machdep.cpu.brand_string) }
+ if ($IsLinux) { return ((Get-Content /proc/cpuinfo | Select-String -Pattern "^model name\s*:\s*(.+)$" | Select-Object -First 1).Matches.Groups[1].Value) }
+ return (Get-CimInstance Win32_Processor).Name
+ } catch {
+ return "unknown"
+ }
+}
Push-Location $projectDir
try {
@@ -25,7 +36,7 @@ Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
## Environment
- OS: $([System.Environment]::OSVersion.VersionString)
-- CPU: $((Get-CimInstance Win32_Processor).Name)
+- CPU: $(Get-CpuName)
- .NET: $(dotnet --version)
- TUnit: $(git describe --tags --always 2>$null || 'unknown')
@@ -41,7 +52,7 @@ Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
Write-Host "========================================`n" -ForegroundColor Cyan
# Regenerate tests for the specified scale
- & pwsh -ExecutionPolicy Bypass -File "$projectDir\generate-tests.ps1" -Scale $scale
+ & pwsh -ExecutionPolicy Bypass -File (Join-Path $projectDir "generate-tests.ps1") -Scale $scale
# Build the project
Write-Host "Building project..." -ForegroundColor Yellow
diff --git a/benchmarks/TUnit.PerformanceBenchmarks/compare-branches.ps1 b/benchmarks/TUnit.PerformanceBenchmarks/compare-branches.ps1
old mode 100644
new mode 100755
diff --git a/benchmarks/TUnit.PerformanceBenchmarks/count-generated-methods.ps1 b/benchmarks/TUnit.PerformanceBenchmarks/count-generated-methods.ps1
old mode 100644
new mode 100755
diff --git a/benchmarks/TUnit.PerformanceBenchmarks/measure-jit.ps1 b/benchmarks/TUnit.PerformanceBenchmarks/measure-jit.ps1
old mode 100644
new mode 100755
diff --git a/benchmarks/TUnit.PerformanceBenchmarks/measure-startup.ps1 b/benchmarks/TUnit.PerformanceBenchmarks/measure-startup.ps1
old mode 100644
new mode 100755
diff --git a/scripts/Remove-MergedWorktrees.ps1 b/scripts/Remove-MergedWorktrees.ps1
index fe2cb533e7f..3d7b3959fa1 100644
--- a/scripts/Remove-MergedWorktrees.ps1
+++ b/scripts/Remove-MergedWorktrees.ps1
@@ -243,7 +243,7 @@ try {
if ($gitdir -notlike "$mainNorm/.git/worktrees/*") { continue }
if (Test-Path -LiteralPath $gitdir) { continue }
if ($WhatIf) { Write-Host "sweep: WOULD remove orphaned dir $($dir.FullName) (dangling gitdir: $gitdir)"; continue }
- Remove-Item -LiteralPath ('\\?\' + ($dir.FullName -replace '/', '\')) -Recurse -Force -ErrorAction SilentlyContinue
+ Remove-DirectoryTree -Path $dir.FullName
if (Test-Path -LiteralPath $dir.FullName) {
Write-Host "sweep: WARNING could not fully remove orphaned dir $($dir.FullName)"
}
diff --git a/scripts/Test-WorktreeCleanup.ps1 b/scripts/Test-WorktreeCleanup.ps1
index eaf579673e4..11d89983dba 100644
--- a/scripts/Test-WorktreeCleanup.ps1
+++ b/scripts/Test-WorktreeCleanup.ps1
@@ -44,6 +44,17 @@ try {
New-Item -ItemType Directory -Path (Split-Path $generatedFile -Parent) | Out-Null
Set-Content -LiteralPath $generatedFile -Value 'generated'
+ # A link to the worktree must not be followed: removing through it would delete the target.
+ $linkToWorktree = Join-Path $testRoot 'link-to-artifacts'
+ $linkCreated = $true
+ try { New-Item -ItemType SymbolicLink -Path $linkToWorktree -Target $artifactWorktree | Out-Null }
+ catch { $linkCreated = $false; Write-Host "Skipping link check: cannot create symbolic links here ($($_.Exception.Message))" }
+ if ($linkCreated) {
+ Remove-MergedWorktree -Repo $repo -Worktree $linkToWorktree -Label 'link fixture'
+ Assert-True (Test-Path -LiteralPath $artifactWorktree) 'Removing through a link deleted the linked worktree.'
+ Remove-Item -LiteralPath $linkToWorktree -Force
+ }
+
Remove-MergedWorktree -Repo $repo -Worktree $artifactWorktree -Label 'artifact fixture'
Assert-True (-not (Test-Path -LiteralPath $artifactWorktree)) 'Artifact-only worktree was not removed.'
@@ -156,6 +167,14 @@ try {
"Repository-generated path was not treated as disposable: $generatedPath"
}
+ # The fallback delete must work with git's forward-slash paths on every OS: the Windows
+ # long-path prefix it adds there makes a Linux/macOS path unresolvable.
+ $fallbackTarget = Join-Path $testRoot 'fallback-delete'
+ New-Item -ItemType Directory -Path (Join-Path $fallbackTarget 'nested/deeper') -Force | Out-Null
+ Set-Content -LiteralPath (Join-Path $fallbackTarget 'nested/deeper/file.txt') -Value 'x'
+ Remove-DirectoryTree -Path ($fallbackTarget -replace '\\', '/')
+ Assert-True (-not (Test-Path -LiteralPath $fallbackTarget)) 'Fallback delete did not remove the directory.'
+
Write-Host 'OK worktree cleanup safety tests passed.'
}
finally {
diff --git a/scripts/WorktreeCleanup.ps1 b/scripts/WorktreeCleanup.ps1
index 6e9f6838d20..e9eb7db7fd3 100644
--- a/scripts/WorktreeCleanup.ps1
+++ b/scripts/WorktreeCleanup.ps1
@@ -9,7 +9,18 @@
# - CLEAR root-level workflow output covered by the repository's .gitignore.
# - Long-path safe: git's own delete now works because core.longpaths=true is set
# system-wide; the `\\?\` extended-length Remove-Item is kept as a fallback for
-# environments where that config is missing.
+# environments where that config is missing (Windows only; see Remove-DirectoryTree).
+
+function Remove-DirectoryTree {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][string]$Path)
+
+ # On Windows, \\?\ disables Win32 path normalization (and with it MAX_PATH), so forward
+ # slashes (git's output format) are NOT translated — convert to backslashes or the delete
+ # no-ops. The prefix means nothing on Linux/macOS, where it makes the path unresolvable.
+ $target = if ($IsWindows -or $PSVersionTable.PSVersion.Major -lt 6) { '\\?\' + ($Path -replace '/', '\') } else { $Path }
+ Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue
+}
function New-OrdinalStringMap {
[CmdletBinding()]
@@ -140,8 +151,26 @@ function Remove-MergedWorktree {
Write-Host "Preserving main or harness-managed worktree: $Worktree"
return
}
- $topLevel = git -C $Worktree rev-parse --show-toplevel 2>$null
- if ($LASTEXITCODE -ne 0 -or [IO.Path]::GetFullPath($topLevel) -ne $worktreePath) {
+ # A link to a worktree is not that worktree: git would follow it and remove the target.
+ # Only the path itself is checked; a symlinked ancestor (macOS's /var) is fine and expected.
+ # Fail closed: if the path cannot be inspected, it cannot be shown not to be a link.
+ $worktreeItem = Get-Item -LiteralPath $Worktree -Force -ErrorAction SilentlyContinue
+ if (-not $worktreeItem) {
+ Write-Host "Preserving worktree $Label : $Worktree (path could not be inspected)"
+ return
+ }
+ if ($worktreeItem.LinkType) {
+ Write-Host "Preserving worktree $Label : $Worktree (path is a link)"
+ return
+ }
+ # Ask git whether the path is the work tree's root rather than comparing paths: git reports
+ # symlink-resolved paths (/private/var/... for macOS's /var/...), Resolve-Path does not.
+ $null = git -C $Worktree rev-parse --show-toplevel 2>$null
+ $isInsideWorkTree = $LASTEXITCODE -eq 0
+ $prefix = git -C $Worktree rev-parse --show-prefix 2>$null
+ $prefixKnown = $LASTEXITCODE -eq 0
+ $isWorkTreeRoot = $isInsideWorkTree -and $prefixKnown -and -not $prefix
+ if (-not $isWorkTreeRoot) {
Write-Host "Preserving worktree $Label : $Worktree (could not verify repository path)"
return
}
@@ -201,9 +230,7 @@ function Remove-MergedWorktree {
Write-Host "WARNING: worktree $Label requires manual removal -- detach the node_modules junction at $($junction.FullName) first, then re-run cleanup: $Worktree"
return
}
- # \\?\ disables Win32 path normalization, so forward slashes (git's output
- # format) are NOT translated — convert to backslashes or the delete no-ops.
- Remove-Item -LiteralPath ('\\?\' + ($Worktree -replace '/', '\')) -Recurse -Force -ErrorAction SilentlyContinue
+ Remove-DirectoryTree -Path $Worktree
}
git -C $Repo worktree prune
diff --git a/scripts/profile-tunit.ps1 b/scripts/profile-tunit.ps1
index a1db6feba8c..e124936f4d9 100644
--- a/scripts/profile-tunit.ps1
+++ b/scripts/profile-tunit.ps1
@@ -79,7 +79,8 @@ if (-not $OutputDir) {
}
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
-$ExePath = Join-Path $ProjectDir "bin" $Configuration $Framework "$ProjectName.exe"
+$ExeSuffix = if ($IsWindows -or $PSVersionTable.PSVersion.Major -lt 6) { ".exe" } else { "" }
+$ExePath = Join-Path $ProjectDir "bin" $Configuration $Framework "$ProjectName$ExeSuffix"
Write-Host ""
Write-Host "===================================================================" -ForegroundColor Cyan
@@ -125,8 +126,9 @@ if (-not (Test-Path $ExePath)) {
$TestArgs = @()
if ($Filter) {
- $TestArgs += "--treenode-filter"
- $TestArgs += $Filter
+ # One "--option=value" element: splatted arguments are glob-expanded on Linux/macOS, and a
+ # separate filter element such as /*/*/MyClass/* would become a list of file paths.
+ $TestArgs += "--treenode-filter=$Filter"
}
if ($ExtraArgs) {
$TestArgs += $ExtraArgs
@@ -191,9 +193,10 @@ if (-not $testProc.HasExited) {
# Start counters collection in background
$counterJob = Start-Job -ScriptBlock {
- param($pid, $file, $interval, $providers)
+ # Not $pid: that is PowerShell's read-only automatic variable, so binding to it fails.
+ param($processId, $file, $interval, $providers)
& dotnet-counters collect `
- --process-id $pid `
+ --process-id $processId `
--output $file `
--format csv `
--refresh-interval $interval `
diff --git a/scripts/profile-tunit.sh b/scripts/profile-tunit.sh
old mode 100644
new mode 100755
diff --git a/scripts/run-all-engine-tests.ps1 b/scripts/run-all-engine-tests.ps1
old mode 100644
new mode 100755
diff --git a/scripts/run-aot-tests.ps1 b/scripts/run-aot-tests.ps1
old mode 100644
new mode 100755
index 8bdc4c15b31..946bad36c7f
--- a/scripts/run-aot-tests.ps1
+++ b/scripts/run-aot-tests.ps1
@@ -11,27 +11,8 @@ $ErrorActionPreference = "Stop"
Write-Host "Running AOT tests..." -ForegroundColor Yellow
-# Get runtime identifier for platform-specific builds
-function Get-RuntimeIdentifier {
- # For PowerShell 5.x compatibility on Windows
- if ($PSVersionTable.PSVersion.Major -lt 6) {
- # Windows PowerShell 5.x
- return "win-x64"
- }
- # PowerShell Core 6+
- if ($IsWindows) {
- return "win-x64"
- } elseif ($IsLinux) {
- return "linux-x64"
- } elseif ($IsMacOS) {
- return "osx-arm64"
- } else {
- # Default to Windows if platform detection fails
- return "win-x64"
- }
-}
-
-$rid = Get-RuntimeIdentifier
+# Publish for the SDK's own runtime identifier: a hard-coded one targeted the wrong architecture
+# on Intel Macs and on Arm64 Linux/Windows.
$isWindowsPlatform = ($PSVersionTable.PSVersion.Major -lt 6) -or ((Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue) -and $IsWindows)
$executableName = if ($isWindowsPlatform) { "TUnit.TestProject.exe" } else { "TUnit.TestProject" }
@@ -47,9 +28,9 @@ try {
Write-Host "Building AOT version..." -ForegroundColor Yellow
# First restore with runtime identifier
- Write-Host "Restoring with runtime identifier $rid..." -ForegroundColor Cyan
+ Write-Host "Restoring for the current runtime..." -ForegroundColor Cyan
dotnet restore TUnit.TestProject.csproj `
- -r $rid 2>&1 | Out-String | Write-Host
+ --use-current-runtime 2>&1 | Out-String | Write-Host
if ($LASTEXITCODE -ne 0) {
Write-Host "Restore failed with exit code $LASTEXITCODE" -ForegroundColor Yellow
@@ -73,7 +54,7 @@ try {
dotnet publish TUnit.TestProject.csproj `
-f $Framework `
-c $Configuration `
- -r $rid `
+ --use-current-runtime `
-p:Aot=true `
-p:SelfContained=true `
-p:IlcGenerateStackTraceData=false `
@@ -89,7 +70,9 @@ try {
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$aotExecutable = Join-Path "TESTPROJECT_AOT" $executableName
- & $aotExecutable --treenode-filter $Filter 2>&1 | Out-String | Write-Host
+ # Quoted: on Linux/macOS PowerShell glob-expands unquoted native arguments, turning the
+ # filter into a list of matching file paths.
+ & $aotExecutable --treenode-filter "$Filter" 2>&1 | Out-String | Write-Host
$success = $LASTEXITCODE -eq 0
$stopwatch.Stop()
diff --git a/scripts/run-reflection-tests.ps1 b/scripts/run-reflection-tests.ps1
old mode 100644
new mode 100755
index c6efa676d8c..93ab8cb1d24
--- a/scripts/run-reflection-tests.ps1
+++ b/scripts/run-reflection-tests.ps1
@@ -25,7 +25,7 @@ try {
dotnet run `
-f $Framework `
--configuration $Configuration `
- --treenode-filter $Filter `
+ --treenode-filter "$Filter" `
--reflection `
--no-build 2>&1 | Out-String | Write-Host
diff --git a/scripts/run-singlefile-tests.ps1 b/scripts/run-singlefile-tests.ps1
old mode 100644
new mode 100755
index bda56623936..35796de95e5
--- a/scripts/run-singlefile-tests.ps1
+++ b/scripts/run-singlefile-tests.ps1
@@ -11,27 +11,8 @@ $ErrorActionPreference = "Stop"
Write-Host "Running SingleFile tests..." -ForegroundColor Yellow
-# Get runtime identifier for platform-specific builds
-function Get-RuntimeIdentifier {
- # For PowerShell 5.x compatibility on Windows
- if ($PSVersionTable.PSVersion.Major -lt 6) {
- # Windows PowerShell 5.x
- return "win-x64"
- }
- # PowerShell Core 6+
- if ($IsWindows) {
- return "win-x64"
- } elseif ($IsLinux) {
- return "linux-x64"
- } elseif ($IsMacOS) {
- return "osx-arm64"
- } else {
- # Default to Windows if platform detection fails
- return "win-x64"
- }
-}
-
-$rid = Get-RuntimeIdentifier
+# Publish for the SDK's own runtime identifier: a hard-coded one targeted the wrong architecture
+# on Intel Macs and on Arm64 Linux/Windows.
$isWindowsPlatform = ($PSVersionTable.PSVersion.Major -lt 6) -or ((Get-Variable -Name 'IsWindows' -ErrorAction SilentlyContinue) -and $IsWindows)
$executableName = if ($isWindowsPlatform) { "TUnit.TestProject.exe" } else { "TUnit.TestProject" }
@@ -49,7 +30,7 @@ try {
dotnet publish `
-f $Framework `
-c $Configuration `
- -r $rid `
+ --use-current-runtime `
-p:SingleFile=true `
-o "TESTPROJECT_SINGLEFILE" 2>&1 | Out-String | Write-Host
@@ -62,7 +43,9 @@ try {
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$singleFileExecutable = Join-Path "TESTPROJECT_SINGLEFILE" $executableName
- & $singleFileExecutable --treenode-filter $Filter 2>&1 | Out-String | Write-Host
+ # Quoted: on Linux/macOS PowerShell glob-expands unquoted native arguments, turning the
+ # filter into a list of matching file paths.
+ & $singleFileExecutable --treenode-filter "$Filter" 2>&1 | Out-String | Write-Host
$success = $LASTEXITCODE -eq 0
$stopwatch.Stop()
diff --git a/scripts/run-source-generation-tests-verbose.ps1 b/scripts/run-source-generation-tests-verbose.ps1
old mode 100644
new mode 100755
index 7aa69bf409b..3ef5e3d0d8d
--- a/scripts/run-source-generation-tests-verbose.ps1
+++ b/scripts/run-source-generation-tests-verbose.ps1
@@ -26,7 +26,7 @@ try {
$output = dotnet run `
-f $Framework `
--configuration $Configuration `
- --treenode-filter $Filter `
+ --treenode-filter "$Filter" `
--no-build 2>&1 | Out-String
# Display the full output
diff --git a/scripts/run-source-generation-tests.ps1 b/scripts/run-source-generation-tests.ps1
old mode 100644
new mode 100755
index 00dcaa47aaa..405d6aec54b
--- a/scripts/run-source-generation-tests.ps1
+++ b/scripts/run-source-generation-tests.ps1
@@ -25,7 +25,7 @@ try {
dotnet run `
-f $Framework `
--configuration $Configuration `
- --treenode-filter $Filter `
+ --treenode-filter "$Filter" `
--no-build 2>&1 | Out-String | Write-Host
$success = $LASTEXITCODE -eq 0
diff --git a/tests/TUnit.Assertions.SourceGenerator.Tests/TUnit.Assertions.SourceGenerator.Tests.csproj b/tests/TUnit.Assertions.SourceGenerator.Tests/TUnit.Assertions.SourceGenerator.Tests.csproj
index aa7e38bc14c..0f97d6bddcd 100644
--- a/tests/TUnit.Assertions.SourceGenerator.Tests/TUnit.Assertions.SourceGenerator.Tests.csproj
+++ b/tests/TUnit.Assertions.SourceGenerator.Tests/TUnit.Assertions.SourceGenerator.Tests.csproj
@@ -2,7 +2,8 @@
- net472;net8.0;net9.0;net10.0
+
+ $(WindowsNetFrameworkTestTarget)net8.0;net9.0;net10.0
diff --git a/tests/TUnit.Core.SourceGenerator.Tests/STAThreadTests.cs b/tests/TUnit.Core.SourceGenerator.Tests/STAThreadTests.cs
index 5f3613b1fea..75a5594615d 100644
--- a/tests/TUnit.Core.SourceGenerator.Tests/STAThreadTests.cs
+++ b/tests/TUnit.Core.SourceGenerator.Tests/STAThreadTests.cs
@@ -1,11 +1,10 @@
-using TUnit.Core.Enums;
using TUnit.Core.SourceGenerator.Tests.Options;
namespace TUnit.Core.SourceGenerator.Tests;
internal class STAThreadTests : TestsBase
{
- [Test, RunOn(OS.Windows)]
+ [Test]
public Task Test() => RunTest(Path.Combine(Git.TestsDirectory.FullName,
"TUnit.TestProject",
"STAThreadTests.cs"),
diff --git a/tests/TUnit.Core.SourceGenerator.Tests/TUnit.Core.SourceGenerator.Tests.csproj b/tests/TUnit.Core.SourceGenerator.Tests/TUnit.Core.SourceGenerator.Tests.csproj
index 791fa033725..f5a54128d57 100644
--- a/tests/TUnit.Core.SourceGenerator.Tests/TUnit.Core.SourceGenerator.Tests.csproj
+++ b/tests/TUnit.Core.SourceGenerator.Tests/TUnit.Core.SourceGenerator.Tests.csproj
@@ -2,7 +2,8 @@
- net472;net8.0;net9.0;net10.0
+
+ $(WindowsNetFrameworkTestTarget)net8.0;net9.0;net10.0
diff --git a/tests/TUnit.Engine.Tests/InvokableTestBase.cs b/tests/TUnit.Engine.Tests/InvokableTestBase.cs
index 6b46687d937..f3da6e32554 100644
--- a/tests/TUnit.Engine.Tests/InvokableTestBase.cs
+++ b/tests/TUnit.Engine.Tests/InvokableTestBase.cs
@@ -1,4 +1,5 @@
using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
using System.Text;
using CliWrap;
using CliWrap.Buffered;
@@ -63,7 +64,7 @@ private async Task RunWithoutAot(string filter,
"--report-trx", "--report-trx-filename", trxFilename,
"--diagnostic-verbosity", "Debug",
"--diagnostic", "--diagnostic-file-prefix", $"log_{GetType().Name}_",
- "--hangdump", "--hangdump-filename", $"hangdump.{Environment.OSVersion.Platform}.tests-{guid}.dmp", "--hangdump-timeout", "5m",
+ "--hangdump", "--hangdump-filename", $"hangdump.{RuntimeInformation.RuntimeIdentifier}.tests-{guid}.dmp", "--hangdump-timeout", "5m",
..runOptions.AdditionalArguments
]
diff --git a/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj b/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj
index e77739cf106..f9d5736ba21 100644
--- a/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj
+++ b/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj
@@ -2,7 +2,8 @@
- net472;net8.0;net9.0;net10.0
+
+ $(WindowsNetFrameworkTestTarget)net8.0;net9.0;net10.0
diff --git a/tools/TUnit.Pipeline/Modules/PublishMockTestsAOTModule.cs b/tools/TUnit.Pipeline/Modules/PublishMockTestsAOTModule.cs
index 67b714ea5f3..05c82888efe 100644
--- a/tools/TUnit.Pipeline/Modules/PublishMockTestsAOTModule.cs
+++ b/tools/TUnit.Pipeline/Modules/PublishMockTestsAOTModule.cs
@@ -40,7 +40,7 @@ protected override ModuleConfiguration Configure() => ModuleConfiguration.Create
return await context.DotNet().Publish(new DotNetPublishOptions
{
ProjectSolution = testProject.Path,
- Runtime = GetRuntimeIdentifier(),
+ Runtime = RuntimeIdentifiers.Current,
Configuration = "Release",
Output = Path.Combine(rootDir, $"MOCKTESTS_AOT_{framework}"),
Properties =
@@ -65,24 +65,4 @@ protected override ModuleConfiguration Configure() => ModuleConfiguration.Create
return results;
}
-
- private static string GetRuntimeIdentifier()
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
- {
- return "linux-x64";
- }
-
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- return "win-x64";
- }
-
- if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- {
- return "osx-arm64";
- }
-
- throw new ArgumentException("Unknown platform");
- }
}
diff --git a/tools/TUnit.Pipeline/Modules/PublishNugetTesterAOTModule.cs b/tools/TUnit.Pipeline/Modules/PublishNugetTesterAOTModule.cs
index 03ee0b10677..0e3de549de2 100644
--- a/tools/TUnit.Pipeline/Modules/PublishNugetTesterAOTModule.cs
+++ b/tools/TUnit.Pipeline/Modules/PublishNugetTesterAOTModule.cs
@@ -1,4 +1,3 @@
-using System.Runtime.InteropServices;
using ModularPipelines.Attributes;
using ModularPipelines.Configuration;
using ModularPipelines.Context;
@@ -42,7 +41,7 @@ protected override ModuleConfiguration Configure() => ModuleConfiguration.Create
return await context.DotNet().Publish(new DotNetPublishOptions
{
ProjectSolution = testProject.Path,
- Runtime = GetRuntimeIdentifier(),
+ Runtime = RuntimeIdentifiers.Current,
Configuration = "Release",
Output = $"NUGETTESTER_AOT_{framework}",
Properties =
@@ -68,24 +67,4 @@ protected override ModuleConfiguration Configure() => ModuleConfiguration.Create
return results;
}
-
- private string GetRuntimeIdentifier()
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
- {
- return "linux-x64";
- }
-
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- return "win-x64";
- }
-
- if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- {
- return "osx-arm64";
- }
-
- throw new ArgumentException("Unknown platform");
- }
}
diff --git a/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs b/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs
index f209891e856..b57042b0c05 100644
--- a/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs
+++ b/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs
@@ -1,4 +1,5 @@
-using ModularPipelines.Attributes;
+using System.Runtime.InteropServices;
+using ModularPipelines.Attributes;
using ModularPipelines.Context;
using ModularPipelines.DotNet.Extensions;
using ModularPipelines.DotNet.Options;
@@ -37,7 +38,7 @@ public class RunEngineTestsModule : Module
Configuration = "Release",
Framework = "net10.0",
Arguments = [
- "--hangdump", "--hangdump-filename", $"hangdump.{Environment.OSVersion.Platform}.engine-tests.dmp", "--hangdump-timeout", "30m",
+ "--hangdump", "--hangdump-filename", $"hangdump.{RuntimeInformation.RuntimeIdentifier}.engine-tests.dmp", "--hangdump-timeout", "30m",
"--timeout", "35m",
],
}, new CommandExecutionOptions
diff --git a/tools/TUnit.Pipeline/RuntimeIdentifiers.cs b/tools/TUnit.Pipeline/RuntimeIdentifiers.cs
new file mode 100644
index 00000000000..ccfb80b7eab
--- /dev/null
+++ b/tools/TUnit.Pipeline/RuntimeIdentifiers.cs
@@ -0,0 +1,35 @@
+using System.Runtime.InteropServices;
+
+namespace TUnit.Pipeline;
+
+internal static class RuntimeIdentifiers
+{
+ ///
+ /// The portable runtime identifier of the machine running the pipeline, e.g. linux-arm64.
+ /// Native AOT cannot cross-compile to another OS, so AOT publishes always target the host.
+ /// Built from the OS and process architecture rather than ,
+ /// which reports a distro-specific RID (e.g. ubuntu.24.04-x64) on source-built SDKs.
+ /// On musl (Alpine) the OS part is linux-musl, detected from the running runtime's RID.
+ /// Only for publishing; labels such as hang-dump names use ,
+ /// which never throws.
+ ///
+ public static string Current { get; } = $"{CurrentOperatingSystem}-{CurrentArchitecture}";
+
+ private static string CurrentOperatingSystem =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win"
+ : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx"
+ : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? (IsMusl ? "linux-musl" : "linux")
+ : throw new PlatformNotSupportedException($"No runtime identifier for {RuntimeInformation.OSDescription}.");
+
+ // The runtime reports linux-musl-x64 (portable SDK) or alpine.3.20-x64 (source-built) on musl.
+ private static bool IsMusl =>
+ RuntimeInformation.RuntimeIdentifier.Contains("musl", StringComparison.Ordinal)
+ || RuntimeInformation.RuntimeIdentifier.StartsWith("alpine", StringComparison.Ordinal);
+
+ private static string CurrentArchitecture => RuntimeInformation.ProcessArchitecture switch
+ {
+ Architecture.X64 => "x64",
+ Architecture.Arm64 => "arm64",
+ var other => throw new PlatformNotSupportedException($"No runtime identifier for architecture {other}."),
+ };
+}