tests/lib/exec.ps1

# 进程/EXE 运行辅助:合并输出捕获、独立 console、向窗口发送回车关闭 MessageBox。
$ErrorActionPreference = 'Stop'

# 经 cmd 把 stdout/stderr 接到同一文件,保留进程内写入顺序,规避 Windows PowerShell native stderr 抛 NativeCommandError。
function Invoke-ExeCaptureMergedOutput {
    param(
        [string]$ExePath,
        [string[]]$Arguments = @(),
        [int]$TimeoutSeconds = 60
    )
    $exePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ExePath)
    if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) { throw "Exe not found: $exePath" }
    $outFile = Join-Path ([System.IO.Path]::GetDirectoryName($exePath)) ('merged-{0}.txt' -f [guid]::NewGuid().ToString('N'))
    $argLine = if ($Arguments.Count) { ($Arguments -join ' ') + ' ' } else { '' }
    $cmdLine = '"{0}" {1}1>"{2}" 2>&1' -f $exePath, $argLine, $outFile
    $psi = [System.Diagnostics.ProcessStartInfo]@{
        FileName         = $env:ComSpec
        Arguments        = "/s /c `"$cmdLine`""
        UseShellExecute  = $false
        CreateNoWindow   = $true
        WorkingDirectory = [System.IO.Path]::GetDirectoryName($exePath)
    }
    $p = [System.Diagnostics.Process]::Start($psi)
    try {
        if (-not $p.WaitForExit($TimeoutSeconds * 1000)) { throw "Merged-output exe timed out: $exePath" }
        return [pscustomobject]@{
            ExitCode = $p.ExitCode
            Output   = (Get-Content -LiteralPath $outFile -Raw -ErrorAction SilentlyContinue)
        }
    }
    finally {
        if (-not $p.HasExited) { Stop-ProcessTree -ProcessId $p.Id }
        $p.Dispose()
        Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
    }
}

# 给控制台 EXE 单独开 hidden console,避免当前进程 stdout 已重定向时子进程继承管道(TTY/isatty 类测试)。
function Invoke-ExeWithPrivateConsole {
    param(
        [string]$ExePath,
        [int]$TimeoutSeconds = 60,
        [string]$WorkingDirectory
    )
    $exePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ExePath)
    if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) { throw "Exe not found: $exePath" }
    if (-not $WorkingDirectory) { $WorkingDirectory = [System.IO.Path]::GetDirectoryName($exePath) }
    $p = Start-Process -FilePath $exePath -WorkingDirectory $WorkingDirectory -PassThru -WindowStyle Hidden
    try {
        if (-not $p.WaitForExit($TimeoutSeconds * 1000)) { throw "Private console exe timed out: $exePath" }
        return $p.ExitCode
    }
    finally {
        if (-not $p.HasExited) { Stop-ProcessTree -ProcessId $p.Id }
    }
}

if (-not ('CIWindowHelper' -as [type])) {
    Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
using System.Text;
public class CIWindowHelper {
    [DllImport("user32.dll", SetLastError = true)]
    private static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
    [DllImport("user32.dll")]
    private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
    [DllImport("user32.dll")]
    private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
    private const uint WM_KEYDOWN = 0x0100;
    private const uint WM_KEYUP = 0x0101;
    private const uint WM_COMMAND = 0x0111;
    private const uint WM_CLOSE = 0x0010;
    private const int VK_RETURN = 0x0D;
    private const int IDOK = 1;
    public static bool SendEnterToProcessMainWindow(int processId) {
        IntPtr target = IntPtr.Zero;
        FindWindowByProcessId((uint)processId, ref target);
        if (target == IntPtr.Zero) return false;
        // 消息要经对话框管理器 IsDialogMessage 才生效,而它只处理活动窗口;后台桌面上回车会被丢弃。
        // 因此同时投递 WM_COMMAND(IDOK) 与 WM_CLOSE,保证消息框总能被关闭。
        PostMessage(target, WM_KEYDOWN, (IntPtr)VK_RETURN, IntPtr.Zero);
        PostMessage(target, WM_KEYUP, (IntPtr)VK_RETURN, IntPtr.Zero);
        PostMessage(target, WM_COMMAND, (IntPtr)IDOK, IntPtr.Zero);
        PostMessage(target, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
        return true;
    }
    private static void FindWindowByProcessId(uint targetPid, ref IntPtr result) {
        IntPtr[] found = new IntPtr[1];
        EnumWindows((hWnd, lp) => {
            uint pid;
            GetWindowThreadProcessId(hWnd, out pid);
            if (pid == targetPid) {
                StringBuilder sb = new StringBuilder(256);
                if (GetClassName(hWnd, sb, sb.Capacity) > 0 && sb.ToString() == "#32770") {
                    found[0] = hWnd;
                    return false;
                }
            }
            return true;
        }, IntPtr.Zero);
        result = found[0];
    }
    private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
    [DllImport("user32.dll")]
    private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
}
'@
 -ReferencedAssemblies System
}

# 启动 exe,持续向其主消息框发送回车/WM_COMMAND,直到退出或超时。返回退出码。
function Invoke-ExeAndSendEnterToWindow {
    param(
        [string]$ExePath,
        [int]$TimeoutSeconds = 25,
        [string]$WorkingDirectory
    )
    $exePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ExePath)
    if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) { throw "Exe not found: $exePath" }
    if (-not $WorkingDirectory) { $WorkingDirectory = [System.IO.Path]::GetDirectoryName($exePath) }
    $psi = [System.Diagnostics.ProcessStartInfo]@{
        FileName         = $exePath
        UseShellExecute  = $true
        CreateNoWindow   = $false
        WorkingDirectory = $WorkingDirectory
    }
    $p = [System.Diagnostics.Process]::Start($psi)
    $sawWindow = $false
    try {
        $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
        while ([DateTime]::UtcNow -lt $deadline -and -not $p.HasExited) {
            if ([CIWindowHelper]::SendEnterToProcessMainWindow($p.Id)) { $sawWindow = $true }
            Start-Sleep -Milliseconds 200
        }
        if (-not $p.HasExited) { [void]$p.WaitForExit(2000) }
    }
    finally {
        if (-not $p.HasExited) {
            Write-Warning ("Invoke-ExeAndSendEnterToWindow: killed {0} after {1}s (window seen: {2})" -f $ExePath, $TimeoutSeconds, $sawWindow)
            Stop-ProcessTree -ProcessId $p.Id
        }
    }
    return $p.ExitCode
}

# 把缓存的构建产物复制到用例私有目录,避免并行用例互相踩文件。
function Copy-BuildAs {
    param(
        [string]$BuildPath,
        [string]$WorkDir,
        [string]$Name
    )
    if (-not $Name) { $Name = [System.IO.Path]::GetFileName($BuildPath) }
    $dest = Join-Path $WorkDir $Name
    New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null
    Copy-Item -LiteralPath $BuildPath -Destination $dest -Force
    return $dest
}

function Wait-ForPath {
    param([string]$Path, [int]$TimeoutSeconds = 20)
    $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
    while (-not (Test-Path -LiteralPath $Path) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 250 }
    return (Test-Path -LiteralPath $Path)
}

# exe21sp 在 stdout 被重定向时把还原出的脚本写到 stdout;worker 的 stdout 始终重定向,故可直接捕获。
function Get-Exe21spContent {
    param([string]$ExePath)
    return (exe21sp -inputFile $ExePath | Out-String)
}

# 让 PS2EXE2ps12exe 兼容层能按模块名发现 ps12exe:把仓库父目录挂到 PSModulePath(仓库文件夹名即 ps12exe),再导入 shim。
function Initialize-Ps2exeShim {
    param([string]$RepoRoot)
    $parent = Split-Path -Path $RepoRoot -Parent
    if ((Split-Path -Path $RepoRoot -Leaf) -eq 'ps12exe') {
        if (($env:PSModulePath -split ';') -notcontains $parent) { $env:PSModulePath = "$parent;$env:PSModulePath" }
    }
    else {
        throw "仓库文件夹名不是 ps12exe($RepoRoot),无法按模块名发现;请重命名或改用 junction 版本。"
    }
    Import-Module (Join-Path $RepoRoot 'src/.subrepo/PS2EXE2ps12exe/PS2EXE2ps12exe.psd1') -Force
}

# 在独立(非重定向)console 中运行 root exe21sp.ps1,用于验证“未重定向时写 <exe>.ps1”的分支。
function Invoke-Exe21spInPrivateConsole {
    param([string]$RepoRoot, [string]$ExePath, [int]$TimeoutSeconds = 60)
    $exe21sp = Join-Path $RepoRoot 'exe21sp.ps1'
    $pwsh = (Get-Process -Id $PID).Path
    $p = Start-Process -FilePath $pwsh -ArgumentList @('-NoProfile', '-NonInteractive', '-File', $exe21sp, '-inputFile', $ExePath) -PassThru -WindowStyle Hidden
    try {
        if (-not $p.WaitForExit($TimeoutSeconds * 1000)) { throw "private-console exe21sp timed out: $ExePath" }
        return $p.ExitCode
    }
    finally { if (-not $p.HasExited) { Stop-ProcessTree -ProcessId $p.Id } }
}