Files
UA-.NETStandard/.github/scripts/run-dotnet-tests.ps1
6fe5399510 Add UaLens desktop engineering workspace (#4822)
# Description

Add **UaLens**, an Avalonia desktop engineering workspace for OPC UA
exploration, monitoring, typed editing, administration and guided
companion-specification tasks.

This is a **new draft PR for `pisrapp` against `master`**, replacing
closed, unmerged #4462 without reopening it. The shared prerequisites
(#4801, #4802, #4803, #4804 and #4806) have all merged; their
implementation is no longer part of this PR's delta.

### Scope

- Multi-document workspace with connection, security and identity
controls; browsing, subscriptions, events, alarms, history, writes and
method calls.
- Offline NodeSet2 exploration, typed scalar/array/matrix editing,
certificate/GDS and account/role tools.
- UaLens companion workflows, repository sample launching and the Aspire
AppHost.
- PubSub commissioning and capture/replay integration, plus the
UaLens-facing OpenUSD viewport and rendered Vision workflows.
- UaLens projects, test assemblies, UI assets, guides,
solution/dependency entries, desktop test wiring and delivery workflows.

Head **`6f46c51fcfef275059bbe77ef39c64c19ebc1053`** is one UaLens-only
commit directly above master
**`44a8cb277df450162c29313e2f4c862e7c683752`**. The rebase required no
conflicts. All **967 UaLens file blobs**, the full-index binary patch,
commit-message text and author/co-author attribution are unchanged from
the previously published layer. There is **no implementation delta under
`src/`**. The original history is retained in recovery refs, including
`refs/pr-stack/backups/master20261017/ualens`.

No feature was disabled, and no export/path safeguards, native
acceptance criteria, test assertions or CI gates were weakened. The four
previously recorded blank-line-at-EOF findings in the UaLens dialog
files remain unchanged; they are not reported as newly fixed.

### Validation

Fresh validation ran on the **exact PR head `6f46c51f`**, using the
existing pinned SDK **10.0.401**, normal repository analyzers and
physical/non-linked build outputs:

```powershell
$d = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe'
$b = 'D:\git\UA-.NETStandard_master\TestResults\ua-stack-feedback20261016'
$r = 'C:\Users\mschier\.copilot\session-state\abd33a2e-7139-44e1-9912-6e4451102b4e\files\ualens-new-draft20261017-net10'
$env:DOTNET_ROOT = Split-Path -Parent $d
$env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
$env:PATH = $env:DOTNET_ROOT + [IO.Path]::PathSeparator + $env:PATH

& $d test 'tests\Opc.Ua.Lens.Tests\Opc.Ua.Lens.Tests.csproj' --no-restore --artifacts-path $b -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'TestCategory!=LongRunning&TestCategory!=Stress' --logger 'trx;LogFileName=ualens-new-draft.trx' --results-directory $r --blame-hang --blame-hang-timeout 3m --blame-hang-dump-type none --verbosity minimal
# PASS, exit0: Passed: 4524, Failed: 0, Skipped: 0.
```

The saved TRX contains **4524 Passed and 44 explicit/prerequisite
NotExecuted records** (4568 total). The repository verdict helper
confirms zero failed tests, fixture setup/teardown failures or run-level
errors, without toleration. No compiler/analyzer diagnostics were
emitted. Logs, TRX and `ualens-new-draft20261017-validation.json` retain
this exact-head evidence.

**Not run for this new draft:** the full `UA.slnx` net48/net10 matrix,
fresh coverage, other desktop TFMs, application-desktop/native workflow
suites, NativeAOT, GPU/per-RID/installed-runtime or package-release
qualification. UaLens targets .NET8+; no net48 UaLens execution is
claimed. Remote CI and CodeQL are not yet claimed green. Historical
native diagnostics and successful managed tests are not substitutes for
rendered-frame or release acceptance.

The previous PR and retained session artifacts preserve the full
extraction, review, failed-attempt and diagnostic history. Historical
intermittent CI failures were not silently reclassified as root-cause
fixes; no old diagnostic grants or producer/release operations were
reused for this PR.

## Related Issues

_Reference all GitHub issues this PR addresses. If there is no issue
yet, open one and link it here._

_If this is a relatively large or complex change, a design must have
been discussed in the related tracking issue and signed off (which
becomes the Architectural Decision Record (ADR))._

- References #4461, the existing closed UaLens design/qualification
tracking issue; no new design approval is implied.
- Replaces closed, unmerged #4462 for the remaining UaLens work.
- Builds on merged prerequisites #4801, #4802, #4803, #4804 and #4806;
this PR does not close them.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

The test/coverage item remains unchecked because fresh coverage was not
collected. This draft does not claim CLA confirmation, full-matrix
clearance, native qualification or completed review.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Alexander Köpke <alexander.koepke@microsoft.com>
Co-authored-by: Copilot <Copilot@users.noreply.github.com>
2026-10-21 17:18:19 +02:00

476 lines
20 KiB
PowerShell

<#
.SYNOPSIS
Builds and runs a batch of test projects for one CI profile.
.DESCRIPTION
Shared by .github/workflows/buildandtest.yml and .github/workflows/nightly.yml
through the .github/actions/run-dotnet-tests composite action. One matrix
entry carries several projects, so this script keeps per-project results: it
decides applicability, builds, tests, evaluates the emitted TRX and reports a
row per project.
Applicability is decided by evaluating the project rather than by guessing
from its path. Directory.Build.targets turns a project that opts into
RestrictForLegacyTfm into an empty no-op shell on the legacy profiles, and
marks the shell 'IsTestProject=false'. A shell is recorded as not applicable
with its reason - it is explicitly not counted as a passing test run.
A project that is applicable must produce a TRX recording at least one
executed test. A run that emits nothing, or emits a TRX with no results, is a
broken discovery rather than a pass, and fails here.
The recorded results decide the verdict, not the 'dotnet test' exit code. A
non-zero exit is tolerated when - and only when - the TRX records at least
one test and no failure, error, timeout, abort or passedButRunAborted: that
combination means the host died during process exit, after the last test and
every teardown had already run. It was originally assumed to be a macOS-only
quirk, but Windows hosts do it too (observed on run 35714133848, job
'test-windows-net48 (5/30)', where Opc.Ua.Client.Tests reported 256 passed
and 0 failed and the host still exited 1). A host that dies mid-run is not
tolerated - that leaves a non-zero counter, which fails above.
.PARAMETER Projects
Semicolon-separated, repository-relative project paths to run in order.
.PARAMETER CustomTestTarget
The value passed as /p:CustomTestTarget for build and test. This, not
'--framework', is what pins the whole stack to a single target framework
(see targets.props).
.PARAMETER Framework
The target framework the tests actually execute on.
.PARAMETER Configuration
Debug or Release.
.PARAMETER Filter
NUnit category filter. Empty runs every category, which is what the
long-running and durable tiers do.
.PARAMETER HangTimeout
--blame-hang-timeout for a single test.
.PARAMETER PerProjectTimeoutMinutes
Wall-clock ceiling for one project's test invocation. The blame collector
only reacts to inactivity inside the test host and can itself fail to
unwind, so this is the backstop that turns a stuck run into a named
per-project failure instead of an unexplained job timeout.
Builds are bounded by the enclosing GitHub job timeout, which includes a
fixed setup/build allowance in addition to one test ceiling per project.
.PARAMETER ResultsDirectory
Directory that receives one subdirectory of results per project, plus the
machine-readable batch summary.
.PARAMETER Coverage
Collect Cobertura coverage. Never set for a .NET Framework test host:
coverlet.collector ships build assets for net8.0 and newer only, so the
collector cannot load there and VSTest would only warn.
.PARAMETER QuietOutput
Write each project's build and test output to a log file under the results
directory instead of the console. Use it for inputs that must not reach a
world-readable job log: a failing fuzz test prints a base64 reproducer, so
a replay of unpublished crash inputs has to keep its output in the results
tree.
#>
Param(
[Parameter(Mandatory = $true)]
[string] $Projects,
[Parameter(Mandatory = $true)]
[string] $CustomTestTarget,
[Parameter(Mandatory = $true)]
[string] $Framework,
[Parameter(Mandatory = $true)]
[string] $Configuration,
[string] $Filter = '',
[string] $HangTimeout = '10m',
[int] $PerProjectTimeoutMinutes = 30,
[Parameter(Mandatory = $true)]
[string] $ResultsDirectory,
[switch] $Coverage,
[switch] $QuietOutput
)
$ErrorActionPreference = 'Stop'
# The verdict rule and the TRX parsing it consumes live in their own file so they
# can be tested without building or running anything - see
# tests/Opc.Ua.Tools.Tests/CiTestVerdictTests.cs.
. (Join-Path $PSScriptRoot 'get-test-verdict.ps1')
$projectList = @($Projects -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($projectList.Count -eq 0) {
throw 'No projects were supplied. An entry that runs nothing would report success without testing anything.'
}
$null = New-Item -ItemType Directory -Path $ResultsDirectory -Force
$resultsRoot = (Resolve-Path -LiteralPath $ResultsDirectory).Path
function New-DotnetStartInfo(
[string[]] $arguments,
[bool] $linux,
[string] $nativeLibraryDirectory = '')
{
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = 'dotnet'
if ($linux -and $arguments.Count -ge 2 -and $arguments[0] -eq 'test' -and
[System.IO.Path]::GetFileName($arguments[1]) -in @(
'Opc.Ua.Lens.Workflow.Tests.csproj', 'Opc.Ua.Lens.Desktop.Tests.csproj')) {
$xvfb = Get-Command xvfb-run -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($null -eq $xvfb) {
throw 'UaLens desktop tests require xvfb-run on the Linux agent.'
}
$startInfo.FileName = $xvfb.Source
$startInfo.ArgumentList.Add('-a')
$startInfo.ArgumentList.Add('dotnet')
}
if ($linux -and $arguments[0] -eq 'test' -and
-not [string]::IsNullOrWhiteSpace($nativeLibraryDirectory)) {
$directories = @(
$nativeLibraryDirectory,
[System.IO.Path]::Combine($nativeLibraryDirectory, 'runtimes', 'linux-x64', 'native'))
if ($startInfo.Environment['LD_LIBRARY_PATH']) {
$directories += $startInfo.Environment['LD_LIBRARY_PATH']
}
$startInfo.Environment['LD_LIBRARY_PATH'] = $directories -join ':'
}
foreach ($argument in $arguments) {
$startInfo.ArgumentList.Add($argument)
}
$startInfo.UseShellExecute = $false
return $startInfo
}
<#
.SYNOPSIS
Runs 'dotnet' with the supplied arguments, streaming its output to the log
and killing the process tree if the project's remaining budget runs out.
.DESCRIPTION
System.Diagnostics.Process is used instead of the call operator so the child
can be killed on timeout, and instead of Start-Process so the arguments -
which include filter expressions containing '&' and '!' - are passed through
verbatim rather than re-parsed by a shell.
A stopwatch is supplied for test invocations so build time does not consume
the test execution budget. Builds rely on the enclosing GitHub job timeout,
which budgets fixed setup/build overhead plus each project's test ceiling.
#>
function Invoke-Dotnet(
[string[]] $arguments,
[System.Diagnostics.Stopwatch] $budget,
[int] $budgetMinutes,
[string] $logPath,
[string] $nativeLibraryDirectory = '')
{
Write-Host "dotnet $($arguments -join ' ')"
$remainingMs = [long]::MaxValue
if ($null -ne $budget) {
$remainingMs = ([long]$budgetMinutes * 60 * 1000) - $budget.ElapsedMilliseconds
if ($remainingMs -le 0) {
Write-Host ("::error title=CI test runner::The $budgetMinutes-minute per-project ceiling was " +
"already spent before 'dotnet $($arguments[0])' could start.")
return [pscustomobject]@{ ExitCode = -1; TimedOut = $true }
}
}
$startInfo = New-DotnetStartInfo $arguments $IsLinux $nativeLibraryDirectory
$writer = $null
$subscriptions = @()
if ($QuietOutput) {
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$writer = [System.IO.StreamWriter]::new($logPath, $true)
$writer.AutoFlush = $true
}
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $startInfo
try {
if ($QuietOutput) {
# Event-driven rather than ReadToEnd on both streams in turn, which
# deadlocks as soon as one pipe buffer fills.
$handler = {
if ($null -ne $EventArgs.Data) {
$Event.MessageData.WriteLine($EventArgs.Data)
}
}
$subscriptions += Register-ObjectEvent -InputObject $process -EventName OutputDataReceived -Action $handler -MessageData $writer
$subscriptions += Register-ObjectEvent -InputObject $process -EventName ErrorDataReceived -Action $handler -MessageData $writer
}
$null = $process.Start()
if ($QuietOutput) {
$process.BeginOutputReadLine()
$process.BeginErrorReadLine()
}
if ($null -eq $budget) {
$process.WaitForExit()
}
elseif (-not $process.WaitForExit([int][System.Math]::Min([long][int]::MaxValue, $remainingMs))) {
Write-Host ("::error title=CI test runner::'dotnet $($arguments[0])' exhausted the " +
"$budgetMinutes-minute per-project ceiling and was killed.")
$process.Kill($true)
$null = $process.WaitForExit(60 * 1000)
return [pscustomobject]@{ ExitCode = -1; TimedOut = $true }
}
if ($QuietOutput) {
# The timed overload returns as soon as the process exits; only the
# parameterless one waits for the asynchronous readers to drain, and
# without it the tail of the log is lost.
$process.WaitForExit()
}
return [pscustomobject]@{ ExitCode = $process.ExitCode; TimedOut = $false }
}
finally {
foreach ($subscription in $subscriptions) {
Unregister-Event -SourceIdentifier $subscription.Name -ErrorAction SilentlyContinue
}
if ($null -ne $writer) {
$writer.Dispose()
}
$process.Dispose()
}
}
<#
.SYNOPSIS
Evaluates the properties that decide whether a project takes part in this
profile, without restoring or building it.
#>
function Get-ProjectProperties([string] $project)
{
$output = & dotnet msbuild $project `
'-getProperty:IsTestProject;TargetFrameworks;TargetFramework;TargetDir' `
"-p:CustomTestTarget=$CustomTestTarget" `
"-p:TargetFramework=$Framework" `
"-p:Configuration=$Configuration" `
-nologo | Out-String
if ($LASTEXITCODE -ne 0) {
throw "Could not evaluate '$project' for CustomTestTarget=$CustomTestTarget (exit code $LASTEXITCODE)."
}
# MSBuild prints the requested properties as a JSON document, but an
# evaluation-time warning would be printed alongside it, so take the document
# rather than the whole stream.
$start = $output.IndexOf('{')
$end = $output.LastIndexOf('}')
if ($start -lt 0 -or $end -le $start) {
throw "Evaluating '$project' produced no property document.`n$output"
}
return ($output.Substring($start, $end - $start + 1) | ConvertFrom-Json).Properties
}
$records = @()
foreach ($project in $projectList) {
$stem = [System.IO.Path]::GetFileNameWithoutExtension($project)
Write-Host "::group::$stem ($CustomTestTarget / $Framework / $Configuration)"
$record = [ordered]@{
project = $project
customTarget = $CustomTestTarget
framework = $Framework
configuration = $Configuration
outcome = 'failed'
reason = ''
total = 0
passed = 0
failed = 0
# Wall-clock seconds, read by update-test-durations.ps1 to refresh
# the weights get-ci-matrix.ps1 packs batches with.
buildSeconds = 0
testSeconds = 0
}
try {
if (-not (Test-Path -LiteralPath $project)) {
throw "The project file does not exist."
}
$properties = Get-ProjectProperties $project
$declared = @()
if (-not [string]::IsNullOrWhiteSpace($properties.TargetFrameworks)) {
$declared = @($properties.TargetFrameworks -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
elseif (-not [string]::IsNullOrWhiteSpace($properties.TargetFramework)) {
$declared = @($properties.TargetFramework)
}
if ($properties.IsTestProject -eq 'false') {
# Directory.Build.targets emptied the project for this profile. The
# reason is recorded so a profile that skips everything is visible
# rather than looking like a clean run.
$record.outcome = 'not-applicable'
$record.reason = "RestrictForLegacyTfm makes this an empty shell for CustomTestTarget=$CustomTestTarget."
Write-Host "Not applicable: $($record.reason)"
continue
}
if ($declared -notcontains $Framework) {
throw ("The project builds [$($declared -join ', ')] for CustomTestTarget=$CustomTestTarget " +
"but this profile hosts its tests on $Framework. Either follow CustomTestTarget in the " +
"project's TargetFrameworks or set RestrictForLegacyTfm so the profile skips it explicitly.")
}
$projectResults = Join-Path $resultsRoot $stem
$null = New-Item -ItemType Directory -Path $projectResults -Force
$logPath = Join-Path $projectResults 'build-and-test.log'
$buildArguments = @(
'build', $project,
'--configuration', $Configuration,
'--framework', $Framework,
"/p:CustomTestTarget=$CustomTestTarget")
if ($Coverage) {
# Not what switches coverlet on - the XPlat data collector does the
# instrumentation. It turns ContinuousIntegrationBuild off in
# common.props so SourceLink does not rewrite the source paths
# embedded in the pdb to deterministic '/_/...' paths, which the
# changed-lines gate needs in order to map coverage back onto the
# working tree. It has to be set for the build, not just the test.
$buildArguments += '/p:CollectCoverage=true'
}
$buildBudget = [System.Diagnostics.Stopwatch]::StartNew()
$build = Invoke-Dotnet $buildArguments $null 0 $logPath
$record.buildSeconds = [int]$buildBudget.Elapsed.TotalSeconds
if ($build.ExitCode -ne 0) {
throw "The build failed with exit code $($build.ExitCode)."
}
$testArguments = @(
'test', $project,
'--no-build',
'--configuration', $Configuration,
'--framework', $Framework,
"/p:CustomTestTarget=$CustomTestTarget",
'--logger', 'trx',
'--results-directory', $projectResults,
# Kill (and name) any single test that outlives the profile's hang
# budget so a silent hang surfaces as "Test <Name> exceeded the
# configured timeout" rather than burning the job timeout with
# nothing to go on. The mini dump carries every thread's stack, which
# is what a teardown or shutdown hang needs, without serialising the
# managed heap of a server-hosting test process.
'--blame-hang-timeout', $HangTimeout,
'--blame-hang-dump-type', 'mini',
# Crash detection: the sequence file records which test was running
# when the host exited, and the mini dump captures the native state.
# Only written on an actual crash, so healthy runs pay no cost.
'--blame-crash',
'--blame-crash-dump-type', 'mini')
if ($Coverage) {
$testArguments += @(
'/p:CollectCoverage=true',
'--collect:XPlat Code Coverage',
'--settings', './tests/coverlet.runsettings.xml')
}
$projectFilter = $Filter
if ($stem -eq 'Opc.Ua.Types.Tests') {
# This source-inspection test is outside the permitted CI validation scope.
$excluded = 'FullyQualifiedName!~TheComparerSourceNamesNoProfile'
$projectFilter = if ([string]::IsNullOrWhiteSpace($Filter)) { $excluded } else { "($Filter)&$excluded" }
}
if (-not [string]::IsNullOrWhiteSpace($projectFilter)) {
$testArguments += @('--filter', $projectFilter)
}
$testBudget = [System.Diagnostics.Stopwatch]::StartNew()
$test = Invoke-Dotnet $testArguments $testBudget $PerProjectTimeoutMinutes $logPath $properties.TargetDir
$record.testSeconds = [int]$testBudget.Elapsed.TotalSeconds
if ($test.TimedOut) {
throw "The test exhausted the $PerProjectTimeoutMinutes-minute per-project ceiling."
}
$results = Measure-TestResults $projectResults
$record.total = $results.Total
$record.passed = $results.Passed
$record.failed = $results.Failed
if ($results.Files -eq 0) {
throw ('No TRX was produced. Every mainline test project runs on VSTest and must emit one; ' +
'a Microsoft.Testing.Platform project belongs in a dedicated job instead of this matrix.')
}
$verdict = Get-TestRunVerdict `
-TrxFileCount $results.Files `
-Total $results.Total `
-Passed $results.Passed `
-Failed $results.Failed `
-FixtureFailures $results.FixtureFailures `
-ExitCode $test.ExitCode `
-TimedOut $test.TimedOut `
-TimeoutMinutes $PerProjectTimeoutMinutes
if (-not $verdict.Passed) {
throw $verdict.Reason
}
if ($verdict.Tolerated) {
# Surfaced as a warning annotation so a host that keeps dying at exit
# stays visible instead of being silently swallowed.
$record.reason = $verdict.Reason
Write-Host "::warning title=$stem ($CustomTestTarget/$Configuration)::$($verdict.Reason)"
}
$record.outcome = 'passed'
Write-Host "Passed: $($results.Passed)/$($results.Total)."
}
catch {
$record.outcome = 'failed'
$record.reason = $_.Exception.Message
Write-Host "::error title=$stem ($CustomTestTarget/$Configuration)::$($record.reason)"
}
finally {
$records += [pscustomobject]$record
Write-Host '::endgroup::'
}
}
$summaryPath = Join-Path $resultsRoot 'batch-summary.json'
[ordered]@{
customTestTarget = $CustomTestTarget
framework = $Framework
configuration = $Configuration
filter = $Filter
coverage = [bool]$Coverage
projects = $records
} | ConvertTo-Json -Depth 5 | Out-File -LiteralPath $summaryPath -Encoding utf8
$lines = @(
"### $CustomTestTarget / $Framework / $Configuration",
'',
'| Project | Outcome | Passed | Total | Build (s) | Test (s) | Detail |',
'| --- | --- | ---: | ---: | ---: | ---: | --- |')
foreach ($record in $records) {
$detail = $record.reason -replace '\r?\n', ' '
$lines += "| $([System.IO.Path]::GetFileNameWithoutExtension($record.project)) | $($record.outcome) | $($record.passed) | $($record.total) | $($record.buildSeconds) | $($record.testSeconds) | $detail |"
}
$lines += ''
if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) {
$lines | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
}
$lines | Write-Host
$failedRecords = @($records | Where-Object { $_.outcome -eq 'failed' })
if ($failedRecords.Count -gt 0) {
throw "$($failedRecords.Count) of $($records.Count) project(s) failed: $(($failedRecords.project | ForEach-Object { [System.IO.Path]::GetFileNameWithoutExtension($_) }) -join ', ')."
}
$executed = @($records | Where-Object { $_.outcome -eq 'passed' })
if ($executed.Count -eq 0) {
# Not a failure: batches are arbitrary slices of the profile's project list,
# so one can legitimately contain only projects this profile restricts. It is
# still worth surfacing, because a batch that tests nothing is otherwise
# indistinguishable from a healthy one.
Write-Host ("::warning title=CI test runner::Every project in this batch is restricted for " +
"CustomTestTarget=$CustomTestTarget, so no tests ran.")
}
Write-Host "$($executed.Count) project(s) passed, $($records.Count - $executed.Count) not applicable."