fix(client-windows): package msedgedriver and make the Edge handler actually use it - #670
fix(client-windows): package msedgedriver and make the Edge handler actually use it#670nblair2 wants to merge 3 commits into
Conversation
The Windows client ships chromedriver.exe and geckodriver.exe because Selenium.WebDriver.ChromeDriver and Selenium.WebDriver.GeckoDriver each carry a build target that copies the driver into $(TargetDir). Nothing supplied a driver for the Edge handler, so BrowserEdge could never start a browser on a host that had not obtained msedgedriver.exe by some other means. Add Selenium.WebDriver.MSEdgeDriver, which is the same shape as the other two (CopyMsEdgeDriverToBin runs BeforeTargets="AfterBuild" and drops driver/win32/msedgedriver.exe next to ghosts.exe). Version is pinned explicitly, matching how ChromeDriver and GeckoDriver are pinned. WebDriverPlatform is deliberately left unset: all three driver packages share that property and resolve it to win32 on Windows, and the ChromeDriver package ships no win64 directory, so forcing it would break the chromedriver copy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vice GetEdgeDriver builds an EdgeDriverService rooted at the application base directory and sets HideCommandPromptWindow on it, then calls new EdgeDriver(options), which discards that service and constructs its own. Both the base-directory pin and the hidden console window were therefore inert. Because the discarded service left DriverServicePath unset, Selenium fell through to DriverFinder and Selenium Manager, which resolves the driver by downloading it. That fails on hosts with no outbound network access, even when msedgedriver.exe sits next to ghosts.exe. Pass the service through, as BrowserChrome already does for ChromeDriverService. Selenium then loads the driver from the base directory and never reaches for the network. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ProcessNames.MSEdge and ProcessNames.MSEdgeDriver held the executables' file
descriptions ("Microsoft Edge", "Microsoft Edge WebDriver (32 bit)") rather
than their process names. Both values are passed to
KillProcessAndChildrenByName, which calls Process.GetProcessesByName, so they
never matched anything and BrowserEdge's cleanup silently reaped nothing.
Left alone, msedge.exe and msedgedriver.exe accumulate for the life of the
client.
Use "msedge" and "msedgedriver", consistent with Chrome => "chrome",
ChromeDriver => "chromedriver", Firefox => "firefox" and
GeckoDriver => "geckodriver".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Does this need to be changed in the universal client as well? |
Yes, probably does. Good catch. |
|
I won't have time to test this until the end of the month but not pressing. |
|
In our deployments, we deploy Edge and Edgedriver separately as over time these versions change. Same for chromedriver, geckodriver. We don't try to package drivers like this up with Ghosts, they are bound to get out of date. |
We currently have chromedriver pinned. Should we remove that? I use this script to install and match driver versions (lightly edited to remove internal stuff): # Install [CMU GHOSTS](https://github.com/cmu-sei/GHOSTS)
$ProgressPreference = 'SilentlyContinue'
$ErrorActionPreference = 'Stop'
$firefoxInstallerUrl = "https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=en-US"
$geckoDriverPath = "C:\Program Files (x86)\geckodriver\"
$tempPath = "C:\Windows\Temp\ghosts"
$arch = if ($ENV:PROCESSOR_ARCHITECTURE -match "AMD64|IA64|ARM64") { "x64" } else { "x86" }
$ghostsZipUrl = "https://github.com/cmu-sei/GHOSTS/releases/download/v9.0.0/ghosts-client-universal-win-$arch.zip"
$ghostsPath = "C:\ghosts"
Write-Host "Installing the CMU GHOSTS Client"
# Create a temp file to download things to
New-Item -Path $tempPath -ItemType Directory -Force
Write-Host "--Installing Firefox latest"
$firefoxInstallerPath = Join-Path -Path $tempPath -ChildPath "firefox-installer.exe"
Invoke-WebRequest -Uri $firefoxInstallerUrl -OutFile $firefoxInstallerPath
Start-Process -FilePath $firefoxInstallerPath -ArgumentList "/Silent" -Wait
Write-Host "--Downloading and extracting GHOSTS to $ghostsPath"
$ghostsZipPath = Join-Path -Path $tempPath -ChildPath "ghosts.zip"
curl.exe --request GET $ghostsZipUrl --output $ghostsZipPath
New-Item -Path $ghostsPath -ItemType Directory -Force
Expand-Archive -Path $ghostsZipPath -DestinationPath $ghostsPath -Force
if (-not (Test-Path "$ghostsPath\ghosts.exe") -or -not (Test-Path "$ghostsPath\geckodriver.exe")) {
throw "GHOSTS extraction failed: ghosts.exe or geckodriver.exe missing from $ghostsPath"
}
Write-Host "--Moving geckodriver from ghosts folder to $geckoDriverPath"
New-Item -Path $geckoDriverPath -ItemType Directory -Force
Copy-Item -Path "$ghostsPath\geckodriver.exe" -Destination $geckoDriverPath
Write-Host "--Updating PATH to include geckodriver"
$currentPath = [System.Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::Machine)
$newPath = "$currentPath;$geckoDriverPath"
[System.Environment]::SetEnvironmentVariable("Path", $newPath, [System.EnvironmentVariableTarget]::Machine)
$updatedPath = [System.Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::Machine)
Write-Host "Updated PATH: $updatedPath"
Write-Host "--Registering AutoIt DLLs"
regsvr32.exe /S "$ghostsPath/AutoItX3.dll"
regsvr32.exe /S "$ghostsPath/AutoItX3_x64.dll"
# Ensure chromedriver.exe version matches installed chome.exe version
Write-Host "--Ensuring chromedriver matches installed Chrome version"
$chromePath = "C:\Program Files\Google\Chrome\Application\chrome.exe"
if (Test-Path $chromePath) {
$chromeVersion = (Get-Item $chromePath).VersionInfo.ProductVersion
Write-Host "Detected Chrome version: $chromeVersion"
$chromeMajorVersion = $chromeVersion.Split(".")[0]
$chromedriverUrl = "https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_$chromeMajorVersion"
$chromedriverVersion = Invoke-WebRequest -Uri $chromedriverUrl -UseBasicParsing
$chromedriverDownloadUrl = "https://storage.googleapis.com/chrome-for-testing-public/$chromedriverVersion/win64/chromedriver-win64.zip"
$chromedriverZipPath = Join-Path -Path $tempPath -ChildPath "chromedriver.zip"
Invoke-WebRequest -Uri $chromedriverDownloadUrl -OutFile $chromedriverZipPath
Expand-Archive -Path $chromedriverZipPath -DestinationPath $tempPath -Force
$extractedExe = Get-ChildItem -Path $tempPath -Filter "chromedriver.exe" -Recurse | Select-Object -First 1
Copy-Item -Path $extractedExe.FullName -Destination "$ghostsPath\chromedriver.exe" -Force
Write-Host "Chromedriver version $(& "$ghostsPath\chromedriver.exe" --version) installed to GHOSTS folder"
} else {
Write-Host "Chrome is not installed. Skipping chromedriver installation."
}
# Clean up
Remove-Item -Path $tempPath -Recurse -Force
Write-Host "GHOSTS has been installed!"A similar check for edgedriver would look something like this: # Ensure msedgedriver.exe version matches installed msedge.exe version
Write-Host "--Ensuring msedgedriver matches installed Edge version"
# Edge installs under Program Files (x86) even on 64-bit Windows
$edgePath = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
if (Test-Path $edgePath) {
$edgeVersion = (Get-Item $edgePath).VersionInfo.ProductVersion
Write-Host "Detected Edge version: $edgeVersion"
$edgeMajorVersion = $edgeVersion.Split(".")[0]
$edgedriverUrl = "https://msedgedriver.microsoft.com/LATEST_RELEASE_${edgeMajorVersion}_WINDOWS"
# This endpoint answers in UTF-16LE with a BOM, so decode the raw bytes instead of
# letting Invoke-WebRequest interpret them as text
$edgedriverResponse = Invoke-WebRequest -Uri $edgedriverUrl -UseBasicParsing
$edgedriverVersion = [System.Text.Encoding]::Unicode.GetString($edgedriverResponse.RawContentStream.ToArray()).Trim([char]0xFEFF).Trim()
$edgedriverDownloadUrl = "https://msedgedriver.microsoft.com/$edgedriverVersion/edgedriver_win64.zip"
$edgedriverZipPath = Join-Path -Path $tempPath -ChildPath "edgedriver.zip"
Invoke-WebRequest -Uri $edgedriverDownloadUrl -OutFile $edgedriverZipPath
Expand-Archive -Path $edgedriverZipPath -DestinationPath $tempPath -Force
$extractedEdgeExe = Get-ChildItem -Path $tempPath -Filter "msedgedriver.exe" -Recurse | Select-Object -First 1
Copy-Item -Path $extractedEdgeExe.FullName -Destination "$ghostsPath\msedgedriver.exe" -Force
Write-Host "Msedgedriver version $(& "$ghostsPath\msedgedriver.exe" --version) installed to GHOSTS folder"
} else {
Write-Host "Edge is not installed. Skipping msedgedriver installation."
} |
|
I believe it depends on how your methodology for deploying hosts works. In our methodology, we don't deploy a host template that has applications preinstalled. Instead, we deploy a base OS template, then deploy all applications like Chrome/Chrome driver, Firefox/Geckodriver, Edge/Edge driver, Ghosts. The versions of each are dependent upon the OS template being modified. We tend to validate a particular version for an OS (like a particular Chrome/Chrome driver) and try to use that same version on different OS flavors (Rocky 8, Rocky 9) if it works just to try to reduce the number of versions we have to keep track of. Our deployments do not have external internet access, so we don't have to worry about the OS auto-upgrading a Chrome version and getting out of date with the deployed Chrome driver. I am currently vetting Ghosts 9 on Ubuntu 26, and right now fighting the battle of finding the right Chrome/Chromedriver to install during deployment. Fortunately the same Ghosts 9 binary once built by dotnet10 works across a variety of different Linux OSes (Rocky 8/9, Ubuntu 20/22/24/26, Oracle 9, Kali Linux) but the browser apps for those OSes tend to be different versions. For Ghosts Classic Windows, it is the same story. The same Ghosts binary works for Win10/11 (and different release versions of those major versions), but the Edge/Edge driver we deploy on those are vetted for each version. (currently, we use the same Edge/Edge driver for both Win10/11, just picked one of the latest versions). We only deploy Edge on Windows hosts, even though it can work on Linux as well. |
Problem
The Windows client has a
BrowserEdgehandler, but Edge cannot actually be driven. Three things are wrong, and all three have to be fixed for the handler to work:No driver is packaged.
chromedriver.exeandgeckodriver.exereach the output directory becauseSelenium.WebDriver.ChromeDriverandSelenium.WebDriver.GeckoDrivereach carry a build target that copies the driver into$(TargetDir). There is no equivalent for Edge, somsedgedriver.exeis never produced by the build.GetEdgeDriverthrows away the service it just built.Handlers/BrowserEdge.csbuilds anEdgeDriverServicerooted at the application base directory and setsHideCommandPromptWindowon it, then callsnew EdgeDriver(options)— which discards that service and constructs its own. Both the base-directory pin and the hidden console window are inert.BrowserChromedoes this correctly (new ChromeDriver(service, options)).The practical consequence: the discarded service leaves
DriverServicePathunset, so Selenium falls through toDriverFinder/Selenium Manager, which resolves the driver by downloading it. On a host with no outbound network access this fails even whenmsedgedriver.exeis sitting next toghosts.exe. (Ghosts.Client.Universal'sBrowserFirefoxhas a comment describing exactly this reasoning for pinning the service.)The Edge process names are file descriptions, not process names.
ProcessNames.MSEdgeis"Microsoft Edge"andProcessNames.MSEdgeDriveris"Microsoft Edge WebDriver (32 bit)". Both are handed toKillProcessAndChildrenByName, which callsProcess.GetProcessesByName, so neither ever matches andBrowserEdge's cleanup silently reaps nothing.msedge.exeandmsedgedriver.exeaccumulate for the life of the client. CompareChrome => "chrome"/ChromeDriver => "chromedriver".Change
Three commits, one concern each, all confined to
Ghosts.Client.Windows:build(client-windows)— addSelenium.WebDriver.MSEdgeDrivertoGhosts.Client.csproj. It is the same shape as the ChromeDriver/GeckoDriver packages already in use (itsCopyMsEdgeDriverToBintarget runsBeforeTargets="AfterBuild"and copiesdriver/win32/msedgedriver.exeinto$(TargetDir)). Pinned explicitly, as the other two are.WebDriverPlatformis deliberately left unset. All three driver packages share that property and resolve it towin32on Windows; the ChromeDriver package ships nowin64directory, so forcing it would break the existing chromedriver copy.fix(client-windows)— pass the already-constructed service:new EdgeDriver(service, options). Selenium then loads the driver from the base directory and never invokes Selenium Manager. This also makes the existingHideCommandPromptWindow = truetake effect.fix(client-windows)—MSEdge => "msedge",MSEdgeDriver => "msedgedriver".Nothing outside the Edge path is touched, and no adjacent code is refactored.
Testing
Not runtime-tested. I don't have a Windows environment to build and exercise the .NET Framework client, so this is verified by inspection plus direct inspection of the NuGet package, not by running GHOSTS.
What I did confirm:
Selenium.WebDriver.MSEdgeDriver149.0.4022.98containsdriver/win32/msedgedriver.exe(PE32, i386 — consistent with the(32 bit)name the oldProcessNamesstring used),driver/win64/msedgedriver.exe, andbuild/Selenium.WebDriver.MSEdgeDriver.targets.CopyMsEdgeDriverToBinwithBeforeTargets="AfterBuild"andCondition="'$(PublishMsEdgeDriver)' == 'false'", wherePublishMsEdgeDriverdefaults tofalse— i.e. a plainmsbuildbuild copies the driver to$(TargetDir)without any extra property.DefinePropertiesMSEdgeDriver.targetsresolvesWebDriverPlatformtowin32when$(OS) == 'Windows_NT'.A maintainer with a Windows build should confirm:
msedgedriver.exeappears inbin\x86\Release\andbin\x64\Release\, andchromedriver.exe/geckodriver.exeare still present (regression check on the sharedWebDriverPlatformproperty)."HandlerType": "BrowserEdge"starts Edge, with no console window flash.msedgedriver.exenext toghosts.exe, Edge still starts andselenium-manager.exenever spawns.msedge.exe/msedgedriver.exeremain.Note
Ghosts.Client.Universal'sBrowserEdgehas the same "no service" shape (new EdgeDriver(options)) and its csproj carries no Edge driver package either. I've left it alone to keep this PR to one concern, but it likely needs the same treatment.🤖 Generated with Claude Code