496 lines
16 KiB
PowerShell
496 lines
16 KiB
PowerShell
param(
|
|
[string]$PackageRoot = 'C:\HyperTwist\packaged\desktop',
|
|
[string]$MapUrl = '',
|
|
[int]$SmokeSeconds = 30,
|
|
[int]$ResX = 1600,
|
|
[int]$ResY = 900,
|
|
[string]$ReportPath = '',
|
|
[switch]$KeepRunning,
|
|
[switch]$UseNullRHI,
|
|
[switch]$NoSound,
|
|
[switch]$RequireStartupDiagnostics,
|
|
[string]$ExpectedStartupDiagnosticsResult = ''
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
function Write-Utf8JsonFile {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path,
|
|
[Parameter(Mandatory = $true)]
|
|
[object]$Value
|
|
)
|
|
|
|
$ParentPath = Split-Path -Parent $Path
|
|
if (-not [string]::IsNullOrWhiteSpace($ParentPath))
|
|
{
|
|
New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
|
|
}
|
|
|
|
$Json = $Value | ConvertTo-Json -Depth 8
|
|
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
|
|
}
|
|
|
|
function Resolve-StartupDiagnosticsLogPath {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ResolvedPackageRoot,
|
|
[Parameter(Mandatory = $true)]
|
|
[DateTime]$NotBeforeUtc,
|
|
[int]$TimeoutSeconds = 8,
|
|
[int]$PollMilliseconds = 250
|
|
)
|
|
|
|
$CandidateDirectories = @(
|
|
(Join-Path $ResolvedPackageRoot 'Saved\Logs'),
|
|
(Join-Path $ResolvedPackageRoot 'Windows\Saved\Logs'),
|
|
(Join-Path $ResolvedPackageRoot 'Windows\UnrealHyperTwist\Saved\Logs'),
|
|
(Join-Path $env:LOCALAPPDATA 'UnrealHyperTwist\Saved\Logs')
|
|
) | Select-Object -Unique
|
|
|
|
$CandidatePaths = @(
|
|
(Join-Path $ResolvedPackageRoot 'Saved\Logs\HyperTwistFirstRunLaunch-latest.log'),
|
|
(Join-Path $ResolvedPackageRoot 'Windows\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'),
|
|
(Join-Path $ResolvedPackageRoot 'Windows\UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'),
|
|
(Join-Path $env:LOCALAPPDATA 'UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log')
|
|
) | Select-Object -Unique
|
|
|
|
$DeadlineUtc = [DateTime]::UtcNow.AddSeconds([Math]::Max(0, $TimeoutSeconds))
|
|
do
|
|
{
|
|
foreach ($CandidatePath in $CandidatePaths)
|
|
{
|
|
if ((Test-Path -LiteralPath $CandidatePath) `
|
|
-and (Get-Item -LiteralPath $CandidatePath).LastWriteTimeUtc -ge $NotBeforeUtc.AddSeconds(-2))
|
|
{
|
|
return $CandidatePath
|
|
}
|
|
}
|
|
|
|
foreach ($CandidateDirectory in $CandidateDirectories)
|
|
{
|
|
if (-not (Test-Path -LiteralPath $CandidateDirectory))
|
|
{
|
|
continue
|
|
}
|
|
|
|
$NewestDiagnosticsLog = Get-ChildItem -LiteralPath $CandidateDirectory -Filter 'HyperTwistFirstRunLaunch*.log' -File -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.LastWriteTimeUtc -ge $NotBeforeUtc.AddSeconds(-2) } |
|
|
Sort-Object LastWriteTimeUtc -Descending |
|
|
Select-Object -First 1
|
|
if ($null -ne $NewestDiagnosticsLog)
|
|
{
|
|
return $NewestDiagnosticsLog.FullName
|
|
}
|
|
}
|
|
|
|
if ([DateTime]::UtcNow -ge $DeadlineUtc)
|
|
{
|
|
break
|
|
}
|
|
|
|
Start-Sleep -Milliseconds ([Math]::Max(50, $PollMilliseconds))
|
|
}
|
|
while ($true)
|
|
|
|
return $null
|
|
}
|
|
|
|
function Get-OwnedPackageProcessIds {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ResolvedPackageRoot,
|
|
[int[]]$ExcludedProcessIds = @()
|
|
)
|
|
|
|
return @(
|
|
Get-OwnedPackageProcessRecords `
|
|
-ResolvedPackageRoot $ResolvedPackageRoot `
|
|
-ExcludedProcessIds $ExcludedProcessIds |
|
|
Select-Object -ExpandProperty ProcessId
|
|
)
|
|
}
|
|
|
|
function Get-OwnedPackageProcessRecords {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ResolvedPackageRoot,
|
|
[int[]]$ExcludedProcessIds = @()
|
|
)
|
|
|
|
$RootPrefix = $ResolvedPackageRoot.TrimEnd(
|
|
[System.IO.Path]::DirectorySeparatorChar,
|
|
[System.IO.Path]::AltDirectorySeparatorChar
|
|
) + [System.IO.Path]::DirectorySeparatorChar
|
|
|
|
return @(
|
|
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
|
Where-Object {
|
|
([string]$_.Name) -like 'UnrealHyperTwist*.exe' `
|
|
-and -not [string]::IsNullOrWhiteSpace([string]$_.ExecutablePath) `
|
|
-and ([string]$_.ExecutablePath).StartsWith(
|
|
$RootPrefix,
|
|
[System.StringComparison]::OrdinalIgnoreCase
|
|
) `
|
|
-and $ExcludedProcessIds -notcontains [int]$_.ProcessId
|
|
}
|
|
)
|
|
}
|
|
|
|
function Get-OwnedListeningTcpEndpoints {
|
|
param(
|
|
[int[]]$ProcessIds
|
|
)
|
|
|
|
if ($ProcessIds.Count -eq 0)
|
|
{
|
|
return @()
|
|
}
|
|
|
|
return @(
|
|
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
|
|
Where-Object { $ProcessIds -contains [int]$_.OwningProcess } |
|
|
Sort-Object OwningProcess, LocalPort |
|
|
ForEach-Object {
|
|
[pscustomobject][ordered]@{
|
|
processId = [int]$_.OwningProcess
|
|
localAddress = [string]$_.LocalAddress
|
|
localPort = [int]$_.LocalPort
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
function Stop-OwnedPackageProcesses {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$ResolvedPackageRoot,
|
|
[int[]]$ExcludedProcessIds = @()
|
|
)
|
|
|
|
$StoppedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]'
|
|
for ($Attempt = 0; $Attempt -lt 5; $Attempt += 1)
|
|
{
|
|
$OwnedProcessIds = @(
|
|
Get-OwnedPackageProcessIds `
|
|
-ResolvedPackageRoot $ResolvedPackageRoot `
|
|
-ExcludedProcessIds $ExcludedProcessIds
|
|
)
|
|
if ($OwnedProcessIds.Count -eq 0)
|
|
{
|
|
break
|
|
}
|
|
|
|
foreach ($OwnedProcessId in $OwnedProcessIds)
|
|
{
|
|
Stop-Process -Id $OwnedProcessId -Force -ErrorAction SilentlyContinue
|
|
[void]$StoppedProcessIds.Add([int]$OwnedProcessId)
|
|
}
|
|
Start-Sleep -Milliseconds 200
|
|
}
|
|
|
|
return @($StoppedProcessIds)
|
|
}
|
|
|
|
function Get-FatalRuntimeLogMatches {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$RuntimeLogPath
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $RuntimeLogPath))
|
|
{
|
|
return @()
|
|
}
|
|
|
|
$FatalPatterns = @(
|
|
'Fatal error:',
|
|
'LowLevelFatalError',
|
|
'Assertion failed:',
|
|
'Critical error:',
|
|
'appError called:',
|
|
'DXGI_ERROR_NOT_CURRENTLY_AVAILABLE',
|
|
'CreateSwapChainResult failed',
|
|
'Unhandled Exception:',
|
|
'StaticShutdownAfterError'
|
|
)
|
|
|
|
return @(Get-Content -LiteralPath $RuntimeLogPath | Where-Object {
|
|
$Line = $_
|
|
foreach ($Pattern in $FatalPatterns)
|
|
{
|
|
if ($Line -like "*$Pattern*")
|
|
{
|
|
return $true
|
|
}
|
|
}
|
|
|
|
return $false
|
|
})
|
|
}
|
|
|
|
$CandidateExecutablePaths = @(
|
|
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
|
|
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
|
|
(Join-Path $PackageRoot 'UnrealHyperTwist.exe')
|
|
)
|
|
|
|
$ExecutablePath = $CandidateExecutablePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
|
|
if ($null -eq $ExecutablePath)
|
|
{
|
|
throw "No packaged UnrealHyperTwist executable was found beneath '$PackageRoot'."
|
|
}
|
|
|
|
$LogDirectory = Join-Path $PackageRoot 'validation\logs'
|
|
New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null
|
|
$RuntimeLogPath = Join-Path $LogDirectory 'desktop-runtime.log'
|
|
$RuntimeDiagnosticsPath = Join-Path $LogDirectory 'desktop-runtime.hyperdiagnostics.log'
|
|
Remove-Item -LiteralPath $RuntimeLogPath -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $RuntimeDiagnosticsPath -Force -ErrorAction SilentlyContinue
|
|
|
|
$ArgumentList = @(
|
|
"-ResX=$ResX",
|
|
"-ResY=$ResY",
|
|
'-windowed',
|
|
'-log',
|
|
'-FORCELOGFLUSH',
|
|
"-abslog=$RuntimeLogPath",
|
|
"-HyperTwistDiagnosticsLog=`"$RuntimeDiagnosticsPath`""
|
|
)
|
|
|
|
if ($UseNullRHI)
|
|
{
|
|
$ArgumentList += '-NullRHI'
|
|
}
|
|
|
|
if ($NoSound)
|
|
{
|
|
$ArgumentList += '-nosound'
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($MapUrl))
|
|
{
|
|
$ArgumentList = @($MapUrl) + $ArgumentList
|
|
}
|
|
|
|
Write-Host "Launching packaged HyperTwist desktop experience from '$ExecutablePath'..."
|
|
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
|
|
$ExistingPackageProcessIds = @(
|
|
Get-OwnedPackageProcessIds -ResolvedPackageRoot $ResolvedPackageRoot
|
|
)
|
|
$GeneratedAtUtc = [DateTime]::UtcNow.ToString('o')
|
|
$Process = $null
|
|
$Report = [ordered]@{
|
|
reportVersion = 'ht-desktop-package-launch-surface/v2'
|
|
generatedAtUtc = $GeneratedAtUtc
|
|
packageRoot = $ResolvedPackageRoot
|
|
executablePath = $ExecutablePath
|
|
mapUrl = $MapUrl
|
|
runtimeLogPath = $RuntimeLogPath
|
|
runtimeLogExists = $false
|
|
runtimeDiagnosticsPath = $RuntimeDiagnosticsPath
|
|
runtimeDiagnosticsExists = $false
|
|
runtimeDiagnosticsInitialized = $false
|
|
detectedFatalLogLines = @()
|
|
smokeSeconds = $SmokeSeconds
|
|
resolution = [ordered]@{
|
|
width = $ResX
|
|
height = $ResY
|
|
}
|
|
keepRunning = [bool]$KeepRunning
|
|
useNullRhi = [bool]$UseNullRHI
|
|
noSound = [bool]$NoSound
|
|
requireStartupDiagnostics = [bool]$RequireStartupDiagnostics
|
|
expectedStartupDiagnosticsResult = $ExpectedStartupDiagnosticsResult
|
|
startupDiagnosticsLogPath = $null
|
|
startupDiagnosticsLogExists = $false
|
|
startupDiagnosticsTail = @()
|
|
startupDiagnosticsResult = $null
|
|
result = 'failed'
|
|
processId = $null
|
|
processIds = @()
|
|
processCommandLines = @()
|
|
ownedListeningTcpEndpoints = @()
|
|
traceControlListenerLines = @()
|
|
traceControlListeningEndpoints = @()
|
|
processStopped = $false
|
|
stoppedProcessIds = @()
|
|
exitCode = $null
|
|
error = $null
|
|
}
|
|
|
|
try
|
|
{
|
|
$LaunchStartedAtUtc = [DateTime]::UtcNow
|
|
$Process = Start-Process -FilePath $ExecutablePath -ArgumentList $ArgumentList -PassThru
|
|
Start-Sleep -Seconds $SmokeSeconds
|
|
|
|
$Process.Refresh()
|
|
$Report.processId = $Process.Id
|
|
$OwnedProcessRecords = @(
|
|
Get-OwnedPackageProcessRecords `
|
|
-ResolvedPackageRoot $ResolvedPackageRoot `
|
|
-ExcludedProcessIds $ExistingPackageProcessIds
|
|
)
|
|
$Report.processIds = @($OwnedProcessRecords | Select-Object -ExpandProperty ProcessId)
|
|
$Report.processCommandLines = @(
|
|
$OwnedProcessRecords |
|
|
ForEach-Object { [string]$_.CommandLine } |
|
|
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
|
)
|
|
$Report.ownedListeningTcpEndpoints = @(
|
|
Get-OwnedListeningTcpEndpoints -ProcessIds $Report.processIds
|
|
)
|
|
$Report.traceControlListeningEndpoints = @(
|
|
$Report.ownedListeningTcpEndpoints |
|
|
Where-Object { [int]$_.localPort -eq 1985 }
|
|
)
|
|
|
|
Start-Sleep -Milliseconds 500
|
|
$Report.runtimeLogExists = Test-Path -LiteralPath $RuntimeLogPath
|
|
$Report.runtimeDiagnosticsExists = Test-Path -LiteralPath $RuntimeDiagnosticsPath
|
|
$Report.detectedFatalLogLines = @(
|
|
@(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeLogPath) +
|
|
@(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeDiagnosticsPath)
|
|
)
|
|
$CurrentRuntimeLogLines = @(
|
|
if ($Report.runtimeLogExists)
|
|
{
|
|
Get-Content -LiteralPath $RuntimeLogPath
|
|
}
|
|
)
|
|
$CurrentRuntimeDiagnosticsLines = @(
|
|
if ($Report.runtimeDiagnosticsExists)
|
|
{
|
|
Get-Content -LiteralPath $RuntimeDiagnosticsPath
|
|
}
|
|
)
|
|
$CurrentRuntimeEvidenceLines = @(
|
|
$CurrentRuntimeLogLines + $CurrentRuntimeDiagnosticsLines
|
|
)
|
|
$Report.runtimeDiagnosticsInitialized = @(
|
|
$CurrentRuntimeDiagnosticsLines |
|
|
Where-Object {
|
|
$_.ToString().IndexOf(
|
|
'[Process] HyperTwist runtime module initialized.',
|
|
[System.StringComparison]::OrdinalIgnoreCase
|
|
) -ge 0
|
|
}
|
|
).Count -gt 0
|
|
$Report.traceControlListenerLines = @(
|
|
$CurrentRuntimeEvidenceLines |
|
|
Where-Object {
|
|
$_.ToString().IndexOf(
|
|
'Control listening on port',
|
|
[System.StringComparison]::OrdinalIgnoreCase
|
|
) -ge 0
|
|
} |
|
|
ForEach-Object { $_.ToString() }
|
|
)
|
|
$StartupDiagnosticsLogPath = Resolve-StartupDiagnosticsLogPath `
|
|
-ResolvedPackageRoot $ResolvedPackageRoot `
|
|
-NotBeforeUtc $LaunchStartedAtUtc
|
|
$Report.startupDiagnosticsLogPath = $StartupDiagnosticsLogPath
|
|
$Report.startupDiagnosticsLogExists = $null -ne $StartupDiagnosticsLogPath
|
|
if ($Report.startupDiagnosticsLogExists)
|
|
{
|
|
$Report.startupDiagnosticsTail = @(
|
|
Get-Content -LiteralPath $StartupDiagnosticsLogPath -Tail 40 |
|
|
ForEach-Object { $_.ToString() }
|
|
)
|
|
$Report.startupDiagnosticsResult = [string](
|
|
$Report.startupDiagnosticsTail |
|
|
Where-Object { $_ -like 'result=*' } |
|
|
Select-Object -Last 1
|
|
)
|
|
}
|
|
|
|
if (-not $Report.runtimeDiagnosticsExists)
|
|
{
|
|
throw 'Packaged desktop launch did not write its direct HyperTwist diagnostics log.'
|
|
}
|
|
if (-not $Report.runtimeDiagnosticsInitialized)
|
|
{
|
|
throw 'Packaged desktop launch did not record direct HyperTwist runtime initialization.'
|
|
}
|
|
if ($Report.detectedFatalLogLines.Count -gt 0)
|
|
{
|
|
throw (
|
|
"Packaged desktop runtime diagnostics recorded fatal startup evidence: " +
|
|
($Report.detectedFatalLogLines -join ' | ')
|
|
)
|
|
}
|
|
if ($Report.traceControlListenerLines.Count -gt 0 `
|
|
-or $Report.traceControlListeningEndpoints.Count -gt 0)
|
|
{
|
|
throw 'Packaged desktop runtime opened an Unreal trace-control listener.'
|
|
}
|
|
|
|
if ($RequireStartupDiagnostics -and -not $Report.startupDiagnosticsLogExists)
|
|
{
|
|
throw "Packaged desktop launch did not write HyperTwistFirstRunLaunch-latest.log."
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($ExpectedStartupDiagnosticsResult) `
|
|
-and $Report.startupDiagnosticsResult -ne "result=$ExpectedStartupDiagnosticsResult")
|
|
{
|
|
throw (
|
|
"Expected startup diagnostics result '$ExpectedStartupDiagnosticsResult' but observed " +
|
|
"'$($Report.startupDiagnosticsResult)'."
|
|
)
|
|
}
|
|
|
|
if ($Report.processIds.Count -eq 0)
|
|
{
|
|
if ($Process.HasExited)
|
|
{
|
|
$Report.exitCode = $Process.ExitCode
|
|
}
|
|
throw 'Packaged desktop executable exited before the smoke interval completed.'
|
|
}
|
|
|
|
$Report.result = 'passed'
|
|
}
|
|
catch
|
|
{
|
|
$Report.error = $_.Exception.Message
|
|
if ($null -ne $Process -and $Process.HasExited)
|
|
{
|
|
$Report.exitCode = $Process.ExitCode
|
|
}
|
|
if (-not $KeepRunning)
|
|
{
|
|
$Report.stoppedProcessIds = @(
|
|
Stop-OwnedPackageProcesses `
|
|
-ResolvedPackageRoot $ResolvedPackageRoot `
|
|
-ExcludedProcessIds $ExistingPackageProcessIds
|
|
)
|
|
$Report.processStopped = $Report.stoppedProcessIds.Count -gt 0
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
|
|
{
|
|
Write-Utf8JsonFile -Path $ReportPath -Value $Report
|
|
}
|
|
|
|
throw
|
|
}
|
|
|
|
Write-Host "Packaged desktop smoke launch succeeded (PID $($Process.Id))."
|
|
if (-not $KeepRunning)
|
|
{
|
|
$Report.stoppedProcessIds = @(
|
|
Stop-OwnedPackageProcesses `
|
|
-ResolvedPackageRoot $ResolvedPackageRoot `
|
|
-ExcludedProcessIds $ExistingPackageProcessIds
|
|
)
|
|
$Report.processStopped = $Report.stoppedProcessIds.Count -gt 0
|
|
Write-Host 'Stopped the complete packaged desktop smoke process tree after successful launch validation.'
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
|
|
{
|
|
Write-Utf8JsonFile -Path $ReportPath -Value $Report
|
|
}
|