Skip to content

Commit dfab1e3

Browse files
committed
Fix UI test browser bootstrap hangs
1 parent 95e2b5e commit dfab1e3

4 files changed

Lines changed: 207 additions & 19 deletions

File tree

.github/workflows/build-validation.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ jobs:
8686

8787
ui_tests:
8888
name: UI Test Suite
89-
runs-on: windows-latest
89+
runs-on: macos-latest
9090
timeout-minutes: 60
9191
needs:
9292
- build
@@ -100,5 +100,9 @@ jobs:
100100
uses: "./.github/steps/install_dependencies"
101101

102102
- name: Run UI Tests
103-
shell: pwsh
104-
run: dotnet test ./DotPilot.UITests/DotPilot.UITests.csproj --logger GitHubActions --blame-crash
103+
shell: bash
104+
run: |
105+
export UNO_UITEST_DRIVER_PATH="${CHROMEWEBDRIVER}"
106+
export UNO_UITEST_CHROME_BINARY_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
107+
export UNO_UITEST_BROWSER_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
108+
dotnet test ./DotPilot.UITests/DotPilot.UITests.csproj --logger GitHubActions --blame-crash

DotPilot.UITests/BrowserAutomationBootstrap.cs

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ internal static partial class BrowserAutomationBootstrap
2525
"/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing";
2626
private const string BrowserVersionArgument = "--version";
2727
private const string BrowserVersionPattern = @"(\d+\.\d+\.\d+\.\d+)";
28+
private const string BrowserVersionProbeTimeoutMessage =
29+
"Timed out while probing the installed Chrome version for DotPilot UI smoke tests.";
2830
private const string BrowserBinaryNotFoundMessage =
2931
"Unable to locate a Chrome browser binary for DotPilot UI smoke tests. " +
3032
"Set UNO_UITEST_CHROME_BINARY_PATH or UNO_UITEST_BROWSER_PATH explicitly.";
@@ -40,6 +42,7 @@ internal static partial class BrowserAutomationBootstrap
4042
"ChromeDriver bootstrap completed without producing the expected executable.";
4143
private const string DriverCacheDirectoryName = "dotpilot-uitest-drivers";
4244
private const string ChromeDriverBundleNamePrefix = "chromedriver-";
45+
private const string DriverVersionCacheFileNameSuffix = ".driver-version";
4346
private const string LatestPatchVersionsUrl =
4447
"https://googlechromelabs.github.io/chrome-for-testing/latest-patch-versions-per-build.json";
4548
private const string ChromeForTestingDownloadBaseUrl =
@@ -53,6 +56,7 @@ internal static partial class BrowserAutomationBootstrap
5356
{
5457
Timeout = TimeSpan.FromMinutes(2),
5558
};
59+
private static readonly TimeSpan BrowserVersionProbeTimeout = TimeSpan.FromSeconds(10);
5660

5761
public static BrowserAutomationSettings Resolve()
5862
{
@@ -122,35 +126,78 @@ private static string ResolveBrowserDriverPath(
122126
private static string EnsureChromeDriverDownloaded(string browserBinaryPath)
123127
{
124128
var browserVersion = ResolveBrowserVersion(browserBinaryPath);
125-
var driverVersion = ResolveChromeDriverVersion(browserVersion);
129+
var browserBuild = BuildChromeVersionKey(browserVersion);
126130
var driverPlatform = ResolveChromeDriverPlatform();
127-
var cacheRootPath = Path.Combine(Path.GetTempPath(), DriverCacheDirectoryName, driverVersion);
128-
var driverDirectory = Path.Combine(cacheRootPath, $"{ChromeDriverBundleNamePrefix}{driverPlatform}");
129-
var driverExecutablePath = Path.Combine(driverDirectory, GetChromeDriverExecutableFileName());
131+
var cacheRootPath = GetDriverCacheRootPath();
130132

131133
HarnessLog.Write($"Browser version '{browserVersion}' resolved for '{browserBinaryPath}'.");
134+
135+
var cachedDriverDirectory = ResolveCachedChromeDriverDirectory(cacheRootPath, browserBuild, driverPlatform);
136+
if (!string.IsNullOrWhiteSpace(cachedDriverDirectory))
137+
{
138+
var cachedDriverExecutablePath = Path.Combine(cachedDriverDirectory, GetChromeDriverExecutableFileName());
139+
EnsureDriverExecutablePermissions(cachedDriverExecutablePath);
140+
HarnessLog.Write($"Reusing cached ChromeDriver at '{cachedDriverExecutablePath}'.");
141+
return cachedDriverDirectory;
142+
}
143+
144+
var driverVersion = ResolveChromeDriverVersion(browserBuild);
145+
var driverVersionRootPath = Path.Combine(cacheRootPath, driverVersion);
146+
var driverDirectory = Path.Combine(driverVersionRootPath, $"{ChromeDriverBundleNamePrefix}{driverPlatform}");
147+
var driverExecutablePath = Path.Combine(driverDirectory, GetChromeDriverExecutableFileName());
132148
HarnessLog.Write($"Matching ChromeDriver version '{driverVersion}' on platform '{driverPlatform}'.");
133149

134150
if (File.Exists(driverExecutablePath))
135151
{
136152
EnsureDriverExecutablePermissions(driverExecutablePath);
153+
PersistDriverVersionMapping(cacheRootPath, browserBuild, driverPlatform, driverVersion);
137154
HarnessLog.Write($"Reusing cached ChromeDriver at '{driverExecutablePath}'.");
138155
return driverDirectory;
139156
}
140157

141-
Directory.CreateDirectory(cacheRootPath);
142-
HarnessLog.Write($"Downloading ChromeDriver to '{cacheRootPath}'.");
143-
DownloadChromeDriverArchive(driverVersion, driverPlatform, cacheRootPath);
158+
Directory.CreateDirectory(driverVersionRootPath);
159+
HarnessLog.Write($"Downloading ChromeDriver to '{driverVersionRootPath}'.");
160+
DownloadChromeDriverArchive(driverVersion, driverPlatform, driverVersionRootPath);
144161
EnsureDriverExecutablePermissions(driverExecutablePath);
145162

146163
if (!File.Exists(driverExecutablePath))
147164
{
148165
throw new InvalidOperationException($"{DriverExecutableNotFoundMessage} Expected path: {driverExecutablePath}");
149166
}
150167

168+
PersistDriverVersionMapping(cacheRootPath, browserBuild, driverPlatform, driverVersion);
151169
return driverDirectory;
152170
}
153171

172+
internal static string? ResolveCachedChromeDriverDirectory(string cacheRootPath, string browserBuild, string driverPlatform)
173+
{
174+
var driverVersionMappingPath = GetDriverVersionMappingPath(cacheRootPath, browserBuild, driverPlatform);
175+
if (!File.Exists(driverVersionMappingPath))
176+
{
177+
return null;
178+
}
179+
180+
var driverVersion = File.ReadAllText(driverVersionMappingPath).Trim();
181+
if (string.IsNullOrWhiteSpace(driverVersion))
182+
{
183+
return null;
184+
}
185+
186+
var driverDirectory = Path.Combine(cacheRootPath, driverVersion, $"{ChromeDriverBundleNamePrefix}{driverPlatform}");
187+
var driverExecutablePath = Path.Combine(driverDirectory, GetChromeDriverExecutableFileName());
188+
return File.Exists(driverExecutablePath) ? driverDirectory : null;
189+
}
190+
191+
internal static void PersistDriverVersionMapping(
192+
string cacheRootPath,
193+
string browserBuild,
194+
string driverPlatform,
195+
string driverVersion)
196+
{
197+
Directory.CreateDirectory(cacheRootPath);
198+
File.WriteAllText(GetDriverVersionMappingPath(cacheRootPath, browserBuild, driverPlatform), driverVersion);
199+
}
200+
154201
private static void DownloadChromeDriverArchive(string driverVersion, string driverPlatform, string cacheRootPath)
155202
{
156203
var archiveName = $"{ChromeDriverBundleNamePrefix}{driverPlatform}.zip";
@@ -194,11 +241,10 @@ private static string ResolveBrowserVersion(string browserBinaryPath)
194241
CreateNoWindow = true,
195242
};
196243

197-
using var process = Process.Start(processStartInfo)
198-
?? throw new InvalidOperationException(BrowserVersionNotFoundMessage);
199-
200-
var output = $"{process.StandardOutput.ReadToEnd()}{Environment.NewLine}{process.StandardError.ReadToEnd()}";
201-
process.WaitForExit();
244+
var output = RunProcessAndCaptureOutput(
245+
processStartInfo,
246+
BrowserVersionProbeTimeout,
247+
BrowserVersionProbeTimeoutMessage);
202248

203249
var match = BrowserVersionRegex().Match(output);
204250
if (!match.Success)
@@ -209,9 +255,8 @@ private static string ResolveBrowserVersion(string browserBinaryPath)
209255
return match.Groups[1].Value;
210256
}
211257

212-
private static string ResolveChromeDriverVersion(string browserVersion)
258+
private static string ResolveChromeDriverVersion(string browserBuild)
213259
{
214-
var browserBuild = BuildChromeVersionKey(browserVersion);
215260
var response = GetResponseBytes(LatestPatchVersionsUrl, DriverVersionNotFoundMessage);
216261
using var document = JsonDocument.Parse(response);
217262

@@ -237,6 +282,34 @@ private static string BuildChromeVersionKey(string browserVersion)
237282
return string.Join('.', segments.Take(3));
238283
}
239284

285+
internal static string RunProcessAndCaptureOutput(
286+
ProcessStartInfo startInfo,
287+
TimeSpan timeout,
288+
string timeoutMessage)
289+
{
290+
using var process = Process.Start(startInfo)
291+
?? throw new InvalidOperationException(timeoutMessage);
292+
var standardOutputTask = process.StandardOutput.ReadToEndAsync();
293+
var standardErrorTask = process.StandardError.ReadToEndAsync();
294+
295+
if (!process.WaitForExit((int)timeout.TotalMilliseconds))
296+
{
297+
try
298+
{
299+
process.Kill(entireProcessTree: true);
300+
}
301+
catch
302+
{
303+
// Best-effort cleanup only.
304+
}
305+
306+
throw new TimeoutException(timeoutMessage);
307+
}
308+
309+
process.WaitForExit();
310+
return $"{standardOutputTask.GetAwaiter().GetResult()}{Environment.NewLine}{standardErrorTask.GetAwaiter().GetResult()}";
311+
}
312+
240313
private static string ResolveChromeDriverPlatform()
241314
{
242315
if (OperatingSystem.IsMacOS())
@@ -393,6 +466,16 @@ private static string GetChromeDriverExecutableFileName()
393466
: ChromeDriverExecutableName;
394467
}
395468

469+
private static string GetDriverCacheRootPath()
470+
{
471+
return Path.Combine(Path.GetTempPath(), DriverCacheDirectoryName);
472+
}
473+
474+
private static string GetDriverVersionMappingPath(string cacheRootPath, string browserBuild, string driverPlatform)
475+
{
476+
return Path.Combine(cacheRootPath, $"{browserBuild}-{driverPlatform}{DriverVersionCacheFileNameSuffix}");
477+
}
478+
396479
private static IEnumerable<string> GetBrowserBinaryEnvironmentVariableNames()
397480
{
398481
yield return BrowserBinaryEnvironmentVariableName;

DotPilot.UITests/BrowserAutomationBootstrapTests.cs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
using System.Diagnostics;
2+
using System.Runtime.InteropServices;
3+
14
namespace DotPilot.UITests;
25

36
[TestFixture]
@@ -66,13 +69,68 @@ public void WhenBrowserBinaryEnvironmentVariableIsMissingThenResolverFallsBackTo
6669
Assert.That(settings.BrowserBinaryPath, Is.EqualTo(browserBinaryPath));
6770
}
6871

72+
[Test]
73+
public void WhenCachedDriverVersionMappingExistsThenResolverUsesCachedDriverDirectory()
74+
{
75+
using var sandbox = new BrowserAutomationSandbox();
76+
var browserBuild = "145.0.7632";
77+
var driverPlatform = GetExpectedDriverPlatform();
78+
var driverVersion = "145.0.7632.117";
79+
var cacheRootPath = sandbox.CreateDirectory("driver-cache");
80+
var driverDirectory = Path.Combine(cacheRootPath, driverVersion, $"chromedriver-{driverPlatform}");
81+
Directory.CreateDirectory(driverDirectory);
82+
sandbox.CreateFile(Path.Combine(driverDirectory, GetChromeDriverExecutableFileName()));
83+
BrowserAutomationBootstrap.PersistDriverVersionMapping(cacheRootPath, browserBuild, driverPlatform, driverVersion);
84+
85+
var resolvedDirectory = BrowserAutomationBootstrap.ResolveCachedChromeDriverDirectory(
86+
cacheRootPath,
87+
browserBuild,
88+
driverPlatform);
89+
90+
Assert.That(resolvedDirectory, Is.EqualTo(driverDirectory));
91+
}
92+
93+
[Test]
94+
public void WhenVersionProbeProcessTimesOutThenItFailsFast()
95+
{
96+
var startInfo = CreateSleepStartInfo();
97+
98+
var exception = Assert.Throws<TimeoutException>(
99+
() => BrowserAutomationBootstrap.RunProcessAndCaptureOutput(
100+
startInfo,
101+
TimeSpan.FromMilliseconds(50),
102+
"version probe timed out"));
103+
104+
Assert.That(exception, Is.Not.Null);
105+
Assert.That(exception!.Message, Does.Contain("version probe timed out"));
106+
}
107+
69108
private static string GetChromeDriverExecutableFileName()
70109
{
71110
return OperatingSystem.IsWindows()
72111
? ChromeDriverExecutableNameWindows
73112
: ChromeDriverExecutableName;
74113
}
75114

115+
private static string GetExpectedDriverPlatform()
116+
{
117+
if (OperatingSystem.IsMacOS())
118+
{
119+
return RuntimeInformation.ProcessArchitecture == Architecture.Arm64
120+
? "mac-arm64"
121+
: "mac-x64";
122+
}
123+
124+
if (OperatingSystem.IsLinux() && RuntimeInformation.ProcessArchitecture == Architecture.X64)
125+
{
126+
return "linux64";
127+
}
128+
129+
return RuntimeInformation.ProcessArchitecture == Architecture.X86
130+
? "win32"
131+
: "win64";
132+
}
133+
76134
private sealed class BrowserAutomationSandbox : IDisposable
77135
{
78136
private readonly string _rootPath = Path.Combine(
@@ -87,10 +145,23 @@ public BrowserAutomationSandbox()
87145
public string CreateFile(string fileName)
88146
{
89147
var filePath = Path.Combine(_rootPath, fileName);
148+
var directoryPath = Path.GetDirectoryName(filePath);
149+
if (!string.IsNullOrWhiteSpace(directoryPath))
150+
{
151+
Directory.CreateDirectory(directoryPath);
152+
}
153+
90154
File.WriteAllText(filePath, fileName);
91155
return filePath;
92156
}
93157

158+
public string CreateDirectory(string relativePath)
159+
{
160+
var directoryPath = Path.Combine(_rootPath, relativePath);
161+
Directory.CreateDirectory(directoryPath);
162+
return directoryPath;
163+
}
164+
94165
public void Dispose()
95166
{
96167
if (Directory.Exists(_rootPath))
@@ -99,4 +170,30 @@ public void Dispose()
99170
}
100171
}
101172
}
173+
174+
private static ProcessStartInfo CreateSleepStartInfo()
175+
{
176+
if (OperatingSystem.IsWindows())
177+
{
178+
return new ProcessStartInfo
179+
{
180+
FileName = "powershell",
181+
Arguments = "-NoProfile -Command Start-Sleep -Seconds 5",
182+
RedirectStandardOutput = true,
183+
RedirectStandardError = true,
184+
UseShellExecute = false,
185+
CreateNoWindow = true,
186+
};
187+
}
188+
189+
return new ProcessStartInfo
190+
{
191+
FileName = "/bin/sh",
192+
Arguments = "-c \"sleep 5\"",
193+
RedirectStandardOutput = true,
194+
RedirectStandardError = true,
195+
UseShellExecute = false,
196+
CreateNoWindow = true,
197+
};
198+
}
102199
}

ui-tests-ci-hang.plan.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ Fix the `DotPilot.UITests` GitHub Actions execution path so the required UI test
7979
- [x] `GitHub Actions job: UI Tests`
8080
Failure symptom: the PR validation run enters `Run UI Tests`, then fails to produce a terminal result and may later surface `Attempting to cancel the build... Error: The operation was canceled.`
8181
Suspected cause: the UI test harness had unbounded teardown calls for `_browserApp.Dispose()` and `BrowserTestHost.Stop()`, so a Windows-specific cleanup hang could leave the job running until GitHub eventually canceled it.
82-
Intended fix path: bound teardown cleanup with explicit timeouts and convert cleanup hangs into deterministic test failures instead of infinite jobs.
83-
Status: bounded cleanup is fixed locally; CI still needs instrumented logs to confirm whether any remaining hang is in driver/bootstrap, browser startup, host readiness, or teardown.
82+
Intended fix path: bound teardown cleanup with explicit timeouts, add harness stage logging, bound Chrome version probing, and move the GitHub UI suite to the macOS browser environment that already ships Chrome and ChromeDriver.
83+
Status: bounded cleanup is fixed locally; the remaining patch now also removes an unbounded browser-version probe and switches CI off the hanging Windows runner path, but GitHub still needs a fresh run to confirm the terminal result.
8484

8585
## Baseline Notes
8686

@@ -95,7 +95,11 @@ Fix the `DotPilot.UITests` GitHub Actions execution path so the required UI test
9595
- Added bounded cleanup execution in `DotPilot.UITests` so teardown now fails fast if app disposal or browser-host shutdown hangs.
9696
- Added focused regression tests for the bounded-cleanup helper to prove success, exception, and timeout behavior.
9797
- Added harness logging around browser binary resolution, ChromeDriver resolution, host startup, setup, and cleanup so the next GitHub run exposes the exact blocking stage instead of sitting silent inside `Run UI Tests`.
98+
- Added a timeout around Chrome `--version` probing so browser bootstrap now fails fast instead of hanging the whole UI job when the version probe process stalls.
99+
- Added driver-version mapping reuse by browser build/platform so the harness can reuse a cached matching ChromeDriver without re-querying the Chrome-for-Testing patch endpoint every time.
100+
- Moved the GitHub Actions `UI Test Suite` job to `macos-latest` and injects the preinstalled Chrome and ChromeDriver paths through the existing Uno.UITest environment variables.
98101
- `dotnet test DotPilot.UITests/DotPilot.UITests.csproj --filter BoundedCleanupTests` passed.
102+
- `dotnet test DotPilot.UITests/DotPilot.UITests.csproj --filter BrowserAutomationBootstrapTests` passed.
99103
- `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` passed with `9` tests green.
100104
- `dotnet test ./DotPilot.UITests/DotPilot.UITests.csproj --logger GitHubActions --blame-crash` passed with `9` tests green.
101105
- `dotnet test DotPilot.slnx` passed.

0 commit comments

Comments
 (0)