From 46c7906bb7a533a1d2e85ac79212c707f33f9f8f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:12:55 -0400 Subject: [PATCH 1/2] test: add Pester coverage for Windows scripts Add focused Pester coverage for driver install, uninstall, signing, PnP validation, and browser gamepad helpers. Run the suite in a reusable Windows workflow and include its Cobertura and JUnit artifacts in the existing Codecov results matrix. Allow executable scripts to be dot-sourced without performing side effects. --- .github/workflows/ci-powershell.yml | 50 +++ .github/workflows/ci-results.yml | 3 + .github/workflows/ci.yml | 11 +- scripts/windows/install-driver.ps1 | 4 + scripts/windows/sign-driver-package.ps1 | 4 + scripts/windows/test-browser-gamepad.ps1 | 4 + scripts/windows/test-installed-driver.ps1 | 4 + scripts/windows/uninstall-driver.ps1 | 4 + tests/scripts/install-driver.Tests.ps1 | 363 ++++++++++++++++++ .../libvirtualhid-driver-common.Tests.ps1 | 170 ++++++++ tests/scripts/sign-driver-package.Tests.ps1 | 125 ++++++ tests/scripts/test-browser-gamepad.Tests.ps1 | 165 ++++++++ tests/scripts/test-installed-driver.Tests.ps1 | 322 ++++++++++++++++ tests/scripts/uninstall-driver.Tests.ps1 | 284 ++++++++++++++ 14 files changed, 1512 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci-powershell.yml create mode 100644 tests/scripts/install-driver.Tests.ps1 create mode 100644 tests/scripts/libvirtualhid-driver-common.Tests.ps1 create mode 100644 tests/scripts/sign-driver-package.Tests.ps1 create mode 100644 tests/scripts/test-browser-gamepad.Tests.ps1 create mode 100644 tests/scripts/test-installed-driver.Tests.ps1 create mode 100644 tests/scripts/uninstall-driver.Tests.ps1 diff --git a/.github/workflows/ci-powershell.yml b/.github/workflows/ci-powershell.yml new file mode 100644 index 0000000..1d8ea71 --- /dev/null +++ b/.github/workflows/ci-powershell.yml @@ -0,0 +1,50 @@ +--- +name: CI-PowerShell (called) +permissions: {} + +on: + workflow_call: + +jobs: + test: + name: Pester + permissions: + contents: read + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run Pester tests + id: test + shell: powershell + run: | + New-Item -ItemType Directory -Path ./build/reports -Force | Out-Null + + Import-Module Pester -RequiredVersion 5.9.0 -Force + $config = New-PesterConfiguration + $config.Run.Path = "./tests/scripts" + $config.Run.Exit = $true + $config.Output.Verbosity = "Detailed" + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = "JUnitXml" + $config.TestResult.OutputPath = "./build/reports/junit.xml" + $config.CodeCoverage.Enabled = $true + $config.CodeCoverage.Path = @( + Get-ChildItem -Path "./scripts/windows/*.ps1" | + Select-Object -ExpandProperty FullName + ) + $config.CodeCoverage.OutputFormat = "Cobertura" + $config.CodeCoverage.OutputPath = "./build/reports/coverage.xml" + + Invoke-Pester -Configuration $config + + - name: Upload report artifact + if: >- + always() && + (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: reports-PowerShell + path: build/reports + if-no-files-found: error diff --git a/.github/workflows/ci-results.yml b/.github/workflows/ci-results.yml index 30c3379..ba6f371 100644 --- a/.github/workflows/ci-results.yml +++ b/.github/workflows/ci-results.yml @@ -37,6 +37,9 @@ jobs: - build_name: Windows-MSVC flag: Windows-MSVC has_coverage: true + - build_name: PowerShell + flag: PowerShell + has_coverage: true steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7189f5..ff3e716 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,13 +75,22 @@ jobs: release_commit: ${{ needs.setup_release.outputs.release_commit }} release_version: ${{ needs.setup_release.outputs.release_version }} + powershell: + name: PowerShell + permissions: + contents: read + uses: ./.github/workflows/ci-powershell.yml + results: name: Coverage and Test Results if: >- always() && (needs.build.result == 'success' || needs.build.result == 'failure') && + (needs.powershell.result == 'success' || needs.powershell.result == 'failure') && startsWith(github.repository, 'LizardByte/') - needs: build + needs: + - build + - powershell permissions: contents: read uses: ./.github/workflows/ci-results.yml diff --git a/scripts/windows/install-driver.ps1 b/scripts/windows/install-driver.ps1 index 43ed7af..6288319 100644 --- a/scripts/windows/install-driver.ps1 +++ b/scripts/windows/install-driver.ps1 @@ -323,6 +323,10 @@ function Install-RootDeviceWithSetupApi { Invoke-CheckedCommand -FilePath $SetupHelperPath -Arguments @("install", $Path, $TargetHardwareId) } +if ($MyInvocation.InvocationName -eq ".") { + return +} + Start-LibVirtualHidTranscript -Path $LogPath try { diff --git a/scripts/windows/sign-driver-package.ps1 b/scripts/windows/sign-driver-package.ps1 index 570f45c..d10bbb5 100644 --- a/scripts/windows/sign-driver-package.ps1 +++ b/scripts/windows/sign-driver-package.ps1 @@ -60,6 +60,10 @@ function Invoke-CheckedCommand { } } +if ($MyInvocation.InvocationName -eq ".") { + return +} + $resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path $catalogPath = Join-Path $resolvedPackagePath $CatalogName if (-not (Test-Path -LiteralPath $catalogPath)) { diff --git a/scripts/windows/test-browser-gamepad.ps1 b/scripts/windows/test-browser-gamepad.ps1 index be22b7f..c8e02ec 100644 --- a/scripts/windows/test-browser-gamepad.ps1 +++ b/scripts/windows/test-browser-gamepad.ps1 @@ -348,6 +348,10 @@ function Get-GamepadApiProbeExpression { "@ } +if ($MyInvocation.InvocationName -eq ".") { + return +} + if ($HoldSeconds -le $TimeoutSeconds) { throw "-HoldSeconds must be greater than -TimeoutSeconds so the adapter remains alive for browser polling." } diff --git a/scripts/windows/test-installed-driver.ps1 b/scripts/windows/test-installed-driver.ps1 index 190421f..4ab1a7b 100644 --- a/scripts/windows/test-installed-driver.ps1 +++ b/scripts/windows/test-installed-driver.ps1 @@ -339,6 +339,10 @@ function Invoke-GamepadAdapterSmoke { } } +if ($MyInvocation.InvocationName -eq ".") { + return +} + Assert-RootDeviceStarted -TargetHardwareId $HardwareId Assert-ControlDeviceOpen -Path $ControlDevicePath Invoke-GamepadAdapterSmoke ` diff --git a/scripts/windows/uninstall-driver.ps1 b/scripts/windows/uninstall-driver.ps1 index 3077002..c7b6820 100644 --- a/scripts/windows/uninstall-driver.ps1 +++ b/scripts/windows/uninstall-driver.ps1 @@ -201,6 +201,10 @@ function Remove-DriverCertificate { } } +if ($MyInvocation.InvocationName -eq ".") { + return +} + Start-LibVirtualHidTranscript -Path $LogPath try { diff --git a/tests/scripts/install-driver.Tests.ps1 b/tests/scripts/install-driver.Tests.ps1 new file mode 100644 index 0000000..e3afd24 --- /dev/null +++ b/tests/scripts/install-driver.Tests.ps1 @@ -0,0 +1,363 @@ +BeforeAll { + $sourcePath = Join-Path $PSScriptRoot "..\..\scripts\windows\install-driver.ps1" + . $sourcePath -InfPath "unused.inf" + + function global:Invoke-LibVirtualHidTestCommand { + param([Parameter(ValueFromRemainingArguments)] $Arguments) + + $null = $Arguments + $global:LASTEXITCODE = $global:LibVirtualHidTestExitCode + } +} + +AfterAll { + Remove-Item -LiteralPath Function:\Invoke-LibVirtualHidTestCommand -ErrorAction SilentlyContinue + Remove-Variable -Name LibVirtualHidTestExitCode -Scope Global -ErrorAction SilentlyContinue +} + +Describe "Invoke-CheckedCommand" { + It "accepts a configured success exit code" { + $global:LibVirtualHidTestExitCode = 5 + + { + Invoke-CheckedCommand ` + -FilePath "Invoke-LibVirtualHidTestCommand" ` + -Arguments @("one", "two") ` + -SuccessExitCodes @(0, 5) + } | Should -Not -Throw + } + + It "throws for an unexpected exit code" { + $global:LibVirtualHidTestExitCode = 12 + + { + Invoke-CheckedCommand ` + -FilePath "Invoke-LibVirtualHidTestCommand" ` + -Arguments @("one") + } | Should -Throw "*exited with code 12*" + } +} + +Describe "install-driver.ps1 entry point" { + It "executes normal invocation and validates the INF path" { + { + & $sourcePath -InfPath (Join-Path $TestDrive "missing.inf") -StageOnly + } | Should -Throw "*Cannot find path*" + } + + It "runs the complete orchestration path safely with WhatIf" { + $infPath = Join-Path $TestDrive "libvirtualhid.inf" + $setupPath = Join-Path $TestDrive "libvirtualhid_driver_setup.exe" + New-Item -ItemType File -Path $infPath, $setupPath | Out-Null + function pnputil.exe { + $global:LASTEXITCODE = 0 + @() + } + Mock Get-CimInstance { @() } + Mock Get-ChildItem { @() } + + try { + { + & $sourcePath ` + -InfPath $infPath ` + -SetupPath $setupPath ` + -HardwareId "ROOT\LIBVIRTUALHID_PESTER" ` + -WhatIf + } | Should -Not -Throw + } finally { + Remove-Item -LiteralPath Function:\pnputil.exe + } + } +} + +Describe "Driver package path helpers" { + It "resolves an explicitly supplied broker" { + $path = Join-Path $TestDrive "libvirtualhid_broker.exe" + New-Item -ItemType File -Path $path | Out-Null + + Resolve-LibVirtualHidBrokerPath -Path $path | Should -Be (Resolve-Path $path).Path + } + + It "rejects a missing explicitly supplied broker" { + { + Resolve-LibVirtualHidBrokerPath -Path (Join-Path $TestDrive "missing.exe") + } | Should -Throw "The broker executable was not found*" + } + + It "returns null when no packaged broker exists" { + Resolve-LibVirtualHidBrokerPath | Should -BeNullOrEmpty + } + + It "resolves an explicitly supplied setup helper" { + $path = Join-Path $TestDrive "libvirtualhid_driver_setup.exe" + New-Item -ItemType File -Path $path | Out-Null + + Resolve-LibVirtualHidDriverSetupPath -Path $path | Should -Be (Resolve-Path $path).Path + } + + It "rejects a missing explicitly supplied setup helper" { + { + Resolve-LibVirtualHidDriverSetupPath -Path (Join-Path $TestDrive "missing.exe") + } | Should -Throw "The driver setup helper was not found*" + } +} + +Describe "Service path quoting" { + It "quotes a broker path" { + Get-LibVirtualHidQuotedServiceBinaryPath -Path "C:\Program Files\libvirtualhid\broker.exe" | + Should -Be '"C:\Program Files\libvirtualhid\broker.exe"' + } + + It "rejects quotation marks in a broker path" { + { + Get-LibVirtualHidQuotedServiceBinaryPath -Path 'C:\bad"path\broker.exe' + } | Should -Throw "*must not contain quotation marks*" + } + + It "formats the service path for Windows PowerShell native argument parsing" { + Get-LibVirtualHidScBinaryPathArgument -Path "C:\Program Files\libvirtualhid\broker.exe" | + Should -Be '"""C:\Program Files\libvirtualhid\broker.exe"""' + } + + It "accepts an exactly quoted service ImagePath" { + Mock Get-ItemProperty { + [pscustomobject]@{ ImagePath = '"C:\Program Files\libvirtualhid\broker.exe"' } + } + + { + Assert-LibVirtualHidBrokerServiceImagePath ` + -Name "libvirtualhid_broker" ` + -Path "C:\Program Files\libvirtualhid\broker.exe" + } | Should -Not -Throw + } + + It "rejects an unquoted service ImagePath" { + Mock Get-ItemProperty { + [pscustomobject]@{ ImagePath = "C:\Program Files\libvirtualhid\broker.exe" } + } + + { + Assert-LibVirtualHidBrokerServiceImagePath ` + -Name "libvirtualhid_broker" ` + -Path "C:\Program Files\libvirtualhid\broker.exe" + } | Should -Throw "*is not safely quoted*" + } +} + +Describe "Broker service helpers" { + BeforeEach { + Mock Remove-ItemProperty {} + Mock Stop-Service {} + } + + It "does not clear a missing service registry key" { + Mock Test-Path { $false } + + Clear-LibVirtualHidBrokerServiceEnvironment -Name "libvirtualhid_broker" + + Should -Invoke Remove-ItemProperty -Times 0 -Exactly -Scope It + } + + It "clears a legacy service environment" { + Mock Test-Path { $true } + + Clear-LibVirtualHidBrokerServiceEnvironment -Name "libvirtualhid_broker" -Confirm:$false + + Should -Invoke Remove-ItemProperty -Times 1 -Exactly -Scope It -ParameterFilter { + $LiteralPath -eq "HKLM:\SYSTEM\CurrentControlSet\Services\libvirtualhid_broker" -and + $Name -eq "Environment" + } + } + + It "does nothing when the broker service is absent" { + Mock Get-Service { $null } + + Stop-LibVirtualHidBrokerService -Name "libvirtualhid_broker" + + Should -Invoke Stop-Service -Times 0 -Exactly -Scope It + } + + It "stops a running broker service" { + $service = [pscustomobject]@{ + Status = "Running" + Waited = $false + } + $service | Add-Member -MemberType ScriptMethod -Name WaitForStatus -Value { + param($Status, $Timeout) + $null = $Status, $Timeout + $this.Waited = $true + } + Mock Get-Service { $service } + + Stop-LibVirtualHidBrokerService -Name "libvirtualhid_broker" -Confirm:$false + + $service.Waited | Should -BeTrue + Should -Invoke Stop-Service -Times 1 -Exactly -Scope It -ParameterFilter { + $Name -eq "libvirtualhid_broker" -and $Force + } + } + + It "skips service registration when no broker executable exists" { + Mock Write-Verbose {} + + Install-LibVirtualHidBrokerService + + Should -Invoke Write-Verbose -Times 1 -Exactly -Scope It -ParameterFilter { + $Message -like "No libvirtualhid broker executable was found*" + } + } + + It "registers and configures a new broker service" { + $path = Join-Path $TestDrive "libvirtualhid_broker.exe" + New-Item -ItemType File -Path $path | Out-Null + $resolvedPath = (Resolve-Path $path).Path + Mock Get-Service { $null } + Mock New-Service {} + Mock Assert-LibVirtualHidBrokerServiceImagePath {} + Mock Clear-LibVirtualHidBrokerServiceEnvironment {} + Mock Invoke-CheckedCommand {} + Mock Start-Service {} + + Install-LibVirtualHidBrokerService -Path $path -Confirm:$false + + Should -Invoke New-Service -Times 1 -Exactly -Scope It -ParameterFilter { + $Name -eq "libvirtualhid_broker" -and + $BinaryPathName -eq ('"' + $resolvedPath + '"') -and + $StartupType -eq "Automatic" + } + Should -Invoke Assert-LibVirtualHidBrokerServiceImagePath -Times 1 -Exactly -Scope It + Should -Invoke Clear-LibVirtualHidBrokerServiceEnvironment -Times 1 -Exactly -Scope It + Should -Invoke Invoke-CheckedCommand -Times 2 -Exactly -Scope It + Should -Invoke Start-Service -Times 1 -Exactly -Scope It + } + + It "updates and configures an existing broker service" { + $path = Join-Path $TestDrive "existing-libvirtualhid_broker.exe" + New-Item -ItemType File -Path $path | Out-Null + Mock Get-Service { [pscustomobject]@{ Status = "Stopped" } } + Mock Stop-LibVirtualHidBrokerService {} + Mock Assert-LibVirtualHidBrokerServiceImagePath {} + Mock Clear-LibVirtualHidBrokerServiceEnvironment {} + Mock Invoke-CheckedCommand {} + Mock Start-Service {} + + Install-LibVirtualHidBrokerService -Path $path -Confirm:$false + + Should -Invoke Stop-LibVirtualHidBrokerService -Times 1 -Exactly -Scope It + Should -Invoke Invoke-CheckedCommand -Times 3 -Exactly -Scope It + Should -Invoke Invoke-CheckedCommand -Times 1 -Exactly -Scope It -ParameterFilter { + $FilePath -eq "sc.exe" -and $Arguments[0] -eq "config" + } + Should -Invoke Start-Service -Times 1 -Exactly -Scope It + } +} + +Describe "Driver installation helpers" { + It "imports an existing certificate into both required stores" { + $path = Join-Path $TestDrive "driver.cer" + New-Item -ItemType File -Path $path | Out-Null + Mock Import-Certificate {} + + Import-DriverCertificate -Path $path -Confirm:$false + + Should -Invoke Import-Certificate -Times 2 -Exactly -Scope It -ParameterFilter { + $FilePath -eq (Resolve-Path $path).Path -and + $CertStoreLocation -in @("Cert:\LocalMachine\Root", "Cert:\LocalMachine\TrustedPublisher") + } + } + + It "ignores a missing certificate" { + Mock Import-Certificate {} + + Import-DriverCertificate -Path (Join-Path $TestDrive "missing.cer") + + Should -Invoke Import-Certificate -Times 0 -Exactly -Scope It + } + + It "removes a stale device with pnputil" { + Mock Invoke-CheckedCommand {} + + Remove-DeviceInstance -InstanceId "ROOT\LIBVIRTUALHID\0000" -Confirm:$false + + Should -Invoke Invoke-CheckedCommand -Times 1 -Exactly -Scope It -ParameterFilter { + $FilePath -eq "pnputil.exe" -and + $Arguments[0] -eq "/remove-device" -and + $Arguments[1] -eq "ROOT\LIBVIRTUALHID\0000" + } + } + + It "sets VhfMode on an existing root device" { + Mock Test-Path { $true } + Mock New-ItemProperty {} + + Set-RootDeviceVhfMode -InstanceId "ROOT\LIBVIRTUALHID\0000" -Confirm:$false + + Should -Invoke New-ItemProperty -Times 1 -Exactly -Scope It -ParameterFilter { + $Name -eq "VhfMode" -and $Value -eq 1 -and $PropertyType -eq "DWord" + } + } + + It "skips VhfMode when the registry device is absent" { + Mock Test-Path { $false } + Mock New-ItemProperty {} + Mock Write-Verbose {} + + Set-RootDeviceVhfMode -InstanceId "ROOT\LIBVIRTUALHID\0000" + + Should -Invoke New-ItemProperty -Times 0 -Exactly -Scope It + Should -Invoke Write-Verbose -Times 1 -Exactly -Scope It + } + + It "warns when restarting the device requires a reboot" { + Mock pnputil.exe { + $global:LASTEXITCODE = 0 + "A reboot is needed to complete the operation." + } + Mock Write-Warning {} + + Restart-RootDevice -InstanceId "ROOT\LIBVIRTUALHID\0000" -Confirm:$false + + Should -Invoke Write-Warning -Times 1 -Exactly -Scope It -ParameterFilter { + $Message -like "Windows reported that a reboot is required*" + } + } + + It "throws when restarting the device fails" { + Mock pnputil.exe { $global:LASTEXITCODE = 7 } + + { + Restart-RootDevice -InstanceId "ROOT\LIBVIRTUALHID\0000" -Confirm:$false + } | Should -Throw "*exited with code 7*" + } + + It "updates a driver and permits SetupAPI reboot status" { + Mock Invoke-CheckedCommand { $global:LASTEXITCODE = 3010 } + Mock Write-Warning {} + + Update-RootDeviceDriverWithSetupApi ` + -Path "driver.inf" ` + -TargetHardwareId "ROOT\LIBVIRTUALHID" ` + -SetupHelperPath "setup.exe" ` + -Confirm:$false + + Should -Invoke Invoke-CheckedCommand -Times 1 -Exactly -Scope It -ParameterFilter { + $FilePath -eq "setup.exe" -and + $Arguments[0] -eq "update" -and + $SuccessExitCodes -contains 3010 + } + Should -Invoke Write-Warning -Times 1 -Exactly -Scope It + } + + It "installs a root device through the SetupAPI helper" { + Mock Invoke-CheckedCommand {} + + Install-RootDeviceWithSetupApi ` + -Path "driver.inf" ` + -TargetHardwareId "ROOT\LIBVIRTUALHID" ` + -SetupHelperPath "setup.exe" + + Should -Invoke Invoke-CheckedCommand -Times 1 -Exactly -Scope It -ParameterFilter { + $FilePath -eq "setup.exe" -and $Arguments -join "," -eq "install,driver.inf,ROOT\LIBVIRTUALHID" + } + } +} diff --git a/tests/scripts/libvirtualhid-driver-common.Tests.ps1 b/tests/scripts/libvirtualhid-driver-common.Tests.ps1 new file mode 100644 index 0000000..4f8d965 --- /dev/null +++ b/tests/scripts/libvirtualhid-driver-common.Tests.ps1 @@ -0,0 +1,170 @@ +BeforeAll { + $sourcePath = Join-Path $PSScriptRoot "..\..\scripts\windows\libvirtualhid-driver-common.ps1" + . $sourcePath +} + +Describe "Transcript helpers" { + BeforeEach { + $script:LibVirtualHidTranscriptStarted = $false + Mock New-Item {} + Mock Start-Transcript {} + Mock Stop-Transcript {} + Mock Write-Warning {} + } + + It "does nothing when no transcript path is supplied" { + Start-LibVirtualHidTranscript + + Should -Invoke Start-Transcript -Times 0 -Exactly -Scope It + } + + It "creates the log directory and starts and stops a transcript" { + Start-LibVirtualHidTranscript -Path "C:\logs\driver.log" + Stop-LibVirtualHidTranscript + + $script:LibVirtualHidTranscriptStarted | Should -BeTrue + Should -Invoke New-Item -Times 1 -Exactly -Scope It -ParameterFilter { + $ItemType -eq "Directory" -and $Path -eq "C:\logs" + } + Should -Invoke Start-Transcript -Times 1 -Exactly -Scope It -ParameterFilter { + $Path -eq "C:\logs\driver.log" -and $Append + } + Should -Invoke Stop-Transcript -Times 1 -Exactly -Scope It + } + + It "does not stop a transcript that was never started" { + Stop-LibVirtualHidTranscript + + Should -Invoke Stop-Transcript -Times 0 -Exactly -Scope It + } + + It "warns when starting the transcript fails" { + Mock Start-Transcript { throw "start failed" } + + Start-LibVirtualHidTranscript -Path "driver.log" + + Should -Invoke Write-Warning -Times 1 -Exactly -Scope It -ParameterFilter { + $Message -eq "Unable to start libvirtualhid driver transcript: start failed" + } + } + + It "warns when stopping the transcript fails" { + $script:LibVirtualHidTranscriptStarted = $true + Mock Stop-Transcript { throw "stop failed" } + + Stop-LibVirtualHidTranscript + + Should -Invoke Write-Warning -Times 1 -Exactly -Scope It -ParameterFilter { + $Message -eq "Unable to stop libvirtualhid driver transcript: stop failed" + } + } +} + +Describe "Get-LibVirtualHidRootDeviceInstanceId" { + BeforeEach { + Mock Write-Verbose {} + } + + It "returns instance identifiers reported by pnputil" { + Mock pnputil.exe { + $global:LASTEXITCODE = 0 + @( + "Instance ID: ROOT\LIBVIRTUALHID\0000", + " Instance ID : ROOT\LIBVIRTUALHID\0001 " + ) + } + Mock Get-CimInstance { throw "CIM should not be used" } + + $result = @(Get-LibVirtualHidRootDeviceInstanceId -TargetHardwareId "ROOT\LIBVIRTUALHID") + + $result | Should -Be @("ROOT\LIBVIRTUALHID\0000", "ROOT\LIBVIRTUALHID\0001") + Should -Invoke Get-CimInstance -Times 0 -Exactly -Scope It + } + + It "falls back to matching CIM devices" { + Mock pnputil.exe { $global:LASTEXITCODE = 1 } + Mock Get-CimInstance { + @( + [pscustomobject]@{ + PNPDeviceID = "ROOT\LIBVIRTUALHID\0000" + HardwareID = @("ROOT\LIBVIRTUALHID") + }, + [pscustomobject]@{ + PNPDeviceID = "ROOT\OTHER\0000" + HardwareID = @("ROOT\OTHER") + }, + [pscustomobject]@{ + PNPDeviceID = "CUSTOM\INSTANCE" + HardwareID = @("ROOT\LIBVIRTUALHID") + } + ) + } + + $result = @(Get-LibVirtualHidRootDeviceInstanceId -TargetHardwareId "ROOT\LIBVIRTUALHID") + + $result | Should -Be @("ROOT\LIBVIRTUALHID\0000", "CUSTOM\INSTANCE") + } + + It "returns an empty collection when both enumerators fail" { + Mock pnputil.exe { throw "pnputil failed" } + Mock Get-CimInstance { throw "CIM failed" } + + @(Get-LibVirtualHidRootDeviceInstanceId -TargetHardwareId "ROOT\LIBVIRTUALHID").Count | + Should -Be 0 + Should -Invoke Write-Verbose -Times 2 -Exactly -Scope It + } +} + +Describe "Get-LibVirtualHidRegistryRootDevice" { + BeforeEach { + Mock Get-ChildItem { + if ($LiteralPath -eq "HKLM:\SYSTEM\CurrentControlSet\Enum\ROOT") { + return [pscustomobject]@{ + PSChildName = "LIBVIRTUALHID" + PSPath = "root-key" + } + } + if ($LiteralPath -eq "root-key") { + return [pscustomobject]@{ + PSChildName = "0000" + PSPath = "instance-key" + } + } + } + Mock Write-Verbose {} + } + + It "reports exact hardware IDs and legacy HID class state" { + Mock Get-ItemProperty { + if ($Name -eq "HardwareID") { + return [pscustomobject]@{ HardwareID = @("ROOT\LIBVIRTUALHID") } + } + return [pscustomobject]@{ ClassGUID = "{745A17A0-74D3-11D0-B6FE-00A0C90F57DA}" } + } + + $result = @(Get-LibVirtualHidRegistryRootDevice -TargetHardwareId "ROOT\LIBVIRTUALHID") + + $result.Count | Should -Be 1 + $result[0].InstanceId | Should -Be "ROOT\LIBVIRTUALHID\0000" + $result[0].HasExactHardwareId | Should -BeTrue + $result[0].HasCorruptHardwareId | Should -BeFalse + $result[0].HasLegacyHidClass | Should -BeTrue + } + + It "recognizes a hardware ID split across corrupt registry values" { + Mock Get-ItemProperty { + if ($Name -eq "HardwareID") { + return [pscustomobject]@{ HardwareID = @("ROOT\LIBVIRTUAL", "HID") } + } + throw "properties unavailable" + } + + $result = @(Get-LibVirtualHidRegistryRootDevice -TargetHardwareId "ROOT\LIBVIRTUALHID") + + $result.Count | Should -Be 1 + $result[0].HasExactHardwareId | Should -BeFalse + $result[0].HasCorruptHardwareId | Should -BeTrue + $result[0].HasLegacyHidClass | Should -BeFalse + Should -Invoke Write-Verbose -Times 1 -Exactly -Scope It + } +} diff --git a/tests/scripts/sign-driver-package.Tests.ps1 b/tests/scripts/sign-driver-package.Tests.ps1 new file mode 100644 index 0000000..6fa5b87 --- /dev/null +++ b/tests/scripts/sign-driver-package.Tests.ps1 @@ -0,0 +1,125 @@ +BeforeAll { + $sourcePath = Join-Path $PSScriptRoot "..\..\scripts\windows\sign-driver-package.ps1" + . $sourcePath -PackagePath "." + + function global:Invoke-LibVirtualHidSignTestCommand { + param([Parameter(ValueFromRemainingArguments)] $Arguments) + + $null = $Arguments + $global:LASTEXITCODE = $global:LibVirtualHidSignTestExitCode + } +} + +AfterAll { + Remove-Item -LiteralPath Function:\Invoke-LibVirtualHidSignTestCommand -ErrorAction SilentlyContinue + Remove-Variable -Name LibVirtualHidSignTestExitCode -Scope Global -ErrorAction SilentlyContinue +} + +Describe "Find-SignTool" { + It "uses signtool from PATH when available" { + Mock Get-Command { + [pscustomobject]@{ Source = "C:\tools\signtool.exe" } + } + + Find-SignTool | Should -Be "C:\tools\signtool.exe" + } + + It "finds the newest x64 SDK signtool" { + Mock Get-Command { $null } + Mock Test-Path { $true } + Mock Get-ChildItem { + @( + [pscustomobject]@{ FullName = "C:\sdk\10.0.1\x64\signtool.exe" }, + [pscustomobject]@{ FullName = "C:\sdk\10.0.2\x86\signtool.exe" }, + [pscustomobject]@{ FullName = "C:\sdk\10.0.3\x64\signtool.exe" } + ) + } + $previousWindowsSdkDir = $env:WindowsSdkDir + try { + $env:WindowsSdkDir = "C:\sdk" + + Find-SignTool | Should -Be "C:\sdk\10.0.3\x64\signtool.exe" + } finally { + $env:WindowsSdkDir = $previousWindowsSdkDir + } + } + + It "throws when no signtool is installed" { + Mock Get-Command { $null } + Mock Test-Path { $false } + $previousWindowsSdkDir = $env:WindowsSdkDir + $previousWdkContentRoot = $env:WDKContentRoot + try { + $env:WindowsSdkDir = $null + $env:WDKContentRoot = $null + + { Find-SignTool } | Should -Throw "signtool.exe was not found*" + } finally { + $env:WindowsSdkDir = $previousWindowsSdkDir + $env:WDKContentRoot = $previousWdkContentRoot + } + } +} + +Describe "sign-driver-package.ps1 entry point" { + It "executes normal invocation and requires the driver catalog" { + { + & $sourcePath -PackagePath $TestDrive + } | Should -Throw "Driver catalog was not found:*" + } + + It "creates, exports, uses, and removes a temporary signing certificate" { + $packagePath = Join-Path $TestDrive "driver" + $certificatePath = Join-Path $TestDrive "certificates\driver.cer" + New-Item -ItemType Directory -Path $packagePath | Out-Null + New-Item -ItemType File -Path (Join-Path $packagePath "libvirtualhid.cat") | Out-Null + Mock New-SelfSignedCertificate { + [pscustomobject]@{ Thumbprint = "ABC123" } + } + Mock Export-Certificate {} -RemoveParameterType Cert + Mock Get-Command { + [pscustomobject]@{ Source = "Invoke-LibVirtualHidSignTestCommand" } + } + Mock Remove-Item {} + $global:LibVirtualHidSignTestExitCode = 0 + + & $sourcePath ` + -PackagePath $packagePath ` + -CertificatePath $certificatePath ` + -ValidDays 3 + + Should -Invoke New-SelfSignedCertificate -Times 1 -Exactly -Scope It -ParameterFilter { + $Subject -eq "CN=libvirtualhid CI Test Driver Signing" -and + $Type -eq "CodeSigningCert" -and + $KeyLength -eq 3072 + } + Should -Invoke Export-Certificate -Times 1 -Exactly -Scope It -ParameterFilter { + $Cert.Thumbprint -eq "ABC123" -and $FilePath -eq $certificatePath + } + Should -Invoke Remove-Item -Times 1 -Exactly -Scope It -ParameterFilter { + $LiteralPath -eq "Cert:\CurrentUser\My\ABC123" + } + } +} + +Describe "Invoke-CheckedCommand" { + It "returns after a successful signing command" { + $global:LibVirtualHidSignTestExitCode = 0 + + { + Invoke-CheckedCommand ` + -FilePath "Invoke-LibVirtualHidSignTestCommand" ` + -Arguments @("sign", "driver.cat") + } | Should -Not -Throw + } + + It "throws after a failed signing command" { + $global:LibVirtualHidSignTestExitCode = 9 + + { + Invoke-CheckedCommand ` + -FilePath "Invoke-LibVirtualHidSignTestCommand" ` + -Arguments @("sign", "driver.cat") + } | Should -Throw "*exited with code 9*" + } +} diff --git a/tests/scripts/test-browser-gamepad.Tests.ps1 b/tests/scripts/test-browser-gamepad.Tests.ps1 new file mode 100644 index 0000000..9f984f6 --- /dev/null +++ b/tests/scripts/test-browser-gamepad.Tests.ps1 @@ -0,0 +1,165 @@ +BeforeAll { + $sourcePath = Join-Path $PSScriptRoot "..\..\scripts\windows\test-browser-gamepad.ps1" + . $sourcePath -GamepadAdapterPath "unused.exe" +} + +Describe "Get-ExpectedGamepadIdPattern" { + It "returns an identifying expression for " -ForEach @( + @{ profile = "generic"; expected = "1209" } + @{ profile = "x360"; expected = "028e" } + @{ profile = "xone"; expected = "02ea" } + @{ profile = "xseries"; expected = "0b12" } + @{ profile = "ds4"; expected = "05c4" } + @{ profile = "ds5"; expected = "0ce6" } + @{ profile = "switch"; expected = "2009" } + ) { + Get-ExpectedGamepadIdPattern -ProfileName $profile | Should -Match $expected + } + + It "rejects an unsupported profile" { + { Get-ExpectedGamepadIdPattern -ProfileName "unknown" } | + Should -Throw "Unsupported profile: unknown" + } +} + +Describe "test-browser-gamepad.ps1 entry point" { + It "executes normal invocation and validates the polling lifetime" { + { + & $sourcePath ` + -GamepadAdapterPath "unused.exe" ` + -TimeoutSeconds 20 ` + -HoldSeconds 20 + } | Should -Throw "-HoldSeconds must be greater than -TimeoutSeconds*" + } +} + +Describe "Resolve-BrowserPath" { + It "resolves an explicitly supplied browser path" { + $path = Join-Path $TestDrive "browser.exe" + New-Item -ItemType File -Path $path | Out-Null + + Resolve-BrowserPath -Path $path | Should -Be (Resolve-Path $path).Path + } + + It "selects the first installed supported browser" { + Mock Test-Path { $LiteralPath -like "*Microsoft\Edge\Application\msedge.exe" } + + Resolve-BrowserPath | Should -Match "Microsoft\\Edge\\Application\\msedge.exe$" + } + + It "requires an installed supported browser" { + Mock Test-Path { $false } + + { Resolve-BrowserPath } | Should -Throw "No supported browser was found*" + } +} + +Describe "Get-FreeTcpPort" { + It "returns an available ephemeral loopback port" { + Get-FreeTcpPort | Should -BeGreaterThan 0 + } +} + +Describe "Wait-ForDevToolsJson" { + It "returns JSON from the browser endpoint" { + $response = [pscustomobject]@{ Browser = "Edge" } + Mock Invoke-RestMethod { $response } + + Wait-ForDevToolsJson -Port 9222 -Path "/json/version" -TimeoutSeconds 1 | + Should -Be $response + } + + It "times out after repeated endpoint failures" { + Mock Invoke-RestMethod { throw "not ready" } + Mock Start-Sleep {} + + { + Wait-ForDevToolsJson -Port 9222 -Path "/json/version" -TimeoutSeconds 0 + } | Should -Throw "Timed out waiting for browser DevTools endpoint*" + Should -Invoke Start-Sleep -Times 1 -Exactly -Scope It + } +} + +Describe "Wait-ForDevToolsPageTarget" { + It "selects the page matching the expected URL" { + Mock Wait-ForDevToolsJson { + @( + [pscustomobject]@{ + type = "page" + url = "edge://newtab" + webSocketDebuggerUrl = "ws://127.0.0.1/devtools/page/newtab" + }, + [pscustomobject]@{ + type = "page" + url = "https://hardwaretester.com/gamepad?test=1" + webSocketDebuggerUrl = "ws://127.0.0.1/devtools/page/test" + } + ) + } + + $result = Wait-ForDevToolsPageTarget ` + -Port 9222 ` + -ExpectedUrl "https://hardwaretester.com/gamepad" ` + -TimeoutSeconds 1 + + $result.url | Should -Be "https://hardwaretester.com/gamepad?test=1" + $result.webSocketDebuggerUrl | Should -Be "ws://127.0.0.1/devtools/page/test" + } + + It "falls back to the first non-browser-internal page" { + Mock Wait-ForDevToolsJson { + @( + [pscustomobject]@{ + type = "page" + url = "chrome://newtab" + webSocketDebuggerUrl = "ws://127.0.0.1/devtools/page/newtab" + }, + [pscustomobject]@{ + type = "page" + url = "https://example.com/" + webSocketDebuggerUrl = "ws://127.0.0.1/devtools/page/fallback" + } + ) + } + + $result = Wait-ForDevToolsPageTarget ` + -Port 9222 ` + -ExpectedUrl "https://hardwaretester.com/gamepad" ` + -TimeoutSeconds 1 + + $result.url | Should -Be "https://example.com/" + } + + It "times out when no page target becomes available" { + Mock Wait-ForDevToolsJson { @() } + Mock Start-Sleep {} + + { + Wait-ForDevToolsPageTarget ` + -Port 9222 ` + -ExpectedUrl "https://hardwaretester.com/gamepad" ` + -TimeoutSeconds 0 + } | Should -Throw "Timed out waiting for a browser page target." + } +} + +Describe "Get-GamepadApiProbeExpression" { + It "embeds the expected pattern, timeout, and strict matching mode" { + $expression = Get-GamepadApiProbeExpression ` + -ExpectedIdPattern 'xbox "series"' ` + -AllowAnyGamepad $false ` + -TimeoutSeconds 17 + + $expression | Should -Match 'new RegExp\("xbox \\"series\\"", "i"\)' + $expression | Should -Match "const allowAnyGamepad = false;" + $expression | Should -Match "Date.now\(\) \+ \(17 \* 1000\)" + } + + It "enables any-gamepad matching when requested" { + Get-GamepadApiProbeExpression ` + -ExpectedIdPattern "ignored" ` + -AllowAnyGamepad $true ` + -TimeoutSeconds 1 | + Should -Match "const allowAnyGamepad = true;" + } +} diff --git a/tests/scripts/test-installed-driver.Tests.ps1 b/tests/scripts/test-installed-driver.Tests.ps1 new file mode 100644 index 0000000..990b0e0 --- /dev/null +++ b/tests/scripts/test-installed-driver.Tests.ps1 @@ -0,0 +1,322 @@ +BeforeAll { + $sourcePath = Join-Path $PSScriptRoot "..\..\scripts\windows\test-installed-driver.ps1" + . $sourcePath +} + +Describe "Invoke-PnPUtil" { + It "returns pnputil output on success" { + Mock pnputil.exe { + $global:LASTEXITCODE = 0 + @("first", "second") + } + + @(Invoke-PnPUtil -Arguments @("/enum-devices")) | Should -Be @("first", "second") + } + + It "throws with command output on failure" { + Mock pnputil.exe { + $global:LASTEXITCODE = 6 + "access denied" + } + + { + Invoke-PnPUtil -Arguments @("/enum-devices") + } | Should -Throw "*exited with code 6*access denied*" + } +} + +Describe "test-installed-driver.ps1 entry point" { + It "executes normal invocation and requires an installed root device" { + function pnputil.exe { + $global:LASTEXITCODE = 0 + @() + } + + try { + { + & $sourcePath -HardwareId "ROOT\LIBVIRTUALHID_PESTER" + } | Should -Throw "No installed libvirtualhid root device was found*" + } finally { + Remove-Item -LiteralPath Function:\pnputil.exe + } + } +} + +Describe "PnPUtil device output parsing" { + It "creates an empty record with a trimmed instance ID" { + $record = ConvertTo-PnPUtilDeviceRecord -InstanceId " ROOT\LIBVIRTUALHID\0000 " + + $record.InstanceId | Should -Be "ROOT\LIBVIRTUALHID\0000" + $record.HardwareIds.Count | Should -Be 0 + $record.Status | Should -BeNullOrEmpty + } + + It "parses single-value fields and hardware ID continuations" { + $record = ConvertTo-PnPUtilDeviceRecord -InstanceId "ROOT\LIBVIRTUALHID\0000" + + $section = ConvertFrom-PnPUtilDeviceLine ` + -Record $record ` + -Line "Device Description: libvirtualhid" ` + -Section $null + $section | Should -BeNullOrEmpty + $section = ConvertFrom-PnPUtilDeviceLine ` + -Record $record ` + -Line "Hardware IDs: ROOT\LIBVIRTUALHID" ` + -Section $section + $section | Should -Be "HardwareIds" + $section = ConvertFrom-PnPUtilDeviceLine ` + -Record $record ` + -Line " ROOT\LIBVIRTUALHID_COMPAT" ` + -Section $section + + $record.DeviceDescription | Should -Be "libvirtualhid" + $record.HardwareIds | Should -Be @("ROOT\LIBVIRTUALHID", "ROOT\LIBVIRTUALHID_COMPAT") + } + + It "parses multiple complete device records" { + $output = @( + "Microsoft PnP Utility", + "Instance ID: ROOT\LIBVIRTUALHID\0000", + "Device Description: libvirtualhid root", + "Driver Name: libvirtualhid.inf", + "Status: Started", + "Problem Code: 0", + "Problem Status: 0x0", + "Hardware IDs: ROOT\LIBVIRTUALHID", + "Instance ID: HID\VID_1209&PID_0001\0000", + "Device Description: virtual gamepad", + "Status: Stopped" + ) + + $records = @(ConvertFrom-PnPUtilDeviceOutput -Output $output) + + $records.Count | Should -Be 2 + $records[0].Status | Should -Be "Started" + $records[0].DriverName | Should -Be "libvirtualhid.inf" + $records[0].ProblemCode | Should -Be "0" + $records[0].ProblemStatus | Should -Be "0x0" + $records[1].InstanceId | Should -Be "HID\VID_1209&PID_0001\0000" + } +} + +Describe "PnP device validation" { + It "writes every available device field as verbose output" { + $record = [pscustomobject]@{ + InstanceId = "ROOT\LIBVIRTUALHID\0000" + DeviceDescription = "libvirtualhid root" + Status = "Started" + DriverName = "libvirtualhid.inf" + HardwareIds = @("ROOT\LIBVIRTUALHID") + ProblemCode = "0" + ProblemStatus = "0x0" + } + Mock Write-Verbose {} + + Write-PnPRecordVerbose -Record $record + + Should -Invoke Write-Verbose -Times 7 -Exactly -Scope It + } + + It "filters device records by instance ID and hardware ID" { + Mock Invoke-PnPUtil { + @( + "Instance ID: ROOT\LIBVIRTUALHID\0000", + "Status: Started", + "Instance ID: CUSTOM\0000", + "Hardware IDs: ROOT\LIBVIRTUALHID", + "Status: Started", + "Instance ID: ROOT\OTHER\0000", + "Status: Started" + ) + } + Mock Write-PnPRecordVerbose {} + + $result = @(Get-PnPUtilDevicesByDeviceId -DeviceId "ROOT\LIBVIRTUALHID") + + $result.Count | Should -Be 2 + Should -Invoke Write-PnPRecordVerbose -Times 2 -Exactly -Scope It + } + + It "accepts a started device" { + $record = [pscustomobject]@{ + InstanceId = "ROOT\LIBVIRTUALHID\0000" + Status = "Started" + ProblemCode = $null + ProblemStatus = $null + } + + { Assert-StartedPnPRecord -Record $record -Description "Root device" } | + Should -Not -Throw + } + + It "reports status and problem details for a stopped device" { + $record = [pscustomobject]@{ + InstanceId = "ROOT\LIBVIRTUALHID\0000" + Status = "Stopped" + ProblemCode = "28" + ProblemStatus = "0xC0000490" + } + + { Assert-StartedPnPRecord -Record $record -Description "Root device" } | + Should -Throw "*Problem Code: 28*Problem Status: 0xC0000490*" + } + + It "requires at least one root device" { + Mock Get-PnPUtilDevicesByDeviceId { @() } + + { Assert-RootDeviceStarted -TargetHardwareId "ROOT\LIBVIRTUALHID" } | + Should -Throw "No installed libvirtualhid root device was found*" + } + + It "validates every matching root device" { + Mock Get-PnPUtilDevicesByDeviceId { + @( + [pscustomobject]@{ InstanceId = "one"; Status = "Started" }, + [pscustomobject]@{ InstanceId = "two"; Status = "Started" } + ) + } + Mock Assert-StartedPnPRecord {} + + Assert-RootDeviceStarted -TargetHardwareId "ROOT\LIBVIRTUALHID" + + Should -Invoke Assert-StartedPnPRecord -Times 2 -Exactly -Scope It + } +} + +Describe "Control device validation" { + It "opens an existing file for shared read and write access" { + $path = Join-Path $TestDrive "control-device" + New-Item -ItemType File -Path $path | Out-Null + + { Assert-ControlDeviceOpen -Path $path } | Should -Not -Throw + } + + It "explains when the control device cannot be opened" { + { + Assert-ControlDeviceOpen -Path (Join-Path $TestDrive "missing-device") + } | Should -Throw "Could not open*" + } +} + +Describe "Gamepad profile metadata" { + It "returns the expected hardware ID for " -ForEach @( + @{ profile = "generic"; expected = "HID\VID_1209&PID_0001" } + @{ profile = "xone"; expected = "HID\VID_045E&PID_02EA&IG_00" } + @{ profile = "xseries"; expected = "HID\VID_045E&PID_0B12&IG_00" } + @{ profile = "ds4"; expected = "HID\VID_054C&PID_05C4" } + @{ profile = "ds5"; expected = "HID\VID_054C&PID_0CE6" } + @{ profile = "switch"; expected = "HID\VID_057E&PID_2009" } + ) { + @(Get-ExpectedGamepadHardwareId -ProfileName $profile) | Should -Be @($expected) + } + + It "rejects an unsupported profile" { + { Get-ExpectedGamepadHardwareId -ProfileName "unknown" } | + Should -Throw "Unsupported profile: unknown" + } +} + +Describe "Gamepad device waits" { + BeforeEach { + Mock Start-Sleep {} + } + + It "accepts a started non-VHF child device" { + Mock Get-PnPUtilDevicesByDeviceId { + [pscustomobject]@{ + InstanceId = "HID\GAMEPAD\0000" + Status = "Started" + DriverName = "xboxgip.inf" + DeviceDescription = "Xbox Controller" + } + } + + { + Wait-ForStartedGamepadChild -ProfileName "xseries" -TimeoutSeconds 1 + } | Should -Not -Throw + } + + It "rejects a missing gamepad child after the timeout" { + Mock Get-PnPUtilDevicesByDeviceId { @() } + + { + Wait-ForStartedGamepadChild -ProfileName "xseries" -TimeoutSeconds 0 + } | Should -Throw "No gamepad child device was found*" + } + + It "accepts a started Xbox 360 companion" { + Mock Get-PnPUtilDevicesByDeviceId { + [pscustomobject]@{ + InstanceId = "ROOT\LIBVIRTUALHID_XBOX360\0000" + Status = "Started" + DriverName = "xusb22.inf" + } + } + + { + Wait-ForStartedXbox360Companion -TimeoutSeconds 1 + } | Should -Not -Throw + } + + It "rejects a missing Xbox 360 companion after the timeout" { + Mock Get-PnPUtilDevicesByDeviceId { @() } + + { + Wait-ForStartedXbox360Companion -TimeoutSeconds 0 + } | Should -Throw "No Xbox 360 XUSB companion was found*" + } +} + +Describe "Invoke-GamepadAdapterSmoke" { + It "does nothing when no adapter path is supplied" { + Mock Start-Process { throw "adapter should not start" } + + Invoke-GamepadAdapterSmoke ` + -ProfileName "xseries" ` + -HoldSeconds 12 ` + -DeviceStartTimeoutSeconds 1 + + Should -Invoke Start-Process -Times 0 -Exactly -Scope It + } + + It "starts, validates, and stops an adapter" { + $path = Join-Path $TestDrive "gamepad_adapter.exe" + New-Item -ItemType File -Path $path | Out-Null + $process = [pscustomobject]@{ HasExited = $false; Id = 42 } + Mock Start-Process { $process } + Mock Start-Sleep {} + Mock Wait-ForStartedGamepadChild {} + Mock Stop-Process {} + Mock Wait-Process {} + + Invoke-GamepadAdapterSmoke ` + -Path $path ` + -ProfileName "xseries" ` + -HoldSeconds 12 ` + -DeviceStartTimeoutSeconds 1 + + Should -Invoke Wait-ForStartedGamepadChild -Times 1 -Exactly -Scope It + Should -Invoke Stop-Process -Times 1 -Exactly -Scope It -ParameterFilter { + $Id -eq 42 -and $Force + } + } + + It "uses the Xbox 360 companion validation path" { + $path = Join-Path $TestDrive "x360-gamepad_adapter.exe" + New-Item -ItemType File -Path $path | Out-Null + $process = [pscustomobject]@{ HasExited = $false; Id = 43 } + Mock Start-Process { $process } + Mock Start-Sleep {} + Mock Wait-ForStartedXbox360Companion {} + Mock Stop-Process {} + Mock Wait-Process {} + + Invoke-GamepadAdapterSmoke ` + -Path $path ` + -ProfileName "x360" ` + -HoldSeconds 12 ` + -DeviceStartTimeoutSeconds 1 + + Should -Invoke Wait-ForStartedXbox360Companion -Times 1 -Exactly -Scope It + } +} diff --git a/tests/scripts/uninstall-driver.Tests.ps1 b/tests/scripts/uninstall-driver.Tests.ps1 new file mode 100644 index 0000000..af89c1c --- /dev/null +++ b/tests/scripts/uninstall-driver.Tests.ps1 @@ -0,0 +1,284 @@ +BeforeAll { + $sourcePath = Join-Path $PSScriptRoot "..\..\scripts\windows\uninstall-driver.ps1" + . $sourcePath + + function global:Invoke-LibVirtualHidUninstallTestCommand { + param([Parameter(ValueFromRemainingArguments)] $Arguments) + + $null = $Arguments + $global:LASTEXITCODE = $global:LibVirtualHidUninstallTestExitCode + } +} + +AfterAll { + Remove-Item -LiteralPath Function:\Invoke-LibVirtualHidUninstallTestCommand -ErrorAction SilentlyContinue + Remove-Variable -Name LibVirtualHidUninstallTestExitCode -Scope Global -ErrorAction SilentlyContinue +} + +Describe "Invoke-CheckedCommand" { + It "returns after an allowed exit code" { + $global:LibVirtualHidUninstallTestExitCode = 0 + + { + Invoke-CheckedCommand ` + -FilePath "Invoke-LibVirtualHidUninstallTestCommand" ` + -Arguments @("delete") + } | Should -Not -Throw + } + + It "throws after a failed command" { + $global:LibVirtualHidUninstallTestExitCode = 3 + + { + Invoke-CheckedCommand ` + -FilePath "Invoke-LibVirtualHidUninstallTestCommand" ` + -Arguments @("delete") + } | Should -Throw "*exited with code 3*" + } +} + +Describe "uninstall-driver.ps1 entry point" { + It "executes normal invocation and validates an explicit package name" { + { + & $sourcePath -PublishedName "lib.inf" + } | Should -Throw "The published driver package name is invalid*" + } +} + +Describe "Remove-LibVirtualHidBrokerService" { + It "does nothing when the service is absent" { + Mock Get-Service { $null } + Mock Stop-Service {} + Mock Invoke-CheckedCommand {} + + Remove-LibVirtualHidBrokerService -Name "libvirtualhid_broker" + + Should -Invoke Stop-Service -Times 0 -Exactly -Scope It + Should -Invoke Invoke-CheckedCommand -Times 0 -Exactly -Scope It + } + + It "stops and deletes an installed service" { + $service = [pscustomobject]@{ + Status = "Running" + Disposed = $false + Waited = $false + } + $service | Add-Member -MemberType ScriptMethod -Name WaitForStatus -Value { + param($Status, $Timeout) + $null = $Status, $Timeout + $this.Waited = $true + } + $service | Add-Member -MemberType ScriptMethod -Name Dispose -Value { + $this.Disposed = $true + } + $script:getServiceCalls = 0 + Mock Get-Service { + $script:getServiceCalls += 1 + if ($script:getServiceCalls -eq 1) { + return $service + } + return $null + } + Mock Stop-Service {} + Mock Invoke-CheckedCommand {} + + Remove-LibVirtualHidBrokerService -Name "libvirtualhid_broker" -Confirm:$false + + $service.Waited | Should -BeTrue + $service.Disposed | Should -BeTrue + Should -Invoke Stop-Service -Times 1 -Exactly -Scope It + Should -Invoke Invoke-CheckedCommand -Times 1 -Exactly -Scope It -ParameterFilter { + $FilePath -eq "sc.exe" -and $Arguments -join "," -eq "delete,libvirtualhid_broker" + } + } +} + +Describe "Find-PublishedName" { + It "returns matching DISM driver package names" { + Mock Get-WindowsDriver { + @( + [pscustomobject]@{ + Driver = "oem42.inf" + OriginalFileName = "C:\drivers\libvirtualhid.inf" + }, + [pscustomobject]@{ + Driver = "input.inf" + OriginalFileName = "C:\drivers\libvirtualhid.inf" + }, + [pscustomobject]@{ + Driver = "oem7.inf" + OriginalFileName = "C:\drivers\other.inf" + } + ) + } + Mock Get-CimInstance { throw "CIM should not be used" } + + @(Find-PublishedName ` + -TargetOriginalName "libvirtualhid.inf" ` + -TargetHardwareId "ROOT\LIBVIRTUALHID") | Should -Be @("oem42.inf") + Should -Invoke Get-CimInstance -Times 0 -Exactly -Scope It + } + + It "returns an empty collection when DISM succeeds without a match" { + Mock Get-WindowsDriver { @() } + + @(Find-PublishedName ` + -TargetOriginalName "libvirtualhid.inf" ` + -TargetHardwareId "ROOT\LIBVIRTUALHID").Count | Should -Be 0 + } + + It "falls back to the package bound to the target device" { + Mock Get-WindowsDriver { throw "DISM failed" } + Mock Get-LibVirtualHidRootDeviceInstanceId { @("ROOT\LIBVIRTUALHID\0000") } + Mock Get-CimInstance { + @( + [pscustomobject]@{ + DeviceID = "ROOT\LIBVIRTUALHID\0000" + InfName = "oem12.inf" + }, + [pscustomobject]@{ + DeviceID = "ROOT\OTHER\0000" + InfName = "oem13.inf" + } + ) + } + + @(Find-PublishedName ` + -TargetOriginalName "libvirtualhid.inf" ` + -TargetHardwareId "ROOT\LIBVIRTUALHID") | Should -Be @("oem12.inf") + } + + It "explains when DISM and the bound-device fallback are unavailable" { + Mock Get-WindowsDriver { throw "DISM failed" } + Mock Get-LibVirtualHidRootDeviceInstanceId { @() } + + { + Find-PublishedName ` + -TargetOriginalName "libvirtualhid.inf" ` + -TargetHardwareId "ROOT\LIBVIRTUALHID" + } | Should -Throw "*no bound device is available for CIM fallback*" + } +} + +Describe "Published package validation" { + It "accepts a normal OEM package name" { + { Assert-PublishedName -Name "oem123.inf" } | Should -Not -Throw + } + + It "rejects an unsafe package name" { + { Assert-PublishedName -Name "libvirtualhid.inf" } | Should -Throw "*is invalid*" + } +} + +Describe "Remove-LibVirtualHidDeviceInstance" { + It "reports pnputil output without warning on success" { + Mock pnputil.exe { + $global:LASTEXITCODE = 0 + "Device removed" + } + Mock Write-Warning {} + + Remove-LibVirtualHidDeviceInstance ` + -InstanceId "ROOT\LIBVIRTUALHID\0000" ` + -Confirm:$false + + Should -Invoke Write-Warning -Times 0 -Exactly -Scope It + } + + It "warns and continues after a failed device removal" { + Mock pnputil.exe { $global:LASTEXITCODE = 5 } + Mock Write-Warning {} + + Remove-LibVirtualHidDeviceInstance ` + -InstanceId "ROOT\LIBVIRTUALHID\0000" ` + -Confirm:$false + + Should -Invoke Write-Warning -Times 1 -Exactly -Scope It -ParameterFilter { + $Message -like "pnputil.exe /remove-device*exited with code 5*" + } + } +} + +Describe "Assert-LibVirtualHidRemoved" { + BeforeEach { + Mock Get-Service { $null } + Mock Get-LibVirtualHidRootDeviceInstanceId { @() } + Mock Find-PublishedName { @() } + } + + It "accepts a fully removed driver" { + { + Assert-LibVirtualHidRemoved ` + -TargetOriginalName "libvirtualhid.inf" ` + -TargetHardwareId "ROOT\LIBVIRTUALHID" ` + -ServiceName "libvirtualhid_broker" + } | Should -Not -Throw + } + + It "rejects a remaining service" { + Mock Get-Service { [pscustomobject]@{ Name = "libvirtualhid_broker" } } + + { + Assert-LibVirtualHidRemoved ` + -TargetOriginalName "libvirtualhid.inf" ` + -TargetHardwareId "ROOT\LIBVIRTUALHID" ` + -ServiceName "libvirtualhid_broker" + } | Should -Throw "*service remains installed*" + } + + It "rejects remaining device instances" { + Mock Get-LibVirtualHidRootDeviceInstanceId { @("ROOT\LIBVIRTUALHID\0000") } + + { + Assert-LibVirtualHidRemoved ` + -TargetOriginalName "libvirtualhid.inf" ` + -TargetHardwareId "ROOT\LIBVIRTUALHID" ` + -ServiceName "libvirtualhid_broker" + } | Should -Throw "*device instances remain installed*" + } + + It "rejects remaining staged packages" { + Mock Find-PublishedName { @("oem4.inf") } + + { + Assert-LibVirtualHidRemoved ` + -TargetOriginalName "libvirtualhid.inf" ` + -TargetHardwareId "ROOT\LIBVIRTUALHID" ` + -ServiceName "libvirtualhid_broker" + } | Should -Throw "*driver packages remain staged*" + } +} + +Describe "Remove-DriverCertificate" { + It "does nothing without a certificate subject" { + Mock Get-ChildItem { throw "certificate stores should not be read" } + + Remove-DriverCertificate + + Should -Invoke Get-ChildItem -Times 0 -Exactly -Scope It + } + + It "removes only matching self-signed certificates" { + Mock Get-ChildItem { + @( + [pscustomobject]@{ + Subject = "CN=libvirtualhid Test" + Issuer = "CN=libvirtualhid Test" + Thumbprint = "AAA" + }, + [pscustomobject]@{ + Subject = "CN=libvirtualhid Test" + Issuer = "CN=Other" + Thumbprint = "BBB" + } + ) + } + Mock Remove-Item {} + + Remove-DriverCertificate -Subject "CN=libvirtualhid Test" -Confirm:$false + + Should -Invoke Remove-Item -Times 2 -Exactly -Scope It -ParameterFilter { + $LiteralPath -match "AAA$" -and $Force + } + } +} From 8ed582f91df17203043779f41448b53e23557738 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:02:49 -0400 Subject: [PATCH 2/2] test: avoid global Pester state Store mocked native command exit codes in process-scoped environment variables and clean them after each test file. This preserves cross-scope command behavior while satisfying PSAvoidGlobalVars. --- tests/scripts/install-driver.Tests.ps1 | 8 ++++---- tests/scripts/sign-driver-package.Tests.ps1 | 10 +++++----- tests/scripts/uninstall-driver.Tests.ps1 | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/scripts/install-driver.Tests.ps1 b/tests/scripts/install-driver.Tests.ps1 index e3afd24..761df18 100644 --- a/tests/scripts/install-driver.Tests.ps1 +++ b/tests/scripts/install-driver.Tests.ps1 @@ -6,18 +6,18 @@ BeforeAll { param([Parameter(ValueFromRemainingArguments)] $Arguments) $null = $Arguments - $global:LASTEXITCODE = $global:LibVirtualHidTestExitCode + $global:LASTEXITCODE = [int] $env:LIBVIRTUALHID_INSTALL_TEST_EXIT_CODE } } AfterAll { Remove-Item -LiteralPath Function:\Invoke-LibVirtualHidTestCommand -ErrorAction SilentlyContinue - Remove-Variable -Name LibVirtualHidTestExitCode -Scope Global -ErrorAction SilentlyContinue + Remove-Item Env:\LIBVIRTUALHID_INSTALL_TEST_EXIT_CODE -ErrorAction SilentlyContinue } Describe "Invoke-CheckedCommand" { It "accepts a configured success exit code" { - $global:LibVirtualHidTestExitCode = 5 + $env:LIBVIRTUALHID_INSTALL_TEST_EXIT_CODE = 5 { Invoke-CheckedCommand ` @@ -28,7 +28,7 @@ Describe "Invoke-CheckedCommand" { } It "throws for an unexpected exit code" { - $global:LibVirtualHidTestExitCode = 12 + $env:LIBVIRTUALHID_INSTALL_TEST_EXIT_CODE = 12 { Invoke-CheckedCommand ` diff --git a/tests/scripts/sign-driver-package.Tests.ps1 b/tests/scripts/sign-driver-package.Tests.ps1 index 6fa5b87..db08d88 100644 --- a/tests/scripts/sign-driver-package.Tests.ps1 +++ b/tests/scripts/sign-driver-package.Tests.ps1 @@ -6,13 +6,13 @@ BeforeAll { param([Parameter(ValueFromRemainingArguments)] $Arguments) $null = $Arguments - $global:LASTEXITCODE = $global:LibVirtualHidSignTestExitCode + $global:LASTEXITCODE = [int] $env:LIBVIRTUALHID_SIGN_TEST_EXIT_CODE } } AfterAll { Remove-Item -LiteralPath Function:\Invoke-LibVirtualHidSignTestCommand -ErrorAction SilentlyContinue - Remove-Variable -Name LibVirtualHidSignTestExitCode -Scope Global -ErrorAction SilentlyContinue + Remove-Item Env:\LIBVIRTUALHID_SIGN_TEST_EXIT_CODE -ErrorAction SilentlyContinue } Describe "Find-SignTool" { @@ -81,7 +81,7 @@ Describe "sign-driver-package.ps1 entry point" { [pscustomobject]@{ Source = "Invoke-LibVirtualHidSignTestCommand" } } Mock Remove-Item {} - $global:LibVirtualHidSignTestExitCode = 0 + $env:LIBVIRTUALHID_SIGN_TEST_EXIT_CODE = 0 & $sourcePath ` -PackagePath $packagePath ` @@ -104,7 +104,7 @@ Describe "sign-driver-package.ps1 entry point" { Describe "Invoke-CheckedCommand" { It "returns after a successful signing command" { - $global:LibVirtualHidSignTestExitCode = 0 + $env:LIBVIRTUALHID_SIGN_TEST_EXIT_CODE = 0 { Invoke-CheckedCommand ` @@ -114,7 +114,7 @@ Describe "Invoke-CheckedCommand" { } It "throws after a failed signing command" { - $global:LibVirtualHidSignTestExitCode = 9 + $env:LIBVIRTUALHID_SIGN_TEST_EXIT_CODE = 9 { Invoke-CheckedCommand ` diff --git a/tests/scripts/uninstall-driver.Tests.ps1 b/tests/scripts/uninstall-driver.Tests.ps1 index af89c1c..7077245 100644 --- a/tests/scripts/uninstall-driver.Tests.ps1 +++ b/tests/scripts/uninstall-driver.Tests.ps1 @@ -6,18 +6,18 @@ BeforeAll { param([Parameter(ValueFromRemainingArguments)] $Arguments) $null = $Arguments - $global:LASTEXITCODE = $global:LibVirtualHidUninstallTestExitCode + $global:LASTEXITCODE = [int] $env:LIBVIRTUALHID_UNINSTALL_TEST_EXIT_CODE } } AfterAll { Remove-Item -LiteralPath Function:\Invoke-LibVirtualHidUninstallTestCommand -ErrorAction SilentlyContinue - Remove-Variable -Name LibVirtualHidUninstallTestExitCode -Scope Global -ErrorAction SilentlyContinue + Remove-Item Env:\LIBVIRTUALHID_UNINSTALL_TEST_EXIT_CODE -ErrorAction SilentlyContinue } Describe "Invoke-CheckedCommand" { It "returns after an allowed exit code" { - $global:LibVirtualHidUninstallTestExitCode = 0 + $env:LIBVIRTUALHID_UNINSTALL_TEST_EXIT_CODE = 0 { Invoke-CheckedCommand ` @@ -27,7 +27,7 @@ Describe "Invoke-CheckedCommand" { } It "throws after a failed command" { - $global:LibVirtualHidUninstallTestExitCode = 3 + $env:LIBVIRTUALHID_UNINSTALL_TEST_EXIT_CODE = 3 { Invoke-CheckedCommand `