feat: advance legacy UI behavior parity
This commit is contained in:
2542
scripts/Invoke-LegacyPackageInputSmoke.ps1
Normal file
2542
scripts/Invoke-LegacyPackageInputSmoke.ps1
Normal file
File diff suppressed because it is too large
Load Diff
242
scripts/Invoke-LegacyPackageUiOnlySmoke.ps1
Normal file
242
scripts/Invoke-LegacyPackageUiOnlySmoke.ps1
Normal file
@@ -0,0 +1,242 @@
|
||||
[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
|
||||
}
|
||||
2036
scripts/Test-LegacyPackageInput.mjs
Normal file
2036
scripts/Test-LegacyPackageInput.mjs
Normal file
File diff suppressed because it is too large
Load Diff
259
scripts/Test-LegacyPackageUiOnlySmoke.mjs
Normal file
259
scripts/Test-LegacyPackageUiOnlySmoke.mjs
Normal file
@@ -0,0 +1,259 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const exactUrl = "https://legacy-parity.mbn.local/index.html";
|
||||
const allowedArguments = new Set(["port", "output", "screenshot", "timeout-ms"]);
|
||||
const forbiddenCdpMethods = new Set([
|
||||
"Browser.close", "Page.close", "Target.closeTarget", "Runtime.terminateExecution",
|
||||
"Input.dispatchMouseEvent", "Input.dispatchKeyEvent", "Input.insertText"
|
||||
]);
|
||||
|
||||
class SmokeFailure extends Error {
|
||||
constructor(kind, message) {
|
||||
super(`${kind}: ${message}`);
|
||||
this.name = "SmokeFailure";
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(kind, message) { throw new SmokeFailure(kind, message); }
|
||||
|
||||
function parseArguments(argv) {
|
||||
if (argv.length === 0 || argv.length % 2 !== 0) {
|
||||
fail("KNOWN_FAILURE", "Use --port, --output, --screenshot, and optional --timeout-ms pairs.");
|
||||
}
|
||||
const values = new Map();
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = argv[index]?.replace(/^--/u, "");
|
||||
const value = argv[index + 1];
|
||||
if (!key || !argv[index].startsWith("--") || !value || !allowedArguments.has(key) || values.has(key)) {
|
||||
fail("KNOWN_FAILURE", "Arguments must be unique supported --name value pairs.");
|
||||
}
|
||||
values.set(key, value);
|
||||
}
|
||||
for (const key of ["port", "output", "screenshot"]) {
|
||||
if (!values.has(key)) fail("KNOWN_FAILURE", `Missing --${key}.`);
|
||||
}
|
||||
const port = Number(values.get("port"));
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535 || port === 30001) {
|
||||
fail("KNOWN_FAILURE", "--port must be a non-Tornado port from 1024 through 65535.");
|
||||
}
|
||||
const outputPath = path.resolve(values.get("output"));
|
||||
const screenshotPath = path.resolve(values.get("screenshot"));
|
||||
if (!path.isAbsolute(values.get("output")) || path.extname(outputPath).toLowerCase() !== ".json" ||
|
||||
!path.isAbsolute(values.get("screenshot")) || path.extname(screenshotPath).toLowerCase() !== ".png" ||
|
||||
outputPath.toLowerCase() === screenshotPath.toLowerCase()) {
|
||||
fail("KNOWN_FAILURE", "Output must be a distinct absolute .json and screenshot a distinct absolute .png.");
|
||||
}
|
||||
const timeoutMilliseconds = values.has("timeout-ms") ? Number(values.get("timeout-ms")) : 30000;
|
||||
if (!Number.isInteger(timeoutMilliseconds) || timeoutMilliseconds < 5000 || timeoutMilliseconds > 120000) {
|
||||
fail("KNOWN_FAILURE", "--timeout-ms must be from 5000 through 120000.");
|
||||
}
|
||||
if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath)) {
|
||||
fail("KNOWN_FAILURE", "Evidence files must not already exist.");
|
||||
}
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
|
||||
return { port, outputPath, screenshotPath, timeoutMilliseconds };
|
||||
}
|
||||
|
||||
const configuration = parseArguments(process.argv.slice(2));
|
||||
const deadline = Date.now() + configuration.timeoutMilliseconds;
|
||||
const evidence = {
|
||||
schemaVersion: 1,
|
||||
result: "RUNNING",
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
target: { expectedUrl: exactUrl, port: configuration.port, id: null, url: null },
|
||||
safety: {
|
||||
noInputDispatched: true,
|
||||
noNativeIntentAfterGuard: true,
|
||||
// app.js emits this empty, read-only bootstrap before CDP can attach.
|
||||
// Seeing native-rendered DRY RUN / IDLE below proves that its state reply arrived;
|
||||
// this harness does not falsely claim to have observed the earlier outbound call.
|
||||
bootstrapReadyContract: {
|
||||
type: "ready",
|
||||
payload: {},
|
||||
inferredFromAuthoritativeState: true,
|
||||
nativeRoute: "LegacyReadyIntent => _controller.Current"
|
||||
},
|
||||
blockedMessages: [],
|
||||
observedMessagesAfterGuard: []
|
||||
},
|
||||
shell: null,
|
||||
screenshot: null,
|
||||
error: null
|
||||
};
|
||||
|
||||
let socket;
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
let guardInstalled = false;
|
||||
|
||||
function remaining() {
|
||||
const value = deadline - Date.now();
|
||||
if (value <= 0) fail("OUTCOME_UNKNOWN", "Observation deadline expired; nothing was retried.");
|
||||
return value;
|
||||
}
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, remaining())));
|
||||
}
|
||||
|
||||
async function discoverTarget() {
|
||||
const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(discoveryUrl, { redirect: "error", signal: AbortSignal.timeout(1000) });
|
||||
const targets = await response.json();
|
||||
const matches = Array.isArray(targets) ? targets.filter(target =>
|
||||
target?.type === "page" && target.url === exactUrl) : [];
|
||||
if (matches.length > 1) fail("KNOWN_FAILURE", "More than one exact package document was exposed.");
|
||||
if (matches.length === 1 && matches[0].id && matches[0].webSocketDebuggerUrl) return matches[0];
|
||||
} catch (error) {
|
||||
if (error instanceof SmokeFailure) throw error;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
fail("KNOWN_FAILURE", "The exact package WebView target was not found.");
|
||||
}
|
||||
|
||||
async function connect(target) {
|
||||
const endpoint = new URL(target.webSocketDebuggerUrl);
|
||||
if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" ||
|
||||
Number(endpoint.port) !== configuration.port || endpoint.pathname !== `/devtools/page/${target.id}`) {
|
||||
fail("KNOWN_FAILURE", "The target CDP endpoint is not the exact loopback page endpoint.");
|
||||
}
|
||||
socket = new WebSocket(endpoint.href);
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new SmokeFailure("OUTCOME_UNKNOWN", "CDP open timed out.")), 5000);
|
||||
socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
|
||||
socket.addEventListener("error", () => { clearTimeout(timer); reject(new SmokeFailure("OUTCOME_UNKNOWN", "CDP open failed.")); }, { once: true });
|
||||
});
|
||||
socket.addEventListener("message", event => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (!Object.hasOwn(message, "id")) return;
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
clearTimeout(request.timer);
|
||||
message.error ? request.reject(new SmokeFailure("KNOWN_FAILURE", `${request.method}: ${message.error.message || "CDP error"}`)) : request.resolve(message.result);
|
||||
});
|
||||
}
|
||||
|
||||
function rpc(method, params = {}) {
|
||||
if (forbiddenCdpMethods.has(method)) fail("KNOWN_FAILURE", `Forbidden CDP method ${method}.`);
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) fail("OUTCOME_UNKNOWN", "CDP is unavailable.");
|
||||
const id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new SmokeFailure("OUTCOME_UNKNOWN", `${method} timed out; nothing was retried.`));
|
||||
}, Math.min(10000, remaining()));
|
||||
pending.set(id, { method, resolve, reject, timer });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
async function evaluate(expression) {
|
||||
const response = await rpc("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true, userGesture: false });
|
||||
if (response.exceptionDetails) fail("KNOWN_FAILURE", response.exceptionDetails.text || "Runtime evaluation failed.");
|
||||
return response.result?.value;
|
||||
}
|
||||
|
||||
async function installNoIntentGuard() {
|
||||
const installed = await evaluate(`(() => {
|
||||
const bridge = window.chrome?.webview;
|
||||
if (!bridge || globalThis.__legacyUiOnlySmoke) return false;
|
||||
const probe = { original: bridge.postMessage, blocked: [], observed: [] };
|
||||
probe.wrapper = message => {
|
||||
const record = { type: typeof message?.type === "string" ? message.type : null, at: new Date().toISOString() };
|
||||
probe.observed.push(record);
|
||||
probe.blocked.push(record);
|
||||
return undefined;
|
||||
};
|
||||
bridge.postMessage = probe.wrapper;
|
||||
if (bridge.postMessage !== probe.wrapper) throw new Error("postMessage guard identity changed");
|
||||
globalThis.__legacyUiOnlySmoke = probe;
|
||||
return true;
|
||||
})()`);
|
||||
if (installed !== true) fail("KNOWN_FAILURE", "The no-intent WebView guard could not be installed exactly once.");
|
||||
guardInstalled = true;
|
||||
}
|
||||
|
||||
async function readShell() {
|
||||
return evaluate(`(() => {
|
||||
const probe = globalThis.__legacyUiOnlySmoke;
|
||||
const text = id => document.getElementById(id)?.textContent?.trim() || null;
|
||||
const button = id => {
|
||||
const element = document.getElementById(id);
|
||||
return element ? { present: true, disabled: element.disabled === true, text: element.textContent.trim() } : { present: false };
|
||||
};
|
||||
return {
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
bodyBusy: document.body?.getAttribute("aria-busy") || null,
|
||||
connectionText: text("playout-connection-state"),
|
||||
playoutStatus: text("playout-status"),
|
||||
title: document.title,
|
||||
controls: {
|
||||
search: button("stock-search-button"),
|
||||
prepare: button("playout-prepare"),
|
||||
takeIn: button("playout-take-in"),
|
||||
next: button("playout-next"),
|
||||
takeOut: button("playout-take-out")
|
||||
},
|
||||
visibleDialogs: Array.from(document.querySelectorAll("[role='dialog'], [role='alertdialog']"))
|
||||
.filter(element => !element.hidden && !element.closest("[hidden]")).map(element => element.id || element.getAttribute("aria-label") || "dialog"),
|
||||
postMessageGuardInstalled: window.chrome?.webview?.postMessage === probe?.wrapper,
|
||||
observedMessagesAfterGuard: probe?.observed || [],
|
||||
blockedMessages: probe?.blocked || []
|
||||
};
|
||||
})()`);
|
||||
}
|
||||
|
||||
function assertShell(shell) {
|
||||
if (!shell || shell.url !== exactUrl || shell.readyState !== "complete" || shell.bodyBusy !== "false" ||
|
||||
shell.connectionText !== "DRY RUN" || !shell.playoutStatus?.startsWith("IDLE") ||
|
||||
shell.visibleDialogs.length !== 0 ||
|
||||
shell.postMessageGuardInstalled !== true || shell.observedMessagesAfterGuard.length !== 0 || shell.blockedMessages.length !== 0) {
|
||||
fail("KNOWN_FAILURE", "The package shell did not remain rendered DryRun/IDLE with zero guarded native intents.");
|
||||
}
|
||||
for (const [name, control] of Object.entries(shell.controls || {})) {
|
||||
if (!control?.present) fail("KNOWN_FAILURE", `Required shell control ${name} is missing.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureScreenshot() {
|
||||
const response = await rpc("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false });
|
||||
if (typeof response.data !== "string" || response.data.length === 0) fail("KNOWN_FAILURE", "CDP returned no screenshot bytes.");
|
||||
const bytes = Buffer.from(response.data, "base64");
|
||||
fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" });
|
||||
return { path: configuration.screenshotPath, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex").toUpperCase() };
|
||||
}
|
||||
|
||||
try {
|
||||
const target = await discoverTarget();
|
||||
evidence.target.id = target.id;
|
||||
evidence.target.url = target.url;
|
||||
await connect(target);
|
||||
await rpc("Runtime.enable");
|
||||
await rpc("Page.enable");
|
||||
await installNoIntentGuard();
|
||||
await sleep(500);
|
||||
evidence.shell = await readShell();
|
||||
assertShell(evidence.shell);
|
||||
evidence.safety.observedMessagesAfterGuard = evidence.shell.observedMessagesAfterGuard;
|
||||
evidence.safety.blockedMessages = evidence.shell.blockedMessages;
|
||||
evidence.screenshot = await captureScreenshot();
|
||||
evidence.result = "PASS";
|
||||
} catch (error) {
|
||||
evidence.result = "FAIL";
|
||||
evidence.error = { kind: error instanceof SmokeFailure ? error.kind : "HARNESS_ERROR", message: String(error?.message || error) };
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
evidence.completedAt = new Date().toISOString();
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.close();
|
||||
fs.writeFileSync(configuration.outputPath, `${JSON.stringify(evidence, null, 2)}\n`, { flag: "wx" });
|
||||
}
|
||||
Reference in New Issue
Block a user