[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 windows = new List(); 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 }