From d6aedb9d6a0d28027785716c663047fba692cec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Sun, 4 Oct 2026 21:12:39 +0200 Subject: [PATCH 1/9] build: make the repo build, test and script cleanly on macOS and Linux - The PublicAPI and generator snapshot projects always targeted net472, so a plain `dotnet test` on Linux/macOS handed that leg to Mono and failed. net472 is now added on Windows only, as the pipeline already assumed; CI uploads *.received.* snapshots on failure so the Windows-only .Net4_7 ones can be taken from there. The STAThread generator snapshot test only compiles in memory and no longer needs to be Windows-only. - Scripts: PowerShell on Linux/macOS glob-expands unquoted native arguments, so a treenode filter such as /*/*/*/*[EngineTest=Pass] turned into a list of file paths and every run-*-tests script failed there. Filters are now quoted. run-aot/run-singlefile hard-coded x64 (and osx-arm64 for any Mac); they use --use-current-runtime. profile-tunit.ps1 assumed a .exe apphost and bound a job parameter to the read-only $pid, so counters never collected. run-baseline.ps1 used the Windows-only Get-CimInstance, and the profile scripts passed "\"-joined paths to dotnet-trace. - Worktree cleanup refused every worktree under a symlinked path (macOS's /var, so its temp dir) because git reports resolved paths, and its fallback delete used the Windows \\?\ prefix everywhere. Test-WorktreeCleanup.ps1, which failed on macOS, passes and covers the fallback. - Pipeline AOT publishes derive the RID from the host instead of hard-coding x64; hang dumps are named by RID rather than "Unix" for both Linux and macOS. - *.ps1 check out with LF (the repo blobs already are), and scripts with a shebang are executable, so ./scripts/x.ps1 works on Unix. - Devcontainer: install the net8/9/10 runtimes the tests need, build TUnit.Dev.slnx (a bare `dotnet build` fails with three root solutions). VS Code: build TUnit.Dev.slnx, a runnable watch task, a filter prompt for TUnit.TestProject, and the default solution. Ignore .DS_Store. Verified on macOS: run-singlefile and run-aot scripts pass end to end (5737 tests), worktree cleanup tests pass, snapshot projects run via `dotnet test --framework net10.0`. Co-Authored-By: Claude Opus 5.5 --- .devcontainer/devcontainer.json | 4 +-- .devcontainer/install-runtimes.sh | 18 ++++++++++ .gitattributes | 7 +++- .github/workflows/dotnet.yml | 13 ++++++++ .gitignore | 3 ++ .vscode/extensions.json | 5 +++ .vscode/launch.json | 13 ++++++-- .vscode/settings.json | 4 +++ .vscode/tasks.json | 18 +++------- .../run-benchmarks.ps1 | 0 .../Profiles/profile-cpu.ps1 | 8 +++-- .../Profiles/profile-gc.ps1 | 8 +++-- .../Profiles/run-baseline.ps1 | 19 ++++++++--- .../compare-branches.ps1 | 0 .../count-generated-methods.ps1 | 0 .../measure-jit.ps1 | 0 .../measure-startup.ps1 | 0 scripts/Remove-MergedWorktrees.ps1 | 2 +- scripts/Test-WorktreeCleanup.ps1 | 8 +++++ scripts/WorktreeCleanup.ps1 | 25 ++++++++++---- scripts/profile-tunit.ps1 | 13 +++++--- scripts/profile-tunit.sh | 0 scripts/run-all-engine-tests.ps1 | 0 scripts/run-aot-tests.ps1 | 33 +++++-------------- scripts/run-reflection-tests.ps1 | 2 +- scripts/run-singlefile-tests.ps1 | 29 ++++------------ .../run-source-generation-tests-verbose.ps1 | 2 +- scripts/run-source-generation-tests.ps1 | 2 +- ...it.Assertions.SourceGenerator.Tests.csproj | 5 ++- .../STAThreadTests.cs | 3 +- .../TUnit.Core.SourceGenerator.Tests.csproj | 5 ++- tests/TUnit.Engine.Tests/InvokableTestBase.cs | 3 +- tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj | 5 ++- .../Modules/PublishMockTestsAOTModule.cs | 22 +------------ .../Modules/PublishNugetTesterAOTModule.cs | 23 +------------ .../Modules/RunEngineTestsModule.cs | 2 +- tools/TUnit.Pipeline/RuntimeIdentifiers.cs | 27 +++++++++++++++ 37 files changed, 189 insertions(+), 142 deletions(-) create mode 100755 .devcontainer/install-runtimes.sh create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json mode change 100644 => 100755 benchmarks/TUnit.Performance.Tests/run-benchmarks.ps1 mode change 100644 => 100755 benchmarks/TUnit.PerformanceBenchmarks/compare-branches.ps1 mode change 100644 => 100755 benchmarks/TUnit.PerformanceBenchmarks/count-generated-methods.ps1 mode change 100644 => 100755 benchmarks/TUnit.PerformanceBenchmarks/measure-jit.ps1 mode change 100644 => 100755 benchmarks/TUnit.PerformanceBenchmarks/measure-startup.ps1 mode change 100644 => 100755 scripts/profile-tunit.sh mode change 100644 => 100755 scripts/run-all-engine-tests.ps1 mode change 100644 => 100755 scripts/run-aot-tests.ps1 mode change 100644 => 100755 scripts/run-reflection-tests.ps1 mode change 100644 => 100755 scripts/run-singlefile-tests.ps1 mode change 100644 => 100755 scripts/run-source-generation-tests-verbose.ps1 mode change 100644 => 100755 scripts/run-source-generation-tests.ps1 create mode 100644 tools/TUnit.Pipeline/RuntimeIdentifiers.cs 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/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..9dee8c816d2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,8 +10,9 @@ "request": "launch", "preLaunchTask": "build", // 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..9b93b464fb5 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,19 +7,7 @@ "type": "process", "args": [ "build", - "${workspaceFolder}/TUnit.slnx", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary;ForceNoAlign" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "publish", - "command": "dotnet", - "type": "process", - "args": [ - "publish", - "${workspaceFolder}/TUnit.slnx", + "${workspaceFolder}/TUnit.Dev.slnx", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary;ForceNoAlign" ], @@ -33,7 +21,9 @@ "watch", "run", "--project", - "${workspaceFolder}/TUnit.slnx" + "${workspaceFolder}/tests/TUnit.UnitTests", + "--framework", + "net10.0" ], "problemMatcher": "$msCompile" } 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..7e6105e0307 100644 --- a/scripts/Test-WorktreeCleanup.ps1 +++ b/scripts/Test-WorktreeCleanup.ps1 @@ -156,6 +156,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..634b84ac501 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,12 @@ 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) { + # 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 + $topLevelOk = $LASTEXITCODE -eq 0 + $prefix = git -C $Worktree rev-parse --show-prefix 2>$null + if (-not $topLevelOk -or $LASTEXITCODE -ne 0 -or $prefix) { Write-Host "Preserving worktree $Label : $Worktree (could not verify repository path)" return } @@ -201,9 +216,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..00193c3e68d 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) { ".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..8cc9f510243 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,10 @@ - net472;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 + + net472;$(TargetFrameworks) 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..47642d2dfa7 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,10 @@ - net472;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 + + net472;$(TargetFrameworks) 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..01f3906de76 100644 --- a/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj +++ b/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj @@ -2,7 +2,10 @@ - net472;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 + + net472;$(TargetFrameworks) 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..5c2b238a244 100644 --- a/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs +++ b/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs @@ -37,7 +37,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.{RuntimeIdentifiers.Current}.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..90aa2504f76 --- /dev/null +++ b/tools/TUnit.Pipeline/RuntimeIdentifiers.cs @@ -0,0 +1,27 @@ +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. + /// + 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) ? "linux" + : throw new PlatformNotSupportedException($"No runtime identifier for {RuntimeInformation.OSDescription}."); + + private static string CurrentArchitecture => RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + var other => throw new PlatformNotSupportedException($"No runtime identifier for architecture {other}."), + }; +} From 610637515f6f0c80f02fada77268849fbafa9865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Sun, 4 Oct 2026 21:14:19 +0200 Subject: [PATCH 2/9] docs: document building and testing TUnit on Linux and macOS CONTRIBUTING now lists what a contributor needs on each OS: the .NET 8/9/10 runtimes the test projects target (Homebrew's dotnet ships one), PowerShell 7, Docker, and the Native AOT toolchain, including the LIBRARY_PATH a Homebrew .NET needs for AOT links. Snapshot instructions pass --framework and explain where the Windows-only .Net4_7 snapshots come from. workflows.md records the framework selection and PowerShell's globbing of unquoted native arguments. Both documented `dotnet test` invocations were run on macOS. Co-Authored-By: Claude Opus 5.5 --- .claude/docs/workflows.md | 6 +++++- .github/CONTRIBUTING.md | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) 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/.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: From ec188797e9a9e2a844a3a991217e1b05dec398f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Sun, 4 Oct 2026 21:23:20 +0200 Subject: [PATCH 3/9] fix(scripts): never remove a worktree through a link; point at snapshot artifact Replacing the path-equality check with `rev-parse --show-prefix` (so macOS's symlinked /var works) also let a symbolic link to a worktree pass: git follows it, so removal through the link deleted the target. The old check refused that case, since the unresolved link path never equalled git's top level. A path that is itself a link is now preserved, and Test-WorktreeCleanup.ps1 covers it (it fails without the guard). The snapshot projects' net472 comment now names the CI artifact that holds the Windows-only .Net4_7 received files. Co-Authored-By: Claude Opus 5.5 --- scripts/Test-WorktreeCleanup.ps1 | 11 +++++++++++ scripts/WorktreeCleanup.ps1 | 5 +++++ .../TUnit.Assertions.SourceGenerator.Tests.csproj | 3 ++- .../TUnit.Core.SourceGenerator.Tests.csproj | 3 ++- tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj | 3 ++- 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/scripts/Test-WorktreeCleanup.ps1 b/scripts/Test-WorktreeCleanup.ps1 index 7e6105e0307..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.' diff --git a/scripts/WorktreeCleanup.ps1 b/scripts/WorktreeCleanup.ps1 index 634b84ac501..0ffeb4ada22 100644 --- a/scripts/WorktreeCleanup.ps1 +++ b/scripts/WorktreeCleanup.ps1 @@ -151,6 +151,11 @@ function Remove-MergedWorktree { Write-Host "Preserving main or harness-managed worktree: $Worktree" return } + # A link to a worktree is not that worktree: git would follow it and remove the target. + if ((Get-Item -LiteralPath $Worktree -Force).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 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 8cc9f510243..fc4e790ac04 100644 --- a/tests/TUnit.Assertions.SourceGenerator.Tests/TUnit.Assertions.SourceGenerator.Tests.csproj +++ b/tests/TUnit.Assertions.SourceGenerator.Tests/TUnit.Assertions.SourceGenerator.Tests.csproj @@ -4,7 +4,8 @@ net8.0;net9.0;net10.0 + The .Net4_7 snapshots are verified by the Windows CI leg; take updates from its + ReceivedSnapshots-windows-latest artifact. --> net472;$(TargetFrameworks) 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 47642d2dfa7..226b602448b 100644 --- a/tests/TUnit.Core.SourceGenerator.Tests/TUnit.Core.SourceGenerator.Tests.csproj +++ b/tests/TUnit.Core.SourceGenerator.Tests/TUnit.Core.SourceGenerator.Tests.csproj @@ -4,7 +4,8 @@ net8.0;net9.0;net10.0 + The .Net4_7 snapshots are verified by the Windows CI leg; take updates from its + ReceivedSnapshots-windows-latest artifact. --> net472;$(TargetFrameworks) diff --git a/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj b/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj index 01f3906de76..da0559457e5 100644 --- a/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj +++ b/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj @@ -4,7 +4,8 @@ net8.0;net9.0;net10.0 + The .Net4_7 snapshots are verified by the Windows CI leg; take updates from its + ReceivedSnapshots-windows-latest artifact. --> net472;$(TargetFrameworks) From aecff40980efb836e87b032b3085d5fd372944fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Sun, 4 Oct 2026 21:24:29 +0200 Subject: [PATCH 4/9] fix(vscode): build TUnit.TestProject before debugging it TUnit.Dev.slnx does not include TUnit.TestProject, so the launch configuration's pre-launch task (building the dev solution) never produced the DLL it starts. A dedicated task now builds the project for net10.0, matching the launch program path. Co-Authored-By: Claude Opus 5.5 --- .vscode/launch.json | 2 +- .vscode/tasks.json | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 9dee8c816d2..38e8420a760 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "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/net10.0/TUnit.TestProject.dll", // TUnit.TestProject contains intentionally failing tests: always run a targeted filter. diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9b93b464fb5..88b8349a671 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -13,6 +13,21 @@ ], "problemMatcher": "$msCompile" }, + { + // TUnit.Dev.slnx does not include TUnit.TestProject, so the debug launch builds it directly. + "label": "build TUnit.TestProject", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/tests/TUnit.TestProject", + "--framework", + "net10.0", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, { "label": "watch", "command": "dotnet", From 2209f9026157af18149ccfe6967af93a48fc7d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Mon, 5 Oct 2026 07:29:15 +0200 Subject: [PATCH 5/9] fix: hang-dump names can't throw; note musl and PowerShell 5.1 per review - RunEngineTestsModule names hang dumps with RuntimeInformation.RuntimeIdentifier, like InvokableTestBase, instead of RuntimeIdentifiers.Current, which throws on architectures it can't publish for. RuntimeIdentifiers documents that it is for publishing only and assumes glibc. - profile-tunit.ps1 keeps the .exe suffix under Windows PowerShell 5.1, where $IsWindows is undefined. - The worktree link guard's comment says why only the path itself is checked. Co-Authored-By: Claude Opus 5.5 --- scripts/WorktreeCleanup.ps1 | 1 + scripts/profile-tunit.ps1 | 2 +- tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs | 2 +- tools/TUnit.Pipeline/RuntimeIdentifiers.cs | 3 +++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/WorktreeCleanup.ps1 b/scripts/WorktreeCleanup.ps1 index 0ffeb4ada22..62ef9781bfb 100644 --- a/scripts/WorktreeCleanup.ps1 +++ b/scripts/WorktreeCleanup.ps1 @@ -152,6 +152,7 @@ function Remove-MergedWorktree { return } # 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. if ((Get-Item -LiteralPath $Worktree -Force).LinkType) { Write-Host "Preserving worktree $Label : $Worktree (path is a link)" return diff --git a/scripts/profile-tunit.ps1 b/scripts/profile-tunit.ps1 index 00193c3e68d..e124936f4d9 100644 --- a/scripts/profile-tunit.ps1 +++ b/scripts/profile-tunit.ps1 @@ -79,7 +79,7 @@ if (-not $OutputDir) { } New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null -$ExeSuffix = if ($IsWindows) { ".exe" } else { "" } +$ExeSuffix = if ($IsWindows -or $PSVersionTable.PSVersion.Major -lt 6) { ".exe" } else { "" } $ExePath = Join-Path $ProjectDir "bin" $Configuration $Framework "$ProjectName$ExeSuffix" Write-Host "" diff --git a/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs b/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs index 5c2b238a244..8319bf08f74 100644 --- a/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs +++ b/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs @@ -37,7 +37,7 @@ public class RunEngineTestsModule : Module Configuration = "Release", Framework = "net10.0", Arguments = [ - "--hangdump", "--hangdump-filename", $"hangdump.{RuntimeIdentifiers.Current}.engine-tests.dmp", "--hangdump-timeout", "30m", + "--hangdump", "--hangdump-filename", $"hangdump.{System.Runtime.InteropServices.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 index 90aa2504f76..c79a184ccb0 100644 --- a/tools/TUnit.Pipeline/RuntimeIdentifiers.cs +++ b/tools/TUnit.Pipeline/RuntimeIdentifiers.cs @@ -9,6 +9,9 @@ internal static class RuntimeIdentifiers /// 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. + /// glibc is assumed: on Alpine/musl this still yields linux-x64, not linux-musl-x64. + /// Only for publishing; labels such as hang-dump names use , + /// which never throws. /// public static string Current { get; } = $"{CurrentOperatingSystem}-{CurrentArchitecture}"; From 9012bd0f7ee50dbf1bfb66411fa93f237cae99fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Mon, 5 Oct 2026 16:54:54 +0200 Subject: [PATCH 6/9] fix(scripts): tolerate a worktree vanishing mid-sweep; name the root-check results Co-Authored-By: Claude Opus 5.5 --- scripts/WorktreeCleanup.ps1 | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/WorktreeCleanup.ps1 b/scripts/WorktreeCleanup.ps1 index 62ef9781bfb..13b91312bd1 100644 --- a/scripts/WorktreeCleanup.ps1 +++ b/scripts/WorktreeCleanup.ps1 @@ -153,16 +153,18 @@ function Remove-MergedWorktree { } # 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. - if ((Get-Item -LiteralPath $Worktree -Force).LinkType) { + if ((Get-Item -LiteralPath $Worktree -Force -ErrorAction SilentlyContinue).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 - $topLevelOk = $LASTEXITCODE -eq 0 + $isInsideWorkTree = $LASTEXITCODE -eq 0 $prefix = git -C $Worktree rev-parse --show-prefix 2>$null - if (-not $topLevelOk -or $LASTEXITCODE -ne 0 -or $prefix) { + $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 } From a1c7b1a5db512b5d34bae3fb862eca71aaf1a8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Mon, 5 Oct 2026 17:17:40 +0200 Subject: [PATCH 7/9] fix(scripts): fail closed when the worktree path cannot be inspected Co-Authored-By: Claude Opus 5.5 --- scripts/WorktreeCleanup.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/WorktreeCleanup.ps1 b/scripts/WorktreeCleanup.ps1 index 13b91312bd1..e9eb7db7fd3 100644 --- a/scripts/WorktreeCleanup.ps1 +++ b/scripts/WorktreeCleanup.ps1 @@ -153,7 +153,13 @@ function Remove-MergedWorktree { } # 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. - if ((Get-Item -LiteralPath $Worktree -Force -ErrorAction SilentlyContinue).LinkType) { + # 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 } From 51f93bfdd1016d923da487f5be976e60c9b37b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Mon, 5 Oct 2026 17:43:42 +0200 Subject: [PATCH 8/9] fix(pipeline): publish for linux-musl- on Alpine Co-Authored-By: Claude Opus 5.5 --- tools/TUnit.Pipeline/RuntimeIdentifiers.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/TUnit.Pipeline/RuntimeIdentifiers.cs b/tools/TUnit.Pipeline/RuntimeIdentifiers.cs index c79a184ccb0..ccfb80b7eab 100644 --- a/tools/TUnit.Pipeline/RuntimeIdentifiers.cs +++ b/tools/TUnit.Pipeline/RuntimeIdentifiers.cs @@ -9,7 +9,7 @@ internal static class RuntimeIdentifiers /// 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. - /// glibc is assumed: on Alpine/musl this still yields linux-x64, not linux-musl-x64. + /// 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. /// @@ -18,9 +18,14 @@ internal static class RuntimeIdentifiers private static string CurrentOperatingSystem => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win" : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" - : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" + : 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", From e5ae732a4f9750f437b10c63ece974b224f74eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Sundstr=C3=B6m?= Date: Mon, 5 Oct 2026 18:14:24 +0200 Subject: [PATCH 9/9] build: define the Windows-only net472 test leg once; tidy a using Co-Authored-By: Claude Opus 5.5 --- Directory.Build.props | 7 +++++++ .../TUnit.Assertions.SourceGenerator.Tests.csproj | 7 ++----- .../TUnit.Core.SourceGenerator.Tests.csproj | 7 ++----- tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj | 7 ++----- tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs | 5 +++-- 5 files changed, 16 insertions(+), 17 deletions(-) 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/tests/TUnit.Assertions.SourceGenerator.Tests/TUnit.Assertions.SourceGenerator.Tests.csproj b/tests/TUnit.Assertions.SourceGenerator.Tests/TUnit.Assertions.SourceGenerator.Tests.csproj index fc4e790ac04..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,11 +2,8 @@ - net8.0;net9.0;net10.0 - - net472;$(TargetFrameworks) + + $(WindowsNetFrameworkTestTarget)net8.0;net9.0;net10.0 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 226b602448b..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,11 +2,8 @@ - net8.0;net9.0;net10.0 - - net472;$(TargetFrameworks) + + $(WindowsNetFrameworkTestTarget)net8.0;net9.0;net10.0 diff --git a/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj b/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj index da0559457e5..f9d5736ba21 100644 --- a/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj +++ b/tests/TUnit.PublicAPI/TUnit.PublicAPI.csproj @@ -2,11 +2,8 @@ - net8.0;net9.0;net10.0 - - net472;$(TargetFrameworks) + + $(WindowsNetFrameworkTestTarget)net8.0;net9.0;net10.0 diff --git a/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs b/tools/TUnit.Pipeline/Modules/RunEngineTestsModule.cs index 8319bf08f74..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.{System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier}.engine-tests.dmp", "--hangdump-timeout", "30m", + "--hangdump", "--hangdump-filename", $"hangdump.{RuntimeInformation.RuntimeIdentifier}.engine-tests.dmp", "--hangdump-timeout", "30m", "--timeout", "35m", ], }, new CommandExecutionOptions