diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1ffcbe405b..985488d984 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -182,9 +182,11 @@ jobs:
azure-credentials: ${{ secrets.AZURE_ACI_CREDENTIALS }}
tag: ServiceControl
- name: Run tests
- run: ./tools/run-tests.ps1 -Projects $Env:TEST_PROJECTS -MaxParallel ${{ matrix.max-parallel || 1 }}
+ uses: Particular/run-tests-action@e49b48c5d8f05ce781825e9a50bcdf8c7dd56ba4
+ with:
+ projects: ${{ steps.select.outputs.test-projects }}
+ max-parallel: ${{ matrix.max-parallel || 1 }}
env:
- TEST_PROJECTS: ${{ steps.select.outputs.test-projects }}
ServiceControl_TESTS_FILTER: ${{ matrix.test-category }}
PARTICULARSOFTWARE_LICENSE: ${{ secrets.LICENSETEXT }}
AZURE_ACI_CREDENTIALS: ${{ secrets.AZURE_ACI_CREDENTIALS }}
diff --git a/src/TestHelper/PortUtility.cs b/src/TestHelper/PortUtility.cs
index 21c1aa4169..48a3fd5ead 100644
--- a/src/TestHelper/PortUtility.cs
+++ b/src/TestHelper/PortUtility.cs
@@ -1,4 +1,4 @@
-namespace TestHelper
+namespace TestHelper
{
using System;
using System.Globalization;
@@ -8,25 +8,42 @@
public static class PortUtility
{
///
- /// The port an embedded server should bind, when the test runner has assigned one.
+ /// The 0-based index that Particular/run-tests-action assigns to each spawned
+ /// dotnet test process immediately before spawning it, so concurrent runs can derive
+ /// distinct per-run resources from it. The value is unique across all runs in the invocation;
+ /// in sequential mode (max-parallel == 1) it is always 0. Defaults to 0
+ /// when unset (e.g. local development outside the action).
///
- public const string AssignedPortVariableName = "ServiceControl_TESTS_RAVENDB_PORT";
+ public const string ParallelIndexVariableName = "PARTICULAR_RUN_TESTS_ACTION_PARALLEL_INDEX";
///
- /// Returns the port assigned by the test runner, or probes for a free one when running alone.
+ /// Spacing between per-run ports. Wide enough that a run's embedded server has room for any
+ /// additional listeners it opens alongside its main port.
+ ///
+ public const int ParallelPortSpacing = 10;
+
+ ///
+ /// Returns the port derived from the run-tests-action per-run parallel index.
///
///
- /// Concurrent test processes cannot each probe: only inspects
- /// the listeners active at that instant, so processes starting together all see the same port
- /// free and all but one then fail to bind.
+ ///
+ /// The action sets on every spawned dotnet test
+ /// process. Each run's port is computed as startPort + (index * ),
+ /// so concurrent runs bind distinct ports (the historic spacing of 10 is preserved). A sequential
+ /// run (index 0) lands on -- the same base the historic probe
+ /// started from, so non-parallel behavior is unchanged.
+ ///
+ ///
+ /// When the index is unset (local development outside the action) it defaults to 0 and the
+ /// base is used directly. remains
+ /// available for callers that want to probe for a free port rather than derive a fixed one.
+ ///
///
public static int GetAssignedOrAvailablePort(int startPort)
{
- var assignedPort = Environment.GetEnvironmentVariable(AssignedPortVariableName);
-
- return string.IsNullOrWhiteSpace(assignedPort)
- ? FindAvailablePort(startPort)
- : int.Parse(assignedPort, CultureInfo.InvariantCulture);
+ var indexText = Environment.GetEnvironmentVariable(ParallelIndexVariableName);
+ var index = int.TryParse(indexText?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var i) ? i : 0;
+ return startPort + (Math.Max(0, index) * ParallelPortSpacing);
}
public static int FindAvailablePort(int startPort)
@@ -47,4 +64,4 @@ public static int FindAvailablePort(int startPort)
return startPort;
}
}
-}
+}
\ No newline at end of file
diff --git a/tools/run-tests.ps1 b/tools/run-tests.ps1
deleted file mode 100644
index 5d47ac6085..0000000000
--- a/tools/run-tests.ps1
+++ /dev/null
@@ -1,147 +0,0 @@
-# Runs dotnet test against an explicit list of test projects, rather than discovering every test
-# project under src. The list comes from tools/select-test-projects.ps1, so a job only pays for the
-# assemblies belonging to its test category.
-#
-# This is a scoped replacement for Particular/run-tests-action, which has no way to be told which
-# projects to run. It should fold back into that action once it grows a 'projects' input.
-#
-# -MaxParallel runs several assemblies at once. CI jobs that merge categories sharing infrastructure
-# use it so the job costs the slowest assembly rather than the sum of all of them. Output is buffered
-# per run and replayed on completion, because interleaved dotnet test output is unreadable.
-
-param(
- [Parameter(Mandatory)]
- [string]$Projects,
-
- [string]$TargetPlatform = 'x64',
-
- [ValidateRange(1, 16)]
- [int]$MaxParallel = 1,
-
- [switch]$ReportWarnings
-)
-
-$ErrorActionPreference = 'Stop'
-
-$projectPaths = $Projects -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
-
-if ($projectPaths.Count -eq 0) {
- throw 'No test projects were supplied.'
-}
-
-Write-Output "Target Platform = $TargetPlatform"
-Write-Output "Max parallel test runs = $MaxParallel"
-
-$reportWarningsValue = if ($ReportWarnings) { 'true' } else { 'false' }
-$isUnix = $PSVersionTable.Platform -eq 'Unix'
-
-$runs = [Collections.Generic.List[object]]::new()
-
-foreach ($project in $projectPaths) {
- $frameworks = @(
- (Select-Xml -Path $project -XPath "/Project/PropertyGroup/TargetFramework").Node.InnerText
- (Select-Xml -Path $project -XPath "/Project/PropertyGroup/TargetFrameworks").Node.InnerText -split ';'
- ) | Where-Object { $_ }
-
- if ($frameworks.Count -eq 0) {
- throw "Could not determine a target framework for $project."
- }
-
- foreach ($framework in $frameworks) {
- if ($isUnix -and ($framework.StartsWith('net4') -or $framework.Contains('-windows'))) {
- Write-Output "Skipping $(Split-Path $project -Leaf) ($framework) because it cannot run on this platform."
- continue
- }
-
- $runs.Add([pscustomobject]@{
- Label = "$(Split-Path $project -Leaf) ($framework)"
- Project = $project
- Framework = $framework
- })
- }
-}
-
-if ($runs.Count -eq 0) {
- throw 'No test projects were runnable on this platform.'
-}
-
-# RavenDB.Embedded binds a fixed port, and the tests otherwise pick one by probing the active
-# listeners, which concurrent processes all do at the same instant and all resolve to the same port.
-# Hand each run its own instead. Left unset when running one at a time, because probing copes better
-# with a port that something else on the machine already holds.
-$assignPorts = $MaxParallel -gt 1
-$nextPort = 33334
-$portSpacing = 10
-
-$exitCode = 0
-
-function Complete-Run($run) {
- Write-Output "::group::Running $($run.Label)"
- foreach ($stream in @($run.OutFile, $run.ErrFile)) {
- if ((Test-Path $stream) -and (Get-Item $stream).Length -gt 0) {
- Get-Content -Path $stream | Write-Output
- }
- Remove-Item -Path $stream -Force -ErrorAction SilentlyContinue
- }
- Write-Output '::endgroup::'
-
- if ($run.Process.ExitCode -ne 0) {
- Write-Output "::error::$($run.Label) exit code = $($run.Process.ExitCode)"
- $script:exitCode = 1
- }
-}
-
-$pending = [Collections.Generic.Queue[object]]::new($runs)
-$active = [Collections.Generic.List[object]]::new()
-
-while ($pending.Count -gt 0 -or $active.Count -gt 0) {
- while ($active.Count -lt $MaxParallel -and $pending.Count -gt 0) {
- $run = $pending.Dequeue()
- $run | Add-Member -NotePropertyName OutFile -NotePropertyValue ([IO.Path]::GetTempFileName())
- $run | Add-Member -NotePropertyName ErrFile -NotePropertyValue ([IO.Path]::GetTempFileName())
-
- $arguments = @(
- 'test', $run.Project
- '--configuration', 'Release'
- '--no-build'
- '--framework', $run.Framework
- '--logger', "GitHubActions;report-warnings=$reportWarningsValue"
- '--'
- 'RunConfiguration.TreatNoTestsAsError=true'
- "RunConfiguration.TargetPlatform=$TargetPlatform"
- )
-
- if ($assignPorts) {
- # Set immediately before spawning, so the child inherits this run's value. Safe because
- # spawning is serialised here even though the runs themselves are not.
- $Env:ServiceControl_TESTS_RAVENDB_PORT = $nextPort
- $nextPort += $portSpacing
- }
-
- Write-Output "Starting $($run.Label)"
-
- $run | Add-Member -NotePropertyName Process -NotePropertyValue (
- Start-Process -FilePath 'dotnet' -ArgumentList $arguments -NoNewWindow -PassThru `
- -RedirectStandardOutput $run.OutFile -RedirectStandardError $run.ErrFile)
- $active.Add($run)
- }
-
- $finished = $active | Where-Object { $_.Process.HasExited }
-
- if (-not $finished) {
- Start-Sleep -Milliseconds 500
- continue
- }
-
- foreach ($run in @($finished)) {
- # Bounded on purpose. The parameterless WaitForExit() also waits for the redirected streams to
- # reach EOF, and on Linux Start-Process pumps them through a pipe, so a test that leaves behind
- # a child holding the inherited handle blocks it forever. HasExited has already told us the
- # test process itself is done; this only gives the pump a moment to drain.
- [void]$run.Process.WaitForExit(5000)
- Complete-Run $run
- [void]$active.Remove($run)
- }
-}
-
-exit $exitCode
diff --git a/tools/select-test-projects.ps1 b/tools/select-test-projects.ps1
index 8ae2dc89aa..8e36542800 100644
--- a/tools/select-test-projects.ps1
+++ b/tools/select-test-projects.ps1
@@ -7,7 +7,7 @@
#
# A category can span several test projects that share infrastructure, so that CI provisions its
# container once and compiles the union of their closures once. Their assemblies then run concurrently,
-# via the -MaxParallel switch on run-tests.ps1.
+# via the 'max-parallel' input on Particular/run-tests-action.
#
# Use -List to print every category and its projects without writing any files.