Files
MBN_STOCK_WEBVIEW/scripts/Invoke-LegacyPackageInputSmoke.ps1

2543 lines
95 KiB
PowerShell

[CmdletBinding()]
param(
[ValidateRange(1024, 65535)]
[int]$Port = 9339,
[string]$Output,
[string]$Screenshot,
[string]$NodePath,
[ValidateRange(10, 120)]
[int]$LaunchTimeoutSeconds = 30,
[ValidateRange(30, 300)]
[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<MibTcpRowOwnerPid>(
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<MibTcp6RowOwnerPid>(
AddressFamilyInterNetworkV6,
delegate(MibTcp6RowOwnerPid row)
{
return row.OwningProcessId == (uint)processId &&
(DecodePort(row.LocalPort) == port || DecodePort(row.RemotePort) == port);
});
}
private static bool ScanTcpTable<TRow>(int addressFamily, Func<TRow, bool> 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,
[int]$TimeoutSeconds,
[Diagnostics.Process]$Application,
[string]$ExpectedApplicationPath) {
$arguments = @(
$ScriptPath,
'--port',
[string]$DebugPort,
'--output',
$OutputPath,
'--screenshot',
$ScreenshotPath,
'--windows-input-request',
$WindowsInputRequestPath,
'--windows-input-ack',
$WindowsInputAcknowledgementPath,
'--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,
[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)
if ([int]$evidence.schemaVersion -ne 1 -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
$evidence.safety.playoutIntentIssued -ne $false -or
$evidence.safety.databaseWriteIntentIssued -ne $false -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.'
}
$allowedOutbound = @(
'ready', 'search-stocks', 'select-stock', 'cut-pointer-down',
'cut-key-down', 'drop-selected-cuts', 'activate-playlist-row',
'select-playlist-row', 'reorder-playlist-rows',
'select-playlist-boundary', 'refresh-named-playlists')
foreach ($messageType in @($evidence.safety.observedOutboundMessageTypes)) {
if ($allowedOutbound -cnotcontains [string]$messageType) {
Throw-SafeFailure 'The evidence contains an outbound message outside the read-only allowlist.'
}
}
$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 (-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.'
}
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 `
$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 `
$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
$script:Phase = 'single-instance activation'
$singleInstance = Assert-SingleInstanceActivation `
$script:ApplicationProcess `
$registration.Executable
$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
}