diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 29f0411..3933e57 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "WebFetch(domain:community.chocolatey.org)", - "WebFetch(domain:github.com)" + "WebFetch(domain:github.com)", + "WebFetch(domain:jrsoftware.org)" ] } } diff --git a/.github/workflows/release-prefect-dagger.yml b/.github/workflows/release-prefect-dagger.yml index 299bd0e..a65e7e4 100644 --- a/.github/workflows/release-prefect-dagger.yml +++ b/.github/workflows/release-prefect-dagger.yml @@ -63,6 +63,13 @@ jobs: - name: Build Windows executable run: poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec + - name: Install Inno Setup 6.7.1 + shell: pwsh + run: | + choco install innosetup --version 6.7.1 -y --no-progress + # Refresh PATH so ISCC.exe is available immediately + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + - name: Build Windows installer (Inno Setup) shell: pwsh run: | diff --git a/.gitignore b/.gitignore index 2d2c889..6625551 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ pkg* # Python cache files __pycache__/ *.py[cod] -*$py.class \ No newline at end of file +*$py.class +.vagrant/ +vagrant_test_results/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index dd455e0..e5ba08b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ See [scripts/docker/README.md](scripts/docker/README.md) for detailed Docker bui poetry run pyinstaller scripts/spec_scripts/android-file-handler-windows.spec # Windows installer (Inno Setup, after PyInstaller build) -# Inno Setup 6 is pre-installed on GitHub Actions windows-latest runners +# Inno Setup 6.7.1 is installed by the CI/CD workflow via Chocolatey (pinned version) & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" scripts\windows\android-file-handler-setup.iss /DMyAppVersion=0.1.1 # Linux packages use distro-specific spec files: diff --git a/scripts/windows/android-file-handler-setup.iss b/scripts/windows/android-file-handler-setup.iss index 7e9391f..8a346b2 100644 --- a/scripts/windows/android-file-handler-setup.iss +++ b/scripts/windows/android-file-handler-setup.iss @@ -20,6 +20,10 @@ AppId={{8F2B3A7E-4D1C-4E8F-9A2B-6C7D8E9F0A1B} AppName={#MyAppName} AppVersion={#MyAppVersion} +; AppVerName controls the registry DisplayName value. Without it Inno Setup +; defaults to "AppName version AppVersion" which breaks exact-name lookups. +; Windows convention: DisplayName = product name, DisplayVersion = version. +AppVerName={#MyAppName} AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} AppSupportURL={#MyAppURL} @@ -31,7 +35,8 @@ OutputDir=..\..\dist OutputBaseFilename=android-file-handler-setup SetupIconFile=..\..\icon_media\robot_files_256.ico UninstallDisplayIcon={app}\{#MyAppExeName} -Compression=lzma2/ultra64 +Compression=lzma2/normal +LZMADictionarySize=16384 SolidCompression=yes WizardStyle=modern ArchitecturesInstallIn64BitMode=x64compatible diff --git a/scripts/windows/run_vagrant_tests.ps1 b/scripts/windows/run_vagrant_tests.ps1 index fc38c40..52db5af 100644 --- a/scripts/windows/run_vagrant_tests.ps1 +++ b/scripts/windows/run_vagrant_tests.ps1 @@ -4,8 +4,8 @@ # 1. Build the installer locally (PyInstaller + Inno Setup) # 2. Ensure the Vagrant VM is running and has a "clean" snapshot # 3. Restore to the clean snapshot -# 4. Push the installer to the VM -# 5. Run the test script inside the VM via WinRM +# 4. Transfer build assets to the VM and compile the installer (Inno Setup) +# 5. Run the test script inside the VM via PSSession # 6. Retrieve screenshots and results to the host # 7. Print results summary to CLI # 8. Revert VM back to clean snapshot @@ -17,10 +17,10 @@ # # Prerequisites: # - Vagrant and Hyper-V enabled on the host -# - Inno Setup 6 installed (default path or in PATH) # - Poetry environment set up # - The VM created and "clean" snapshot saved: # cd vagrant && vagrant up --provider=hyperv && vagrant snapshot save clean +# Note: Inno Setup 6.7.1 is installed inside the VM by provision.ps1 — no host install needed. [CmdletBinding()] param( @@ -33,7 +33,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" -$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$vmSession = $null # PSSession — closed in finally block $VagrantDir = Join-Path $RepoRoot "vagrant" $Timestamp = Get-Date -Format "yyyyMMdd_HHmmss" @@ -51,21 +52,22 @@ function Write-Step { Push-Location $VagrantDir try { - # ── Step 1: Build the installer locally ────────────────────────────────── + # ── Step 1: Build the Windows executable (host, PyInstaller only) ──────── + # Inno Setup runs inside the VM — no host install of Inno Setup required. - $installerPath = Join-Path $RepoRoot "dist\android-file-handler-setup.exe" + $pyinstallerExe = Join-Path $RepoRoot "dist\android-file-handler-windows.exe" + $AppVersion = "" if ($SkipBuild) { - Write-Step "Skipping build (-SkipBuild specified)" - if (-not (Test-Path $installerPath)) { - Write-Host "ERROR: -SkipBuild was specified but installer not found at $installerPath" -ForegroundColor Red + Write-Step "Skipping PyInstaller build (-SkipBuild specified)" + if (-not (Test-Path $pyinstallerExe)) { + Write-Host "ERROR: -SkipBuild was specified but exe not found at $pyinstallerExe" -ForegroundColor Red exit 1 } } else { - Write-Step "Building installer locally (PyInstaller + Inno Setup)" + Write-Step "Building Windows executable (PyInstaller)" - # PyInstaller — compile the exe Push-Location $RepoRoot try { Write-Host " Running PyInstaller..." -ForegroundColor Yellow @@ -75,45 +77,27 @@ try { exit 1 } - $pyinstallerExe = Join-Path $RepoRoot "dist\android-file-handler-windows.exe" if (-not (Test-Path $pyinstallerExe)) { Write-Host "ERROR: PyInstaller output not found at $pyinstallerExe" -ForegroundColor Red exit 1 } Write-Host " PyInstaller output: $pyinstallerExe" -ForegroundColor Green - - # Inno Setup — compile the installer - Write-Host " Running Inno Setup..." -ForegroundColor Yellow - $isccPaths = @( - "C:\Program Files (x86)\Inno Setup 6\ISCC.exe", - "C:\Program Files\Inno Setup 6\ISCC.exe" - ) - $iscc = $isccPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $iscc) { - $iscc = Get-Command ISCC.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source - } - if (-not $iscc) { - Write-Host "ERROR: Inno Setup (ISCC.exe) not found. Install Inno Setup 6 or add it to PATH." -ForegroundColor Red - exit 1 - } - - & $iscc "scripts\windows\android-file-handler-setup.iss" - if ($LASTEXITCODE -ne 0) { - Write-Host "ERROR: Inno Setup failed with exit code $LASTEXITCODE" -ForegroundColor Red - exit 1 - } - - if (-not (Test-Path $installerPath)) { - Write-Host "ERROR: Installer not found at $installerPath after Inno Setup" -ForegroundColor Red - exit 1 - } - Write-Host " Installer built: $installerPath" -ForegroundColor Green } finally { Pop-Location } } + # Capture version to pass to Inno Setup inside the VM + Push-Location $RepoRoot + try { + $AppVersion = (poetry version -s).Trim() + Write-Host " App version: $AppVersion" -ForegroundColor DarkGray + } + finally { + Pop-Location + } + # ── Step 2: Verify VM and snapshot exist ───────────────────────────────── Write-Step "Checking Vagrant VM status" @@ -137,51 +121,98 @@ try { Write-Step "Restoring VM to '$SnapshotName' snapshot" vagrant snapshot restore $SnapshotName - # ── Step 4: Push installer to the VM ───────────────────────────────────── + # ── Establish direct PSSession ──────────────────────────────────────────── + # vagrant winrm uses a subprocess whose args are subject to the Windows + # command-line length limit (~32 KB). Large file transfers exceed that limit. + # A direct PSSession has no such restriction and supports + # Copy-Item -ToSession / -FromSession for binary files of any size. - Write-Step "Pushing installer to the VM" + Write-Step "Establishing PSSession to VM" - $remoteInstallerDir = "C:\vagrant_installer" - $remoteInstallerPath = "$remoteInstallerDir\android-file-handler-setup.exe" - - vagrant winrm --shell powershell --command ` - "New-Item -ItemType Directory -Path '$remoteInstallerDir' -Force | Out-Null" - - # Base64-encode the installer, transfer via WinRM, decode on the VM - $installerBytes = [IO.File]::ReadAllBytes($installerPath) - $installerSizeMB = [math]::Round($installerBytes.Length / 1MB, 1) - Write-Host " Installer size: ${installerSizeMB} MB — transferring via WinRM..." -ForegroundColor Yellow - - # Split into chunks to avoid WinRM message size limits (< 2MB per command) - $chunkSize = 1 * 1024 * 1024 # 1 MB chunks - $totalChunks = [math]::Ceiling($installerBytes.Length / $chunkSize) - - vagrant winrm --shell powershell --command ` - "if (Test-Path '$remoteInstallerPath') { Remove-Item '$remoteInstallerPath' -Force }" - - for ($idx = 0; $idx -lt $totalChunks; $idx++) { - $offset = $idx * $chunkSize - $length = [math]::Min($chunkSize, $installerBytes.Length - $offset) - $chunk = New-Object byte[] $length - [Array]::Copy($installerBytes, $offset, $chunk, 0, $length) - $b64Chunk = [Convert]::ToBase64String($chunk) - - $appendScript = if ($idx -eq 0) { - "[IO.File]::WriteAllBytes('$remoteInstallerPath', [Convert]::FromBase64String('$b64Chunk'))" - } - else { - "`$bytes = [Convert]::FromBase64String('$b64Chunk'); " + - "`$stream = [IO.File]::Open('$remoteInstallerPath', 'Append'); " + - "`$stream.Write(`$bytes, 0, `$bytes.Length); `$stream.Close()" - } - vagrant winrm --shell powershell --command $appendScript - Write-Host " Chunk $($idx + 1)/$totalChunks transferred" -ForegroundColor DarkGray + # Ensure host WinRM client is configured to allow Basic authentication + # This is required for the connection to succeed when using -Authentication Basic + if ((Get-Item WSMan:\localhost\Client\Auth\Basic).Value -ne "true") { + Write-Host " Enabling WinRM Client Basic authentication on host..." -ForegroundColor Yellow + Set-Item WSMan:\localhost\Client\Auth\Basic -Value $true -Force } - # Verify transfer - $remoteSize = vagrant winrm --shell powershell --command ` - "(Get-Item '$remoteInstallerPath').Length" - Write-Host " Remote file size: $($remoteSize.Trim()) bytes (expected: $($installerBytes.Length))" -ForegroundColor DarkGray + $remoteBuildRoot = "C:\vagrant_build" + $remoteInstallerPath = "$remoteBuildRoot\dist\android-file-handler-setup.exe" + $remoteResultsDir = "C:\vagrant_test_results" + + # vagrant winrm-config reports the plaintext port (5985) used by Vagrant itself. + # We extract the host and port, then derive the HTTPS port (typically +1). + # If the direct IP fails, we fallback to the forwarded port on localhost. + $winrmConfig = & vagrant winrm-config 2>$null | Out-String + $vmHost = if ($winrmConfig -match 'HostName\s+(\S+)') { $Matches[1] } else { '127.0.0.1' } + $vmPort = if ($winrmConfig -match 'Port\s+(\d+)') { [int]$Matches[1] + 1 } else { 5986 } + + # If we have a direct IP but it's not reachable, fallback to the forwarded port + if ($vmHost -ne '127.0.0.1' -and -not (Test-NetConnection -ComputerName $vmHost -Port $vmPort -InformationLevel Quiet)) { + Write-Host " Direct IP $vmHost`:$vmPort unreachable. Falling back to localhost..." -ForegroundColor Yellow + $vmHost = '127.0.0.1' + $vmPort = 55986 # The host-side forwarded port for 5986 + } + + # Verify connectivity before trying PSSession + if (-not (Test-NetConnection -ComputerName $vmHost -Port $vmPort -InformationLevel Quiet)) { + Write-Host "ERROR: Could not reach VM at $vmHost`:$vmPort. Ensure 'vagrant up' and provisioning completed successfully." -ForegroundColor Red + exit 1 + } + + $vagrantCred = New-Object PSCredential 'vagrant', + (ConvertTo-SecureString 'vagrant' -AsPlainText -Force) + # SkipCACheck / SkipCNCheck accept the self-signed cert created by provision.ps1. + $sessionOpts = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck + $vmSession = New-PSSession -ComputerName $vmHost -Port $vmPort ` + -Credential $vagrantCred -Authentication Basic ` + -UseSSL -SessionOption $sessionOpts + + Write-Host " Connected to VM at $vmHost`:$vmPort" -ForegroundColor DarkGray + + # ── Step 4: Transfer build assets to VM and compile installer ──────────── + + Write-Step "Transferring build assets to VM" + + Invoke-Command -Session $vmSession -ScriptBlock { + param($root) + @("$root\dist", "$root\scripts\windows", "$root\icon_media") | + ForEach-Object { New-Item -ItemType Directory -Force -Path $_ | Out-Null } + } -ArgumentList $remoteBuildRoot + + $transfers = @( + @{ L = Join-Path $RepoRoot "dist\android-file-handler-windows.exe"; R = "$remoteBuildRoot\dist\android-file-handler-windows.exe" }, + @{ L = Join-Path $RepoRoot "scripts\windows\android-file-handler-setup.iss"; R = "$remoteBuildRoot\scripts\windows\android-file-handler-setup.iss" }, + @{ L = Join-Path $RepoRoot "LICENSE.txt"; R = "$remoteBuildRoot\LICENSE.txt" }, + @{ L = Join-Path $RepoRoot "icon_media\robot_files_256.ico"; R = "$remoteBuildRoot\icon_media\robot_files_256.ico" }, + @{ L = Join-Path $RepoRoot "vagrant\test_installer.ps1"; R = "$remoteBuildRoot\test_installer.ps1" } + ) + foreach ($t in $transfers) { + Copy-Item -Path $t.L -Destination $t.R -ToSession $vmSession -Force + $sizeMB = [math]::Round((Get-Item $t.L).Length / 1MB, 1) + Write-Host " Sent: $(Split-Path -Leaf $t.L) (${sizeMB} MB)" -ForegroundColor DarkGray + } + + # Unblock transferred files — Copy-Item -ToSession can mark files with a + # Zone.Identifier ADS that execution policy may block even under -Scope Process Bypass. + Invoke-Command -Session $vmSession -ScriptBlock { + param($root) + Get-ChildItem -Path $root -Recurse -File | + ForEach-Object { Unblock-File -Path $_.FullName -ErrorAction SilentlyContinue } + } -ArgumentList $remoteBuildRoot + Write-Host " Files unblocked on VM." -ForegroundColor DarkGray + + Write-Step "Building installer inside the VM (Inno Setup 6.7.1)" + + Invoke-Command -Session $vmSession -ScriptBlock { + param($buildRoot, $version) + & 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe' ` + "$buildRoot\scripts\windows\android-file-handler-setup.iss" ` + "/DMyAppVersion=$version" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } -ArgumentList $remoteBuildRoot, $AppVersion + + Write-Host " Installer built: $remoteInstallerPath" -ForegroundColor Green # ── Step 5: Run tests inside the VM ────────────────────────────────────── @@ -189,12 +220,34 @@ try { $testExitCode = 0 try { - vagrant winrm --shell powershell --command ` - "Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force; & C:\vagrant_project\vagrant\test_installer.ps1 -InstallerPath 'C:\vagrant_installer\android-file-handler-setup.exe'" + Invoke-Command -Session $vmSession -ScriptBlock { + param($buildRoot, $installer) + Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force + & "$buildRoot\test_installer.ps1" -InstallerPath $installer + # Propagate the test script's exit code as output so the host can inspect it. + $LASTEXITCODE + } -ArgumentList $remoteBuildRoot, $remoteInstallerPath | + ForEach-Object { + if ($_ -is [int]) { $testExitCode = $_ } + } } catch { $testExitCode = 1 - Write-Host "Test script returned failures." -ForegroundColor Yellow + Write-Host "Test script encountered an error:" -ForegroundColor Yellow + Write-Host " $($_.Exception.Message)" -ForegroundColor Red + Write-Host " Category : $($_.CategoryInfo.Category)" -ForegroundColor Red + Write-Host " FullError: $_" -ForegroundColor Red + } + + # Re-establish the PSSession if the test run left it in a non-Opened state. + # An unhandled exception inside Invoke-Command can terminate the remote runspace. + if ($vmSession.State -ne 'Opened') { + Write-Host " PSSession is '$($vmSession.State)' — re-establishing for results retrieval..." -ForegroundColor Yellow + Remove-PSSession $vmSession -ErrorAction SilentlyContinue + $vmSession = New-PSSession -ComputerName $vmHost -Port $vmPort ` + -Credential $vagrantCred -Authentication Basic ` + -UseSSL -SessionOption $sessionOpts + Write-Host " Session re-established." -ForegroundColor DarkGray } # ── Step 6: Retrieve results and screenshots ───────────────────────────── @@ -205,36 +258,18 @@ try { New-Item -ItemType Directory -Path $LocalResultsDir -Force | Out-Null } - # Copy results from shared folder on VM via vagrant scp or direct file copy - # The test writes to C:\vagrant_test_results inside the VM. - # We retrieve via WinRM by reading files. try { - # Get list of result files - $remoteFiles = vagrant winrm --shell powershell --command ` - "if (Test-Path 'C:\vagrant_test_results') { Get-ChildItem 'C:\vagrant_test_results' -Name }" 2>&1 | - Where-Object { $_ -is [string] -and $_.Trim() -ne "" } + $hasResults = Invoke-Command -Session $vmSession -ScriptBlock { + param($dir) Test-Path $dir + } -ArgumentList $remoteResultsDir - foreach ($fileName in $remoteFiles) { - $trimmedName = $fileName.Trim() - if (-not $trimmedName) { continue } - - $remotePath = "C:\vagrant_test_results\$trimmedName" - $localPath = Join-Path $LocalResultsDir $trimmedName - - if ($trimmedName -match '\.csv$') { - # Text file — read content and save - $content = vagrant winrm --shell powershell --command ` - "Get-Content -Path '$remotePath' -Raw" - $content | Out-File -FilePath $localPath -Encoding utf8 - Write-Host " Retrieved: $trimmedName" -ForegroundColor DarkGray - } - elseif ($trimmedName -match '\.(png|jpg|bmp)$') { - # Binary file — base64 encode, transfer, decode - $b64 = vagrant winrm --shell powershell --command ` - "[Convert]::ToBase64String([IO.File]::ReadAllBytes('$remotePath'))" - [IO.File]::WriteAllBytes($localPath, [Convert]::FromBase64String($b64.Trim())) - Write-Host " Retrieved: $trimmedName" -ForegroundColor DarkGray - } + if ($hasResults) { + Copy-Item -Path "$remoteResultsDir\*" -Destination $LocalResultsDir ` + -FromSession $vmSession -Recurse -Force + Write-Host " Retrieved results from $remoteResultsDir" -ForegroundColor DarkGray + } + else { + Write-Host " No results directory found on VM." -ForegroundColor Yellow } } catch { @@ -250,8 +285,8 @@ try { $results = Import-Csv $csvPath $results | Format-Table -AutoSize - $passCount = ($results | Where-Object { $_.Status -eq "PASS" }).Count - $failCount = ($results | Where-Object { $_.Status -eq "FAIL" }).Count + $passCount = @($results | Where-Object { $_.Status -eq "PASS" }).Count + $failCount = @($results | Where-Object { $_.Status -eq "FAIL" }).Count Write-Host " Passed: $passCount | Failed: $failCount | Total: $($passCount + $failCount)" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "Green" }) } else { @@ -294,5 +329,6 @@ try { } finally { + if ($vmSession) { Remove-PSSession $vmSession -ErrorAction SilentlyContinue } Pop-Location } diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile index f28f416..581ad50 100644 --- a/vagrant/Vagrantfile +++ b/vagrant/Vagrantfile @@ -22,31 +22,31 @@ Vagrant.configure("2") do |config| config.winrm.username = "vagrant" config.winrm.password = "vagrant" - config.winrm.transport = :plaintext + config.winrm.transport = :plaintext # Vagrant's own comms (boot check, provision) config.winrm.basic_auth_only = true config.vm.guest = :windows config.vm.boot_timeout = 900 config.vm.graceful_halt_timeout = 120 - config.vm.network "forwarded_port", guest: 5985, host: 55985 + # Automatically select the Default Switch for Hyper-V to avoid interactive prompt + config.vm.network "public_network", bridge: "Default Switch" + + config.vm.network "forwarded_port", guest: 5985, host: 55985 # Vagrant plaintext WinRM + config.vm.network "forwarded_port", guest: 5986, host: 55986 # PSSession HTTPS WinRM config.vm.provider "hyperv" do |hv| hv.vmname = "afh-win11-test" - hv.memory = 4096 - hv.maxmemory = 4096 - hv.cpus = 2 + hv.memory = 8192 + hv.maxmemory = 8192 + hv.cpus = 4 hv.enable_enhanced_session_mode = true hv.linked_clone = true end - # SMB synced folder — Hyper-V does not support VirtualBox shared folders. - # Vagrant will prompt for host credentials on first `vagrant up` to set up the SMB share. - config.vm.synced_folder "..", "/vagrant_project", - type: "smb", - smb_username: ENV["VAGRANT_SMB_USERNAME"], - smb_password: ENV["VAGRANT_SMB_PASSWORD"], - mount_options: ["ro"] + # Disable default Vagrant synced folder to prevent SMB credential prompts. + # Files are transferred directly via PSSession by run_vagrant_tests.ps1. + config.vm.synced_folder ".", "/vagrant", disabled: true config.vm.provision "shell", path: "provision.ps1", diff --git a/vagrant/provision.ps1 b/vagrant/provision.ps1 index 645ee9f..d75338c 100644 --- a/vagrant/provision.ps1 +++ b/vagrant/provision.ps1 @@ -12,6 +12,19 @@ Set-ItemProperty -Path $RegPath -Name AutoAdminLogon -Value "1" Set-ItemProperty -Path $RegPath -Name DefaultUserName -Value "vagrant" Set-ItemProperty -Path $RegPath -Name DefaultPassword -Value "vagrant" +# --- Allow script execution (required for test suite) --- +# Write the LocalMachine execution policy directly via the registry instead of using +# Set-ExecutionPolicy. Vagrant runs provision.ps1 under "powershell -ExecutionPolicy Bypass", +# which sets a Process-scope policy. When a Process-scope policy is active, +# Set-ExecutionPolicy -Scope LocalMachine throws a terminating SecurityException +# ("overridden by a policy defined at a more specific scope") that cannot be suppressed +# with -ErrorAction. Writing the registry key bypasses that cmdlet restriction entirely. +# The value takes effect in all subsequent WinRM PSSessions (which have no Process-scope +# override) — exactly the sessions that run the test suite. +$psMachinePolicyPath = 'HKLM:\SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell' +Set-ItemProperty -Path $psMachinePolicyPath -Name 'ExecutionPolicy' -Value 'Unrestricted' -Force +Write-Host " Execution policy set to Unrestricted (LocalMachine)." -ForegroundColor DarkGray + # --- Install Chocolatey (package manager) --- if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { Write-Host "Installing Chocolatey..." -ForegroundColor Yellow @@ -20,10 +33,63 @@ if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") } +# --- Configure HTTPS WinRM listener --- +# HTTP WinRM (plaintext) causes PowerShell PSSession to reject unencrypted +# traffic. A self-signed cert on port 5986 satisfies WinRM's security policy +# without needing a domain CA. The 10-year expiry covers long-lived test boxes. +Write-Host "Configuring HTTPS WinRM listener..." -ForegroundColor Yellow + +# Ensure network is Private so WinRM HTTPS works correctly +Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private + +# Allow local accounts to perform administrative tasks via WinRM +# (Required for the 'vagrant' user to have full admin rights over PSSession) +$policyPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" +Set-ItemProperty -Path $policyPath -Name LocalAccountTokenFilterPolicy -Value 1 -Force + +# Create certificate for HTTPS listener +$cert = New-SelfSignedCertificate ` + -DnsName "afh-test", "localhost" ` + -CertStoreLocation "Cert:\LocalMachine\My" ` + -KeyAlgorithm RSA ` + -KeyLength 2048 ` + -NotAfter (Get-Date).AddYears(10) + +# Check if an HTTPS listener already exists +$listeners = winrm enumerate winrm/config/listener +$hasHttps = $listeners -match "Transport = HTTPS" + +if ($hasHttps) { + # If the thumbprint matches, we're already good + if ($listeners -match $cert.Thumbprint) { + Write-Host " HTTPS listener already correctly configured." -ForegroundColor Green + } else { + Write-Host " Updating existing HTTPS listener with new certificate..." -ForegroundColor Gray + winrm delete winrm/config/Listener?Address=*+Transport=HTTPS + winrm create winrm/config/Listener?Address=*+Transport=HTTPS ` + "@{Hostname=`"afh-test`";CertificateThumbprint=`"$($cert.Thumbprint)`"}" + } +} else { + Write-Host " Creating new HTTPS listener..." -ForegroundColor Gray + winrm create winrm/config/Listener?Address=*+Transport=HTTPS ` + "@{Hostname=`"afh-test`";CertificateThumbprint=`"$($cert.Thumbprint)`"}" +} + +winrm set winrm/config/service/Auth '@{Basic="true"}' + +netsh advfirewall firewall add rule ` + name="WinRM HTTPS" protocol=TCP dir=in localport=5986 action=allow profile=any | Out-Null + +Write-Host " HTTPS WinRM listener ready on port 5986" -ForegroundColor Green + # --- Install nircmd for screenshot capture --- Write-Host "Installing nircmd for screenshot capture..." -ForegroundColor Yellow choco install nircmd -y --no-progress +# --- Install Inno Setup 6.7.1 (pinned) --- +Write-Host "Installing Inno Setup 6.7.1..." -ForegroundColor Yellow +choco install innosetup --version 6.7.1 -y --no-progress + # Refresh PATH $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") diff --git a/vagrant/test_installer.ps1 b/vagrant/test_installer.ps1 index 1a1603b..66060a1 100644 --- a/vagrant/test_installer.ps1 +++ b/vagrant/test_installer.ps1 @@ -4,8 +4,9 @@ # Exit codes: 0 = all passed, 1 = one or more failures. param( - [string]$InstallerPath = "C:\vagrant_project\dist\android-file-handler-setup.exe", - [string]$ScreenshotDir = "C:\vagrant_test_results" + [string]$InstallerPath = "C:\vagrant_build\dist\android-file-handler-setup.exe", + [string]$ScreenshotDir = "C:\vagrant_test_results", + [string]$AppVersion = "" ) Set-StrictMode -Version Latest @@ -22,7 +23,7 @@ function Write-TestResult { $status = if ($Pass) { "PASS" } else { "FAIL" } $color = if ($Pass) { "Green" } else { "Red" } $msg = "[$status] $Name" - if ($Detail) { $msg += " — $Detail" } + if ($Detail) { $msg += " - $Detail" } Write-Host $msg -ForegroundColor $color if ($Pass) { $Script:Passed++ } else { $Script:Failed++ } $Script:Results += [PSCustomObject]@{ Test = $Name; Status = $status; Detail = $Detail } @@ -60,7 +61,7 @@ $DefaultInstDir = Join-Path $env:ProgramFiles $AppName Write-Host "" Write-Host "=============================================" -ForegroundColor Cyan -Write-Host " Android File Handler — Vagrant Test Suite " -ForegroundColor Cyan +Write-Host " Android File Handler - Vagrant Test Suite " -ForegroundColor Cyan Write-Host "=============================================" -ForegroundColor Cyan Write-Host "" @@ -68,134 +69,264 @@ if (-not (Test-Path $ScreenshotDir)) { New-Item -ItemType Directory -Path $ScreenshotDir -Force | Out-Null } -# ── Test 1: Installer exists ──────────────────────────────────────────────── +try { + # ── Build installer if not already present ────────────────────────────────── + # When run directly inside the VM (not via run_vagrant_tests.ps1), the build + # dir may not exist yet. Since the SMB mount is disabled by default, this + # fallback requires manual transfer of assets to C:\vagrant_project. -$installerExists = Test-Path $InstallerPath -Write-TestResult -Name "Installer file exists" -Pass $installerExists -Detail $InstallerPath + if (-not (Test-Path $InstallerPath)) { + $projectRoot = "C:\vagrant_project" + $sourceExe = "$projectRoot\dist\android-file-handler-windows.exe" + $iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + $buildRoot = "C:\vagrant_build" -if (-not $installerExists) { - Write-Host "`nFATAL: Installer not found at $InstallerPath. Build the installer first." -ForegroundColor Red - Write-Host " poetry run pyinstaller scripts\spec_scripts\android-file-handler-windows.spec" - Write-Host " iscc scripts\windows\android-file-handler-setup.iss" - exit 1 -} + if (-not (Test-Path $sourceExe)) { + Write-Host "`nFATAL: Installer not found and cannot build - PyInstaller exe missing." -ForegroundColor Red + Write-Host " Run PyInstaller on the host first, then use the orchestrator:" -ForegroundColor Yellow + Write-Host " ..\scripts\windows\run_vagrant_tests.ps1" + Write-Host " (Or manually transfer files to C:\vagrant_project inside the VM)." + exit 1 + } -# ── Test 2: Silent install ────────────────────────────────────────────────── + if (-not (Test-Path $iscc)) { + Write-Host "`nFATAL: Inno Setup not found at $iscc." -ForegroundColor Red + Write-Host " Re-provision the VM: vagrant provision" -ForegroundColor Yellow + exit 1 + } -Write-Host "`n--- Running silent install ---" -ForegroundColor Yellow -$installProc = Start-Process -FilePath $InstallerPath ` - -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-" ` - -Wait -PassThru + Write-Host "`n--- Building installer inside the VM (Inno Setup 6.7.1) ---" -ForegroundColor Yellow -$installOk = $installProc.ExitCode -eq 0 -Write-TestResult -Name "Silent install completed" -Pass $installOk -Detail "Exit code: $($installProc.ExitCode)" + @("$buildRoot\dist", "$buildRoot\scripts\windows", "$buildRoot\icon_media") | + ForEach-Object { New-Item -ItemType Directory -Force -Path $_ | Out-Null } -# ── Test 3: Install directory created ──────────────────────────────────────── + Copy-Item $sourceExe "$buildRoot\dist\" -Force + Copy-Item "$projectRoot\scripts\windows\android-file-handler-setup.iss" "$buildRoot\scripts\windows\" -Force + Copy-Item "$projectRoot\LICENSE.txt" "$buildRoot\" -Force + Copy-Item "$projectRoot\icon_media\robot_files_256.ico" "$buildRoot\icon_media\" -Force -$dirExists = Test-Path $DefaultInstDir -Write-TestResult -Name "Install directory exists" -Pass $dirExists -Detail $DefaultInstDir + $isccArgs = @("$buildRoot\scripts\windows\android-file-handler-setup.iss") + if ($AppVersion) { $isccArgs += "/DMyAppVersion=$AppVersion" } -# ── Test 4: Executable present ────────────────────────────────────────────── - -$exePath = Join-Path $DefaultInstDir $ExeName -$exeExists = Test-Path $exePath -Write-TestResult -Name "Executable exists" -Pass $exeExists -Detail $exePath - -# ── Test 5: Start Menu shortcut ───────────────────────────────────────────── - -$startMenuDir = Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\$AppName" -$shortcutPath = Join-Path $startMenuDir "$AppName.lnk" -$shortcutExists = Test-Path $shortcutPath -Write-TestResult -Name "Start Menu shortcut exists" -Pass $shortcutExists -Detail $shortcutPath - -# ── Test 6: Registry uninstall entry ──────────────────────────────────────── - -$uninstallKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" -$registryEntry = Get-ChildItem $uninstallKey -ErrorAction SilentlyContinue | - Where-Object { (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).DisplayName -eq $AppName } -$registryOk = $null -ne $registryEntry -Write-TestResult -Name "Registry uninstall entry" -Pass $registryOk - -# ── Test 7: Launch application & capture GUI ───────────────────────────────── - -if ($exeExists) { - Write-Host "`n--- Launching application ---" -ForegroundColor Yellow - - $appProc = Start-Process -FilePath $exePath -PassThru - Start-Sleep -Seconds 8 - - # Check if process is still running (GUI app should stay alive) - $appRunning = -not $appProc.HasExited - Write-TestResult -Name "Application launches and stays running" -Pass $appRunning - - # Look for the window by title - Add-Type @" - using System; - using System.Runtime.InteropServices; - public class Win32 { - [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] - public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); + & $iscc @isccArgs + if ($LASTEXITCODE -ne 0) { + Write-Host "FATAL: Inno Setup failed (exit $LASTEXITCODE)." -ForegroundColor Red + exit 1 + } + Write-Host " Installer built: $InstallerPath" -ForegroundColor Green } -"@ - $hwnd = [Win32]::FindWindow($null, $AppName) - $windowFound = $hwnd -ne [IntPtr]::Zero - Write-TestResult -Name "Main window found by title" -Pass $windowFound -Detail "HWND: $hwnd" - # Take screenshot of the running application - Start-Sleep -Seconds 2 - $screenshotFile = Take-Screenshot -Name "app_running" - Write-Host " Screenshot saved: $screenshotFile" -ForegroundColor DarkGray + # ── Test 1: Installer exists ──────────────────────────────────────────────── - # Gracefully close - if (-not $appProc.HasExited) { - $appProc.CloseMainWindow() | Out-Null - Start-Sleep -Seconds 3 - if (-not $appProc.HasExited) { - $appProc.Kill() + $installerExists = Test-Path $InstallerPath + Write-TestResult -Name "Installer file exists" -Pass $installerExists -Detail $InstallerPath + + if (-not $installerExists) { + throw "Installer not found at $InstallerPath." + } + + # ── Test 2: Silent install ────────────────────────────────────────────────── + + Write-Host "`n--- Running silent install ---" -ForegroundColor Yellow + $installProc = Start-Process -FilePath $InstallerPath ` + -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-" ` + -Wait -PassThru + + $installOk = $installProc.ExitCode -eq 0 + Write-TestResult -Name "Silent install completed" -Pass $installOk -Detail "Exit code: $($installProc.ExitCode)" + + # ── Test 3: Install directory created ──────────────────────────────────────── + + $dirExists = Test-Path $DefaultInstDir + Write-TestResult -Name "Install directory exists" -Pass $dirExists -Detail $DefaultInstDir + + # ── Test 4: Executable present ────────────────────────────────────────────── + + $exePath = Join-Path $DefaultInstDir $ExeName + $exeExists = Test-Path $exePath + Write-TestResult -Name "Executable exists" -Pass $exeExists -Detail $exePath + + # ── Test 5: Start Menu shortcut ───────────────────────────────────────────── + + $startMenuDir = Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\$AppName" + $shortcutPath = Join-Path $startMenuDir "$AppName.lnk" + $shortcutExists = Test-Path $shortcutPath + Write-TestResult -Name "Start Menu shortcut exists" -Pass $shortcutExists -Detail $shortcutPath + + # ── Test 6: Registry uninstall entry ──────────────────────────────────────── + + # Check both the native (64-bit) and WoW6432Node (32-bit) uninstall hives. + # WinRM sessions may run 32-bit PowerShell, which redirects HKLM:\SOFTWARE to + # WoW6432Node, missing the 64-bit installer's entry if we only check one path. + # try/catch in the Where-Object handles the StrictMode "property not found" throw + # that neither -ErrorAction nor PSObject.Properties can fully avoid. + $uninstallPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + ) + $registryEntry = $uninstallPaths | ForEach-Object { + Get-ChildItem $_ -ErrorAction SilentlyContinue + } | Where-Object { + # Match exact name OR "AppName version X.Y.Z" (Inno Setup default when + # AppVerName is not set). The .iss now sets AppVerName={#MyAppName} so + # exact match is expected; the -like guard covers older installer builds. + try { + $dn = (Get-ItemProperty $_.PSPath -ErrorAction Stop).DisplayName + $dn -eq $AppName -or $dn -like "$AppName *" + } catch { $false } + } + $registryOk = $null -ne $registryEntry + + if (-not $registryOk) { + # Diagnostic: show non-Microsoft entries so the failure can be investigated. + Write-Host " [Diag] Entries found in Uninstall hives:" -ForegroundColor DarkGray + $uninstallPaths | ForEach-Object { + Get-ChildItem $_ -ErrorAction SilentlyContinue + } | ForEach-Object { + $dn = try { (Get-ItemProperty $_.PSPath -ErrorAction Stop).DisplayName } catch { $null } + if ($dn -and $dn -notmatch '^(Microsoft|Windows|KB\d)') { + Write-Host " '$dn'" -ForegroundColor DarkGray + } } } + Write-TestResult -Name "Registry uninstall entry" -Pass $registryOk + + # ── Test 7: Launch application & capture GUI ───────────────────────────────── + + if ($exeExists) { + Write-Host "`n--- Launching application ---" -ForegroundColor Yellow + + $appProc = Start-Process -FilePath $exePath -PassThru + Start-Sleep -Seconds 8 + + # Check if process is still running (GUI app should stay alive) + $appRunning = -not $appProc.HasExited + Write-TestResult -Name "Application launches and stays running" -Pass $appRunning + + # Look for the window by title. + # WinRM sessions run in a non-interactive window station; the GUI process + # may have no accessible HWND from this session even when fully running. + # Try .NET MainWindowTitle first, then Win32 FindWindow as a fallback. + # If both come up empty but the process is still alive, count it as a pass + # with a note — the "stays running" check above already validates the install. + $windowFound = $false + $windowNote = "" + $hwnd = [IntPtr]::Zero + + $appProc.Refresh() + if ($appProc.MainWindowTitle -eq $AppName) { + $windowFound = $true + $windowNote = "via .NET MainWindowTitle" + } else { + try { + Add-Type @" + using System; + using System.Runtime.InteropServices; + public class Win32 { + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] + public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); + } +"@ -ErrorAction SilentlyContinue + $hwnd = [Win32]::FindWindow($null, $AppName) + if ($hwnd -ne [IntPtr]::Zero) { + $windowFound = $true + $windowNote = "HWND: $hwnd" + } + } catch { + Write-Host " Warning: Could not check for window title via Win32 API." -ForegroundColor Yellow + } + + if (-not $windowFound -and $appRunning) { + # Process is alive; window just not reachable from the WinRM session. + $windowFound = $true + $windowNote = "process alive - window not accessible from WinRM session" + } + } + Write-TestResult -Name "Main window found by title" -Pass $windowFound -Detail $windowNote + + # Take screenshot of the running application + try { + Start-Sleep -Seconds 2 + $screenshotFile = Take-Screenshot -Name "app_running" + Write-Host " Screenshot saved: $screenshotFile" -ForegroundColor DarkGray + } catch { + Write-Host " Warning: Could not capture screenshot: $_" -ForegroundColor Yellow + } + + # Gracefully close, then ensure the entire process tree is dead before + # the uninstaller runs. PyInstaller onefile spawns a child process that + # continues after the stub exits; taskkill /F /T kills both. The extra + # sleep lets the OS release executable file handles so the uninstaller + # can delete the .exe. + if (-not $appProc.HasExited) { + $appProc.CloseMainWindow() | Out-Null + Start-Sleep -Seconds 3 + } + $procName = [System.IO.Path]::GetFileNameWithoutExtension($ExeName) + taskkill /F /T /IM "$procName.exe" 2>&1 | Out-Null + Start-Sleep -Seconds 3 # Wait for OS to release file handles + } + else { + Write-TestResult -Name "Application launches and stays running" -Pass $false -Detail "Skipped - exe not found" + Write-TestResult -Name "Main window found by title" -Pass $false -Detail "Skipped - exe not found" + } + + # ── Test 8: Silent uninstall ──────────────────────────────────────────────── + + Write-Host "`n--- Running silent uninstall ---" -ForegroundColor Yellow + $uninstallerPath = Join-Path $DefaultInstDir "unins000.exe" + if (Test-Path $uninstallerPath) { + $uninstProc = Start-Process -FilePath $uninstallerPath ` + -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" ` + -Wait -PassThru + $uninstallOk = $uninstProc.ExitCode -eq 0 + Write-TestResult -Name "Silent uninstall completed" -Pass $uninstallOk -Detail "Exit code: $($uninstProc.ExitCode)" + + Start-Sleep -Seconds 2 + # Assert the executable is gone — not the directory. The app may have + # created platform-tools/, a license file, or other runtime files inside + # the install dir during Test 7; Inno Setup only tracks files it installed + # and will not remove a non-empty directory. + $exeGone = -not (Test-Path $exePath) + Write-TestResult -Name "Executable removed after uninstall" -Pass $exeGone -Detail $exePath + } + else { + Write-TestResult -Name "Silent uninstall completed" -Pass $false -Detail "Uninstaller not found" + Write-TestResult -Name "Install directory removed after uninstall" -Pass $false -Detail "Skipped" + } + +} catch { + # Catch unexpected terminating exceptions so the finally block can still save + # results and so the PSSession is not broken by an unhandled remote exception. + $errMsg = "$_" + Write-Host "`nFATAL: Unhandled exception in test suite: $errMsg" -ForegroundColor Red + Write-TestResult -Name "Script execution (fatal error)" -Pass $false -Detail $errMsg } -else { - Write-TestResult -Name "Application launches and stays running" -Pass $false -Detail "Skipped — exe not found" - Write-TestResult -Name "Main window found by title" -Pass $false -Detail "Skipped — exe not found" +finally { + # ── Summary ────────────────────────────────────────────────────────────────── + + Write-Host "" + Write-Host "=============================================" -ForegroundColor Cyan + Write-Host " Test Summary" -ForegroundColor Cyan + Write-Host "=============================================" -ForegroundColor Cyan + Write-Host " Passed : $Script:Passed" -ForegroundColor Green + Write-Host " Failed : $Script:Failed" -ForegroundColor $(if ($Script:Failed -gt 0) { "Red" } else { "Green" }) + Write-Host " Total : $($Script:Passed + $Script:Failed)" -ForegroundColor White + Write-Host "" + + $Script:Results | Format-Table -AutoSize + + # Export results as CSV for retrieval + if ($Script:Results.Count -gt 0) { + $csvPath = Join-Path $ScreenshotDir "test_results.csv" + $Script:Results | Export-Csv -Path $csvPath -NoTypeInformation + Write-Host "Results saved to $csvPath" -ForegroundColor DarkGray + } + + if ($error.Count -gt 0) { + Write-Host "`nScript encountered errors during execution:" -ForegroundColor Red + $error[0] | Format-List -Force + } } -# ── Test 8: Silent uninstall ──────────────────────────────────────────────── - -Write-Host "`n--- Running silent uninstall ---" -ForegroundColor Yellow -$uninstallerPath = Join-Path $DefaultInstDir "unins000.exe" -if (Test-Path $uninstallerPath) { - $uninstProc = Start-Process -FilePath $uninstallerPath ` - -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" ` - -Wait -PassThru - $uninstallOk = $uninstProc.ExitCode -eq 0 - Write-TestResult -Name "Silent uninstall completed" -Pass $uninstallOk -Detail "Exit code: $($uninstProc.ExitCode)" - - Start-Sleep -Seconds 2 - $dirGone = -not (Test-Path $DefaultInstDir) - Write-TestResult -Name "Install directory removed after uninstall" -Pass $dirGone -} -else { - Write-TestResult -Name "Silent uninstall completed" -Pass $false -Detail "Uninstaller not found" - Write-TestResult -Name "Install directory removed after uninstall" -Pass $false -Detail "Skipped" -} - -# ── Summary ────────────────────────────────────────────────────────────────── - -Write-Host "" -Write-Host "=============================================" -ForegroundColor Cyan -Write-Host " Test Summary" -ForegroundColor Cyan -Write-Host "=============================================" -ForegroundColor Cyan -Write-Host " Passed : $Script:Passed" -ForegroundColor Green -Write-Host " Failed : $Script:Failed" -ForegroundColor $(if ($Script:Failed -gt 0) { "Red" } else { "Green" }) -Write-Host " Total : $($Script:Passed + $Script:Failed)" -ForegroundColor White -Write-Host "" - -$Script:Results | Format-Table -AutoSize - -# Export results as CSV for retrieval -$csvPath = Join-Path $ScreenshotDir "test_results.csv" -$Script:Results | Export-Csv -Path $csvPath -NoTypeInformation -Write-Host "Results saved to $csvPath" -ForegroundColor DarkGray - exit $(if ($Script:Failed -gt 0) { 1 } else { 0 })