| 1 | #Requires -Version 5.1 |
| 2 | |
| 3 | [CmdletBinding()] |
| 4 | param() |
| 5 | |
| 6 | Set-StrictMode -Version Latest |
| 7 | $ErrorActionPreference = "Stop" |
| 8 | |
| 9 | $scoopVersion = "v0.5.3" |
| 10 | $appName = "reasonix-desktop" |
| 11 | $appVersion = "0.0.0-ci" |
| 12 | $activeVersion = "v$appVersion" |
| 13 | $shortcutName = "Reasonix Desktop" |
| 14 | $repoRoot = Split-Path -Parent $PSScriptRoot |
| 15 | $workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("reasonix-scoop-launch-" + [guid]::NewGuid().ToString("N")) |
| 16 | $fixtureRoot = Join-Path $workRoot "fixture" |
| 17 | $packageRoot = Join-Path $fixtureRoot "package" |
| 18 | $scoopRoot = Join-Path $workRoot "scoop-root" |
| 19 | $scoopHome = Join-Path $scoopRoot "apps\scoop\current" |
| 20 | $manifestPath = Join-Path $fixtureRoot "$appName.json" |
| 21 | $archivePath = Join-Path $fixtureRoot "reasonix-windows-portable.zip" |
| 22 | $markerPath = Join-Path $workRoot "desktop-launched.json" |
| 23 | $shortcutPath = Join-Path ([Environment]::GetFolderPath("StartMenu")) "Programs\Scoop Apps\$shortcutName.lnk" |
| 24 | $server = $null |
| 25 | |
| 26 | function Invoke-Native { |
| 27 | param( |
| 28 | [Parameter(Mandatory = $true)] |
| 29 | [string]$FilePath, |
| 30 | |
| 31 | [Parameter(ValueFromRemainingArguments = $true)] |
| 32 | [string[]]$ArgumentList |
| 33 | ) |
| 34 | |
| 35 | & $FilePath @ArgumentList |
| 36 | if ($LASTEXITCODE -ne 0) { |
| 37 | throw "Command failed with exit code $LASTEXITCODE`: $FilePath $($ArgumentList -join ' ')" |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | function Write-Utf8NoBom { |
| 42 | param( |
| 43 | [Parameter(Mandatory = $true)] |
| 44 | [string]$Path, |
| 45 | |
| 46 | [Parameter(Mandatory = $true)] |
| 47 | [string]$Content |
| 48 | ) |
| 49 | |
| 50 | $encoding = New-Object System.Text.UTF8Encoding($false) |
| 51 | [System.IO.File]::WriteAllText($Path, $Content, $encoding) |
| 52 | } |
| 53 | |
| 54 | try { |
| 55 | if ($env:OS -ne "Windows_NT") { |
| 56 | throw "This integration test must run on Windows." |
| 57 | } |
| 58 | if (Test-Path -LiteralPath $shortcutPath) { |
| 59 | throw "Refusing to overwrite an existing shortcut: $shortcutPath" |
| 60 | } |
| 61 | |
| 62 | New-Item -ItemType Directory -Force -Path (Join-Path $packageRoot "versions\$activeVersion") | Out-Null |
| 63 | New-Item -ItemType Directory -Force -Path (Split-Path -Parent $scoopHome) | Out-Null |
| 64 | |
| 65 | $launcherPath = Join-Path $packageRoot "reasonix-launcher.exe" |
| 66 | Push-Location $repoRoot |
| 67 | try { |
| 68 | Invoke-Native -FilePath "go" -ArgumentList @( |
| 69 | "build", "-trimpath", "-ldflags", "-s -w -H windowsgui -X main.version=$appVersion", |
| 70 | "-o", $launcherPath, "./cmd/reasonix-launcher" |
| 71 | ) |
| 72 | } finally { |
| 73 | Pop-Location |
| 74 | } |
| 75 | |
| 76 | $fixtureSource = Join-Path $fixtureRoot "desktop-fixture.go" |
| 77 | $desktopPath = Join-Path $packageRoot "versions\$activeVersion\reasonix-desktop.exe" |
| 78 | Write-Utf8NoBom -Path $fixtureSource -Content @' |
| 79 | package main |
| 80 | |
| 81 | import ( |
| 82 | "encoding/json" |
| 83 | "os" |
| 84 | ) |
| 85 | |
| 86 | func main() { |
| 87 | marker := os.Getenv("REASONIX_SCOOP_MARKER") |
| 88 | executable, _ := os.Executable() |
| 89 | workingDirectory, _ := os.Getwd() |
| 90 | payload, _ := json.Marshal(map[string]any{ |
| 91 | "args": os.Args[1:], |
| 92 | "executable": executable, |
| 93 | "workingDir": workingDirectory, |
| 94 | }) |
| 95 | if marker == "" || os.WriteFile(marker, payload, 0o600) != nil { |
| 96 | os.Exit(1) |
| 97 | } |
| 98 | } |
| 99 | '@ |
| 100 | Invoke-Native -FilePath "go" -ArgumentList @( |
| 101 | "build", "-trimpath", "-ldflags", "-s -w -H windowsgui", "-o", $desktopPath, $fixtureSource |
| 102 | ) |
| 103 | |
| 104 | Write-Utf8NoBom -Path (Join-Path $packageRoot "current.json") -Content @" |
| 105 | { |
| 106 | "schemaVersion": 1, |
| 107 | "activeVersion": "$activeVersion", |
| 108 | "activeDir": "versions/$activeVersion" |
| 109 | } |
| 110 | "@ |
| 111 | |
| 112 | Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $archivePath -CompressionLevel Optimal |
| 113 | $archiveHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archivePath).Hash.ToLowerInvariant() |
| 114 | |
| 115 | $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) |
| 116 | $listener.Start() |
| 117 | $port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port |
| 118 | $listener.Stop() |
| 119 | |
| 120 | $python = Get-Command python -ErrorAction Stop |
| 121 | $serverOut = Join-Path $workRoot "http-server.stdout.log" |
| 122 | $serverErr = Join-Path $workRoot "http-server.stderr.log" |
| 123 | $server = Start-Process -FilePath $python.Source -ArgumentList @( |
| 124 | "-m", "http.server", $port, "--bind", "127.0.0.1", "--directory", $fixtureRoot |
| 125 | ) -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -PassThru |
| 126 | |
| 127 | $archiveUrl = "http://127.0.0.1:$port/$(Split-Path -Leaf $archivePath)" |
| 128 | $ready = $false |
| 129 | for ($attempt = 0; $attempt -lt 40; $attempt++) { |
| 130 | try { |
| 131 | $response = Invoke-WebRequest -UseBasicParsing -Uri $archiveUrl -Method Head -TimeoutSec 2 |
| 132 | if ($response.StatusCode -eq 200) { |
| 133 | $ready = $true |
| 134 | break |
| 135 | } |
| 136 | } catch { |
| 137 | Start-Sleep -Milliseconds 250 |
| 138 | } |
| 139 | } |
| 140 | if (!$ready) { |
| 141 | throw "Fixture HTTP server did not become ready." |
| 142 | } |
| 143 | |
| 144 | Write-Utf8NoBom -Path $manifestPath -Content @" |
| 145 | { |
| 146 | "version": "$appVersion", |
| 147 | "description": "Reasonix Scoop integration fixture", |
| 148 | "homepage": "https://github.com/esengine/DeepSeek-Reasonix", |
| 149 | "license": "Apache-2.0", |
| 150 | "url": "$archiveUrl", |
| 151 | "hash": "$archiveHash", |
| 152 | "shortcuts": [ |
| 153 | ["reasonix-launcher.exe", "$shortcutName", "launch --detach"] |
| 154 | ] |
| 155 | } |
| 156 | "@ |
| 157 | |
| 158 | Invoke-Native -FilePath "git" -ArgumentList @( |
| 159 | "clone", "--branch", $scoopVersion, "--depth", "1", "https://github.com/ScoopInstaller/Scoop.git", $scoopHome |
| 160 | ) |
| 161 | $env:SCOOP = $scoopRoot |
| 162 | $env:SCOOP_CACHE = Join-Path $workRoot "scoop-cache" |
| 163 | $env:REASONIX_SCOOP_MARKER = $markerPath |
| 164 | $scoopCommand = Join-Path $scoopHome "bin\scoop.ps1" |
| 165 | Invoke-Native -FilePath "powershell.exe" -ArgumentList @( |
| 166 | "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $scoopCommand, |
| 167 | "install", "--no-update-scoop", $manifestPath |
| 168 | ) |
| 169 | |
| 170 | $currentPath = Join-Path $scoopRoot "apps\$appName\current" |
| 171 | $currentItem = Get-Item -LiteralPath $currentPath -Force |
| 172 | if (($currentItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) { |
| 173 | throw "Scoop current path is not a directory junction: $currentPath" |
| 174 | } |
| 175 | |
| 176 | if (!(Test-Path -LiteralPath $shortcutPath -PathType Leaf)) { |
| 177 | throw "Scoop did not create the expected shortcut: $shortcutPath" |
| 178 | } |
| 179 | $shell = New-Object -ComObject WScript.Shell |
| 180 | $shortcut = $shell.CreateShortcut($shortcutPath) |
| 181 | $expectedTarget = Join-Path $currentPath "reasonix-launcher.exe" |
| 182 | if (![string]::Equals($shortcut.TargetPath, $expectedTarget, [System.StringComparison]::OrdinalIgnoreCase)) { |
| 183 | throw "Unexpected shortcut target. Expected '$expectedTarget', got '$($shortcut.TargetPath)'." |
| 184 | } |
| 185 | if ($shortcut.Arguments -ne "launch --detach") { |
| 186 | throw "Unexpected shortcut arguments: '$($shortcut.Arguments)'." |
| 187 | } |
| 188 | |
| 189 | Start-Process -FilePath $shortcutPath |
| 190 | $deadline = [DateTime]::UtcNow.AddSeconds(30) |
| 191 | while (!(Test-Path -LiteralPath $markerPath) -and [DateTime]::UtcNow -lt $deadline) { |
| 192 | Start-Sleep -Milliseconds 250 |
| 193 | } |
| 194 | if (!(Test-Path -LiteralPath $markerPath)) { |
| 195 | throw "The desktop fixture was not launched through the Scoop shortcut." |
| 196 | } |
| 197 | |
| 198 | $launch = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json |
| 199 | $versionPath = Join-Path $scoopRoot "apps\$appName\$appVersion" |
| 200 | $expectedDesktop = Join-Path $versionPath "versions\$activeVersion\reasonix-desktop.exe" |
| 201 | if (![string]::Equals($launch.executable, $expectedDesktop, [System.StringComparison]::OrdinalIgnoreCase)) { |
| 202 | throw "Unexpected desktop executable. Expected '$expectedDesktop', got '$($launch.executable)'." |
| 203 | } |
| 204 | if (![string]::Equals($launch.workingDir, $versionPath, [System.StringComparison]::OrdinalIgnoreCase)) { |
| 205 | throw "Unexpected launcher working directory. Expected '$versionPath', got '$($launch.workingDir)'." |
| 206 | } |
| 207 | if (@($launch.args).Count -ne 0) { |
| 208 | throw "Legacy shortcut arguments reached the desktop fixture: $($launch.args -join ' ')" |
| 209 | } |
| 210 | |
| 211 | Write-Host "Scoop install, current junction, shortcut, and desktop launch verified." |
| 212 | } finally { |
| 213 | if ($server -and !$server.HasExited) { |
| 214 | Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue |
| 215 | } |
| 216 | if (Test-Path -LiteralPath $shortcutPath) { |
| 217 | Remove-Item -LiteralPath $shortcutPath -Force -ErrorAction SilentlyContinue |
| 218 | } |
| 219 | if (Test-Path -LiteralPath $workRoot) { |
| 220 | Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue |
| 221 | } |
| 222 | } |
| 223 |