param([string]$BuildDirectory = (Join-Path $PSScriptRoot '../build/staged')) $ErrorActionPreference = 'Stop' if (Get-Process KSwitcher,'KSwitcher.Config' -ErrorAction SilentlyContinue) { throw 'Close KSwitcher and its configurator before this isolated process test.' } Add-Type @' using System; using System.Runtime.InteropServices; public static class ConfigurationTestUi { [StructLayout(LayoutKind.Explicit, Size=40)] public struct Input { [FieldOffset(0)] public uint type; [FieldOffset(8)] public ushort key; [FieldOffset(12)] public uint flags; } [DllImport("user32.dll")] public static extern uint SendInput(uint count,Input[] input,int size); public static uint PressF24() { return SendInput(2,new Input[]{new Input{type=1,key=0x87},new Input{type=1,key=0x87,flags=2}},40); } [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr w,uint msg,IntPtr count,System.Text.StringBuilder text); [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls,string title); [DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr w,int id); [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp); [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr w,uint msg,IntPtr wp,IntPtr lp); [DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SetText(IntPtr w,uint msg,IntPtr wp,string text); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr w,out uint pid); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a,uint b,bool attach); [DllImport("user32.dll")] public static extern IntPtr SetFocus(IntPtr w); public static bool FocusControl(IntPtr w) { uint pid; uint target=GetWindowThreadProcessId(w,out pid), current=GetCurrentThreadId(); if(!AttachThreadInput(current,target,true)) return false; try { SetFocus(w); return true; } finally { AttachThreadInput(current,target,false); } } } '@ $root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-config-tests-'+[guid]::NewGuid().ToString('N')) $processes=[Collections.Generic.List[Diagnostics.Process]]::new() $checks=0 function Assert($ok,$label) { if (!$ok) { throw $label }; $script:checks++ } function Window($class) { for($i=0;$i -lt 100;$i++) { $title=switch($class){'KSwitcher.Main'{'KSwitcher'};'KSwitcher.Settings'{'KSwitcher settings'};'KSwitcher.Snippets'{'Text snippets'}} $window=[ConfigurationTestUi]::FindWindow($class,$title) if($window -ne [IntPtr]::Zero){return $window} Start-Sleep -Milliseconds 50 } foreach($process in $processes){$process.Refresh(); Write-Host ("Diagnostic process {0}: exited={1} title={2}" -f $process.Id,$process.HasExited,$process.MainWindowTitle)} throw "Window did not appear: $class" } function Launch($file,$argument) { $options=@{FilePath=(Join-Path $root $file);PassThru=$true;WindowStyle='Hidden'} if($argument){$options.ArgumentList=$argument} $process=Start-Process @options $processes.Add($process) return $process } function Click($window,$id) { $null=[ConfigurationTestUi]::PostMessage($window,0x111,[IntPtr]$id,[IntPtr]::Zero) } try { New-Item -ItemType Directory -Path (Join-Path $root 'data') -Force | Out-Null foreach($file in @('KSwitcher.exe','KSwitcher.Config.exe')) { Copy-Item -LiteralPath (Join-Path $BuildDirectory $file) -Destination $root } # No clipboard history capture, auto-correction, autostart or user data in the fixture. $settingsPath=Join-Path $root 'data/settings.json' @{format='kswitcher-settings';schemaVersion=1;historyEnabled=$false;autoCorrectLayout=$false;writeLog=$false} | ConvertTo-Json | Set-Content -LiteralPath $settingsPath -Encoding utf8 $resident=Launch 'KSwitcher.exe' '' $main=Window 'KSwitcher.Main' $config=Launch 'KSwitcher.Config.exe' '' $settings=Window 'KSwitcher.Settings' [uint32]$ownerId=0 $null=[ConfigurationTestUi]::GetWindowThreadProcessId($settings,[ref]$ownerId) Assert ($ownerId -eq $config.Id -and $ownerId -ne $resident.Id) 'settings live in the separate process' $second=Launch 'KSwitcher.Config.exe' '' Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'second launch forwards to existing instance' Assert ((Window 'KSwitcher.Settings') -eq $settings) 'existing settings window reused' # Only inject into the isolated fixture window, never another foreground app. Assert ([ConfigurationTestUi]::GetForegroundWindow() -eq $settings) 'fixture owns foreground before shortcut capture test' $field=[ConfigurationTestUi]::GetDlgItem($settings,210) Assert ([ConfigurationTestUi]::FocusControl($field)) 'shortcut field receives actual focus' Assert ([ConfigurationTestUi]::PressF24() -eq 2) 'test F24 events accepted' $text=[Text.StringBuilder]::new(128) for($i=0;$i -lt 100;$i++) { $null=$text.Clear() $null=[ConfigurationTestUi]::GetText($field,0x0d,[IntPtr]128,$text) if($text.ToString() -eq 'F24'){break} Start-Sleep -Milliseconds 20 } Assert ($text.ToString() -eq 'F24') 'resident hook forwards shortcut capture to configurator' $null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($settings,240),0x0c,[IntPtr]::Zero,'321') Click $settings 1 Assert ($config.WaitForExit(6000) -and $config.ExitCode -eq 0) 'save exits configurator' Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'save committed through resident process' Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).toggleHotkey -eq 'F24') 'captured shortcut persisted by resident' $resident.Refresh(); Assert (!$resident.HasExited) 'resident survives settings save' $config=Launch 'KSwitcher.Config.exe' '' $settings=Window 'KSwitcher.Settings' $null=[ConfigurationTestUi]::SetText([ConfigurationTestUi]::GetDlgItem($settings,240),0x0c,[IntPtr]::Zero,'456') Click $settings 2 Assert ($config.WaitForExit(6000)) 'cancel exits configurator' Assert ((Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json).maxItems -eq 321) 'cancel preserves saved settings' $config=Launch 'KSwitcher.Config.exe' '--snippets' $snippets=Window 'KSwitcher.Snippets' $second=Launch 'KSwitcher.Config.exe' '' Assert ($second.WaitForExit(6000) -and $second.ExitCode -eq 0) 'settings and snippets share a single process' $settings=Window 'KSwitcher.Settings' Click $settings 2 Start-Sleep -Milliseconds 100 $config.Refresh(); Assert (!$config.HasExited) 'closing settings keeps open snippets alive' Click $snippets 2 Assert ($config.WaitForExit(6000)) 'closing last window exits configurator' $config=Launch 'KSwitcher.Config.exe' '' $null=Window 'KSwitcher.Settings' $null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero) Assert ($resident.WaitForExit(10000)) 'resident shuts down normally' Assert ($config.WaitForExit(6000)) 'resident exit closes configurator' $config=Launch 'KSwitcher.Config.exe' '' $main=Window 'KSwitcher.Main' [uint32]$bootstrappedId=0 $null=[ConfigurationTestUi]::GetWindowThreadProcessId($main,[ref]$bootstrappedId) $bootstrapped=Get-Process -Id $bootstrappedId $processes.Add($bootstrapped) $settings=Window 'KSwitcher.Settings' Assert ($bootstrappedId -ne $resident.Id) 'standalone configurator starts its resident sibling' Click $settings 2 Assert ($config.WaitForExit(6000)) 'standalone configurator exits after cancel' $null=[ConfigurationTestUi]::PostMessage($main,0x10,[IntPtr]::Zero,[IntPtr]::Zero) Assert ($bootstrapped.WaitForExit(10000)) 'bootstrapped resident shuts down normally' "Passed $checks configurator process checks" } finally { foreach($process in $processes) { $process.Refresh() if(!$process.HasExited) { Stop-Process -Id $process.Id -Force } $process.Dispose() } # The generated fixture is the only recursive deletion target. $resolved=[IO.Path]::GetFullPath($root) $temp=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()) if(!$resolved.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($resolved) -notlike 'KSwitcher-config-tests-*') { throw 'Unexpected fixture path' } Remove-Item -LiteralPath $resolved -Recurse -Force }