mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 07:07:24 +08:00
# 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>
476 lines
20 KiB
PowerShell
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."
|