Files
MBN_STOCK_WEBVIEW/scripts/Invoke-LegacyPackageLocalStateSmoke.ps1

1141 lines
47 KiB
PowerShell

[CmdletBinding()]
param(
[ValidateRange(1024, 65535)]
[int]$Port = 9347,
[string]$Output,
[string]$Screenshot,
[string]$ExchangeDirectory,
[string]$LegacyDataDirectory,
[string]$NodePath,
[ValidateRange(10, 120)]
[int]$LaunchTimeoutSeconds = 30,
[ValidateRange(60, 900)]
[int]$HarnessTimeoutSeconds = 240,
[switch]$StaticAudit
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$requestedPort = $Port
$requestedOutput = $Output
$requestedScreenshot = $Screenshot
$requestedExchangeDirectory = $ExchangeDirectory
$requestedLegacyDataDirectory = $LegacyDataDirectory
$requestedNodePath = $NodePath
$requestedLaunchTimeout = $LaunchTimeoutSeconds
$requestedHarnessTimeout = $HarnessTimeoutSeconds
$sharedScript = Join-Path $PSScriptRoot 'Invoke-LegacyPackageInputSmoke.ps1'
if (-not [IO.File]::Exists($sharedScript)) {
throw [InvalidOperationException]::new('The shared package input safety script is missing.')
}
# Reuse the exact registered-package, latest DebugAppX, DryRun, TCP ownership,
# process identity and normal-close guards. -DefinitionsOnly performs no launch.
. $sharedScript `
-BuildFlavor DebugAppX `
-Profile ReadOnly `
-Port $requestedPort `
-Output $requestedOutput `
-Screenshot $requestedScreenshot `
-NodePath $requestedNodePath `
-LaunchTimeoutSeconds $requestedLaunchTimeout `
-HarnessTimeoutSeconds $requestedHarnessTimeout `
-DefinitionsOnly
$Port = $requestedPort
$Output = $requestedOutput
$Screenshot = $requestedScreenshot
$ExchangeDirectory = $requestedExchangeDirectory
$LegacyDataDirectory = $requestedLegacyDataDirectory
$NodePath = $requestedNodePath
$LaunchTimeoutSeconds = $requestedLaunchTimeout
$HarnessTimeoutSeconds = $requestedHarnessTimeout
$HarnessPath = Join-Path $PSScriptRoot 'Test-LegacyPackageLocalStateSmoke.mjs'
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
public static class LegacyLocalStateNative
{
private delegate bool EnumWindowsCallback(IntPtr window, IntPtr parameter);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumWindowsCallback callback, IntPtr parameter);
[DllImport("user32.dll")]
private static extern IntPtr GetWindow(IntPtr window, uint command);
[DllImport("user32.dll")]
private static extern IntPtr GetAncestor(IntPtr window, uint flags);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr window);
[DllImport("user32.dll")]
private static extern bool BringWindowToTop(IntPtr window);
[DllImport("user32.dll")]
private static extern IntPtr SetActiveWindow(IntPtr window);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool AttachThreadInput(uint from, uint to, bool attach);
[DllImport("user32.dll")]
private static extern bool IsWindow(IntPtr window);
[DllImport("user32.dll")]
private static extern bool IsWindowVisible(IntPtr window);
[DllImport("user32.dll")]
private static extern bool IsWindowEnabled(IntPtr window);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetClassName(IntPtr window, StringBuilder value, int maximum);
private const uint GetWindowOwner = 4;
private const uint GetAncestorRoot = 2;
public static bool Exists(IntPtr window)
{
return window != IntPtr.Zero && IsWindow(window);
}
public static bool Visible(IntPtr window)
{
return Exists(window) && IsWindowVisible(window);
}
public static bool Enabled(IntPtr window)
{
return Exists(window) && IsWindowEnabled(window);
}
public static int ProcessId(IntPtr window)
{
uint processId;
return GetWindowThreadProcessId(window, out processId) == 0 ? 0 : checked((int)processId);
}
public static IntPtr Root(IntPtr window)
{
return window == IntPtr.Zero
? IntPtr.Zero : GetAncestor(window, GetAncestorRoot);
}
public static bool OwnerChainContains(IntPtr window, IntPtr expectedOwner)
{
if (window == IntPtr.Zero || expectedOwner == IntPtr.Zero) return false;
IntPtr expectedRoot = Root(expectedOwner);
IntPtr current = window;
for (int depth = 0; depth < 12 && current != IntPtr.Zero; depth++)
{
if (current == expectedOwner || Root(current) == expectedRoot) return true;
current = GetWindow(current, GetWindowOwner);
}
return false;
}
public static string ClassName(IntPtr window)
{
StringBuilder value = new StringBuilder(512);
return window != IntPtr.Zero && GetClassName(window, value, value.Capacity) > 0
? value.ToString() : String.Empty;
}
public static IntPtr[] VisibleTopLevelWindows()
{
List<IntPtr> windows = new List<IntPtr>();
EnumWindowsCallback callback = delegate(IntPtr window, IntPtr parameter)
{
if (window != IntPtr.Zero && IsWindowVisible(window))
{
windows.Add(window);
}
return true;
};
if (!EnumWindows(callback, IntPtr.Zero))
{
throw new InvalidOperationException("The native top-level window list is unavailable.");
}
return windows.ToArray();
}
public static void AcquireOwnedForeground(IntPtr window, IntPtr expectedOwner)
{
if (!Visible(window) || !Enabled(window) ||
!OwnerChainContains(window, expectedOwner))
{
throw new InvalidOperationException("The owned picker identity changed before foreground acquisition.");
}
IntPtr foreground = GetForegroundWindow();
if (Root(foreground) == window) return;
uint ignored;
uint foregroundThread = foreground == IntPtr.Zero
? 0 : GetWindowThreadProcessId(foreground, out ignored);
uint targetThread = GetWindowThreadProcessId(window, out ignored);
uint currentThread = GetCurrentThreadId();
if (targetThread == 0 || currentThread == 0)
{
throw new InvalidOperationException("The picker input threads are unavailable.");
}
bool attachedForeground = false;
bool attachedTarget = false;
try
{
if (foregroundThread != 0 && foregroundThread != currentThread &&
foregroundThread != targetThread)
{
if (!AttachThreadInput(currentThread, foregroundThread, true))
{
throw new InvalidOperationException("The foreground input queue could not be attached to the picker verifier.");
}
attachedForeground = true;
}
if (targetThread != currentThread)
{
if (!AttachThreadInput(currentThread, targetThread, true))
{
throw new InvalidOperationException("The picker input queue could not be attached.");
}
attachedTarget = true;
}
BringWindowToTop(window);
SetActiveWindow(window);
SetForegroundWindow(window);
}
finally
{
if (attachedTarget) AttachThreadInput(currentThread, targetThread, false);
if (attachedForeground) AttachThreadInput(currentThread, foregroundThread, false);
}
DateTime deadline = DateTime.UtcNow.AddSeconds(3);
while (DateTime.UtcNow < deadline && Root(GetForegroundWindow()) != window)
{
if (!Visible(window) || !Enabled(window) ||
!OwnerChainContains(window, expectedOwner))
{
throw new InvalidOperationException("The owned picker identity changed during foreground acquisition.");
}
Thread.Sleep(10);
}
if (Root(GetForegroundWindow()) != window)
{
throw new InvalidOperationException("The exact owned picker could not acquire foreground input.");
}
}
}
'@
$ExpectedSourceSha256 =
'1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2'
$ExpectedInitialDestinationSha256 =
'13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087'
$ExpectedInitialPairLine = $Utf8.GetString([Convert]::FromBase64String(
'MV5LUlgxMDDsp4DsiJgs7L2U7Iqk7ZS8IOyngOyImF5eXl5eXl4='))
$ComparisonFileName = $Utf8.GetString([Convert]::FromBase64String(
'7KKF66qp67mE6rWQLmRhdA=='))
$ExpectedInitialPairIdentity = $Utf8.GetString([Convert]::FromBase64String(
'S1JYMTAw7KeA7IiYfOy9lOyKpO2UvCDsp4DsiJg='))
$FolderSelectLabel = $Utf8.GetString([Convert]::FromBase64String(
'7Y+0642UIOyEoO2DnQ=='))
$CancelLabel = $Utf8.GetString([Convert]::FromBase64String('7Leo7IaM'))
if ([string]::IsNullOrWhiteSpace($LegacyDataDirectory)) {
$LegacyDataDirectory = [IO.Path]::GetFullPath((Join-Path $RepositoryRoot `
'..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Data'))
} else {
$LegacyDataDirectory = [IO.Path]::GetFullPath($LegacyDataDirectory)
}
$SourceComparisonPath = Join-Path $LegacyDataDirectory $ComparisonFileName
$DestinationComparisonPath = Join-Path `
([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) `
(Join-Path 'MBN_STOCK_WEBVIEW\Data' $ComparisonFileName)
$RuntimeSettingsPath = Join-Path `
([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) `
'MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json'
$Cp949 = [Text.Encoding]::GetEncoding(
949,
[Text.EncoderExceptionFallback]::new(),
[Text.DecoderExceptionFallback]::new())
$ExpectedExchangePlan = @(
[pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null },
[pscustomobject]@{ Operation = 'application-click'; TargetId = 'settings-navigation'; FolderKind = $null },
[pscustomobject]@{ Operation = 'folder-picker-click-cancel'; TargetId = 'design-folder-picker'; FolderKind = 'design' },
[pscustomobject]@{ Operation = 'folder-picker-click-cancel'; TargetId = 'resource-folder-picker'; FolderKind = 'resource' },
[pscustomobject]@{ Operation = 'folder-picker-click-cancel'; TargetId = 'background-folder-picker'; FolderKind = 'background' },
[pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null },
[pscustomobject]@{ Operation = 'application-click'; TargetId = 'comparison-navigation'; FolderKind = $null },
[pscustomobject]@{ Operation = 'application-click'; TargetId = 'comparison-import'; FolderKind = $null },
[pscustomobject]@{ Operation = 'application-click'; TargetId = 'native-confirmation-yes'; FolderKind = $null },
[pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null },
[pscustomobject]@{ Operation = 'application-click'; TargetId = 'comparison-import'; FolderKind = $null },
[pscustomobject]@{ Operation = 'application-click'; TargetId = 'native-confirmation-yes'; FolderKind = $null },
[pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null }
)
$script:LocalInputRequestCalls = 0
$script:LocalInputAcknowledgementCalls = 0
$script:BaselineSettingsState = $null
$script:InitialDestinationState = $null
$script:FirstImportedDestinationState = $null
$script:LastObservedFileState = $null
function Assert-SafeRegularFile([string]$Path, [string]$Label, [int64]$MaximumBytes) {
if (-not [IO.File]::Exists($Path)) {
Throw-SafeFailure "$Label is missing."
}
$item = Get-Item -LiteralPath $Path -Force
if ($item.PSIsContainer -or
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
$item.Length -le 0 -or $item.Length -gt $MaximumBytes) {
Throw-SafeFailure "$Label is not a bounded regular file."
}
}
function Get-ComparisonFileState(
[string]$Path,
[string]$Label,
[bool]$IsReadOnlySource) {
Assert-SafeRegularFile $Path $Label (64 * 1024)
$bytes = [IO.File]::ReadAllBytes($Path)
try {
$text = $Cp949.GetString($bytes)
}
catch {
Throw-SafeFailure "$Label is not strict CP949."
}
$rows = @($text -split "`r?`n" | Where-Object { $_.Length -gt 0 })
if ($rows.Count -lt 1 -or $rows.Count -gt 500) {
Throw-SafeFailure "$Label row count is outside the closed contract."
}
for ($index = 0; $index -lt $rows.Count; $index++) {
if ($rows[$index] -cnotmatch ('\A' + ($index + 1) + '\^[^\^,]+,[^\^,]+(?:\^[^\^]*){7}\z')) {
Throw-SafeFailure "$Label does not retain the exact legacy row shape."
}
}
$pairField = ($rows[0] -split '\^', 3)[1]
$pair = $pairField -split ',', 2
return [ordered]@{
exists = $true
length = [int64]$bytes.Length
sha256 = Get-ByteArraySha256 $bytes
rowCount = $rows.Count
firstPair = $pair[0] + '|' + $pair[1]
isReadOnlySource = $IsReadOnlySource
}
}
function Get-SettingsFileState {
if (-not [IO.File]::Exists($RuntimeSettingsPath)) {
return [ordered]@{
exists = $false
length = 0
sha256 = $null
}
}
Assert-SafeRegularFile $RuntimeSettingsPath 'Runtime folder settings JSON' (16 * 1024)
$bytes = [IO.File]::ReadAllBytes($RuntimeSettingsPath)
try {
[void]$StrictUtf8.GetString($bytes)
}
catch {
Throw-SafeFailure 'Runtime folder settings JSON is not strict UTF-8.'
}
return [ordered]@{
exists = $true
length = [int64]$bytes.Length
sha256 = Get-ByteArraySha256 $bytes
}
}
function Get-LocalFileState {
return [ordered]@{
source = Get-ComparisonFileState `
$SourceComparisonPath `
'Original comparison source' `
$true
destination = Get-ComparisonFileState `
$DestinationComparisonPath `
'Current local comparison file' `
$false
settings = Get-SettingsFileState
}
}
function Test-JsonEquivalent($Left, $Right) {
return (($Left | ConvertTo-Json -Depth 12 -Compress) -ceq
($Right | ConvertTo-Json -Depth 12 -Compress))
}
function Assert-LocalFilePreflight {
$state = Get-LocalFileState
if ([string]$state.source.sha256 -cne $ExpectedSourceSha256 -or
[int]$state.source.rowCount -ne 8 -or
[string]$state.destination.sha256 -cne $ExpectedInitialDestinationSha256 -or
[int]$state.destination.rowCount -ne 1 -or
[string]$state.destination.firstPair -cne $ExpectedInitialPairIdentity) {
Throw-SafeFailure `
('The original 8-row source or exact current one-row baseline changed; ' +
'no import was started.')
}
$initialBytes = [IO.File]::ReadAllBytes($DestinationComparisonPath)
if ($Cp949.GetString($initialBytes).TrimEnd("`r", "`n") -cne
$ExpectedInitialPairLine) {
Throw-SafeFailure 'The current one-row comparison baseline is not the expected known pair.'
}
$script:BaselineSettingsState = $state.settings
$script:InitialDestinationState = $state.destination
$script:LastObservedFileState = $state
return $state
}
function Get-TopLevelWindowHandles {
$handles = New-Object 'Collections.Generic.HashSet[int64]'
foreach ($handle in [LegacyLocalStateNative]::VisibleTopLevelWindows()) {
if ($handle -ne [IntPtr]::Zero) { [void]$handles.Add($handle.ToInt64()) }
}
return $handles
}
function Get-OwnedFolderPickerCandidates(
[Collections.Generic.HashSet[int64]]$BaselineHandles,
[IntPtr]$MainWindowHandle,
[int]$ApplicationProcessId) {
$candidates = @()
foreach ($handle in [LegacyLocalStateNative]::VisibleTopLevelWindows()) {
try {
if ($handle -eq [IntPtr]::Zero -or
$BaselineHandles.Contains($handle.ToInt64()) -or
-not [LegacyLocalStateNative]::Visible($handle)) {
continue
}
$sameProcess = [LegacyLocalStateNative]::ProcessId($handle) -eq $ApplicationProcessId
$ownerLinked = $sameProcess -or
[LegacyLocalStateNative]::OwnerChainContains($handle, $MainWindowHandle)
if (-not $ownerLinked) { continue }
$window = [System.Windows.Automation.AutomationElement]::FromHandle($handle)
if ($null -eq $window) { continue }
$windowName = [string]$window.Current.Name
$windowClass = [LegacyLocalStateNative]::ClassName($handle)
if ($windowClass -cne '#32770' -or
(-not $windowName.StartsWith(
$FolderSelectLabel,
[StringComparison]::Ordinal) -and
$windowName -cnotmatch '\ASelect Folder(?:\(&.\))?\z')) {
continue
}
# The Win32 folder picker may expose its bottom Button windows as
# ControlType.Pane through UI Automation (observed on the Korean
# Windows shell). Keep the label/count gate, but also recognize
# the exact legacy Button class/IDs instead of requiring only the
# synthesized ControlType.Button view.
$controls = $window.FindAll(
[System.Windows.Automation.TreeScope]::Descendants,
[System.Windows.Automation.Condition]::TrueCondition)
$confirmCount = 0
$cancelCount = 0
foreach ($control in $controls) {
$automationId = [string]$control.Current.AutomationId
$isAutomationButton =
$control.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button
$isLegacyDialogButton =
[string]$control.Current.ClassName -ceq 'Button' -and
($automationId -ceq '1' -or $automationId -ceq '2')
if (-not $isAutomationButton -and -not $isLegacyDialogButton) { continue }
$name = [string]$control.Current.Name
if ($name.StartsWith($FolderSelectLabel, [StringComparison]::Ordinal) -or
$name -cmatch '\A(?:Select Folder|Select)(?:\(&.\))?\z') {
$confirmCount++
}
if ($name.StartsWith($CancelLabel, [StringComparison]::Ordinal) -or
$name -cmatch '\ACancel(?:\(&.\))?\z') {
$cancelCount++
}
}
if ($confirmCount -ne 1 -or $cancelCount -ne 1) { continue }
$windowPatternObject = $null
if (-not $window.TryGetCurrentPattern(
[System.Windows.Automation.WindowPattern]::Pattern,
[ref]$windowPatternObject) -or
$null -eq $windowPatternObject) {
continue
}
$windowPattern = $windowPatternObject -as
[System.Windows.Automation.WindowPattern]
$isModal = [bool]$windowPattern.Current.IsModal
if (-not $isModal) { continue }
$candidates += [pscustomobject]@{
Element = $window
Handle = $handle
ProcessId = [LegacyLocalStateNative]::ProcessId($handle)
SameProcess = $sameProcess
OwnerLinked = $ownerLinked
Name = $windowName
ClassName = $windowClass
ConfirmButtonCount = $confirmCount
CancelButtonCount = $cancelCount
IsModal = $isModal
}
}
catch {
# A transient shell window is ignored unless it satisfies every
# stable ownership and folder-button condition in one observation.
}
}
return @($candidates)
}
function Wait-UniqueOwnedFolderPicker(
[Collections.Generic.HashSet[int64]]$BaselineHandles,
[Diagnostics.Process]$Application,
[string]$ExpectedApplicationPath,
[datetime]$Deadline) {
while ([datetime]::UtcNow -lt $Deadline) {
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
Assert-NoTornadoConnection $Application.Id
$candidates = @(Get-OwnedFolderPickerCandidates `
$BaselineHandles `
$Application.MainWindowHandle `
$Application.Id)
if ($candidates.Count -gt 1) {
Throw-SafeFailure 'More than one newly opened app-owned folder picker exists.'
}
if ($candidates.Count -eq 1) {
$picker = $candidates[0]
Assert-NoTornadoConnection $Application.Id
[LegacyLocalStateNative]::AcquireOwnedForeground(
$picker.Handle,
$Application.MainWindowHandle)
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
Assert-NoTornadoConnection $Application.Id
$revalidated = @(Get-OwnedFolderPickerCandidates `
$BaselineHandles `
$Application.MainWindowHandle `
$Application.Id)
if ($revalidated.Count -ne 1 -or
$revalidated[0].Handle -ne $picker.Handle) {
Throw-SafeFailure 'The unique app-owned folder picker identity changed during foreground acquisition.'
}
$foregroundRoot = [LegacyLocalStateNative]::Root(
[LegacyPackageInputNative]::ReadForegroundWindow())
if ($foregroundRoot -eq $picker.Handle) { return $picker }
}
Start-Sleep -Milliseconds 50
}
Throw-SafeFailure 'The exact app-owned folder picker did not appear in time.'
}
function Wait-FolderPickerClosed(
[IntPtr]$PickerHandle,
[Diagnostics.Process]$Application,
[string]$ExpectedApplicationPath,
[datetime]$Deadline) {
while ([datetime]::UtcNow -lt $Deadline) {
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
Assert-NoTornadoConnection $Application.Id
if (-not [LegacyLocalStateNative]::Exists($PickerHandle)) {
return
}
Start-Sleep -Milliseconds 50
}
Throw-SafeFailure 'The exact folder picker was not destroyed after the one Escape input.'
}
function ConvertTo-LocalFiniteNumber($Value, [string]$Label) {
try {
$number = [Convert]::ToDouble($Value, [Globalization.CultureInfo]::InvariantCulture)
}
catch { Throw-SafeFailure "$Label is not numeric." }
if ([double]::IsNaN($number) -or [double]::IsInfinity($number)) {
Throw-SafeFailure "$Label is not finite."
}
return $number
}
function Write-LocalAcknowledgement(
[string]$Path,
$Request,
$Files,
$Picker) {
$acknowledgement = [ordered]@{
schemaVersion = 1
token = [string]$Request.token
sequence = [int]$Request.sequence
result = 'PASS'
operation = [string]$Request.operation
targetId = if ($null -eq $Request.targetId) { $null } else { [string]$Request.targetId }
folderKind = if ($null -eq $Request.folderKind) { $null } else { [string]$Request.folderKind }
packageIdentityValidated = $true
dryRunValidated = $true
tornadoConnectionCount = 0
mouseDownCalls = [int]$Request.mouseDownCalls
mouseUpCalls = [int]$Request.mouseUpCalls
escapeKeyCalls = [int]$Request.escapeKeyCalls
inputRetryCount = 0
picker = $Picker
files = $Files
}
$bytes = $Utf8.GetBytes(($acknowledgement | ConvertTo-Json -Depth 12 -Compress) + "`n")
$temporary = $Path + '.' + [Guid]::NewGuid().ToString('N') + '.tmp'
try {
$stream = [IO.FileStream]::new(
$temporary,
[IO.FileMode]::CreateNew,
[IO.FileAccess]::Write,
[IO.FileShare]::None)
try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush($true)
}
finally { $stream.Dispose() }
[IO.File]::Move($temporary, $Path)
}
catch {
try { if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } } catch { }
Throw-SafeFailure 'The immutable local-state acknowledgement could not be created.'
}
return [pscustomobject]@{
Sha256 = Get-ByteArraySha256 $bytes
}
}
function Invoke-LocalStateExchange(
[int]$Sequence,
[string]$RequestPath,
[string]$AcknowledgementPath,
[Diagnostics.Process]$Application,
[string]$ExpectedApplicationPath) {
$script:LocalInputRequestCalls++
if ($script:LocalInputRequestCalls -ne $Sequence -or
[IO.File]::Exists($AcknowledgementPath)) {
Throw-SafeFailure 'The local-state one-shot input sequence changed.'
}
$requestEvidence = Wait-ReadSmallJsonEvidenceFile `
$RequestPath `
"Local-state request $Sequence" `
([datetime]::UtcNow.AddSeconds(3))
$request = $requestEvidence.Value
Assert-ExactJsonPropertyNames $request @(
'schemaVersion', 'token', 'sequence', 'targetUrl', 'operation',
'targetId', 'folderKind', 'point', 'viewport', 'devicePixelRatio',
'mouseDownCalls', 'mouseUpCalls', 'escapeKeyCalls', 'inputRetryCount') `
"Local-state request $Sequence"
$expected = $ExpectedExchangePlan[$Sequence - 1]
$targetId = if ($null -eq $request.targetId) { $null } else { [string]$request.targetId }
$folderKind = if ($null -eq $request.folderKind) { $null } else { [string]$request.folderKind }
if ([int](ConvertTo-LocalFiniteNumber $request.schemaVersion 'schemaVersion') -ne 1 -or
[int](ConvertTo-LocalFiniteNumber $request.sequence 'sequence') -ne $Sequence -or
$request.token -isnot [string] -or [string]$request.token -cnotmatch '\A[0-9A-F]{32}\z' -or
[string]$request.targetUrl -cne 'https://legacy-parity.mbn.local/index.html' -or
[string]$request.operation -cne [string]$expected.Operation -or
$targetId -cne $expected.TargetId -or $folderKind -cne $expected.FolderKind -or
[int](ConvertTo-LocalFiniteNumber $request.inputRetryCount 'inputRetryCount') -ne 0) {
Throw-SafeFailure "Local-state request $Sequence is outside the closed plan."
}
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
Assert-NoTornadoConnection $Application.Id
$pickerEvidence = $null
if ([string]$expected.Operation -ceq 'observe-local-files') {
if ($null -ne $request.point -or $null -ne $request.viewport -or
$null -ne $request.devicePixelRatio -or
[int]$request.mouseDownCalls -ne 0 -or [int]$request.mouseUpCalls -ne 0 -or
[int]$request.escapeKeyCalls -ne 0) {
Throw-SafeFailure 'A file observation request attempted to carry input geometry.'
}
}
else {
Assert-ExactJsonPropertyNames $request.point @('x', 'y') "Request $Sequence point"
Assert-ExactJsonPropertyNames $request.viewport @('width', 'height') "Request $Sequence viewport"
$x = ConvertTo-LocalFiniteNumber $request.point.x 'point.x'
$y = ConvertTo-LocalFiniteNumber $request.point.y 'point.y'
$width = ConvertTo-LocalFiniteNumber $request.viewport.width 'viewport.width'
$height = ConvertTo-LocalFiniteNumber $request.viewport.height 'viewport.height'
$scale = ConvertTo-LocalFiniteNumber $request.devicePixelRatio 'devicePixelRatio'
$expectedEscapeCount = if ([string]$expected.Operation -ceq
'folder-picker-click-cancel') { 1 } else { 0 }
if ($width -lt 1 -or $width -gt 16384 -or $height -lt 1 -or $height -gt 16384 -or
$x -lt 0 -or $x -ge $width -or $y -lt 0 -or $y -ge $height -or
$scale -lt 0.25 -or $scale -gt 5 -or
[int]$request.mouseDownCalls -ne 1 -or [int]$request.mouseUpCalls -ne 1 -or
[int]$request.escapeKeyCalls -ne $expectedEscapeCount) {
Throw-SafeFailure "Request $Sequence input geometry or exact count is invalid."
}
$baselineWindows = if ([string]$expected.Operation -ceq
'folder-picker-click-cancel') { Get-TopLevelWindowHandles } else { $null }
[LegacyPackageInputNative]::SendApplicationClick(
$Application.MainWindowHandle,
$Application.Id,
30001,
$x,
$y,
$width,
$height,
$scale,
[string]$expected.Operation -ceq 'folder-picker-click-cancel')
if ([string]$expected.Operation -ceq 'folder-picker-click-cancel') {
$picker = Wait-UniqueOwnedFolderPicker `
$baselineWindows `
$Application `
$ExpectedApplicationPath `
([datetime]::UtcNow.AddSeconds(10))
[LegacyPackageInputNative]::SendEscapeToForegroundWindow($picker.Handle)
Wait-FolderPickerClosed `
$picker.Handle `
$Application `
$ExpectedApplicationPath `
([datetime]::UtcNow.AddSeconds(10))
$pickerEvidence = [ordered]@{
uniqueNewPicker = $true
ownerLinkedToApplication = [bool]$picker.OwnerLinked
sameProcessAsApplication = [bool]$picker.SameProcess
processId = [int]$picker.ProcessId
windowClass = [string]$picker.ClassName
windowTitle = [string]$picker.Name
isModal = [bool]$picker.IsModal
folderSelectButtonCount = [int]$picker.ConfirmButtonCount
cancelButtonCount = [int]$picker.CancelButtonCount
escapeCancelled = $true
closedAfterEscape = $true
}
}
}
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
Assert-NoTornadoConnection $Application.Id
# A physical Yes click starts an asynchronous atomic store operation. Do
# not race that write by opening the destination in the click handler. Node
# waits for the native completion receipt and then publishes the dedicated
# observation request (10 or 13), which is the first permitted fresh read.
$files = if ($Sequence -eq 9 -or $Sequence -eq 12) {
$script:LastObservedFileState
}
else {
$fresh = Get-LocalFileState
$script:LastObservedFileState = $fresh
$fresh
}
if (-not (Test-JsonEquivalent $files.settings $script:BaselineSettingsState)) {
Throw-SafeFailure 'The runtime folder settings file changed during a cancel-only smoke.'
}
if ([string]$files.source.sha256 -cne $ExpectedSourceSha256 -or
[int]$files.source.rowCount -ne 8) {
Throw-SafeFailure 'The original read-only comparison source changed during the smoke.'
}
if ($Sequence -lt 9 -and
[string]$files.destination.sha256 -cne $ExpectedInitialDestinationSha256) {
Throw-SafeFailure 'The comparison destination changed before the authorized Yes input.'
}
if ($Sequence -eq 10) {
if ([int]$files.destination.rowCount -ne 9 -or
[string]$files.destination.firstPair -cne $ExpectedInitialPairIdentity -or
[string]$files.destination.sha256 -ceq $ExpectedInitialDestinationSha256) {
Throw-SafeFailure 'The first authorized import is not an appended nine-row file.'
}
$script:FirstImportedDestinationState = $files.destination
}
if ($Sequence -eq 13 -and
(-not (Test-JsonEquivalent $files.destination $script:FirstImportedDestinationState))) {
Throw-SafeFailure 'The second import changed the comparison file instead of added0 idempotence.'
}
$script:LocalInputAcknowledgementCalls++
if ($script:LocalInputAcknowledgementCalls -ne $Sequence) {
Throw-SafeFailure 'The local-state acknowledgement budget changed.'
}
$acknowledgement = Write-LocalAcknowledgement `
$AcknowledgementPath `
$request `
$files `
$pickerEvidence
return [pscustomobject]@{
Sequence = $Sequence
RequestSha256 = $requestEvidence.Sha256
AcknowledgementSha256 = $acknowledgement.Sha256
}
}
function Invoke-LocalHarnessUnderWatch(
[string]$Executable,
[string]$ScriptPath,
[int]$DebugPort,
[string]$OutputPath,
[string]$ScreenshotPath,
[string]$ExchangePath,
[int]$TimeoutSeconds,
[Diagnostics.Process]$Application,
[string]$ExpectedApplicationPath) {
$rawArguments = @(
$ScriptPath,
'--port', [string]$DebugPort,
'--output', $OutputPath,
'--screenshot', $ScreenshotPath,
'--exchange-directory', $ExchangePath,
'--timeout-ms', [string]($TimeoutSeconds * 1000))
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $Executable
$startInfo.WorkingDirectory = $RepositoryRoot
$startInfo.Arguments = ($rawArguments | ForEach-Object {
ConvertTo-QuotedProcessArgument ([string]$_)
}) -join ' '
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardInput = $true
try { $node = [Diagnostics.Process]::Start($startInfo) }
catch { Throw-SafeFailure 'The local-state Node harness could not be started.' }
if ($null -eq $node) { Throw-SafeFailure 'The local-state Node harness did not start.' }
$deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds + 30)
$sequence = 1
$exchanges = @()
$monitoringFailure = $null
try {
while (-not $node.HasExited) {
if ([datetime]::UtcNow -ge $deadline) {
Throw-SafeFailure 'The local-state harness exceeded its global deadline.'
}
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
Assert-NoTornadoConnection $Application.Id
if ($sequence -le $ExpectedExchangePlan.Count) {
$requestPath = Join-Path $ExchangePath (
$sequence.ToString('00') + '.request.json')
$ackPath = Join-Path $ExchangePath (
$sequence.ToString('00') + '.ack.json')
if ([IO.File]::Exists($requestPath)) {
$exchanges += Invoke-LocalStateExchange `
$sequence `
$requestPath `
$ackPath `
$Application `
$ExpectedApplicationPath
$sequence++
}
}
Start-Sleep -Milliseconds 50
$node.Refresh()
}
if ($sequence -ne ($ExpectedExchangePlan.Count + 1) -or
$script:LocalInputRequestCalls -ne $ExpectedExchangePlan.Count -or
$script:LocalInputAcknowledgementCalls -ne $ExpectedExchangePlan.Count) {
Throw-SafeFailure 'The closed thirteen-step local-state sequence did not complete.'
}
}
catch { $monitoringFailure = $_.Exception.Message }
if ($null -ne $monitoringFailure) {
$stopped = $node.HasExited
if (-not $stopped) {
try {
$node.StandardInput.WriteLine('CANCEL')
$node.StandardInput.Flush()
$stopped = $node.WaitForExit(5000)
}
catch { $stopped = $false }
}
if (-not $stopped) {
try {
$node.Kill()
$stopped = $node.WaitForExit(10000)
}
catch { $stopped = $false }
}
Throw-SafeFailure (
"$monitoringFailure The input helper was stopped if possible; " +
'the packaged app was left open and no input was retried.')
}
return [pscustomobject]@{
ExitCode = $node.ExitCode
Exchanges = @($exchanges)
}
}
function Read-LocalHarnessEvidenceFile([string]$Path) {
try {
$item = Get-Item -LiteralPath $Path -Force
if ($item.PSIsContainer -or
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
$item.Length -le 0 -or $item.Length -gt 128KB) {
Throw-SafeFailure 'Local-state harness evidence is not a bounded regular file.'
}
$bytes = [IO.File]::ReadAllBytes($Path)
if ($bytes.Length -ne $item.Length) {
Throw-SafeFailure 'Local-state harness evidence changed while it was read.'
}
$value = $StrictUtf8.GetString($bytes) | ConvertFrom-Json -ErrorAction Stop
return [pscustomobject]@{
Value = $value
Bytes = $bytes
Sha256 = Get-ByteArraySha256 $bytes
}
}
catch [InvalidOperationException] {
throw
}
catch {
Throw-SafeFailure 'Local-state harness evidence is not strict bounded JSON.'
}
}
function Assert-LocalHarnessEvidence(
[string]$EvidencePath,
[string]$ScreenshotPath,
[string]$ExchangePath) {
$sealed = Read-LocalHarnessEvidenceFile $EvidencePath
$value = $sealed.Value
if ([int]$value.schemaVersion -ne 1 -or
[string]$value.profile -cne 'local-settings-comparison-import' -or
[string]$value.result -cne 'PASS' -or $null -ne $value.error -or
[string]$value.target.expectedUrl -cne
'https://legacy-parity.mbn.local/index.html' -or
[int]$value.target.port -ne $Port -or
[string]$value.target.url -cne
'https://legacy-parity.mbn.local/index.html' -or
[string]$value.safety.requiredMode -cne 'dryRun' -or
[string]$value.safety.requiredPhase -cne 'idle' -or
$value.safety.appCloseRequested -ne $false -or
$value.safety.playoutIntentIssued -ne $false -or
[int]$value.safety.inputRetryCount -ne 0 -or
@($value.safety.blockedOutboundMessages).Count -ne 0 -or
@($value.safety.stateViolations).Count -ne 0 -or
$value.settings.allCancelled -ne $true -or
$value.settings.settingsFileUnchanged -ne $true -or
-not (Test-ExactStringSequence @($value.settings.pickerKinds) @(
'design', 'resource', 'background')) -or
$value.comparison.initialPairPreserved -ne $true -or
$value.comparison.secondImportIdempotent -ne $true -or
[int]$value.comparison.afterFirstImport.receipt.addedPairCount -ne 8 -or
[int]$value.comparison.afterFirstImport.pairCount -ne 9 -or
[int]$value.comparison.afterSecondImport.receipt.addedPairCount -ne 0 -or
[int]$value.comparison.afterSecondImport.pairCount -ne 9 -or
[string]$value.files.destinationAfterFirstImport.sha256 -cne
[string]$value.files.destinationAfterSecondImport.sha256 -or
@($value.exchanges).Count -ne 13) {
Throw-SafeFailure 'The local-state evidence does not satisfy the exact cancel/import contract.'
}
$screenshot = Get-Item -LiteralPath $ScreenshotPath -Force
if ($screenshot.Length -le 0 -or
[int64]$value.screenshot.bytes -ne $screenshot.Length -or
[string]$value.screenshot.sha256 -cne
(Get-FileHash -Algorithm SHA256 -LiteralPath $ScreenshotPath).Hash) {
Throw-SafeFailure 'The local-state screenshot does not match its sealed evidence.'
}
for ($index = 0; $index -lt 13; $index++) {
$sequence = $index + 1
$entry = $value.exchanges[$index]
$requestPath = Join-Path $ExchangePath ($sequence.ToString('00') + '.request.json')
$ackPath = Join-Path $ExchangePath ($sequence.ToString('00') + '.ack.json')
if ([int]$entry.sequence -ne $sequence -or
-not (Test-PathEqual ([string]$entry.requestPath) $requestPath) -or
-not (Test-PathEqual ([string]$entry.acknowledgementPath) $ackPath) -or
[string]$entry.requestSha256 -cne
(Get-FileHash -Algorithm SHA256 -LiteralPath $requestPath).Hash -or
[string]$entry.acknowledgementSha256 -cne
(Get-FileHash -Algorithm SHA256 -LiteralPath $ackPath).Hash) {
Throw-SafeFailure "Local-state exchange $sequence is not sealed exactly."
}
$expectedOperation = [string]$ExpectedExchangePlan[$index].Operation
if ($expectedOperation -ceq 'observe-local-files') {
if ($null -ne $entry.physicalPointer) {
Throw-SafeFailure "File observation $sequence unexpectedly contains pointer input."
}
}
elseif ($null -eq $entry.physicalPointer -or
$entry.physicalPointer.down.isTrusted -ne $true -or
$entry.physicalPointer.up.isTrusted -ne $true -or
[string]$entry.physicalPointer.down.pointerType -cne 'mouse' -or
[string]$entry.physicalPointer.up.pointerType -cne 'mouse' -or
[string]$entry.physicalPointer.down.targetId -cne
[string]$ExpectedExchangePlan[$index].TargetId -or
[string]$entry.physicalPointer.up.targetId -cne
[string]$ExpectedExchangePlan[$index].TargetId) {
Throw-SafeFailure "Input exchange $sequence lacks exact trusted pointer evidence."
}
}
return $value
}
if ($StaticAudit) {
[pscustomobject]@{
result = 'PASS'
profile = 'local-settings-comparison-import'
packageFlavor = 'DebugAppX'
exchangeCount = $ExpectedExchangePlan.Count
physicalClickCount = @($ExpectedExchangePlan | Where-Object {
$_.Operation -ceq 'application-click' -or
$_.Operation -ceq 'folder-picker-click-cancel'
}).Count
folderPickerCancelCount = @($ExpectedExchangePlan | Where-Object {
$_.Operation -ceq 'folder-picker-click-cancel'
}).Count
fileObservationCount = @($ExpectedExchangePlan | Where-Object {
$_.Operation -ceq 'observe-local-files'
}).Count
inputRetryCount = 0
expectedSourceSha256 = $ExpectedSourceSha256
expectedSourceRows = 8
expectedInitialDestinationSha256 = $ExpectedInitialDestinationSha256
expectedInitialDestinationRows = 1
sendInputClickAvailable = $null -ne
[LegacyPackageInputNative].GetMethod('SendApplicationClick')
sendInputEscapeAvailable = $null -ne
[LegacyPackageInputNative].GetMethod('SendEscapeToForegroundWindow')
harnessPath = $HarnessPath
harnessSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $HarnessPath).Hash
} | ConvertTo-Json -Compress
return
}
$timestamp = [datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ')
$Output = Resolve-EvidencePath $Output "legacy-package-local-state-$timestamp.json"
$Screenshot = Resolve-EvidencePath $Screenshot "legacy-package-local-state-$timestamp.png"
if ([string]::IsNullOrWhiteSpace($ExchangeDirectory)) {
$ExchangeDirectory = [IO.Path]::ChangeExtension($Output, $null) + '.exchanges'
}
elseif (-not [IO.Path]::IsPathRooted($ExchangeDirectory)) {
$ExchangeDirectory = [IO.Path]::GetFullPath((Join-Path $RepositoryRoot $ExchangeDirectory))
}
else { $ExchangeDirectory = [IO.Path]::GetFullPath($ExchangeDirectory) }
$NodePath = Resolve-NodeExecutable $NodePath
try {
if (-not [IO.File]::Exists($HarnessPath)) {
Throw-SafeFailure 'The tracked local-state harness is missing.'
}
Assert-OutputPathAvailable $Output 'Output'
Assert-OutputPathAvailable $Screenshot 'Screenshot'
Assert-OutputPathAvailable $ExchangeDirectory 'Exchange directory'
[void][IO.Directory]::CreateDirectory($ExchangeDirectory)
if (@(Get-ChildItem -LiteralPath $ExchangeDirectory -Force).Count -ne 0) {
Throw-SafeFailure 'The new exchange directory is not empty.'
}
Assert-LocalFilePreflight | Out-Null
Assert-LocalConfigurationIsDryRun
Assert-NoUnsafeEnvironment
$registration = Get-ExactDevelopmentPackage
Assert-NoExistingApplication
Assert-PortIsFree $Port
$script:Phase = 'package launch'
$script:ApplicationProcess = Start-PackagedDryRunApplication $registration $Port
$script:ApplicationWasLaunched = $true
[void](Wait-ExactLoopbackListener `
$Port `
$script:ApplicationProcess `
([datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds)))
Assert-NoTornadoConnection $script:ApplicationProcess.Id
$script:Phase = 'local settings and comparison input harness'
$harness = Invoke-LocalHarnessUnderWatch `
$NodePath `
$HarnessPath `
$Port `
$Output `
$Screenshot `
$ExchangeDirectory `
$HarnessTimeoutSeconds `
$script:ApplicationProcess `
$registration.Executable
if ($harness.ExitCode -ne 0) {
Throw-SafeFailure "The local-state harness failed with exit code $($harness.ExitCode)."
}
$evidence = Assert-LocalHarnessEvidence $Output $Screenshot $ExchangeDirectory
if ($harness.Exchanges.Count -ne 13) {
Throw-SafeFailure 'The wrapper did not observe all thirteen sealed exchanges.'
}
Assert-ExactApplicationStillRunning $script:ApplicationProcess $registration.Executable
Assert-NoTornadoConnection $script:ApplicationProcess.Id
$finalFiles = Get-LocalFileState
if (-not (Test-JsonEquivalent $finalFiles.settings $script:BaselineSettingsState) -or
-not (Test-JsonEquivalent $finalFiles.destination $script:FirstImportedDestinationState) -or
[string]$finalFiles.source.sha256 -cne $ExpectedSourceSha256) {
Throw-SafeFailure 'Final local files do not match the sealed cancel/idempotence result.'
}
# This authorized import changed user-local comparison data. Avoid a second
# activation; normal close is the only remaining app action.
$script:Phase = 'normal close'
Invoke-ExactNormalClose $script:ApplicationProcess $registration.Executable
Wait-PortReleased $Port ([datetime]::UtcNow.AddSeconds(15))
if (@(Get-LegacyApplicationProcesses).Count -ne 0) {
Throw-SafeFailure 'A LegacyParity process remained after normal exit.'
}
$script:Phase = 'complete'
[pscustomobject]@{
result = [string]$evidence.result
packageFullName = $PackageFullName
packageMode = $PackageModeLabel
playoutMode = 'DryRun'
processId = $script:ApplicationProcess.Id
tornadoPortConnections = 0
inputRetryCount = 0
localInputRequestCalls = $script:LocalInputRequestCalls
localInputAcknowledgementCalls = $script:LocalInputAcknowledgementCalls
settingsPickerCancelCount = 3
comparisonImportAddedCount = 8
comparisonSecondImportAddedCount = 0
output = $Output
outputSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Output).Hash
screenshot = $Screenshot
screenshotSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Screenshot).Hash
exchangeDirectory = $ExchangeDirectory
}
}
catch {
$message = $_.Exception.Message
if ($script:CloseMainWindowCalls -gt 0 -or $script:PrimaryInvokeCalls -gt 0) {
Throw-SafeFailure (
"Local-state smoke became ambiguous during '$($script:Phase)': $message " +
'No close retry or forced termination was attempted.')
}
$running = $false
if ($script:ApplicationWasLaunched -and $null -ne $script:ApplicationProcess) {
try {
$script:ApplicationProcess.Refresh()
$running = -not $script:ApplicationProcess.HasExited
}
catch { $running = $false }
}
if ($running) {
Throw-SafeFailure (
"Local-state smoke stopped during '$($script:Phase)': $message " +
'The app was intentionally left open; no input retry, guessed cleanup, ' +
'close retry or forced termination was attempted.')
}
throw
}