1171 lines
49 KiB
PowerShell
1171 lines
49 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'
|
|
$ExpectedFreshDestinationSha256 =
|
|
'13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087'
|
|
$ExpectedImportedDestinationSha256 =
|
|
'2575C2420800670B0022EBEE819BA3D9640DB14E9552426DF46075D079F1F697'
|
|
$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:DestinationBaselineMode = $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.firstPair -cne $ExpectedInitialPairIdentity) {
|
|
Throw-SafeFailure `
|
|
('The original 8-row source or known comparison baseline changed; ' +
|
|
'no import was started.')
|
|
}
|
|
$isFresh =
|
|
[string]$state.destination.sha256 -ceq $ExpectedFreshDestinationSha256 -and
|
|
[int]$state.destination.rowCount -eq 1
|
|
$isAlreadyImported =
|
|
[string]$state.destination.sha256 -ceq $ExpectedImportedDestinationSha256 -and
|
|
[int]$state.destination.rowCount -eq 9
|
|
if (-not $isFresh -and -not $isAlreadyImported) {
|
|
Throw-SafeFailure `
|
|
'The comparison destination is neither the known fresh nor imported baseline.'
|
|
}
|
|
if ($isFresh) {
|
|
$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:DestinationBaselineMode = if ($isFresh) { 'fresh' } else { 'alreadyImported' }
|
|
$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
|
|
(-not (Test-JsonEquivalent $files.destination $script:InitialDestinationState))) {
|
|
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 -cne $ExpectedImportedDestinationSha256) {
|
|
Throw-SafeFailure 'The first authorized import did not retain the known nine-row result.'
|
|
}
|
|
$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
|
|
$baselineMode = [string]$value.files.initialDestinationMode
|
|
$expectedFirstAdded = if ($baselineMode -ceq 'fresh') { 8 } else { 0 }
|
|
$expectedFirstSkipped = if ($baselineMode -ceq 'fresh') { 0 } else { 8 }
|
|
$expectedInitialPairCount = if ($baselineMode -ceq 'fresh') { 1 } else { 9 }
|
|
if (($baselineMode -cne 'fresh' -and $baselineMode -cne 'alreadyImported') -or
|
|
$baselineMode -cne $script:DestinationBaselineMode -or
|
|
[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
|
|
[int]$value.comparison.initialPairCount -ne $expectedInitialPairCount -or
|
|
$value.comparison.secondImportIdempotent -ne $true -or
|
|
[int]$value.comparison.afterFirstImport.receipt.addedPairCount -ne
|
|
$expectedFirstAdded -or
|
|
[int]$value.comparison.afterFirstImport.receipt.skippedDuplicateCount -ne
|
|
$expectedFirstSkipped -or
|
|
[int]$value.comparison.afterFirstImport.pairCount -ne 9 -or
|
|
[int]$value.comparison.afterSecondImport.receipt.addedPairCount -ne 0 -or
|
|
[int]$value.comparison.afterSecondImport.receipt.skippedDuplicateCount -ne 8 -or
|
|
[int]$value.comparison.afterSecondImport.pairCount -ne 9 -or
|
|
[string]$value.files.destinationAfterFirstImport.sha256 -cne
|
|
$ExpectedImportedDestinationSha256 -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
|
|
expectedFreshDestinationSha256 = $ExpectedFreshDestinationSha256
|
|
expectedFreshDestinationRows = 1
|
|
expectedImportedDestinationSha256 = $ExpectedImportedDestinationSha256
|
|
expectedImportedDestinationRows = 9
|
|
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.'
|
|
}
|
|
|
|
# The authorized import may have appended the fresh baseline or confirmed
|
|
# the existing imported baseline. Normal close is the only remaining 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
|
|
comparisonBaselineMode = $script:DestinationBaselineMode
|
|
comparisonImportAddedCount = if ($script:DestinationBaselineMode -ceq 'fresh') { 8 } else { 0 }
|
|
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
|
|
}
|