Files
1jehuang--jcode/scripts/test_windows_setup_evaluation.ps1
Jcode f748173809 fix(windows): upgrade a running launcher safely
Allow the stable launcher path to be replaced while an existing Jcode process is still using the previous executable. Preserve rollback behavior, clean stale launcher backups during later installs and uninstall, and cover live replacement plus injected final-move failure cases.
2026-07-17 02:02:49 -07:00

466 lines
26 KiB
PowerShell

param()
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$installScript = Join-Path $repoRoot 'scripts\install.ps1'
$uninstallScript = Join-Path $repoRoot 'scripts\uninstall.ps1'
$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("jcode-windows-setup-eval-{0}" -f ([guid]::NewGuid().ToString('N')))
$envNames = @(
'LOCALAPPDATA',
'APPDATA',
'USERPROFILE',
'JCODE_HOME',
'TEMP',
'TMP',
'PATH',
'JCODE_INSTALL_PS1_IMPORT_ONLY',
'JCODE_UNINSTALL_PS1_IMPORT_ONLY',
'JCODE_SKIP_SERVER_RELOAD',
'JCODE_INSTALL_SKIP_BINARY_VALIDATION',
'JCODE_DISABLE_ENV_BROADCAST',
'JCODE_WINDOWS_SETUP_SKIP_EXTERNALS'
)
$originalEnv = @{}
foreach ($name in $envNames) {
$originalEnv[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
}
function Restore-TestEnvironment {
foreach ($name in $script:envNames) {
$value = $script:originalEnv[$name]
if ($null -eq $value) {
[Environment]::SetEnvironmentVariable($name, $null, 'Process')
} else {
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
}
}
function Assert-True($Condition, [string]$Message) {
if (-not $Condition) { throw $Message }
}
function Assert-False($Condition, [string]$Message) {
if ($Condition) { throw $Message }
}
function Assert-Equal($Expected, $Actual, [string]$Message) {
if ($Expected -ne $Actual) {
throw "$Message`nExpected: $Expected`nActual: $Actual"
}
}
function Assert-Contains([string]$Haystack, [string]$Needle, [string]$Message) {
if (-not $Haystack.Contains($Needle)) {
throw "$Message`nMissing: $Needle"
}
}
function Assert-NotContains([string]$Haystack, [string]$Needle, [string]$Message) {
if ($Haystack.Contains($Needle)) {
throw "$Message`nUnexpected: $Needle"
}
}
function Assert-PathExists([string]$Path, [string]$Message) {
Assert-True (Test-Path -LiteralPath $Path) $Message
}
function Assert-PathMissing([string]$Path, [string]$Message) {
Assert-False (Test-Path -LiteralPath $Path) $Message
}
function Assert-PathCount([string]$PathValue, [string]$Entry, [int]$ExpectedCount, [string]$Message) {
$entryKey = ConvertTo-JcodePathKey $Entry
$count = 0
foreach ($candidate in (Split-JcodePathList $PathValue)) {
if ((ConvertTo-JcodePathKey $candidate) -eq $entryKey) { $count += 1 }
}
Assert-Equal $ExpectedCount $count $Message
}
function Invoke-Case([string]$Name, [scriptblock]$Body) {
Write-Host "CASE $Name"
& $Body
$script:passedCases += 1
}
function New-IsolatedWindowsProfile([string]$Name) {
$root = Join-Path $script:testRoot $Name
$local = Join-Path $root 'Local App Data'
$profile = Join-Path $root 'User Profile'
$appData = Join-Path $profile 'AppData\Roaming'
$jcodeHome = Join-Path $root 'Jcode Home'
$temp = Join-Path $root 'Temp'
foreach ($dir in @($local, $profile, $appData, $jcodeHome, $temp)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
$env:LOCALAPPDATA = $local
$env:USERPROFILE = $profile
$env:APPDATA = $appData
$env:JCODE_HOME = $jcodeHome
$env:TEMP = $temp
$env:TMP = $temp
return [pscustomobject]@{
Root = $root
LocalAppData = $local
UserProfile = $profile
AppData = $appData
JcodeHome = $jcodeHome
Temp = $temp
InstallDir = Join-Path $local 'jcode\bin'
LauncherPath = Join-Path $local 'jcode\bin\jcode.exe'
BuildsDir = Join-Path $local 'jcode\builds'
SetupHintsPath = Join-Path $jcodeHome 'setup_hints.json'
HotkeyDir = Join-Path $jcodeHome 'hotkey'
StartupShortcutPath = Join-Path $appData 'Microsoft\Windows\Start Menu\Programs\Startup\jcode-hotkey.lnk'
}
}
function Set-InstallScriptProfileGlobals($Profile) {
$script:JcodeHome = $Profile.JcodeHome
$script:HotkeyDir = $Profile.HotkeyDir
$script:SetupHintsPath = $Profile.SetupHintsPath
}
function Get-CimInstance { @() }
$passedCases = 0
$coveredScenarios = [ordered]@{
clean_install = $false
upgrade_idempotency = $false
path_persistence_deduplication = $false
wm_settingchange = $false
copilot_key_mapping = $false
opt_out = $false
uninstall_cleanup = $false
spaces_non_ascii_paths = $false
missing_windows_terminal = $false
rollback_failure = $false
}
New-Item -ItemType Directory -Path $testRoot -Force | Out-Null
try {
$env:JCODE_INSTALL_PS1_IMPORT_ONLY = '1'
$env:JCODE_SKIP_SERVER_RELOAD = '1'
$env:JCODE_INSTALL_SKIP_BINARY_VALIDATION = '1'
. $installScript -SkipAlacrittySetup -SkipHotkeySetup
Invoke-Case 'release_lookup_avoids_unauthenticated_github_api' {
Assert-Equal 'v1.2.3' (Resolve-JcodeReleaseTagFromUri 'https://github.com/1jehuang/jcode/releases/tag/v1.2.3') 'release redirect parser should extract the stable tag'
Assert-Equal 'v1.2.3-rc.1' (Resolve-JcodeReleaseTagFromUri 'https://github.com/1jehuang/jcode/releases/tag/v1.2.3-rc.1?source=latest') 'release redirect parser should stop before query parameters'
Assert-Equal $true (Test-JcodeReleaseTag 'v1.2.3') 'stable release tags should validate'
Assert-Equal $false (Test-JcodeReleaseTag 'latest') 'unversioned release labels should not validate'
$scriptText = Get-Content -LiteralPath $installScript -Raw
Assert-NotContains $scriptText 'api.github.com/repos/$Repo/releases/latest' 'installer should not use the rate-limited unauthenticated GitHub API'
Assert-Contains $scriptText 'jcode.sh/releases' 'installer should include independent static release metadata'
$script:releaseLookupRequests = @()
function Invoke-WebRequest {
param(
[switch]$UseBasicParsing,
[string]$Method,
[Parameter(Mandatory = $true)][string]$Uri,
[string]$OutFile
)
$script:releaseLookupRequests += $Uri
if ($Uri -eq 'https://jcode.sh/releases/latest/version') {
return [pscustomobject]@{ Content = "v1.2.3`n" }
}
if ($Uri -eq 'https://jcode.sh/releases/v1.2.3/download-bases') {
return [pscustomobject]@{ Content = "https://mirror.example/releases/v1.2.3`n" }
}
if ($Uri -eq 'https://github.com/1jehuang/jcode/releases/latest') {
throw 'simulated GitHub block'
}
throw "unexpected URI: $Uri"
}
try {
Assert-Equal 'v1.2.3' (Get-LatestJcodeReleaseTag) 'static metadata should cover a blocked GitHub release lookup'
$bases = @(Get-JcodeReleaseDownloadBases 'v1.2.3')
Assert-Equal 'https://mirror.example/releases/v1.2.3' $bases[0] 'configured mirror should be preferred'
Assert-Equal 'https://github.com/1jehuang/jcode/releases/download/v1.2.3' $bases[1] 'GitHub should remain the final fallback'
} finally {
Remove-Item Function:\Invoke-WebRequest -ErrorAction SilentlyContinue
}
}
Invoke-Case 'path_persistence_and_deduplication' {
$profile = New-IsolatedWindowsProfile 'path-dedupe'
$installVariant = ($profile.InstallDir.ToUpperInvariant() + '\')
$currentPath = "C:\Tools;$installVariant;$($profile.InstallDir);C:\Tools\;C:\Other"
$pathUpdate = Resolve-JcodePathUpdate -InstallDir $profile.InstallDir -CurrentPath $currentPath
Assert-Equal "$($profile.InstallDir);C:\Tools;C:\Tools\;C:\Other" $pathUpdate.Path 'install PATH update should prepend the canonical launcher dir without rewriting unrelated entries'
Assert-PathCount $pathUpdate.Path $profile.InstallDir 1 'updated PATH should contain exactly one jcode launcher dir'
Assert-Equal 2 $pathUpdate.RemovedManagedEntries 'PATH update should remove both stale jcode launcher entries before re-adding one'
Assert-Equal 0 $pathUpdate.RemovedDuplicateEntries 'PATH update should preserve unrelated duplicate entries'
$script:setCalls = 0
$script:broadcastCalls = 0
$script:appliedPath = $null
$setPathAction = { param($value) $script:setCalls += 1; $script:appliedPath = $value }
$broadcastAction = { $script:broadcastCalls += 1 }
$first = Set-JcodeUserPath -InstallDir $profile.InstallDir -CurrentPath 'C:\Tools' -SetUserPathAction $setPathAction -BroadcastAction $broadcastAction
Assert-Equal 1 $script:setCalls 'user PATH setter should be called once when PATH changes'
Assert-Equal 1 $script:broadcastCalls 'environment broadcast should be called once when PATH changes'
Assert-Equal $true $first.Broadcasted 'changed PATH update should report a broadcast'
$second = Set-JcodeUserPath -InstallDir $profile.InstallDir -CurrentPath $script:appliedPath -SetUserPathAction $setPathAction -BroadcastAction $broadcastAction
Assert-Equal 1 $script:setCalls 'user PATH setter should not be called when PATH is already correct'
Assert-Equal 1 $script:broadcastCalls 'environment broadcast should not be called when PATH is unchanged'
Assert-Equal $false $second.Broadcasted 'unchanged PATH update should not report a broadcast'
$script:coveredScenarios.path_persistence_deduplication = $true
}
Invoke-Case 'wm_settingchange_broadcast_contract' {
$text = Get-Content -LiteralPath $installScript -Raw
Assert-Contains $text '0x001A' 'installer should broadcast WM_SETTINGCHANGE'
Assert-Contains $text '"Environment"' 'installer should broadcast the Environment lParam'
Assert-Contains $text 'SendMessageTimeout([IntPtr]0xffff' 'installer should broadcast to HWND_BROADCAST'
$env:JCODE_DISABLE_ENV_BROADCAST = '1'
Assert-Equal $false (Send-JcodeEnvironmentChangedBroadcast) 'broadcast helper should honor the deterministic no-mutate opt-out'
$env:JCODE_DISABLE_ENV_BROADCAST = $null
$script:coveredScenarios.wm_settingchange = $true
}
$script:mockUserPath = $null
$script:pathWrites = 0
$script:pathBroadcasts = 0
function Set-JcodeUserPath {
param(
[Parameter(Mandatory = $true)][string]$InstallDir,
[AllowNull()][string]$CurrentPath,
[scriptblock]$SetUserPathAction,
[scriptblock]$BroadcastAction,
[bool]$Broadcast = $true
)
$update = Resolve-JcodePathUpdate -InstallDir $InstallDir -CurrentPath $script:mockUserPath
if ($update.Changed) {
$script:mockUserPath = $update.Path
$script:pathWrites += 1
if ($Broadcast) { $script:pathBroadcasts += 1 }
}
$update | Add-Member -NotePropertyName Broadcasted -NotePropertyValue ([bool]($update.Changed -and $Broadcast))
return $update
}
function Test-AlacrittyInstalled { return $false }
function Find-AlacrittyPath { return $null }
Invoke-Case 'clean_install_isolated_profile_and_opt_out' {
$profile = New-IsolatedWindowsProfile 'clean-install'
Set-InstallScriptProfileGlobals $profile
$source = Join-Path $profile.Root 'jcode-v1.exe'
Set-Content -Path $source -Value 'version-one' -NoNewline
$script:mockUserPath = 'C:\Tools'
$script:pathWrites = 0
$script:pathBroadcasts = 0
$script:InstallDir = $profile.InstallDir
$script:Version = 'v0.0.1-eval'
$script:ArtifactExePath = $source
$script:ArtifactTgzPath = $null
$script:SkipAlacrittySetup = $true
$script:SkipHotkeySetup = $true
Invoke-JcodeInstall
Assert-PathExists $profile.LauncherPath 'clean install should create the launcher in the isolated LOCALAPPDATA tree'
Assert-Equal 'version-one' (Get-Content -LiteralPath $profile.LauncherPath -Raw) 'launcher should contain the local artifact contents'
Assert-PathExists (Join-Path $profile.BuildsDir 'stable\jcode.exe') 'clean install should populate the stable build channel'
Assert-PathExists (Join-Path $profile.BuildsDir 'versions\0.0.1-eval\jcode.exe') 'clean install should populate the immutable versioned build'
Assert-PathCount $script:mockUserPath $profile.InstallDir 1 'clean install should persist exactly one launcher PATH entry in the mocked user PATH'
Assert-Equal 1 $script:pathWrites 'clean install should write mocked user PATH once'
Assert-Equal 1 $script:pathBroadcasts 'clean install should broadcast exactly once for a PATH change'
Assert-PathExists $profile.SetupHintsPath 'clean install should write setup hints into isolated JCODE_HOME'
$state = Get-Content -LiteralPath $profile.SetupHintsPath -Raw | ConvertFrom-Json
Assert-Equal $false $state.hotkey_configured 'SkipHotkeySetup should record hotkey opt-out without configuring the listener'
Assert-Equal $false $state.alacritty_configured 'SkipAlacrittySetup should avoid deterministic terminal installation side effects'
Assert-PathMissing $profile.HotkeyDir 'hotkey opt-out should not create hotkey files'
$script:coveredScenarios.clean_install = $true
$script:coveredScenarios.opt_out = $true
}
Invoke-Case 'upgrade_and_idempotency_do_not_duplicate_path' {
$profile = New-IsolatedWindowsProfile 'upgrade-install'
Set-InstallScriptProfileGlobals $profile
$sourceV1 = Join-Path $profile.Root 'jcode-v1.exe'
$sourceV2 = Join-Path $profile.Root 'jcode-v2.exe'
Set-Content -Path $sourceV1 -Value 'version-one' -NoNewline
Set-Content -Path $sourceV2 -Value 'version-two' -NoNewline
$script:mockUserPath = 'C:\Tools'
$script:pathWrites = 0
$script:pathBroadcasts = 0
$script:InstallDir = $profile.InstallDir
$script:SkipAlacrittySetup = $true
$script:SkipHotkeySetup = $true
$script:Version = 'v1.0.0-eval'
$script:ArtifactExePath = $sourceV1
Invoke-JcodeInstall
$writesAfterFirstInstall = $script:pathWrites
$broadcastsAfterFirstInstall = $script:pathBroadcasts
$script:Version = 'v1.0.1-eval'
$script:ArtifactExePath = $sourceV2
Invoke-JcodeInstall
Assert-Equal 'version-two' (Get-Content -LiteralPath $profile.LauncherPath -Raw) 'upgrade should replace launcher contents with the new build'
Assert-PathCount $script:mockUserPath $profile.InstallDir 1 'upgrade should preserve exactly one launcher PATH entry'
Assert-Equal $writesAfterFirstInstall $script:pathWrites 'upgrade should not rewrite PATH when it is already correct'
Assert-Equal $broadcastsAfterFirstInstall $script:pathBroadcasts 'upgrade should not rebroadcast when PATH is unchanged'
Invoke-JcodeInstall
Assert-PathCount $script:mockUserPath $profile.InstallDir 1 'reinstalling same version should remain idempotent for PATH'
Assert-Equal $writesAfterFirstInstall $script:pathWrites 'idempotent reinstall should not write PATH again'
$script:coveredScenarios.upgrade_idempotency = $true
}
Invoke-Case 'copilot_key_mapping_and_spaces_non_ascii_paths' {
$profile = New-IsolatedWindowsProfile 'hotkey-spaces-nonascii'
Set-InstallScriptProfileGlobals $profile
$env:JCODE_WINDOWS_SETUP_SKIP_EXTERNALS = '1'
$jcodeExe = Join-Path $profile.Root '路径 With Spaces\jcode.exe'
New-Item -ItemType Directory -Path (Split-Path -Parent $jcodeExe) -Force | Out-Null
Set-Content -Path $jcodeExe -Value 'fake exe' -NoNewline
New-Item -ItemType Directory -Path $profile.HotkeyDir -Force | Out-Null
Set-Content -Path (Join-Path $profile.HotkeyDir 'jcode-hotkey.ps1') -Value 'legacy listener' -Force
$ok = Install-JcodeHotkey -JcodeExePath $jcodeExe
Assert-Equal $true $ok 'hotkey install should succeed using the deterministic external-command skip hook'
$vbsPath = Join-Path $profile.HotkeyDir 'jcode-hotkey-launcher.vbs'
Assert-PathMissing $vbsPath 'hotkey install should remove the legacy hidden VBScript trampoline'
$shortcutScriptPath = Join-Path $profile.HotkeyDir 'jcode-hotkey-shortcut.ps1'
Assert-PathExists $shortcutScriptPath 'hotkey install should render the deterministic Startup shortcut script under the isolated JCODE_HOME'
$shortcutScript = Get-Content -LiteralPath $shortcutScriptPath -Raw
Assert-Contains $shortcutScript 'powershell.exe' 'Startup shortcut should target PowerShell directly'
Assert-Contains $shortcutScript 'ExecutionPolicy RemoteSigned' 'Startup shortcut should use RemoteSigned execution policy'
Assert-NotContains $shortcutScript 'ExecutionPolicy Bypass' 'Startup shortcut should not bypass execution policy'
Assert-Contains $shortcutScript 'setup-hotkey --listen-windows-hotkey' 'Startup shortcut should start the native Windows hotkey listener'
Assert-Contains $shortcutScript $jcodeExe 'Startup shortcut should preserve spaces and non-ASCII characters in the jcode path'
Assert-PathMissing (Join-Path $profile.HotkeyDir 'jcode-hotkey.ps1') 'hotkey upgrade should remove the legacy PowerShell listener'
$scriptText = Get-Content -LiteralPath $installScript -Raw
Assert-Contains $scriptText 'Configured Alt+; and the Copilot key' 'installer should document both Windows launch-key mappings'
$script:coveredScenarios.copilot_key_mapping = $true
$script:coveredScenarios.spaces_non_ascii_paths = $true
}
Invoke-Case 'missing_windows_terminal_is_not_required' {
$profile = New-IsolatedWindowsProfile 'missing-windows-terminal'
Set-InstallScriptProfileGlobals $profile
$env:JCODE_WINDOWS_SETUP_SKIP_EXTERNALS = '1'
$env:WT_SESSION = $null
$env:WindowsTerminal = $null
$jcodeExe = Join-Path $profile.Root 'No Windows Terminal\jcode.exe'
New-Item -ItemType Directory -Path (Split-Path -Parent $jcodeExe) -Force | Out-Null
Set-Content -Path $jcodeExe -Value 'fake exe' -NoNewline
Assert-Equal $true (Install-JcodeHotkey -JcodeExePath $jcodeExe) 'hotkey setup should not require Windows Terminal to be installed or active'
$scriptText = Get-Content -LiteralPath $installScript -Raw
Assert-NotContains $scriptText 'wt.exe' 'installer should not shell out to Windows Terminal for hotkey setup'
$script:coveredScenarios.missing_windows_terminal = $true
}
Invoke-Case 'launcher_rollback_failure_preserves_existing_install' {
$profile = New-IsolatedWindowsProfile 'rollback-failure'
New-Item -ItemType Directory -Path $profile.InstallDir -Force | Out-Null
Set-Content -Path $profile.LauncherPath -Value 'known-good' -NoNewline
$missingSource = Join-Path $profile.Root 'missing-source.exe'
$threw = $false
try {
Install-JcodeLauncher -SourcePath $missingSource -LauncherPath $profile.LauncherPath | Out-Null
} catch {
$threw = $true
}
Assert-Equal $true $threw 'launcher install should surface copy failures'
Assert-Equal 'known-good' (Get-Content -LiteralPath $profile.LauncherPath -Raw) 'failed launcher install should preserve the existing launcher'
$tempLaunchers = @(Get-ChildItem -LiteralPath $profile.InstallDir -Filter '.jcode-launcher-*.tmp.exe' -Force -ErrorAction SilentlyContinue)
Assert-Equal 0 $tempLaunchers.Count 'failed launcher install should not leave temporary launcher files behind'
$script:coveredScenarios.rollback_failure = $true
}
$env:JCODE_UNINSTALL_PS1_IMPORT_ONLY = '1'
. $uninstallScript
function Get-CimInstance { @() }
$script:uninstallUserPath = $null
$script:uninstallSetCalls = 0
$script:uninstallBroadcasts = 0
function Remove-JcodeUserPath {
param(
[Parameter(Mandatory = $true)][string]$InstallDir,
[AllowNull()][string]$CurrentPath,
[scriptblock]$SetUserPathAction,
[scriptblock]$BroadcastAction,
[bool]$Broadcast = $true
)
$update = Resolve-JcodePathRemoval -InstallDir $InstallDir -CurrentPath $script:uninstallUserPath
if ($update.Changed) {
$script:uninstallUserPath = $update.Path
$script:uninstallSetCalls += 1
if ($Broadcast) { $script:uninstallBroadcasts += 1 }
}
$update | Add-Member -NotePropertyName Broadcasted -NotePropertyValue ([bool]($update.Changed -and $Broadcast))
return $update
}
Invoke-Case 'uninstall_guards_purge_paths_and_process_scope' {
$profile = New-IsolatedWindowsProfile 'uninstall-safety'
Assert-Equal $true (Test-JcodeSafePurgePath $profile.JcodeHome) 'dedicated jcode data directories should be purgeable'
Assert-Equal $false (Test-JcodeSafePurgePath $profile.UserProfile) 'the user profile must never be accepted as a purge target'
Assert-Equal $false (Test-JcodeSafePurgePath $profile.Root) 'a parent workspace must never be accepted as a purge target'
Assert-Equal $true (Test-JcodeManagedExecutablePath -ExecutablePath $profile.LauncherPath -LauncherPath $profile.LauncherPath -BuildsDir $profile.BuildsDir) 'the installed launcher should be recognized as managed'
Assert-Equal $true (Test-JcodeManagedExecutablePath -ExecutablePath (Join-Path $profile.InstallDir '.jcode-launcher-old-a1b2c3.exe') -LauncherPath $profile.LauncherPath -BuildsDir $profile.BuildsDir) 'a renamed live-upgrade launcher should be recognized as managed'
Assert-Equal $false (Test-JcodeManagedExecutablePath -ExecutablePath (Join-Path $profile.InstallDir 'other-tool.exe') -LauncherPath $profile.LauncherPath -BuildsDir $profile.BuildsDir) 'unrelated executables beside the launcher must not be terminated'
Assert-Equal $true (Test-JcodeManagedExecutablePath -ExecutablePath (Join-Path $profile.BuildsDir 'stable\jcode.exe') -LauncherPath $profile.LauncherPath -BuildsDir $profile.BuildsDir) 'installed version binaries should be recognized as managed'
Assert-Equal $false (Test-JcodeManagedExecutablePath -ExecutablePath (Join-Path $profile.Root 'development\jcode.exe') -LauncherPath $profile.LauncherPath -BuildsDir $profile.BuildsDir) 'unrelated development binaries must not be terminated'
}
Invoke-Case 'uninstall_cleanup_removes_binaries_path_and_keeps_user_data' {
$profile = New-IsolatedWindowsProfile 'uninstall-cleanup'
New-Item -ItemType Directory -Path $profile.InstallDir -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $profile.BuildsDir 'stable') -Force | Out-Null
New-Item -ItemType Directory -Path $profile.JcodeHome -Force | Out-Null
New-Item -ItemType Directory -Path $profile.HotkeyDir -Force | Out-Null
New-Item -ItemType Directory -Path (Split-Path -Parent $profile.StartupShortcutPath) -Force | Out-Null
Set-Content -Path $profile.LauncherPath -Value 'installed launcher' -NoNewline
$oldLauncherPath = Join-Path $profile.InstallDir '.jcode-launcher-old-a1b2c3.exe'
Set-Content -Path $oldLauncherPath -Value 'previous running launcher' -NoNewline
Set-Content -Path (Join-Path $profile.BuildsDir 'stable\jcode.exe') -Value 'stable build' -NoNewline
Set-Content -Path (Join-Path $profile.JcodeHome 'config.toml') -Value 'kept = true' -NoNewline
Set-Content -Path (Join-Path $profile.HotkeyDir 'jcode-hotkey.ps1') -Value 'legacy listener' -NoNewline
Set-Content -Path $profile.StartupShortcutPath -Value 'startup shortcut' -NoNewline
@{ hotkey_configured = $true; hotkey_dismissed = $false } | ConvertTo-Json | Set-Content -Path $profile.SetupHintsPath -Encoding UTF8
$installVariant = ($profile.InstallDir.ToUpperInvariant() + '\')
$script:uninstallUserPath = "$($profile.InstallDir);C:\Keep;$installVariant"
$script:uninstallSetCalls = 0
$script:uninstallBroadcasts = 0
$exitCode = Invoke-JcodeUninstall -InstallDir $profile.InstallDir -Yes
Assert-Equal 0 $exitCode 'uninstall should complete successfully in the isolated profile'
Assert-PathMissing $profile.LauncherPath 'uninstall should remove the launcher'
Assert-PathMissing $oldLauncherPath 'uninstall should remove renamed live-upgrade launchers'
Assert-PathMissing $profile.InstallDir 'uninstall should remove the empty launcher directory'
Assert-PathMissing $profile.BuildsDir 'uninstall should remove installed build binaries'
Assert-PathMissing $profile.StartupShortcutPath 'uninstall should remove the launch-hotkey Startup shortcut'
Assert-PathMissing (Join-Path $profile.HotkeyDir 'jcode-hotkey.ps1') 'uninstall should remove legacy launch-hotkey artifacts'
Assert-PathExists (Join-Path $profile.JcodeHome 'config.toml') 'uninstall without -Purge should keep user data'
$setupHints = Get-Content -LiteralPath $profile.SetupHintsPath -Raw | ConvertFrom-Json
Assert-Equal $false $setupHints.hotkey_configured 'uninstall should clear the persisted hotkey-configured state'
Assert-Equal $true $setupHints.hotkey_dismissed 'uninstall should keep the removed hotkey prompt dismissed'
Assert-Equal 'C:\Keep' $script:uninstallUserPath 'uninstall should remove all jcode-managed PATH variants and keep unrelated entries'
Assert-Equal 1 $script:uninstallSetCalls 'uninstall should write mocked user PATH once when cleanup changes it'
Assert-Equal 1 $script:uninstallBroadcasts 'uninstall should broadcast once after PATH cleanup'
$script:coveredScenarios.uninstall_cleanup = $true
}
$missingScenarios = @($coveredScenarios.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key })
Assert-Equal 0 $missingScenarios.Count ("missing scenario coverage: {0}" -f ($missingScenarios -join ', '))
Write-Host "Scenario checklist: $($coveredScenarios.Count)/$($coveredScenarios.Count) requested Windows setup scenarios covered." -ForegroundColor Green
Write-Host "All $passedCases Windows setup evaluation cases passed." -ForegroundColor Green
} finally {
Restore-TestEnvironment
Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue
}