[CmdletBinding()] param( [ValidateRange(1024, 65535)] [int]$Port = 9340, [string]$Output, [string]$Screenshot, [string]$NodePath, [ValidateRange(10, 120)] [int]$LaunchTimeoutSeconds = 30, [ValidateRange(5, 120)] [int]$HarnessTimeoutSeconds = 45 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; using System.Text; public static class LegacyPackageUiOnlyNative { [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern int GetPackageFullName(IntPtr process, ref int length, StringBuilder value); public static string PackageFullName(IntPtr process) { int length = 0; if (GetPackageFullName(process, ref length, null) != 122 || length < 2 || length > 4096) throw new InvalidOperationException("Package identity is unavailable."); var value = new StringBuilder(length); if (GetPackageFullName(process, ref length, value) != 0 || value.Length == 0) throw new InvalidOperationException("Package identity could not be read."); return value.ToString(); } } '@ $PackageName = 'Wickedness.MBNStockWebView.LegacyParity' $PackageFamilyName = 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0' $PackageFullName = 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0' $PackagePublisher = 'CN=Comtrophy' $ApplicationId = 'LegacyParityApp' $ExecutableName = 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe' $RepositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $Harness = Join-Path $PSScriptRoot 'Test-LegacyPackageUiOnlySmoke.mjs' $ExpectedInstallLocation = [IO.Path]::GetFullPath((Join-Path $RepositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64')) $ConfigurationPath = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'MBN_STOCK_WEBVIEW\Config\playout.local.json' $ExpectedWindowTitle = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('Vi1TdG9jayDspp3qtozsoJXrs7TshqHstpzsi5zsiqTthZwgZm9yIOunpOydvOqyveygnFRWICgyNi4wMy4yNik=')) $ExpectedDialogTitle = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('66ek7J286rK97KCcVFY=')) $ExpectedDialogMessage = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7ZSE66Gc6re4656o7J2EIOyiheujjO2VmOqyoOyKteuLiOq5jD8=')) $ExpectedPrimaryButton = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7JiI')) $ExpectedCloseButton = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7JWE64uI7JqU')) $script:Application = $null $script:Launched = $false $script:CloseCalls = 0 $script:ConfirmCalls = 0 function Fail([string]$Message) { throw [InvalidOperationException]::new($Message) } function SamePath([string]$Left, [string]$Right) { [string]::Equals([IO.Path]::GetFullPath($Left).TrimEnd('\'), [IO.Path]::GetFullPath($Right).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase) } function Resolve-Evidence([string]$Value, [string]$Name) { if ([string]::IsNullOrWhiteSpace($Value)) { return [IO.Path]::GetFullPath((Join-Path $RepositoryRoot (Join-Path 'artifacts\package-ui-only-smoke' $Name))) } if (-not [IO.Path]::IsPathRooted($Value)) { return [IO.Path]::GetFullPath((Join-Path $RepositoryRoot $Value)) } return [IO.Path]::GetFullPath($Value) } function Assert-NewFile([string]$Path, [string]$Label) { if ([IO.File]::Exists($Path) -or [IO.Directory]::Exists($Path)) { Fail "$Label already exists; evidence is never overwritten." } $parent = [IO.Path]::GetDirectoryName($Path) if ([string]::IsNullOrWhiteSpace($parent)) { Fail "$Label has no parent." } [void][IO.Directory]::CreateDirectory($parent) if (((Get-Item -LiteralPath $parent -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { Fail "$Label parent must not be a reparse point." } } function Resolve-Node([string]$Requested) { if (-not [string]::IsNullOrWhiteSpace($Requested)) { $candidate = [IO.Path]::GetFullPath($Requested) } else { $candidate = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' } if (-not [IO.File]::Exists($candidate) -or [IO.Path]::GetFileName($candidate) -cne 'node.exe') { Fail 'A valid node.exe is required.' } [IO.Path]::GetFullPath($candidate) } function Assert-DryRunConfig { if (-not [IO.File]::Exists($ConfigurationPath)) { return } $item = Get-Item -LiteralPath $ConfigurationPath -Force if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $item.Length -gt 1MB) { Fail 'The local playout configuration cannot be inspected safely.' } try { $raw = [IO.File]::ReadAllText($ConfigurationPath, [Text.Encoding]::UTF8); $parsed = $raw | ConvertFrom-Json -ErrorAction Stop } catch { Fail 'The local playout configuration is invalid.' } $matches = [regex]::Matches($raw, '(?i)"mode"\s*:') $property = @($parsed.PSObject.Properties | Where-Object { [string]::Equals($_.Name, 'Mode', [StringComparison]::OrdinalIgnoreCase) }) if (($matches.Count -eq 0 -and $property.Count -eq 0)) { return } if ($matches.Count -ne 1 -or $property.Count -ne 1 -or -not [string]::Equals([string]$property[0].Value, 'DryRun', [StringComparison]::OrdinalIgnoreCase)) { Fail 'The local playout mode is not uniquely DryRun.' } } function Assert-NoUnsafeEnvironment { foreach ($scope in @([EnvironmentVariableTarget]::Process, [EnvironmentVariableTarget]::User, [EnvironmentVariableTarget]::Machine)) { foreach ($nameObject in [Environment]::GetEnvironmentVariables($scope).Keys) { $name = [string]$nameObject if ($name -like 'MBN_STOCK_PLAYOUT_*' -or $name -like 'WEBVIEW2_*' -or $name -like 'COREWEBVIEW2_*' -or $name -in @('MBN_STOCK_K3D_NATIVE_SHA256', 'MBN_STOCK_K3D_INTEROP_SHA256')) { Fail "Unsafe launch environment variable is present: $scope/$name." } } } } function Get-Registration { $packages = @(Get-AppxPackage -Name $PackageName -ErrorAction Stop) if ($packages.Count -ne 1) { Fail 'Exactly one development package is required.' } $package = $packages[0] if (-not $package.IsDevelopmentMode -or [string]$package.SignatureKind -cne 'None' -or [string]$package.Status -cne 'Ok' -or [string]$package.PackageFullName -cne $PackageFullName -or [string]$package.PackageFamilyName -cne $PackageFamilyName -or [string]$package.Publisher -cne $PackagePublisher -or [string]$package.Version -cne '0.1.0.0' -or [string]$package.Architecture -cne 'X64' -or -not (SamePath $package.InstallLocation $ExpectedInstallLocation)) { Fail 'The exact Release x64 development registration is not present.' } $executable = Join-Path $package.InstallLocation $ExecutableName if (-not [IO.File]::Exists($executable)) { Fail 'The registered executable is missing.' } [pscustomobject]@{ InstallLocation = [IO.Path]::GetFullPath($package.InstallLocation); Executable = [IO.Path]::GetFullPath($executable) } } function Get-AppProcesses { @(Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($ExecutableName)) -ErrorAction SilentlyContinue | Where-Object { -not $_.HasExited }) } function Get-ProcessTcpConnections([int]$ProcessId) { try { @(Get-NetTCPConnection -ErrorAction Stop | Where-Object { [int]$_.OwningProcess -eq $ProcessId }) } catch { Fail 'The system TCP table could not be inspected.' } } function Assert-NoTornado([int]$ProcessId) { $matches = @(Get-ProcessTcpConnections $ProcessId | Where-Object { $_.LocalPort -eq 30001 -or $_.RemotePort -eq 30001 }) if ($matches.Count -ne 0) { Fail 'The DryRun package process owns a Tornado port 30001 connection.' } } function Assert-OnlyLoopbackTcp([int]$ProcessId) { $connections = @(Get-ProcessTcpConnections $ProcessId) foreach ($connection in $connections) { $isListener = [string]$connection.State -ceq 'Listen' $localLoopback = [string]$connection.LocalAddress -in @('127.0.0.1', '::1') $connectedLoopback = ([string]$connection.LocalAddress -in @('127.0.0.1', '::1')) -and ([string]$connection.RemoteAddress -in @('127.0.0.1', '::1')) if ((-not $isListener -and -not $connectedLoopback) -or ($isListener -and -not $localLoopback)) { Fail 'The package process owns a non-loopback TCP connection.' } } } function Assert-PortFree([int]$CandidatePort) { if ($CandidatePort -eq 30001 -or @(Get-NetTCPConnection -State Listen -LocalPort $CandidatePort -ErrorAction SilentlyContinue).Count -ne 0) { Fail 'The selected CDP port is unavailable or unsafe.' } } function Assert-Application([Diagnostics.Process]$Application, [string]$Path) { $Application.Refresh() if ($Application.HasExited -or -not (SamePath $Application.Path $Path) -or $Application.MainWindowHandle -eq [IntPtr]::Zero -or [string]$Application.MainWindowTitle -cne $ExpectedWindowTitle -or [LegacyPackageUiOnlyNative]::PackageFullName($Application.Handle) -cne $PackageFullName) { Fail 'The application identity changed or is not exact.' } Assert-NoTornado $Application.Id Assert-OnlyLoopbackTcp $Application.Id } function Start-Package([pscustomobject]$Registration, [int]$DebugPort) { $command = Get-Command Invoke-CommandInDesktopPackage -ErrorAction SilentlyContinue if ($null -eq $command -or [string]$command.Source -cne 'Appx') { Fail 'The Appx package-context launch helper is required.' } $encodedExe = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Registration.Executable)) $encodedDir = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Registration.InstallLocation)) $arguments = "--remote-debugging-address=127.0.0.1 --remote-debugging-port=$DebugPort" $encodedArgs = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($arguments)) $child = @" `$exe=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedExe')) `$dir=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedDir')) `$args=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedArgs')) [Environment]::SetEnvironmentVariable('MBN_STOCK_PLAYOUT_MODE','DryRun',[EnvironmentVariableTarget]::Process) [Environment]::SetEnvironmentVariable('WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS',`$args,[EnvironmentVariableTarget]::Process) `$start=New-Object Diagnostics.ProcessStartInfo `$start.FileName=`$exe; `$start.WorkingDirectory=`$dir; `$start.UseShellExecute=`$false if (`$null -eq [Diagnostics.Process]::Start(`$start)) { exit 91 } "@ $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($child)) $notBefore = [datetime]::UtcNow Invoke-CommandInDesktopPackage -PackageFamilyName $PackageFamilyName -AppId $ApplicationId -Command (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -Args "-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand $encoded" -PreventBreakaway -ErrorAction Stop | Out-Null $deadline = [datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds) while ([datetime]::UtcNow -lt $deadline) { $items = @(Get-AppProcesses) if ($items.Count -gt 1) { Fail 'More than one package application process appeared.' } if ($items.Count -eq 1 -and $items[0].StartTime.ToUniversalTime() -ge $notBefore.AddSeconds(-1)) { $candidate=$items[0] $candidate.Refresh() if (-not (SamePath $candidate.Path $Registration.Executable) -or [LegacyPackageUiOnlyNative]::PackageFullName($candidate.Handle) -cne $PackageFullName) { Fail 'The launched package process identity is not exact.' } if ($candidate.MainWindowHandle -ne [IntPtr]::Zero) { if ([string]$candidate.MainWindowTitle -cne $ExpectedWindowTitle) { Fail 'The launched package window title is not exact.' } Assert-Application $candidate $Registration.Executable return $candidate } } Start-Sleep -Milliseconds 100 } Fail 'The exact packaged application did not start in time.' } function Test-DescendsFrom([int]$ProcessId, [int]$AncestorId) { $visited = [Collections.Generic.HashSet[int]]::new(); $current = $ProcessId while ($current -gt 0 -and $visited.Add($current)) { if ($current -eq $AncestorId) { return $true } try { $record = Get-CimInstance Win32_Process -Filter "ProcessId = $current" -ErrorAction Stop } catch { return $false } if ($null -eq $record) { return $false }; $current = [int]$record.ParentProcessId } return $false } function Wait-Listener([int]$CandidatePort, [int]$ProcessId) { $deadline = [datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds) while ([datetime]::UtcNow -lt $deadline) { $listeners = @(Get-NetTCPConnection -State Listen -LocalPort $CandidatePort -ErrorAction SilentlyContinue) if ($listeners.Count -eq 1) { try { $owner = Get-Process -Id ([int]$listeners[0].OwningProcess) -ErrorAction Stop } catch { $owner = $null } if ($null -ne $owner -and [IO.Path]::GetFileName($owner.Path) -ceq 'msedgewebview2.exe' -and (Test-DescendsFrom $owner.Id $ProcessId)) { return } } Start-Sleep -Milliseconds 100 } Fail 'The exact WebView loopback listener did not appear.' } function Invoke-NormalClose([Diagnostics.Process]$Application, [string]$Path) { Assert-Application $Application $Path Add-Type -AssemblyName UIAutomationClient; Add-Type -AssemblyName UIAutomationTypes $script:CloseCalls++; if ($script:CloseCalls -ne 1 -or -not $Application.CloseMainWindow()) { Fail 'The single normal close request was not accepted.' } $deadline = [datetime]::UtcNow.AddSeconds(15); $dialog = $null while ([datetime]::UtcNow -lt $deadline) { $Application.Refresh(); if ($Application.HasExited) { Fail 'The application exited before neutral confirmation.' }; Assert-NoTornado $Application.Id; Assert-OnlyLoopbackTcp $Application.Id $root=[Windows.Automation.AutomationElement]::FromHandle($Application.MainWindowHandle) if ($null -ne $root) { $matches=@($root.FindAll([Windows.Automation.TreeScope]::Descendants,[Windows.Automation.Condition]::TrueCondition) | Where-Object { $_.Current.ControlType -eq [Windows.Automation.ControlType]::Window -and $_.Current.ClassName -ceq 'Popup' -and $_.Current.Name -ceq $ExpectedDialogTitle }); if ($matches.Count -gt 1) { Fail 'The neutral close dialog is not unique.' }; if ($matches.Count -eq 1) { $dialog=$matches[0]; break } } Start-Sleep -Milliseconds 100 } if ($null -eq $dialog) { Fail 'The exact neutral close dialog did not appear.' } $all=$dialog.FindAll([Windows.Automation.TreeScope]::Descendants,[Windows.Automation.Condition]::TrueCondition) $buttons=@($all | Where-Object { $_.Current.ControlType -eq [Windows.Automation.ControlType]::Button }) $texts=@($all | Where-Object { $_.Current.ControlType -eq [Windows.Automation.ControlType]::Text }) $yes=@($buttons | Where-Object { $_.Current.AutomationId -ceq 'PrimaryButton' -and $_.Current.Name -ceq $ExpectedPrimaryButton -and $_.Current.IsEnabled }); $no=@($buttons | Where-Object { $_.Current.AutomationId -ceq 'CloseButton' -and $_.Current.Name -ceq $ExpectedCloseButton -and $_.Current.IsEnabled }); $message=@($texts | Where-Object { $_.Current.Name -ceq $ExpectedDialogMessage }) if ($buttons.Count -ne 2 -or $yes.Count -ne 1 -or $no.Count -ne 1 -or $message.Count -ne 1) { Fail 'The close confirmation is not the exact neutral dialog.' } $pattern=$null; if (-not $yes[0].TryGetCurrentPattern([Windows.Automation.InvokePattern]::Pattern,[ref]$pattern)) { Fail 'The exact close confirmation cannot be invoked.' } $script:ConfirmCalls++; if ($script:ConfirmCalls -ne 1) { Fail 'The close confirmation budget changed.' }; ($pattern -as [Windows.Automation.InvokePattern]).Invoke() $exitDeadline=[datetime]::UtcNow.AddSeconds(30); while ([datetime]::UtcNow -lt $exitDeadline) { $Application.Refresh(); if ($Application.HasExited) { return }; Assert-NoTornado $Application.Id; Assert-OnlyLoopbackTcp $Application.Id; Start-Sleep -Milliseconds 100 }; Fail 'Normal exit was not observed.' } $stamp=[datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ'); $Output=Resolve-Evidence $Output "legacy-package-ui-only-$stamp.json"; $Screenshot=Resolve-Evidence $Screenshot "legacy-package-ui-only-$stamp.png"; $NodePath=Resolve-Node $NodePath try { if (-not [IO.File]::Exists($Harness)) { Fail 'The UI-only harness is missing.' } Assert-NewFile $Output 'Output'; Assert-NewFile $Screenshot 'Screenshot'; Assert-DryRunConfig; Assert-NoUnsafeEnvironment; $registration=Get-Registration; if (@(Get-AppProcesses).Count -ne 0) { Fail 'An application instance is already running.' }; Assert-PortFree $Port $script:Application=Start-Package $registration $Port; $script:Launched=$true; Wait-Listener $Port $script:Application.Id; Assert-NoTornado $script:Application.Id; Assert-OnlyLoopbackTcp $script:Application.Id $arguments=@($Harness,'--port',$Port,'--output',$Output,'--screenshot',$Screenshot,'--timeout-ms',($HarnessTimeoutSeconds*1000)); $run=Start-Process -FilePath $NodePath -ArgumentList $arguments -WorkingDirectory $RepositoryRoot -NoNewWindow -PassThru $harnessDeadline=[datetime]::UtcNow.AddSeconds($HarnessTimeoutSeconds + 10) while (-not $run.HasExited -and [datetime]::UtcNow -lt $harnessDeadline) { Assert-Application $script:Application $registration.Executable; Start-Sleep -Milliseconds 100; $run.Refresh() } if (-not $run.HasExited) { Fail 'The UI-only CDP harness did not finish in time; it was not terminated.' } $run.WaitForExit() $run.Refresh() $harnessExitCode=[int]$run.ExitCode if ($harnessExitCode -ne 0) { Fail "The UI-only CDP harness failed with exit code $harnessExitCode." } if (-not [IO.File]::Exists($Output) -or -not [IO.File]::Exists($Screenshot)) { Fail 'The UI-only evidence files are missing.' } $evidence=[IO.File]::ReadAllText($Output,[Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop $screenshotItem=Get-Item -LiteralPath $Screenshot -Force $screenshotHash=(Get-FileHash -Algorithm SHA256 -LiteralPath $Screenshot).Hash if ([int]$evidence.schemaVersion -ne 1 -or [string]$evidence.result -cne 'PASS' -or $null -ne $evidence.error -or [string]$evidence.target.expectedUrl -cne 'https://legacy-parity.mbn.local/index.html' -or [string]$evidence.target.url -cne 'https://legacy-parity.mbn.local/index.html' -or [int]$evidence.target.port -ne $Port -or $evidence.safety.noInputDispatched -ne $true -or $evidence.safety.noNativeIntentAfterGuard -ne $true -or @($evidence.safety.observedMessagesAfterGuard).Count -ne 0 -or @($evidence.safety.blockedMessages).Count -ne 0 -or [string]$evidence.shell.connectionText -cne 'DRY RUN' -or -not ([string]$evidence.shell.playoutStatus).StartsWith('IDLE',[StringComparison]::Ordinal) -or [string]$evidence.shell.readyState -cne 'complete' -or -not (SamePath ([string]$evidence.screenshot.path) $Screenshot) -or $screenshotItem.Length -le 0 -or [int64]$evidence.screenshot.bytes -ne $screenshotItem.Length -or [string]$evidence.screenshot.sha256 -cne $screenshotHash) { Fail 'The sealed UI-only evidence is not exact.' } Assert-Application $script:Application $registration.Executable; Invoke-NormalClose $script:Application $registration.Executable [pscustomobject]@{ result='PASS'; packageFullName=$PackageFullName; packageMode='Development Release x64'; playoutMode='DryRun'; processId=$script:Application.Id; cdpAddress='127.0.0.1'; cdpPort=$Port; tornadoPortConnections=0; nativePostMessageIntentsAfterGuard=0; mouseKeyboardInput=0; singleInstanceActivation=0; dbReadIntent=0; dbWriteIntent=0; closeMainWindowCalls=$script:CloseCalls; primaryInvokeCalls=$script:ConfirmCalls; output=$Output; screenshot=$Screenshot } } catch { $message=$_.Exception.Message if ($script:CloseCalls -gt 0 -or $script:ConfirmCalls -gt 0) { Fail "UI-only package smoke became ambiguous: $message No close retry or forced termination was attempted." } $running=$false; if ($script:Launched -and $null -ne $script:Application) { try { $script:Application.Refresh(); $running=-not $script:Application.HasExited } catch {} } if ($running) { Fail "UI-only package smoke stopped: $message The application was intentionally left open; no forced termination was attempted." } throw }