[CmdletBinding()] param( [ValidateSet('ReadOnly', 'DryRunPlayout', 'FullUiDb')] [string]$Profile = 'ReadOnly', [ValidateSet('All', 'PList', 'GraphE', 'Catalog', 'Screens')] [string]$FullUiDbScope = 'All', [ValidateRange(1024, 65535)] [int]$Port = 9339, [string]$Output, [string]$Screenshot, [string]$NodePath, [ValidateRange(10, 120)] [int]$LaunchTimeoutSeconds = 30, [ValidateRange(30, 900)] [int]$HarnessTimeoutSeconds = 180 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; using System.Text; using System.Threading; public static class LegacyPackageInputNative { [StructLayout(LayoutKind.Sequential)] private struct Point { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] private struct Rect { public int Left; public int Top; public int Right; public int Bottom; } private struct ClientGeometry { public int ScreenLeft; public int ScreenTop; public int Width; public int Height; } [StructLayout(LayoutKind.Sequential)] private struct MibTcpRowOwnerPid { public uint State; public uint LocalAddress; public uint LocalPort; public uint RemoteAddress; public uint RemotePort; public uint OwningProcessId; } [StructLayout(LayoutKind.Sequential)] private struct MibTcp6RowOwnerPid { [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] LocalAddress; public uint LocalScopeId; public uint LocalPort; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] RemoteAddress; public uint RemoteScopeId; public uint RemotePort; public uint State; public uint OwningProcessId; } [StructLayout(LayoutKind.Sequential)] private struct MouseInput { public int Dx; public int Dy; public uint MouseData; public uint Flags; public uint Time; public UIntPtr ExtraInfo; } [StructLayout(LayoutKind.Sequential)] private struct KeyboardInput { public ushort VirtualKey; public ushort ScanCode; public uint Flags; public uint Time; public UIntPtr ExtraInfo; } [StructLayout(LayoutKind.Explicit)] private struct InputUnion { [FieldOffset(0)] public MouseInput Mouse; [FieldOffset(0)] public KeyboardInput Keyboard; } [StructLayout(LayoutKind.Sequential)] private struct Input { public uint Type; public InputUnion Union; } [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern int GetPackageFullName( IntPtr process, ref int packageFullNameLength, StringBuilder packageFullName); [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); [DllImport("iphlpapi.dll", SetLastError = true)] private static extern uint GetExtendedTcpTable( IntPtr table, ref int size, bool order, int ipVersion, int tableClass, uint reserved); [DllImport("user32.dll")] private static extern bool ClientToScreen(IntPtr window, ref Point point); [DllImport("user32.dll")] private static extern bool GetCursorPos(out Point point); [DllImport("user32.dll")] private static extern IntPtr WindowFromPoint(Point point); [DllImport("user32.dll")] private static extern IntPtr GetAncestor(IntPtr window, uint flags); [DllImport("user32.dll")] private static extern bool GetClientRect(IntPtr window, out Rect rectangle); [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")] private static extern bool IsIconic(IntPtr window); [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); [DllImport("user32.dll", SetLastError = true)] private static extern bool AttachThreadInput(uint from, uint to, bool attach); [DllImport("user32.dll")] private static extern int GetSystemMetrics(int index); [DllImport("user32.dll")] private static extern uint SendInput(uint count, Input[] inputs, int size); [DllImport("user32.dll")] private static extern short GetAsyncKeyState(int virtualKey); private const int ErrorInsufficientBuffer = 122; private const int AddressFamilyInterNetwork = 2; private const int AddressFamilyInterNetworkV6 = 23; private const int TcpTableOwnerPidAll = 5; private const uint GetAncestorRoot = 2; private const uint InputMouse = 0; private const uint InputKeyboard = 1; private const uint MouseMove = 0x0001; private const uint MouseLeftDown = 0x0002; private const uint MouseLeftUp = 0x0004; private const uint MouseMoveNoCoalesce = 0x2000; private const uint MouseVirtualDesk = 0x4000; private const uint MouseAbsolute = 0x8000; private const uint KeyUp = 0x0002; private const int VirtualKeyLeftButton = 0x01; private const ushort VirtualKeyHome = 0x24; private const int ClientPixelTolerance = 1; public static string ReadPackageFullName(IntPtr process) { int length = 0; int result = GetPackageFullName(process, ref length, null); if (result != ErrorInsufficientBuffer || length <= 1 || length > 4096) { throw new InvalidOperationException("The process package identity is unavailable."); } StringBuilder value = new StringBuilder(length); result = GetPackageFullName(process, ref length, value); if (result != 0 || value.Length == 0) { throw new InvalidOperationException("The process package identity could not be read."); } return value.ToString(); } public static bool HasTcpConnectionForProcessAndPort(int processId, int port) { if (processId <= 0 || port < 1 || port > 65535) { throw new InvalidOperationException("The TCP ownership query is invalid."); } return HasTcp4Connection(processId, port) || HasTcp6Connection(processId, port); } public static void SendRowHeaderDragAfterThenHome( IntPtr window, int processId, int forbiddenPort, double sourceCssX, double sourceCssY, double targetCssX, double targetCssY, double viewportCssWidth, double viewportCssHeight, double devicePixelRatio) { if (window == IntPtr.Zero || processId <= 0 || forbiddenPort < 1 || forbiddenPort > 65535 || !IsFinitePositive(viewportCssWidth) || !IsFinitePositive(viewportCssHeight) || !IsFinitePositive(devicePixelRatio) || devicePixelRatio < 0.25 || devicePixelRatio > 5.0) { throw new InvalidOperationException("The Windows input coordinate contract is invalid."); } AssertKeyReleased(VirtualKeyLeftButton, "The left mouse button was held before input."); AssertKeyReleased(VirtualKeyHome, "The Home key was held before input."); AssertWindowOwnedByProcess(window, processId); AssertNoForbiddenTcp(processId, forbiddenPort); ClientGeometry baseline = ReadAndAssertClientGeometry( window, viewportCssWidth, viewportCssHeight, devicePixelRatio); AcquireForeground(window, processId, forbiddenPort); SleepWithWindowGuard( 400, window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline); Point source = CssPointToScreen( baseline, sourceCssX, sourceCssY, devicePixelRatio); Point target = CssPointToScreen( baseline, targetCssX, targetCssY, devicePixelRatio); if (source.X == target.X && source.Y == target.Y) { throw new InvalidOperationException("The Windows drag source and target are identical."); } AssertPointOwnedByWindow(window, source); AssertPointOwnedByWindow(window, target); bool leftButtonDown = false; bool homeKeyDown = false; try { SendMouseMove(source.X, source.Y); SleepWithInputGuard( 200, window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, source, target, source, false, false); AssertKeyReleased( VirtualKeyLeftButton, "The left mouse button became held before the mouse press."); AssertKeyReleased( VirtualKeyHome, "The Home key became held before the mouse press."); SendMouseButton(MouseLeftDown); leftButtonDown = true; SleepWithInputGuard( 100, window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, source, target, source, true, false); Point threshold = new Point { X = source.X + Math.Max(8, (int)Math.Round(12.0 * devicePixelRatio)), Y = source.Y }; AssertPointOwnedByWindow(window, threshold); SendMousePath( source, threshold, 6, 35, window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, source, target); SendMousePath( threshold, target, 24, 35, window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, source, target); SendMouseButton(MouseLeftUp); leftButtonDown = false; SleepWithInputGuard( 750, window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, source, target, target, false, false); AssertKeyReleased( VirtualKeyHome, "The Home key became held before the keyboard input."); SendKeyboard(VirtualKeyHome, false); homeKeyDown = true; SleepWithInputGuard( 35, window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, source, target, target, false, true); SendKeyboard(VirtualKeyHome, true); homeKeyDown = false; SleepWithInputGuard( 300, window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, source, target, target, false, false); } finally { Exception cleanupFailure = null; if (homeKeyDown) { try { SendKeyboard(VirtualKeyHome, true); homeKeyDown = false; } catch (Exception error) { cleanupFailure = error; } } if (leftButtonDown) { try { SendMouseButton(MouseLeftUp); leftButtonDown = false; } catch (Exception error) { if (cleanupFailure == null) { cleanupFailure = error; } } } try { AssertKeyReleased( VirtualKeyLeftButton, "The left mouse button remained held after input cleanup."); } catch (Exception error) { if (cleanupFailure == null) { cleanupFailure = error; } } try { AssertKeyReleased( VirtualKeyHome, "The Home key remained held after input cleanup."); } catch (Exception error) { if (cleanupFailure == null) { cleanupFailure = error; } } if (cleanupFailure != null) { throw new InvalidOperationException( "Injected input cleanup could not prove that all keys were released.", cleanupFailure); } } } private static void AcquireForeground(IntPtr window, int processId, int forbiddenPort) { AssertWindowOwnedByProcess(window, processId); AssertNoForbiddenTcp(processId, forbiddenPort); if (GetForegroundWindow() == window) { return; } IntPtr foregroundWindow = GetForegroundWindow(); uint ignored; uint foregroundThread = foregroundWindow == IntPtr.Zero ? 0 : GetWindowThreadProcessId(foregroundWindow, out ignored); uint targetThread = GetWindowThreadProcessId(window, out ignored); uint currentThread = GetCurrentThreadId(); if (targetThread == 0 || currentThread == 0) { throw new InvalidOperationException("The foreground 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."); } attachedForeground = true; } if (targetThread != currentThread) { if (!AttachThreadInput(currentThread, targetThread, true)) { throw new InvalidOperationException("The package input queue could not be attached."); } attachedTarget = true; } if (IsIconic(window)) { throw new InvalidOperationException("The package window is minimized; geometry is no longer stable."); } 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 && GetForegroundWindow() != window) { AssertWindowOwnedByProcess(window, processId); AssertNoForbiddenTcp(processId, forbiddenPort); Thread.Sleep(10); AssertNoForbiddenTcp(processId, forbiddenPort); } if (GetForegroundWindow() != window) { throw new InvalidOperationException("The exact package window could not own foreground input."); } AssertWindowOwnedByProcess(window, processId); AssertNoForbiddenTcp(processId, forbiddenPort); } private static void AssertWindowOwnedByProcess(IntPtr window, int processId) { uint actualProcessId; uint threadId = GetWindowThreadProcessId(window, out actualProcessId); if (threadId == 0 || actualProcessId != (uint)processId) { throw new InvalidOperationException("The input window is not owned by the exact application process."); } } private static void AssertNoForbiddenTcp(int processId, int forbiddenPort) { if (HasTcpConnectionForProcessAndPort(processId, forbiddenPort)) { throw new InvalidOperationException("The DryRun application owns a forbidden TCP connection."); } } private static ClientGeometry ReadAndAssertClientGeometry( IntPtr window, double viewportCssWidth, double viewportCssHeight, double devicePixelRatio) { Rect client; if (!GetClientRect(window, out client) || client.Left != 0 || client.Top != 0 || client.Right <= 0 || client.Bottom <= 0) { throw new InvalidOperationException("The package client rectangle is invalid."); } int expectedWidth = (int)Math.Round(viewportCssWidth * devicePixelRatio); int expectedHeight = (int)Math.Round(viewportCssHeight * devicePixelRatio); if (expectedWidth <= 0 || expectedHeight <= 0 || Math.Abs(client.Right - expectedWidth) > ClientPixelTolerance || Math.Abs(client.Bottom - expectedHeight) > ClientPixelTolerance) { throw new InvalidOperationException( "The browser viewport does not match the package client rectangle."); } Point origin = new Point { X = 0, Y = 0 }; if (!ClientToScreen(window, ref origin)) { throw new InvalidOperationException("The package client origin is unavailable."); } return new ClientGeometry { ScreenLeft = origin.X, ScreenTop = origin.Y, Width = client.Right, Height = client.Bottom }; } private static void AssertStableClientGeometry( IntPtr window, double viewportCssWidth, double viewportCssHeight, double devicePixelRatio, ClientGeometry baseline) { ClientGeometry current = ReadAndAssertClientGeometry( window, viewportCssWidth, viewportCssHeight, devicePixelRatio); if (current.ScreenLeft != baseline.ScreenLeft || current.ScreenTop != baseline.ScreenTop || current.Width != baseline.Width || current.Height != baseline.Height) { throw new InvalidOperationException("The package client geometry changed during input."); } } private static void AssertWindowGuard( IntPtr window, int processId, int forbiddenPort, double viewportCssWidth, double viewportCssHeight, double devicePixelRatio, ClientGeometry baseline) { AssertWindowOwnedByProcess(window, processId); AssertNoForbiddenTcp(processId, forbiddenPort); if (GetForegroundWindow() != window || IsIconic(window)) { throw new InvalidOperationException("The exact package window lost visible foreground input."); } AssertStableClientGeometry( window, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline); } private static void AssertInputGuard( IntPtr window, int processId, int forbiddenPort, double viewportCssWidth, double viewportCssHeight, double devicePixelRatio, ClientGeometry baseline, Point source, Point target, Point expectedCursor, bool expectLeftButtonDown, bool expectHomeDown) { AssertWindowGuard( window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline); AssertPointOwnedByWindow(window, source); AssertPointOwnedByWindow(window, target); AssertPointOwnedByWindow(window, expectedCursor); AssertCursorAt(expectedCursor); AssertKeyState( VirtualKeyLeftButton, expectLeftButtonDown, "The left mouse button state changed during input."); AssertKeyState( VirtualKeyHome, expectHomeDown, "The Home key state changed during input."); } private static void SleepWithWindowGuard( int milliseconds, IntPtr window, int processId, int forbiddenPort, double viewportCssWidth, double viewportCssHeight, double devicePixelRatio, ClientGeometry baseline) { int remaining = milliseconds; while (remaining > 0) { AssertWindowGuard( window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline); int slice = Math.Min(10, remaining); Thread.Sleep(slice); remaining -= slice; } AssertWindowGuard( window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline); } private static void SleepWithInputGuard( int milliseconds, IntPtr window, int processId, int forbiddenPort, double viewportCssWidth, double viewportCssHeight, double devicePixelRatio, ClientGeometry baseline, Point source, Point target, Point expectedCursor, bool expectLeftButtonDown, bool expectHomeDown) { int remaining = milliseconds; while (remaining > 0) { AssertInputGuard( window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, source, target, expectedCursor, expectLeftButtonDown, expectHomeDown); int slice = Math.Min(10, remaining); Thread.Sleep(slice); remaining -= slice; } AssertInputGuard( window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, source, target, expectedCursor, expectLeftButtonDown, expectHomeDown); } private static void AssertPointOwnedByWindow(IntPtr window, Point point) { IntPtr hit = WindowFromPoint(point); IntPtr root = hit == IntPtr.Zero ? IntPtr.Zero : GetAncestor(hit, GetAncestorRoot); if (root != window) { throw new InvalidOperationException("A Windows input point is obscured by another top-level window."); } } private static void AssertCursorAt(Point expected) { Point actual; if (!GetCursorPos(out actual) || Math.Abs(actual.X - expected.X) > 2 || Math.Abs(actual.Y - expected.Y) > 2) { throw new InvalidOperationException("The physical cursor diverged during the one-shot input sequence."); } } private static bool HasTcp4Connection(int processId, int port) { return ScanTcpTable( AddressFamilyInterNetwork, delegate(MibTcpRowOwnerPid row) { return row.OwningProcessId == (uint)processId && (DecodePort(row.LocalPort) == port || DecodePort(row.RemotePort) == port); }); } private static bool HasTcp6Connection(int processId, int port) { return ScanTcpTable( AddressFamilyInterNetworkV6, delegate(MibTcp6RowOwnerPid row) { return row.OwningProcessId == (uint)processId && (DecodePort(row.LocalPort) == port || DecodePort(row.RemotePort) == port); }); } private static bool ScanTcpTable(int addressFamily, Func predicate) where TRow : struct { int size = 0; uint result = GetExtendedTcpTable( IntPtr.Zero, ref size, false, addressFamily, TcpTableOwnerPidAll, 0); if (result != ErrorInsufficientBuffer || size < sizeof(int) || size > 64 * 1024 * 1024) { throw new InvalidOperationException("The native TCP table size is unavailable."); } IntPtr table = Marshal.AllocHGlobal(size); try { result = GetExtendedTcpTable( table, ref size, false, addressFamily, TcpTableOwnerPidAll, 0); if (result != 0) { throw new InvalidOperationException("The native TCP table could not be read."); } int count = Marshal.ReadInt32(table); int rowSize = Marshal.SizeOf(typeof(TRow)); if (count < 0 || count > 100000 || (long)sizeof(int) + (long)count * rowSize > size) { throw new InvalidOperationException("The native TCP table layout is invalid."); } IntPtr rowPointer = IntPtr.Add(table, sizeof(int)); for (int index = 0; index < count; index++) { TRow row = (TRow)Marshal.PtrToStructure(rowPointer, typeof(TRow)); if (predicate(row)) { return true; } rowPointer = IntPtr.Add(rowPointer, rowSize); } return false; } finally { Marshal.FreeHGlobal(table); } } private static int DecodePort(uint value) { return (int)(((value & 0x000000ffU) << 8) | ((value & 0x0000ff00U) >> 8)); } private static bool IsFinitePositive(double value) { return !Double.IsNaN(value) && !Double.IsInfinity(value) && value > 0; } private static Point CssPointToScreen( ClientGeometry geometry, double cssX, double cssY, double devicePixelRatio) { if (Double.IsNaN(cssX) || Double.IsInfinity(cssX) || Double.IsNaN(cssY) || Double.IsInfinity(cssY) || cssX < 0 || cssY < 0) { throw new InvalidOperationException("A CSS input point is invalid."); } int x = (int)Math.Round(cssX * devicePixelRatio); int y = (int)Math.Round(cssY * devicePixelRatio); if (x < 0 || x >= geometry.Width || y < 0 || y >= geometry.Height) { throw new InvalidOperationException("A CSS input point is outside the package client area."); } return new Point { X = geometry.ScreenLeft + x, Y = geometry.ScreenTop + y }; } private static void SendMousePath( Point from, Point to, int steps, int delayMilliseconds, IntPtr window, int processId, int forbiddenPort, double viewportCssWidth, double viewportCssHeight, double devicePixelRatio, ClientGeometry baseline, Point source, Point target) { for (int index = 1; index <= steps; index++) { double amount = (double)index / steps; int x = (int)Math.Round(from.X + (to.X - from.X) * amount); int y = (int)Math.Round(from.Y + (to.Y - from.Y) * amount); SendMouseMove(x, y); Point current = new Point { X = x, Y = y }; SleepWithInputGuard( delayMilliseconds, window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, source, target, current, true, false); } } private static void SendMouseMove(int screenX, int screenY) { int left = GetSystemMetrics(76); int top = GetSystemMetrics(77); int width = GetSystemMetrics(78); int height = GetSystemMetrics(79); if (width <= 1 || height <= 1 || screenX < left || screenX >= left + width || screenY < top || screenY >= top + height) { throw new InvalidOperationException("The Windows input point is outside the virtual desktop."); } int absoluteX = (int)Math.Round((screenX - left) * 65535.0 / (width - 1)); int absoluteY = (int)Math.Round((screenY - top) * 65535.0 / (height - 1)); Input input = new Input { Type = InputMouse, Union = new InputUnion { Mouse = new MouseInput { Dx = absoluteX, Dy = absoluteY, Flags = MouseMove | MouseMoveNoCoalesce | MouseVirtualDesk | MouseAbsolute } } }; SendOne(input); } private static void SendMouseButton(uint flags) { Input input = new Input { Type = InputMouse, Union = new InputUnion { Mouse = new MouseInput { Flags = flags } } }; SendOne(input); } private static void SendKeyboard(ushort virtualKey, bool keyUp) { Input input = new Input { Type = InputKeyboard, Union = new InputUnion { Keyboard = new KeyboardInput { VirtualKey = virtualKey, Flags = keyUp ? KeyUp : 0 } } }; SendOne(input); } private static void SendOne(Input input) { Input[] inputs = new Input[] { input }; if (SendInput(1, inputs, Marshal.SizeOf(typeof(Input))) != 1) { throw new InvalidOperationException("SendInput did not accept the one-shot input event."); } } private static void AssertKeyReleased(int virtualKey, string message) { AssertKeyState(virtualKey, false, message); } private static void AssertKeyState(int virtualKey, bool expectedDown, string message) { bool actualDown = (GetAsyncKeyState(virtualKey) & 0x8000) != 0; if (actualDown != expectedDown) { throw new InvalidOperationException(message); } } } '@ $PackageName = 'Wickedness.MBNStockWebView.LegacyParity' $PackageFamilyName = 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0' $PackageFullName = 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0' $PackagePublisher = 'CN=Comtrophy' $PackageVersion = '0.1.0.0' $ApplicationId = 'LegacyParityApp' $ExecutableName = 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe' $Utf8 = [Text.UTF8Encoding]::new($false) $StrictUtf8 = [Text.UTF8Encoding]::new($false, $true) $ExpectedWindowTitle = $Utf8.GetString([Convert]::FromBase64String( 'Vi1TdG9jayDspp3qtozsoJXrs7TshqHstpzsi5zsiqTthZwgZm9yIOunpOydvOqyveygnFRWICgyNi4wMy4yNik=')) $ExpectedDialogTitle = $Utf8.GetString([Convert]::FromBase64String( '66ek7J286rK97KCcVFY=')) $ExpectedDialogMessage = $Utf8.GetString([Convert]::FromBase64String( '7ZSE66Gc6re4656o7J2EIOyiheujjO2VmOqyoOyKteuLiOq5jD8=')) $ExpectedPrimaryButton = $Utf8.GetString([Convert]::FromBase64String('7JiI')) $ExpectedCloseButton = $Utf8.GetString([Convert]::FromBase64String('7JWE64uI7JqU')) $LiveAuthorizationVariable = 'MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT' $K3dNativeHashVariable = 'MBN_STOCK_K3D_NATIVE_SHA256' $K3dInteropHashVariable = 'MBN_STOCK_K3D_INTEROP_SHA256' $WebViewArgumentsVariable = 'WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS' $RepositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $HarnessPath = Join-Path $PSScriptRoot 'Test-LegacyPackageInput.mjs' $ExpectedInstallLocation = Join-Path $RepositoryRoot ` 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64' $ExpectedInstallLocation = [IO.Path]::GetFullPath($ExpectedInstallLocation) $ConfigurationPath = Join-Path ` ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) ` 'MBN_STOCK_WEBVIEW\Config\playout.local.json' $script:ApplicationProcess = $null $script:ApplicationWasLaunched = $false $script:CloseMainWindowCalls = 0 $script:PrimaryInvokeCalls = 0 $script:SingleInstanceActivationCalls = 0 $script:WindowsInputRequestCalls = 0 $script:WindowsInputAcknowledgementCalls = 0 $script:Phase = 'preflight' function Throw-SafeFailure([string]$Message) { throw [InvalidOperationException]::new($Message) } function Test-PathEqual([string]$Left, [string]$Right) { return [string]::Equals( [IO.Path]::GetFullPath($Left).TrimEnd('\'), [IO.Path]::GetFullPath($Right).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase) } function Assert-ExactProcessPackageIdentity([Diagnostics.Process]$Process) { try { $actualPackageFullName = [LegacyPackageInputNative]::ReadPackageFullName($Process.Handle) } catch { Throw-SafeFailure 'The running application package identity could not be inspected.' } if ([string]$actualPackageFullName -cne $PackageFullName) { Throw-SafeFailure 'The running application does not have the exact registered package identity.' } } function Resolve-EvidencePath([string]$Value, [string]$DefaultFileName) { if ([string]::IsNullOrWhiteSpace($Value)) { return [IO.Path]::GetFullPath((Join-Path ` (Join-Path $RepositoryRoot 'artifacts\package-input-smoke') ` $DefaultFileName)) } if ([IO.Path]::IsPathRooted($Value)) { return [IO.Path]::GetFullPath($Value) } return [IO.Path]::GetFullPath((Join-Path $RepositoryRoot $Value)) } function Assert-OutputPathAvailable([string]$Path, [string]$Label) { if ([IO.File]::Exists($Path) -or [IO.Directory]::Exists($Path)) { Throw-SafeFailure "$Label already exists; evidence files are never overwritten." } $parent = [IO.Path]::GetDirectoryName($Path) if ([string]::IsNullOrWhiteSpace($parent)) { Throw-SafeFailure "$Label has no parent directory." } if (-not [IO.Directory]::Exists($parent)) { [void][IO.Directory]::CreateDirectory($parent) } $parentItem = Get-Item -LiteralPath $parent -Force if (($parentItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { Throw-SafeFailure "$Label parent must not be a reparse point." } } function Resolve-NodeExecutable([string]$RequestedPath) { if (-not [string]::IsNullOrWhiteSpace($RequestedPath)) { $resolved = [IO.Path]::GetFullPath($RequestedPath) if (-not [IO.File]::Exists($resolved) -or [IO.Path]::GetFileName($resolved) -cne 'node.exe') { Throw-SafeFailure 'The requested Node executable does not exist.' } return $resolved } $bundled = Join-Path $env:USERPROFILE ` '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' if ([IO.File]::Exists($bundled)) { return [IO.Path]::GetFullPath($bundled) } $command = Get-Command node.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -eq $command) { Throw-SafeFailure 'Node was not found. Supply -NodePath with an absolute node.exe path.' } return [IO.Path]::GetFullPath($command.Source) } function Assert-LocalConfigurationIsDryRun { if (-not [IO.File]::Exists($ConfigurationPath)) { return } $item = Get-Item -LiteralPath $ConfigurationPath -Force if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $item.Length -gt 1MB) { Throw-SafeFailure 'The local playout configuration cannot be safely inspected.' } try { $raw = [IO.File]::ReadAllText($ConfigurationPath, [Text.Encoding]::UTF8) $configuration = $raw | ConvertFrom-Json -ErrorAction Stop } catch { # Do not include parser diagnostics: they can echo configuration content. Throw-SafeFailure 'The local playout configuration could not be parsed safely.' } if ($null -eq $configuration) { Throw-SafeFailure 'The local playout configuration is empty.' } $modeProperties = @($configuration.PSObject.Properties | Where-Object { [string]::Equals($_.Name, 'Mode', [StringComparison]::OrdinalIgnoreCase) }) $rawModePropertyCount = [regex]::Matches( $raw, '(?i)"mode"\s*:').Count if ($modeProperties.Count -eq 0 -and $rawModePropertyCount -eq 0) { # PlayoutOptions.Mode defaults to DryRun when the property is absent. return } if ($modeProperties.Count -ne 1 -or $rawModePropertyCount -ne 1) { Throw-SafeFailure 'The local playout mode is not uniquely defined.' } $mode = $modeProperties[0].Value $isDryRun = ($mode -is [string] -and [string]::Equals($mode, 'DryRun', [StringComparison]::OrdinalIgnoreCase)) -or ($mode -isnot [string] -and [string]$mode -ceq '1') if (-not $isDryRun) { Throw-SafeFailure 'The local playout configuration does not resolve to DryRun.' } } function Assert-NoUnsafeEnvironment { $forbiddenExactNames = @( $LiveAuthorizationVariable, $K3dNativeHashVariable, $K3dInteropHashVariable ) $targets = @( [EnvironmentVariableTarget]::Process, [EnvironmentVariableTarget]::User, [EnvironmentVariableTarget]::Machine ) foreach ($target in $targets) { try { $environment = [Environment]::GetEnvironmentVariables($target) } catch { Throw-SafeFailure "The $target environment could not be inspected." } foreach ($nameObject in $environment.Keys) { $name = [string]$nameObject if ($forbiddenExactNames -contains $name -or $name -like 'MBN_STOCK_PLAYOUT_*' -or $name -like 'WEBVIEW2_*' -or $name -like 'COREWEBVIEW2_*') { # Report names/scopes only. Never report environment values. Throw-SafeFailure "Unsafe launch environment variable is present: $target/$name." } } } } function Get-ExactDevelopmentPackage { $packages = @(Get-AppxPackage -Name $PackageName -ErrorAction Stop) if ($packages.Count -ne 1) { Throw-SafeFailure 'Exactly one current-user LegacyParity development package is required.' } $package = $packages[0] if (-not $package.IsDevelopmentMode -or [string]$package.SignatureKind -cne 'None' -or [string]$package.Status -cne 'Ok' -or [string]$package.Name -cne $PackageName -or [string]$package.PackageFullName -cne $PackageFullName -or [string]$package.PackageFamilyName -cne $PackageFamilyName -or [string]$package.Publisher -cne $PackagePublisher -or [string]$package.Version -cne $PackageVersion -or [string]$package.Architecture -cne 'X64' -or -not (Test-PathEqual ([string]$package.InstallLocation) $ExpectedInstallLocation)) { Throw-SafeFailure 'The registered package is not the exact Release x64 development package.' } try { $manifest = Get-AppxPackageManifest -Package $package -ErrorAction Stop } catch { Throw-SafeFailure 'The registered package manifest could not be inspected.' } $applications = @($manifest.Package.Applications.Application) if ($applications.Count -ne 1 -or [string]$applications[0].Id -cne $ApplicationId -or [string]$applications[0].Executable -cne $ExecutableName -or [string]$applications[0].EntryPoint -cne 'Windows.FullTrustApplication') { Throw-SafeFailure 'The registered package application identity is not exact.' } $executable = Join-Path ([string]$package.InstallLocation) $ExecutableName if (-not [IO.File]::Exists($executable)) { Throw-SafeFailure 'The registered Release application executable is missing.' } return [pscustomobject]@{ Package = $package Executable = [IO.Path]::GetFullPath($executable) InstallLocation = [IO.Path]::GetFullPath([string]$package.InstallLocation) } } function Get-LegacyApplicationProcesses { $live = New-Object 'Collections.Generic.List[Diagnostics.Process]' foreach ($candidate in @(Get-Process ` -Name ([IO.Path]::GetFileNameWithoutExtension($ExecutableName)) ` -ErrorAction SilentlyContinue)) { try { $candidate.Refresh() if (-not $candidate.HasExited) { [void]$live.Add($candidate) } } catch { # A process that disappears during enumeration is not a live instance. } } return @($live) } function Assert-NoExistingApplication { if (@(Get-LegacyApplicationProcesses).Count -ne 0) { Throw-SafeFailure 'A LegacyParity application process is already running.' } } function Get-TcpSnapshot { try { return @(Get-NetTCPConnection -ErrorAction Stop) } catch { Throw-SafeFailure 'The TCP connection table could not be inspected.' } } function Assert-PortIsFree([int]$CandidatePort) { if ($CandidatePort -eq 30001) { Throw-SafeFailure 'The WebView debugging port must not be the Tornado port.' } $listeners = @(Get-TcpSnapshot | Where-Object { [string]$_.State -ceq 'Listen' -and $_.LocalPort -eq $CandidatePort }) if ($listeners.Count -ne 0) { Throw-SafeFailure 'The chosen loopback debugging port is already in use.' } } function Assert-NoTornadoConnection([int]$ProcessId) { try { $hasConnection = [LegacyPackageInputNative]::HasTcpConnectionForProcessAndPort( $ProcessId, 30001) } catch { Throw-SafeFailure 'The native TCP ownership table could not be inspected.' } if ($hasConnection) { Throw-SafeFailure 'The DryRun application owns a Tornado port connection.' } } function ConvertTo-QuotedProcessArgument([string]$Value) { if ($Value.IndexOfAny([char[]]@('"', "`r", "`n")) -ge 0) { Throw-SafeFailure 'A process argument contains a forbidden character.' } return '"' + $Value + '"' } function Get-ByteArraySha256([byte[]]$Bytes) { $algorithm = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($algorithm.ComputeHash($Bytes))).Replace('-', '') } finally { $algorithm.Dispose() } } function Assert-ExactJsonPropertyNames( [object]$Value, [string[]]$ExpectedNames, [string]$Label) { if ($null -eq $Value) { Throw-SafeFailure "$Label is missing." } $actualNames = @($Value.PSObject.Properties | ForEach-Object { [string]$_.Name }) if ($actualNames.Count -ne $ExpectedNames.Count) { Throw-SafeFailure "$Label has an unexpected schema." } foreach ($name in $ExpectedNames) { if ($actualNames -cnotcontains $name) { Throw-SafeFailure "$Label has an unexpected schema." } } } function Test-ExactStringSequence([object[]]$Left, [object[]]$Right) { $leftValues = @($Left | ForEach-Object { [string]$_ }) $rightValues = @($Right | ForEach-Object { [string]$_ }) if ($leftValues.Count -ne $rightValues.Count) { return $false } for ($index = 0; $index -lt $leftValues.Count; $index++) { if ($leftValues[$index] -cne $rightValues[$index]) { return $false } } return $true } function Read-SmallJsonEvidenceFile([string]$Path, [string]$Label) { 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 16KB) { Throw-SafeFailure "$Label is not a small regular file." } $bytes = [IO.File]::ReadAllBytes($Path) if ($bytes.Length -ne $item.Length) { Throw-SafeFailure "$Label 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 "$Label could not be validated as UTF-8 JSON." } } function Wait-ReadSmallJsonEvidenceFile( [string]$Path, [string]$Label, [datetime]$Deadline) { $previousLength = -1L $stableObservations = 0 while ([datetime]::UtcNow -lt $Deadline) { if ([IO.File]::Exists($Path)) { try { $item = Get-Item -LiteralPath $Path -Force $isCandidate = -not $item.PSIsContainer -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -and $item.Length -gt 0 -and $item.Length -le 16KB if ($isCandidate -and $item.Length -eq $previousLength) { $stableObservations++ } else { $stableObservations = 0 } $previousLength = $item.Length if ($isCandidate -and $stableObservations -ge 1) { return Read-SmallJsonEvidenceFile $Path $Label } } catch [InvalidOperationException] { throw } catch { $stableObservations = 0 $previousLength = -1L } } Start-Sleep -Milliseconds 25 } Throw-SafeFailure "$Label was not published as one stable small regular file." } function ConvertTo-FiniteNumber([object]$Value, [string]$Label) { $isJsonNumber = $Value -is [byte] -or $Value -is [sbyte] -or $Value -is [int16] -or $Value -is [uint16] -or $Value -is [int32] -or $Value -is [uint32] -or $Value -is [int64] -or $Value -is [uint64] -or $Value -is [single] -or $Value -is [double] -or $Value -is [decimal] if (-not $isJsonNumber) { Throw-SafeFailure "$Label is not a JSON number." } 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-WindowsInputAcknowledgement([string]$Path, [string]$Token) { $acknowledgement = [ordered]@{ schemaVersion = 1 token = $Token result = 'PASS' operation = 'row-header-drag-after-then-home' foregroundValidated = $true packageIdentityValidated = $true mouseDownCalls = 1 mouseUpCalls = 1 homeKeyCalls = 1 inputRetryCount = 0 } $bytes = $Utf8.GetBytes(($acknowledgement | ConvertTo-Json -Compress) + "`n") $parent = [IO.Path]::GetDirectoryName($Path) $temporaryPath = Join-Path $parent ( ([IO.Path]::GetFileName($Path)) + '.' + [Guid]::NewGuid().ToString('N') + '.tmp') try { $stream = [IO.FileStream]::new( $temporaryPath, [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($temporaryPath, $Path) } catch { try { if ([IO.File]::Exists($temporaryPath)) { [IO.File]::Delete($temporaryPath) } } catch { # Cleanup failure cannot authorize another acknowledgement attempt. } Throw-SafeFailure 'The one-shot Windows input acknowledgement could not be created.' } return [pscustomobject]@{ Bytes = $bytes Sha256 = Get-ByteArraySha256 $bytes } } function Invoke-ExactWindowsInputRequest( [string]$RequestPath, [string]$AcknowledgementPath, [Diagnostics.Process]$Application, [string]$ExpectedApplicationPath) { $script:WindowsInputRequestCalls++ if ($script:WindowsInputRequestCalls -ne 1 -or [IO.File]::Exists($AcknowledgementPath)) { Throw-SafeFailure 'The Windows input one-shot budget or acknowledgement precondition changed.' } $requestEvidence = Wait-ReadSmallJsonEvidenceFile ` $RequestPath ` 'Windows input request' ` ([datetime]::UtcNow.AddSeconds(3)) $request = $requestEvidence.Value Assert-ExactJsonPropertyNames $request @( 'schemaVersion', 'token', 'targetUrl', 'operation', 'sourceRowId', 'targetRowId', 'position', 'source', 'target', 'viewport', 'devicePixelRatio', 'beforeOrder', 'afterOrder', 'expectedSelectedRowId', 'expectedActiveRowIdAfterHome', 'expectedFocusedRowId', 'mouseDownCalls', 'mouseUpCalls', 'homeKeyCalls') 'Windows input request' Assert-ExactJsonPropertyNames $request.source @('x', 'y') 'Windows input source' Assert-ExactJsonPropertyNames $request.target @('x', 'y') 'Windows input target' Assert-ExactJsonPropertyNames $request.viewport @('width', 'height') 'Windows input viewport' $sourceRowId = [string]$request.sourceRowId $targetRowId = [string]$request.targetRowId $token = [string]$request.token $schemaVersion = ConvertTo-FiniteNumber $request.schemaVersion 'Windows input schema version' $mouseDownCalls = ConvertTo-FiniteNumber $request.mouseDownCalls 'Windows input mouse-down count' $mouseUpCalls = ConvertTo-FiniteNumber $request.mouseUpCalls 'Windows input mouse-up count' $homeKeyCalls = ConvertTo-FiniteNumber $request.homeKeyCalls 'Windows input Home-key count' if ($request.token -isnot [string] -or $request.targetUrl -isnot [string] -or $request.operation -isnot [string] -or $request.sourceRowId -isnot [string] -or $request.targetRowId -isnot [string] -or $request.position -isnot [string] -or $request.expectedSelectedRowId -isnot [string] -or $request.expectedActiveRowIdAfterHome -isnot [string] -or $request.expectedFocusedRowId -isnot [string] -or $schemaVersion -ne 1 -or $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 'row-header-drag-after-then-home' -or [string]$request.position -cne 'after' -or $sourceRowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z' -or $targetRowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z' -or $sourceRowId -ceq $targetRowId -or [string]$request.expectedSelectedRowId -cne $sourceRowId -or [string]$request.expectedFocusedRowId -cne $sourceRowId -or $mouseDownCalls -ne 1 -or $mouseUpCalls -ne 1 -or $homeKeyCalls -ne 1) { Throw-SafeFailure 'The Windows input request does not match the approved one-shot operation.' } $beforeOrder = @($request.beforeOrder | ForEach-Object { [string]$_ }) $afterOrder = @($request.afterOrder | ForEach-Object { [string]$_ }) if ($beforeOrder.Count -ne 3 -or $afterOrder.Count -ne 3 -or (@($beforeOrder | Select-Object -Unique)).Count -ne 3 -or (@($afterOrder | Select-Object -Unique)).Count -ne 3 -or $beforeOrder -cnotcontains $sourceRowId -or $beforeOrder -cnotcontains $targetRowId) { Throw-SafeFailure 'The Windows input request row order is not a unique three-row fixture.' } foreach ($rowId in $beforeOrder + $afterOrder) { if ($rowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z') { Throw-SafeFailure 'The Windows input request contains an invalid row identity.' } } $computed = New-Object 'Collections.Generic.List[string]' foreach ($rowId in $beforeOrder) { if ($rowId -cne $sourceRowId) { [void]$computed.Add($rowId) } } $targetIndex = $computed.IndexOf($targetRowId) if ($targetIndex -lt 0) { Throw-SafeFailure 'The Windows input target is absent after removing the source row.' } $computed.Insert($targetIndex + 1, $sourceRowId) for ($index = 0; $index -lt $computed.Count; $index++) { if ($computed[$index] -cne $afterOrder[$index]) { Throw-SafeFailure 'The Windows input request is not the exact single-row move-after transform.' } } if ([string]$request.expectedActiveRowIdAfterHome -cne $afterOrder[0]) { Throw-SafeFailure 'The Windows input Home boundary expectation is not the first resulting row.' } $viewportWidth = ConvertTo-FiniteNumber $request.viewport.width 'Windows input viewport width' $viewportHeight = ConvertTo-FiniteNumber $request.viewport.height 'Windows input viewport height' $sourceX = ConvertTo-FiniteNumber $request.source.x 'Windows input source X' $sourceY = ConvertTo-FiniteNumber $request.source.y 'Windows input source Y' $targetX = ConvertTo-FiniteNumber $request.target.x 'Windows input target X' $targetY = ConvertTo-FiniteNumber $request.target.y 'Windows input target Y' $devicePixelRatio = ConvertTo-FiniteNumber $request.devicePixelRatio 'Windows input device pixel ratio' if ($viewportWidth -lt 1 -or $viewportWidth -gt 16384 -or $viewportHeight -lt 1 -or $viewportHeight -gt 16384 -or $sourceX -lt 0 -or $sourceX -ge $viewportWidth -or $sourceY -lt 0 -or $sourceY -ge $viewportHeight -or $targetX -lt 0 -or $targetX -ge $viewportWidth -or $targetY -lt 0 -or $targetY -ge $viewportHeight -or $devicePixelRatio -lt 0.25 -or $devicePixelRatio -gt 5) { Throw-SafeFailure 'The Windows input geometry is outside the exact package viewport contract.' } Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath Assert-NoTornadoConnection $Application.Id [LegacyPackageInputNative]::SendRowHeaderDragAfterThenHome( $Application.MainWindowHandle, $Application.Id, 30001, $sourceX, $sourceY, $targetX, $targetY, $viewportWidth, $viewportHeight, $devicePixelRatio) Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath Assert-NoTornadoConnection $Application.Id $script:WindowsInputAcknowledgementCalls++ if ($script:WindowsInputAcknowledgementCalls -ne 1) { Throw-SafeFailure 'The Windows input acknowledgement budget changed.' } $acknowledgementEvidence = Write-WindowsInputAcknowledgement $AcknowledgementPath $token return [pscustomobject]@{ RequestSha256 = $requestEvidence.Sha256 AcknowledgementSha256 = $acknowledgementEvidence.Sha256 } } function Invoke-NodeHarnessUnderTcpWatch( [string]$Executable, [string]$ScriptPath, [int]$DebugPort, [string]$OutputPath, [string]$ScreenshotPath, [string]$WindowsInputRequestPath, [string]$WindowsInputAcknowledgementPath, [string]$HarnessProfile, [string]$HarnessScope, [int]$TimeoutSeconds, [Diagnostics.Process]$Application, [string]$ExpectedApplicationPath) { $arguments = @( $ScriptPath, '--port', [string]$DebugPort, '--output', $OutputPath, '--screenshot', $ScreenshotPath, '--windows-input-request', $WindowsInputRequestPath, '--windows-input-ack', $WindowsInputAcknowledgementPath, '--profile', $HarnessProfile, '--scope', $HarnessScope, '--timeout-ms', [string]($TimeoutSeconds * 1000) ) | ForEach-Object { ConvertTo-QuotedProcessArgument ([string]$_) } $startInfo = New-Object Diagnostics.ProcessStartInfo $startInfo.FileName = $Executable $startInfo.WorkingDirectory = $RepositoryRoot $startInfo.Arguments = $arguments -join ' ' $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardInput = $true try { $nodeProcess = [Diagnostics.Process]::Start($startInfo) } catch { Throw-SafeFailure 'The real input harness process could not be started.' } if ($null -eq $nodeProcess) { Throw-SafeFailure 'The real input harness process did not start.' } $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds + 30) $monitoringFailure = $null $windowsInputHandled = $false $windowsInputEvidence = $null try { while (-not $nodeProcess.HasExited) { if ([datetime]::UtcNow -ge $deadline) { Throw-SafeFailure 'The input harness exceeded its global deadline.' } Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath Assert-NoTornadoConnection $Application.Id if ([IO.File]::Exists($WindowsInputRequestPath) -and -not $windowsInputHandled) { $windowsInputEvidence = Invoke-ExactWindowsInputRequest ` $WindowsInputRequestPath ` $WindowsInputAcknowledgementPath ` $Application ` $ExpectedApplicationPath $windowsInputHandled = $true } Start-Sleep -Milliseconds 100 $nodeProcess.Refresh() } if (-not $windowsInputHandled -or $script:WindowsInputRequestCalls -ne 1 -or $script:WindowsInputAcknowledgementCalls -ne 1 -or -not [IO.File]::Exists($WindowsInputRequestPath) -or -not [IO.File]::Exists($WindowsInputAcknowledgementPath)) { Throw-SafeFailure 'The required one-shot Windows input request was not completed.' } Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath Assert-NoTornadoConnection $Application.Id } catch { $monitoringFailure = $_.Exception.Message } if ($null -ne $monitoringFailure) { $helperStopped = $nodeProcess.HasExited if (-not $helperStopped) { try { $nodeProcess.StandardInput.WriteLine('CANCEL') $nodeProcess.StandardInput.Flush() $helperStopped = $nodeProcess.WaitForExit(5000) } catch { $helperStopped = $false } } if (-not $helperStopped) { try { # Only the child Node input driver is terminated after its one-shot # cancellation cleanup failed. The packaged app remains open. $nodeProcess.Kill() $helperStopped = $nodeProcess.WaitForExit(10000) } catch { $helperStopped = $false } } if (-not $helperStopped) { Throw-SafeFailure ( "$monitoringFailure The Node input helper could not be stopped; " + 'the application was left open and no close was attempted.') } Throw-SafeFailure ( "$monitoringFailure The Node input helper was stopped; " + 'the application was left open and no close was attempted.') } return [pscustomobject]@{ ExitCode = $nodeProcess.ExitCode WindowsInput = $windowsInputEvidence } } function Test-ProcessDescendsFrom([int]$ProcessId, [int]$ExpectedAncestorId) { $visited = New-Object 'Collections.Generic.HashSet[int]' $current = $ProcessId for ($depth = 0; $depth -lt 12; $depth++) { if ($current -eq $ExpectedAncestorId) { return $true } if ($current -le 0 -or -not $visited.Add($current)) { return $false } try { $record = Get-CimInstance Win32_Process -Filter "ProcessId = $current" ` -ErrorAction Stop } catch { return $false } if ($null -eq $record) { return $false } $current = [int]$record.ParentProcessId } return $false } function Wait-ExactLoopbackListener( [int]$CandidatePort, [Diagnostics.Process]$Application, [datetime]$Deadline) { while ([datetime]::UtcNow -lt $Deadline) { if ($Application.HasExited) { Throw-SafeFailure 'The application exited before the WebView listener appeared.' } $listeners = @(Get-TcpSnapshot | Where-Object { [string]$_.State -ceq 'Listen' -and $_.LocalPort -eq $CandidatePort }) if ($listeners.Count -gt 1) { Throw-SafeFailure 'More than one process is listening on the WebView debugging port.' } if ($listeners.Count -eq 1) { $listener = $listeners[0] if ([string]$listener.LocalAddress -cne '127.0.0.1') { Throw-SafeFailure 'The WebView debugging listener is not bound to exact IPv4 loopback.' } try { $owner = Get-Process -Id ([int]$listener.OwningProcess) -ErrorAction Stop $ownerPath = $owner.Path } catch { Throw-SafeFailure 'The WebView debugging listener owner could not be identified.' } if ([string]$owner.ProcessName -cne 'msedgewebview2' -or [IO.Path]::GetFileName($ownerPath) -cne 'msedgewebview2.exe' -or -not (Test-ProcessDescendsFrom $owner.Id $Application.Id)) { Throw-SafeFailure 'The WebView debugging listener is not owned by this application.' } return $listener } Start-Sleep -Milliseconds 100 } Throw-SafeFailure 'The exact WebView debugging listener did not appear in time.' } function Get-StartedApplication( [string]$ExpectedPath, [datetime]$NotBefore, [datetime]$Deadline) { while ([datetime]::UtcNow -lt $Deadline) { $processes = @(Get-LegacyApplicationProcesses) if ($processes.Count -gt 1) { Throw-SafeFailure 'More than one LegacyParity process appeared after launch.' } if ($processes.Count -eq 1) { $process = $processes[0] try { $process.Refresh() $path = $process.Path $startUtc = $process.StartTime.ToUniversalTime() } catch { Throw-SafeFailure 'The launched application identity could not be inspected.' } if (-not (Test-PathEqual $path $ExpectedPath) -or $startUtc -lt $NotBefore.AddSeconds(-1)) { Throw-SafeFailure 'The launched application identity is not exact.' } try { $cim = Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)" ` -ErrorAction Stop } catch { Throw-SafeFailure 'The launched application command line could not be inspected.' } if ($null -eq $cim -or [string]::IsNullOrWhiteSpace([string]$cim.CommandLine) -or [string]$cim.CommandLine -match '(?i)(?:^|\s)--development-live(?:\s|$)') { Throw-SafeFailure 'The Release application launch arguments are not safe.' } Assert-ExactProcessPackageIdentity $process return $process } Start-Sleep -Milliseconds 100 } Throw-SafeFailure 'The packaged Release application did not start in time.' } function Start-PackagedDryRunApplication( [pscustomobject]$Registration, [int]$DebugPort) { $invokeCommand = Get-Command Invoke-CommandInDesktopPackage -ErrorAction SilentlyContinue if ($null -eq $invokeCommand -or [string]$invokeCommand.Source -cne 'Appx') { Throw-SafeFailure 'Invoke-CommandInDesktopPackage from the Appx module is required.' } $childPowerShell = Join-Path $env:SystemRoot ` 'System32\WindowsPowerShell\v1.0\powershell.exe' if (-not [IO.File]::Exists($childPowerShell)) { Throw-SafeFailure 'The child Windows PowerShell executable is missing.' } $executableBase64 = [Convert]::ToBase64String( [Text.Encoding]::UTF8.GetBytes($Registration.Executable)) $workingDirectoryBase64 = [Convert]::ToBase64String( [Text.Encoding]::UTF8.GetBytes($Registration.InstallLocation)) $browserArguments = "--remote-debugging-address=127.0.0.1 --remote-debugging-port=$DebugPort" $browserArgumentsBase64 = [Convert]::ToBase64String( [Text.Encoding]::UTF8.GetBytes($browserArguments)) $childScript = @" `$ErrorActionPreference = 'Stop' `$executable = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$executableBase64')) `$workingDirectory = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$workingDirectoryBase64')) `$browserArguments = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$browserArgumentsBase64')) foreach (`$nameObject in @([Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process).Keys)) { `$name = [string]`$nameObject if (`$name -like 'MBN_STOCK_PLAYOUT_*' -or `$name -like 'WEBVIEW2_*' -or `$name -like 'COREWEBVIEW2_*' -or `$name -ceq '$K3dNativeHashVariable' -or `$name -ceq '$K3dInteropHashVariable') { [Environment]::SetEnvironmentVariable(`$name, `$null, [EnvironmentVariableTarget]::Process) } } [Environment]::SetEnvironmentVariable('MBN_STOCK_PLAYOUT_MODE', 'DryRun', [EnvironmentVariableTarget]::Process) [Environment]::SetEnvironmentVariable('$WebViewArgumentsVariable', `$browserArguments, [EnvironmentVariableTarget]::Process) `$startInfo = New-Object Diagnostics.ProcessStartInfo `$startInfo.FileName = `$executable `$startInfo.WorkingDirectory = `$workingDirectory `$startInfo.UseShellExecute = `$false `$startInfo.CreateNoWindow = `$false `$started = [Diagnostics.Process]::Start(`$startInfo) if (`$null -eq `$started) { exit 91 } "@ $encodedCommand = [Convert]::ToBase64String( [Text.Encoding]::Unicode.GetBytes($childScript)) $childArguments = "-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand $encodedCommand" $notBefore = [datetime]::UtcNow try { [void](Invoke-CommandInDesktopPackage ` -PackageFamilyName $PackageFamilyName ` -AppId $ApplicationId ` -Command $childPowerShell ` -Args $childArguments ` -PreventBreakaway ` -ErrorAction Stop) } catch { Throw-SafeFailure 'The package-context launch helper failed.' } $deadline = [datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds) return Get-StartedApplication $Registration.Executable $notBefore $deadline } function Assert-ExactApplicationStillRunning( [Diagnostics.Process]$Application, [string]$ExpectedPath) { try { $Application.Refresh() if ($Application.HasExited -or -not (Test-PathEqual $Application.Path $ExpectedPath) -or $Application.MainWindowHandle -eq [IntPtr]::Zero -or [string]$Application.MainWindowTitle -cne $ExpectedWindowTitle) { Throw-SafeFailure 'The application identity changed during the input smoke test.' } Assert-ExactProcessPackageIdentity $Application } catch [InvalidOperationException] { throw } catch { Throw-SafeFailure 'The application identity could not be revalidated.' } } function Assert-NoExistingContentDialog([IntPtr]$MainWindowHandle) { Add-Type -AssemblyName UIAutomationClient Add-Type -AssemblyName UIAutomationTypes $root = [Windows.Automation.AutomationElement]::FromHandle($MainWindowHandle) if ($null -eq $root) { Throw-SafeFailure 'The main window is unavailable to UI Automation.' } $all = $root.FindAll( [Windows.Automation.TreeScope]::Descendants, [Windows.Automation.Condition]::TrueCondition) for ($index = 0; $index -lt $all.Count; $index++) { $element = $all[$index] if ($element.Current.ControlType -eq [Windows.Automation.ControlType]::Window -and [string]$element.Current.ClassName -ceq 'Popup') { Throw-SafeFailure 'A dialog was already open before the normal close request.' } } } function Assert-SingleInstanceActivation( [Diagnostics.Process]$Application, [string]$ExpectedPath) { Assert-ExactApplicationStillRunning $Application $ExpectedPath $Application.Refresh() $expectedId = $Application.Id $expectedStartUtc = $Application.StartTime.ToUniversalTime() $expectedHandle = $Application.MainWindowHandle $expectedTitle = $Application.MainWindowTitle $preActivationProcesses = @(Get-LegacyApplicationProcesses) if ($preActivationProcesses.Count -ne 1 -or $preActivationProcesses[0].Id -ne $expectedId) { Throw-SafeFailure 'Single-instance activation requires exactly the pinned original process.' } Assert-NoTornadoConnection $expectedId $explorer = Join-Path $env:SystemRoot 'explorer.exe' if (-not [IO.File]::Exists($explorer)) { Throw-SafeFailure 'Explorer is unavailable for the single-instance activation check.' } $script:SingleInstanceActivationCalls++ if ($script:SingleInstanceActivationCalls -ne 1) { Throw-SafeFailure 'The single-instance activation budget changed.' } $activationRequestedUtc = [datetime]::UtcNow try { [void](Start-Process ` -FilePath $explorer ` -ArgumentList "shell:AppsFolder\$PackageFamilyName!$ApplicationId" ` -WindowStyle Hidden ` -PassThru ` -ErrorAction Stop) } catch { Throw-SafeFailure 'The single-instance package activation could not be requested.' } $redirector = $null $redirectorId = $null $redirectorStartUtc = $null $redirectorExited = $false $stableOriginalSinceUtc = $null $deadline = [datetime]::UtcNow.AddSeconds(8) while ([datetime]::UtcNow -lt $deadline) { $processes = @(Get-LegacyApplicationProcesses) $originals = @($processes | Where-Object { $_.Id -eq $expectedId }) $additional = @($processes | Where-Object { $_.Id -ne $expectedId }) if ($originals.Count -ne 1 -or $additional.Count -gt 1) { Throw-SafeFailure 'Package activation did not preserve one original and at most one redirector.' } $process = $originals[0] try { $process.Refresh() $sameIdentity = $process.Id -eq $expectedId -and (Test-PathEqual $process.Path $ExpectedPath) -and $process.StartTime.ToUniversalTime() -eq $expectedStartUtc -and $process.MainWindowHandle -eq $expectedHandle -and [string]$process.MainWindowTitle -ceq $expectedTitle } catch { $sameIdentity = $false } if (-not $sameIdentity) { Throw-SafeFailure 'Package activation changed the running application identity.' } Assert-ExactProcessPackageIdentity $process Assert-NoTornadoConnection $expectedId if ($additional.Count -eq 1) { if ($redirectorExited) { Throw-SafeFailure 'A second redirector appeared after the verified redirector exited.' } $candidate = $additional[0] try { $candidate.Refresh() $candidateId = $candidate.Id $candidateStartUtc = $candidate.StartTime.ToUniversalTime() $candidatePath = $candidate.Path $candidateHandle = $candidate.MainWindowHandle $candidateTitle = [string]$candidate.MainWindowTitle } catch { Throw-SafeFailure 'The transient activation redirector could not be inspected exactly.' } if ($candidateStartUtc -lt $activationRequestedUtc -or -not (Test-PathEqual $candidatePath $ExpectedPath) -or $candidateHandle -ne [IntPtr]::Zero -or -not [string]::IsNullOrEmpty($candidateTitle)) { Throw-SafeFailure 'The transient activation redirector identity is not exact.' } Assert-ExactProcessPackageIdentity $candidate Assert-NoTornadoConnection $candidateId if ($null -eq $redirectorId) { $redirector = $candidate $redirectorId = $candidateId $redirectorStartUtc = $candidateStartUtc } elseif ($candidateId -ne $redirectorId -or $candidateStartUtc -ne $redirectorStartUtc) { Throw-SafeFailure 'More than one transient activation redirector was observed.' } # Command-line inspection is opportunistic because a redirector may exit # between the native identity checks and the management query. try { $cimRows = @(Get-CimInstance Win32_Process ` -Filter "ProcessId = $candidateId" ` -ErrorAction Stop) if ($cimRows.Count -gt 1) { Throw-SafeFailure 'The transient redirector command line is not unique.' } if ($cimRows.Count -eq 1 -and -not [string]::IsNullOrWhiteSpace([string]$cimRows[0].CommandLine) -and [string]$cimRows[0].CommandLine -match '(?i)(?:^|\s)--development-live(?:[=\s]|$)') { Throw-SafeFailure 'The transient redirector used a development-live argument.' } } catch [InvalidOperationException] { throw } catch { # The already-verified transient may exit before CIM can inspect it. } $stableOriginalSinceUtc = $null } elseif ($null -ne $redirectorId) { try { $redirector.Refresh() $isRedirectorExited = $redirector.HasExited } catch { $isRedirectorExited = $true } if (-not $isRedirectorExited) { Throw-SafeFailure 'The pinned redirector disappeared from exact process enumeration before exit.' } if (-not $redirectorExited) { $redirectorExited = $true $stableOriginalSinceUtc = [datetime]::UtcNow } elseif ($null -eq $stableOriginalSinceUtc) { $stableOriginalSinceUtc = [datetime]::UtcNow } if (([datetime]::UtcNow - $stableOriginalSinceUtc).TotalMilliseconds -ge 750) { $stableOriginalMilliseconds = [int][Math]::Floor( ([datetime]::UtcNow - $stableOriginalSinceUtc).TotalMilliseconds) Assert-ExactApplicationStillRunning $Application $ExpectedPath Assert-NoTornadoConnection $expectedId return [pscustomobject]@{ ActivationCalls = $script:SingleInstanceActivationCalls ProcessCount = 1 ProcessId = $expectedId StartTimeUtc = $expectedStartUtc.ToString('o') MainWindowHandle = [int64]$expectedHandle RedirectorProcessId = $redirectorId RedirectorStartTimeUtc = $redirectorStartUtc.ToString('o') RedirectorExited = $true StableOriginalMilliseconds = $stableOriginalMilliseconds } } } Start-Sleep -Milliseconds 25 } if ($null -eq $redirectorId) { Throw-SafeFailure 'The single activation did not expose one verifiable transient redirector.' } if (-not $redirectorExited) { Throw-SafeFailure 'The verified transient activation redirector did not exit in time.' } Throw-SafeFailure 'The original instance was not stable alone for 750 milliseconds.' } function Wait-ExactNeutralCloseDialog( [IntPtr]$MainWindowHandle, [Diagnostics.Process]$Application, [datetime]$Deadline) { while ([datetime]::UtcNow -lt $Deadline) { if ($Application.HasExited) { Throw-SafeFailure 'The application exited before close confirmation.' } Assert-NoTornadoConnection $Application.Id $root = [Windows.Automation.AutomationElement]::FromHandle($MainWindowHandle) if ($null -eq $root) { Throw-SafeFailure 'The main window disappeared before close confirmation.' } $all = $root.FindAll( [Windows.Automation.TreeScope]::Descendants, [Windows.Automation.Condition]::TrueCondition) $matches = New-Object Collections.Generic.List[object] for ($index = 0; $index -lt $all.Count; $index++) { $element = $all[$index] if ($element.Current.ControlType -eq [Windows.Automation.ControlType]::Window -and [string]$element.Current.ClassName -ceq 'Popup' -and [string]$element.Current.Name -ceq $ExpectedDialogTitle) { [void]$matches.Add($element) } } if ($matches.Count -gt 1) { Throw-SafeFailure 'The neutral close dialog is not unique.' } if ($matches.Count -eq 1) { Assert-NoTornadoConnection $Application.Id return $matches[0] } Start-Sleep -Milliseconds 100 } Throw-SafeFailure 'The neutral close dialog did not appear in time.' } function Invoke-ExactNormalClose( [Diagnostics.Process]$Application, [string]$ExpectedPath) { Assert-ExactApplicationStillRunning $Application $ExpectedPath $mainWindowHandle = $Application.MainWindowHandle Assert-NoExistingContentDialog $mainWindowHandle $script:CloseMainWindowCalls++ if ($script:CloseMainWindowCalls -ne 1 -or -not $Application.CloseMainWindow()) { Throw-SafeFailure 'The single normal close request was not accepted.' } $dialog = Wait-ExactNeutralCloseDialog ` $mainWindowHandle ` $Application ` ([datetime]::UtcNow.AddSeconds(15)) $descendants = $dialog.FindAll( [Windows.Automation.TreeScope]::Descendants, [Windows.Automation.Condition]::TrueCondition) $buttons = New-Object Collections.Generic.List[object] $texts = New-Object Collections.Generic.List[object] for ($index = 0; $index -lt $descendants.Count; $index++) { $element = $descendants[$index] if ($element.Current.ControlType -eq [Windows.Automation.ControlType]::Button) { [void]$buttons.Add($element) } elseif ($element.Current.ControlType -eq [Windows.Automation.ControlType]::Text) { [void]$texts.Add($element) } } $primary = @($buttons | Where-Object { [string]$_.Current.AutomationId -ceq 'PrimaryButton' -and [string]$_.Current.Name -ceq $ExpectedPrimaryButton -and $_.Current.IsEnabled -and -not $_.Current.IsOffscreen }) $close = @($buttons | Where-Object { [string]$_.Current.AutomationId -ceq 'CloseButton' -and [string]$_.Current.Name -ceq $ExpectedCloseButton -and $_.Current.IsEnabled -and -not $_.Current.IsOffscreen }) $message = @($texts | Where-Object { [string]$_.Current.Name -ceq $ExpectedDialogMessage }) if ($buttons.Count -ne 2 -or $primary.Count -ne 1 -or $close.Count -ne 1 -or $message.Count -ne 1) { Throw-SafeFailure 'The close dialog is not the exact neutral Yes/No confirmation.' } $patternObject = $null if (-not $primary[0].TryGetCurrentPattern( [Windows.Automation.InvokePattern]::Pattern, [ref]$patternObject) -or $null -eq $patternObject) { Throw-SafeFailure 'The exact Yes button has no InvokePattern.' } $script:PrimaryInvokeCalls++ if ($script:PrimaryInvokeCalls -ne 1) { Throw-SafeFailure 'The close confirmation invocation budget changed.' } ($patternObject -as [Windows.Automation.InvokePattern]).Invoke() $exitDeadline = [datetime]::UtcNow.AddSeconds(30) while ([datetime]::UtcNow -lt $exitDeadline) { $Application.Refresh() if ($Application.HasExited) { return } Assert-NoTornadoConnection $Application.Id Start-Sleep -Milliseconds 100 } Throw-SafeFailure 'Normal application exit was not observed after the single confirmation.' } function Wait-PortReleased([int]$CandidatePort, [datetime]$Deadline) { while ([datetime]::UtcNow -lt $Deadline) { $listeners = @(Get-TcpSnapshot | Where-Object { [string]$_.State -ceq 'Listen' -and $_.LocalPort -eq $CandidatePort }) if ($listeners.Count -eq 0) { return } Start-Sleep -Milliseconds 100 } Throw-SafeFailure 'The WebView debugging listener remained after normal application exit.' } function Read-AndAssertHarnessEvidence( [string]$OutputPath, [string]$ScreenshotPath, [string]$WindowsInputRequestPath, [string]$WindowsInputAcknowledgementPath, [string]$HarnessProfile, [string]$HarnessScope, [int]$ExpectedPort, [int]$ExpectedTimeoutSeconds) { try { $evidence = [IO.File]::ReadAllText($OutputPath, [Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop $result = [string]$evidence.result $safeResult = $result -ceq 'PASS' -or $result -ceq 'PASS_WITH_WARNING' $warnings = @($evidence.warnings) $expectedProfile = switch -CaseSensitive ($HarnessProfile) { 'FullUiDb' { 'full-ui-db'; break } 'DryRunPlayout' { 'dry-run-playout'; break } default { 'read-only' } } $expectedScope = if ($HarnessProfile -ceq 'FullUiDb') { $HarnessScope.ToLowerInvariant() } else { 'all' } $databaseWriteContractOk = if ($HarnessProfile -ceq 'FullUiDb') { $evidence.safety.databaseWriteIntentIssued -eq $true -and @($evidence.safety.approvedDatabaseWriteMessages).Count -ge 3 -and [string]$evidence.fullUiDb.result -ceq 'PASS' -and $evidence.fullUiDb.cleanupVerified -eq $true } else { $evidence.safety.databaseWriteIntentIssued -eq $false -and @($evidence.safety.approvedDatabaseWriteMessages).Count -eq 0 -and $null -eq $evidence.fullUiDb } $playoutIntentContractOk = if ($HarnessProfile -ceq 'DryRunPlayout') { $evidence.safety.playoutIntentIssued -eq $true -and [string]$evidence.dryRunPlayout.result -ceq 'PASS' -and -not [string]::IsNullOrWhiteSpace( [string]$evidence.dryRunPlayout.selectedEntryId) -and -not [string]::IsNullOrWhiteSpace( [string]$evidence.dryRunPlayout.nextEntryId) -and -not [string]::IsNullOrWhiteSpace( [string]$evidence.dryRunPlayout.stagedEntryId) -and [string]$evidence.dryRunPlayout.selectedEntryId -cne [string]$evidence.dryRunPlayout.nextEntryId -and [string]$evidence.dryRunPlayout.stagedEntryId -cne [string]$evidence.dryRunPlayout.selectedEntryId -and [int]$evidence.dryRunPlayout.playlistCountBeforeSelection -eq [int]$evidence.dryRunPlayout.playlistCountAfterDoubleClick -and [int]$evidence.dryRunPlayout.programCutSelection -ge 0 } else { $evidence.safety.playoutIntentIssued -eq $false -and $null -eq $evidence.dryRunPlayout } if ([int]$evidence.schemaVersion -ne 1 -or [string]$evidence.profile -cne $expectedProfile -or [string]$evidence.scope -cne $expectedScope -or -not $safeResult -or ($result -ceq 'PASS_WITH_WARNING' -and $warnings.Count -eq 0) -or $null -ne $evidence.error -or [string]$evidence.target.expectedUrl -cne 'https://legacy-parity.mbn.local/index.html' -or [int]$evidence.target.port -ne $ExpectedPort -or [int]$evidence.target.timeoutMilliseconds -ne ($ExpectedTimeoutSeconds * 1000) -or [string]$evidence.target.url -cne 'https://legacy-parity.mbn.local/index.html' -or [string]$evidence.safety.requiredMode -cne 'dryRun' -or [string]$evidence.safety.requiredPhase -cne 'idle' -or $evidence.safety.appCloseRequested -ne $false -or -not $playoutIntentContractOk -or -not $databaseWriteContractOk -or $evidence.safety.externalCancellationRequested -ne $false -or @($evidence.safety.blockedOutboundMessages).Count -ne 0 -or @($evidence.safety.stateViolations).Count -ne 0 -or [string]::IsNullOrWhiteSpace([string]$evidence.screenshot.path) -or -not (Test-PathEqual ([string]$evidence.screenshot.path) $ScreenshotPath)) { Throw-SafeFailure 'The real input evidence does not satisfy the DryRun safety contract.' } if ($HarnessProfile -ceq 'ReadOnly' -or $HarnessProfile -ceq 'DryRunPlayout') { $allowedOutbound = @( 'ready', 'search-stocks', 'select-stock', 'cut-pointer-down', 'cut-key-down', 'drop-selected-cuts', 'select-tab', 'hover-swap-tabs', 'activate-playlist-row', 'select-playlist-row', 'reorder-playlist-rows', 'select-playlist-boundary', 'refresh-named-playlists') if ($HarnessProfile -ceq 'DryRunPlayout') { $allowedOutbound += @('take-in', 'next-playout', 'take-out') } foreach ($messageType in @($evidence.safety.observedOutboundMessageTypes)) { if ($allowedOutbound -cnotcontains [string]$messageType) { Throw-SafeFailure 'The evidence contains an outbound message outside the selected non-write allowlist.' } } } else { $playoutMessages = @( 'prepare-playout', 'take-in', 'next-playout', 'take-out', 'choose-background', 'toggle-background') foreach ($messageType in @($evidence.safety.observedOutboundMessageTypes)) { if ($playoutMessages -ccontains [string]$messageType) { Throw-SafeFailure 'The full UI/DB evidence contains a playout message.' } } } $screenshotItem = Get-Item -LiteralPath $ScreenshotPath -Force $screenshotHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $ScreenshotPath).Hash if ($screenshotItem.PSIsContainer -or ($screenshotItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $screenshotItem.Length -le 0 -or [int64]$evidence.screenshot.bytes -ne $screenshotItem.Length -or [string]$evidence.screenshot.sha256 -cne $screenshotHash) { Throw-SafeFailure 'The screenshot evidence does not match its sealed file.' } $requestEvidence = Read-SmallJsonEvidenceFile ` $WindowsInputRequestPath ` 'Final Windows input request' $acknowledgementEvidence = Read-SmallJsonEvidenceFile ` $WindowsInputAcknowledgementPath ` 'Final Windows input acknowledgement' $request = $requestEvidence.Value if ([bool]$evidence.windowsInput.required -ne $true -or [string]$evidence.windowsInput.result -cne 'PASS' -or -not (Test-PathEqual ([string]$evidence.windowsInput.requestPath) ` $WindowsInputRequestPath) -or -not (Test-PathEqual ([string]$evidence.windowsInput.acknowledgementPath) ` $WindowsInputAcknowledgementPath) -or [string]$evidence.windowsInput.requestSha256 -cne $requestEvidence.Sha256 -or [string]$evidence.windowsInput.acknowledgementSha256 -cne ` $acknowledgementEvidence.Sha256 -or -not (Test-ExactStringSequence ` @($evidence.windowsInput.beforeOrder) ` @($request.beforeOrder)) -or -not (Test-ExactStringSequence ` @($evidence.windowsInput.afterOrder) ` @($request.afterOrder)) -or [string]$evidence.windowsInput.activeRowIdAfterHome -cne ` [string]$request.expectedActiveRowIdAfterHome) { Throw-SafeFailure 'The sealed Windows input evidence does not match its one-shot request.' } $snapshot = $evidence.finalSnapshot $state = $snapshot.state $playout = $state.playout $dom = $snapshot.dom if ($null -eq $snapshot -or $null -eq $state -or $null -eq $playout -or $null -eq $dom -or $state.isBusy -ne $false -or [string]$playout.mode -cne 'dryRun' -or [string]$playout.connectionState -cne 'dryRunReady' -or [string]$playout.phase -cne 'idle' -or $playout.isConnected -ne $false -or $playout.liveTakeInAllowed -ne $false -or $playout.isPlayCompletionPending -ne $false -or $playout.isTakeOutCompletionPending -ne $false -or $playout.outcomeUnknown -ne $false -or $playout.isBusy -ne $false -or $playout.refreshActive -ne $false -or $null -ne $playout.preparedCode -or $null -ne $playout.onAirCode -or $null -ne $playout.currentEntryId -or $snapshot.safety.postMessageWrapped -ne $true -or @($snapshot.safety.blockedOutboundMessages).Count -ne 0 -or @($snapshot.safety.stateViolations).Count -ne 0 -or [string]$dom.readyState -cne 'complete' -or [string]$dom.url -cne 'https://legacy-parity.mbn.local/index.html' -or [string]$dom.bodyBusy -cne 'false' -or [string]$dom.connectionText -cne 'DRY RUN' -or $dom.legacyDialogHidden -ne $true -or $dom.namedModalHidden -ne $true -or $dom.namedConfirmationHidden -ne $true) { Throw-SafeFailure 'The sealed final application state is not exact DryRun IDLE.' } $expectedAfterOrder = @($request.afterOrder | ForEach-Object { [string]$_ }) $statePlaylist = @($state.playlist) $domPlaylist = @($dom.playlist) $statePlaylistIds = @($statePlaylist | ForEach-Object { [string]$_.rowId }) $domPlaylistIds = @($domPlaylist | ForEach-Object { [string]$_.rowId }) $selectedStateIds = @($statePlaylist | Where-Object { $_.isSelected -eq $true } | ForEach-Object { [string]$_.rowId }) $activeStateIds = @($statePlaylist | Where-Object { $_.isActive -eq $true } | ForEach-Object { [string]$_.rowId }) $selectedDomIds = @($domPlaylist | Where-Object { $_.selected -eq $true } | ForEach-Object { [string]$_.rowId }) $activeDomIds = @($domPlaylist | Where-Object { $_.active -eq $true } | ForEach-Object { [string]$_.rowId }) $dragVisualCount = @($domPlaylist | Where-Object { $_.dragging -eq $true -or $_.dragBefore -eq $true -or $_.dragAfter -eq $true }).Count if (($HarnessProfile -ceq 'ReadOnly' -or $HarnessProfile -ceq 'DryRunPlayout') -and ( -not (Test-ExactStringSequence $statePlaylistIds $expectedAfterOrder) -or -not (Test-ExactStringSequence $domPlaylistIds $expectedAfterOrder) -or -not (Test-ExactStringSequence $selectedStateIds @([string]$request.sourceRowId)) -or -not (Test-ExactStringSequence $selectedDomIds @([string]$request.sourceRowId)) -or -not (Test-ExactStringSequence ` $activeStateIds ` @([string]$request.expectedActiveRowIdAfterHome)) -or -not (Test-ExactStringSequence ` $activeDomIds ` @([string]$request.expectedActiveRowIdAfterHome)) -or $dragVisualCount -ne 0 -or @($dom.playlistPointerCapture.rowIds).Count -ne 0)) { Throw-SafeFailure 'The sealed final playlist state does not match the Windows input result.' } $expectedFullUiDbPlaylistCount = if ($HarnessScope -ceq 'GraphE' -or $HarnessScope -ceq 'Screens' -or $HarnessScope -ceq 'All') { 0 } else { 3 } if ($HarnessProfile -ceq 'FullUiDb' -and ( $statePlaylist.Count -ne $expectedFullUiDbPlaylistCount -or $domPlaylist.Count -ne $expectedFullUiDbPlaylistCount -or $dragVisualCount -ne 0 -or @($dom.playlistPointerCapture.rowIds).Count -ne 0)) { Throw-SafeFailure 'The sealed full UI/DB playlist readback is not exact.' } return $evidence } catch [InvalidOperationException] { throw } catch { # Never echo evidence JSON or parser diagnostics. Throw-SafeFailure 'The real input evidence could not be validated safely.' } } $timestamp = [datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') $Output = Resolve-EvidencePath $Output "legacy-package-input-$timestamp.json" $Screenshot = Resolve-EvidencePath $Screenshot "legacy-package-input-$timestamp.png" $WindowsInputRequest = [IO.Path]::ChangeExtension( $Output, '.windows-input-request.json') $WindowsInputAcknowledgement = [IO.Path]::ChangeExtension( $Output, '.windows-input-ack.json') $NodePath = Resolve-NodeExecutable $NodePath try { if (-not [IO.File]::Exists($HarnessPath)) { Throw-SafeFailure 'The tracked package input harness is missing.' } if (-not [string]::Equals( [IO.Path]::GetExtension($Output), '.json', [StringComparison]::OrdinalIgnoreCase) -or -not [string]::Equals( [IO.Path]::GetExtension($Screenshot), '.png', [StringComparison]::OrdinalIgnoreCase)) { Throw-SafeFailure 'Output must be .json and screenshot must be .png.' } $evidencePaths = @( $Output, $Screenshot, $WindowsInputRequest, $WindowsInputAcknowledgement) for ($leftIndex = 0; $leftIndex -lt $evidencePaths.Count; $leftIndex++) { for ($rightIndex = $leftIndex + 1; $rightIndex -lt $evidencePaths.Count; $rightIndex++) { if (Test-PathEqual $evidencePaths[$leftIndex] $evidencePaths[$rightIndex]) { Throw-SafeFailure 'All evidence paths must be different.' } } } Assert-OutputPathAvailable $Output 'Output' Assert-OutputPathAvailable $Screenshot 'Screenshot' Assert-OutputPathAvailable $WindowsInputRequest 'Windows input request' Assert-OutputPathAvailable $WindowsInputAcknowledgement 'Windows input acknowledgement' 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 = 'real input harness' $harnessRun = Invoke-NodeHarnessUnderTcpWatch ` $NodePath ` $HarnessPath ` $Port ` $Output ` $Screenshot ` $WindowsInputRequest ` $WindowsInputAcknowledgement ` $(if ($Profile -ceq 'FullUiDb') { 'full-ui-db' } elseif ($Profile -ceq 'DryRunPlayout') { 'dry-run-playout' } else { 'read-only' }) ` $(if ($Profile -ceq 'FullUiDb') { $FullUiDbScope.ToLowerInvariant() } else { 'all' }) ` $HarnessTimeoutSeconds ` $script:ApplicationProcess ` $registration.Executable if ($harnessRun.ExitCode -ne 0) { Throw-SafeFailure "The real input harness failed with exit code $($harnessRun.ExitCode)." } if (-not [IO.File]::Exists($Output) -or -not [IO.File]::Exists($Screenshot) -or -not [IO.File]::Exists($WindowsInputRequest) -or -not [IO.File]::Exists($WindowsInputAcknowledgement) -or (Get-Item -LiteralPath $Output).Length -le 0 -or (Get-Item -LiteralPath $Screenshot).Length -le 0 -or (Get-Item -LiteralPath $WindowsInputRequest).Length -le 0 -or (Get-Item -LiteralPath $WindowsInputAcknowledgement).Length -le 0) { Throw-SafeFailure 'The real input evidence files are missing or empty.' } $harnessEvidence = Read-AndAssertHarnessEvidence ` $Output ` $Screenshot ` $WindowsInputRequest ` $WindowsInputAcknowledgement ` $Profile ` $FullUiDbScope ` $Port ` $HarnessTimeoutSeconds if ($null -eq $harnessRun.WindowsInput -or [string]$harnessRun.WindowsInput.RequestSha256 -cne [string]$harnessEvidence.windowsInput.requestSha256 -or [string]$harnessRun.WindowsInput.AcknowledgementSha256 -cne [string]$harnessEvidence.windowsInput.acknowledgementSha256) { Throw-SafeFailure 'The wrapper and harness Windows input evidence hashes differ.' } Assert-ExactApplicationStillRunning $script:ApplicationProcess $registration.Executable Assert-NoTornadoConnection $script:ApplicationProcess.Id if ($Profile -cne 'FullUiDb') { $script:Phase = 'single-instance activation' $singleInstance = Assert-SingleInstanceActivation ` $script:ApplicationProcess ` $registration.Executable } else { # The read-only package smoke already owns the single-instance contract. # Avoid launching an unrelated redirector after an authorized DB-write run. $singleInstance = [pscustomobject]@{ ActivationCalls = 0 ProcessCount = 1 RedirectorProcessId = $null RedirectorStartTimeUtc = $null RedirectorExited = $null StableOriginalMilliseconds = $null } } $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]$harnessEvidence.result packageFullName = $PackageFullName packageMode = 'Development Release x64' playoutMode = 'DryRun' processId = $script:ApplicationProcess.Id cdpAddress = '127.0.0.1' cdpPort = $Port tornadoPortConnections = 0 tornadoMonitoringScope = 'application PID from exact launch identity through normal exit' windowsInputRequestCalls = $script:WindowsInputRequestCalls windowsInputAcknowledgementCalls = $script:WindowsInputAcknowledgementCalls windowsInputRequest = $WindowsInputRequest windowsInputRequestSha256 = [string]$harnessRun.WindowsInput.RequestSha256 windowsInputAcknowledgement = $WindowsInputAcknowledgement windowsInputAcknowledgementSha256 = ` [string]$harnessRun.WindowsInput.AcknowledgementSha256 singleInstanceActivationCalls = $singleInstance.ActivationCalls singleInstanceProcessCount = $singleInstance.ProcessCount singleInstanceRedirectorProcessId = $singleInstance.RedirectorProcessId singleInstanceRedirectorStartTimeUtc = $singleInstance.RedirectorStartTimeUtc singleInstanceRedirectorExited = $singleInstance.RedirectorExited singleInstanceStableOriginalMilliseconds = $singleInstance.StableOriginalMilliseconds closeMainWindowCalls = $script:CloseMainWindowCalls primaryInvokeCalls = $script:PrimaryInvokeCalls output = $Output outputSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Output).Hash screenshot = $Screenshot screenshotSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Screenshot).Hash } } catch { $message = $_.Exception.Message if ($script:CloseMainWindowCalls -gt 0 -or $script:PrimaryInvokeCalls -gt 0) { Throw-SafeFailure ( "Package input 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 (-not $running) { try { $running = @(Get-LegacyApplicationProcesses).Count -gt 0 } catch { # An inability to re-enumerate cannot authorize another close attempt. $running = $false } } if ($running) { Throw-SafeFailure ( "Package input smoke stopped during '$($script:Phase)': $message " + 'The application was intentionally left open; no close retry or forced termination was attempted.') } throw }