4250 lines
173 KiB
PowerShell
4250 lines
173 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[ValidateSet('Release', 'DebugAppX')]
|
|
[string]$BuildFlavor = 'Release',
|
|
|
|
[ValidateSet('ReadOnly', 'DryRunPlayout', 'FullUiDb', 'ProgramPlaylistDb')]
|
|
[string]$Profile = 'ReadOnly',
|
|
|
|
[ValidateSet('All', 'PList', 'GraphE', 'Catalog', 'Screens')]
|
|
[string]$FullUiDbScope = 'All',
|
|
|
|
[string]$RecoverNamedPlaylistTitle,
|
|
|
|
[ValidateRange(1024, 65535)]
|
|
[int]$Port = 9339,
|
|
|
|
[string]$Output,
|
|
|
|
[string]$Screenshot,
|
|
|
|
[string]$NodePath,
|
|
|
|
[ValidateRange(10, 120)]
|
|
[int]$LaunchTimeoutSeconds = 30,
|
|
|
|
[ValidateRange(30, 900)]
|
|
[int]$HarnessTimeoutSeconds = 180,
|
|
|
|
# The local-settings/import smoke reuses the exact package identity, launch,
|
|
# DryRun and normal-close guards from this script. Returning after the
|
|
# definitions keeps those guards single-sourced without launching anything.
|
|
[switch]$DefinitionsOnly
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
Add-Type -TypeDefinition @'
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
using System.Threading;
|
|
|
|
public static class LegacyPackageInputNative
|
|
{
|
|
private delegate bool EnumWindowsCallback(IntPtr window, IntPtr parameter);
|
|
|
|
[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 EnumWindows(EnumWindowsCallback callback, IntPtr parameter);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern IntPtr GetWindow(IntPtr window, uint command);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool IsWindow(IntPtr window);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool IsWindowVisible(IntPtr window);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool IsWindowEnabled(IntPtr window);
|
|
|
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
private static extern int GetWindowText(
|
|
IntPtr window,
|
|
StringBuilder value,
|
|
int maximum);
|
|
|
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
private static extern int GetClassName(
|
|
IntPtr window,
|
|
StringBuilder value,
|
|
int maximum);
|
|
|
|
[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 GetWindowOwner = 4;
|
|
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 VirtualKeyShift = 0x10;
|
|
private const ushort VirtualKeyHome = 0x24;
|
|
private const ushort VirtualKeyDelete = 0x2E;
|
|
private const ushort VirtualKeyEscape = 0x1B;
|
|
private const ushort VirtualKeyF2 = 0x71;
|
|
private const ushort VirtualKeyF3 = 0x72;
|
|
private const int ClientPixelTolerance = 1;
|
|
|
|
public static bool WindowExists(IntPtr window)
|
|
{
|
|
return window != IntPtr.Zero && IsWindow(window);
|
|
}
|
|
|
|
public static bool WindowVisible(IntPtr window)
|
|
{
|
|
return WindowExists(window) && IsWindowVisible(window);
|
|
}
|
|
|
|
public static bool WindowEnabled(IntPtr window)
|
|
{
|
|
return WindowExists(window) && IsWindowEnabled(window);
|
|
}
|
|
|
|
public static int WindowProcessId(IntPtr window)
|
|
{
|
|
uint processId;
|
|
return GetWindowThreadProcessId(window, out processId) == 0
|
|
? 0 : checked((int)processId);
|
|
}
|
|
|
|
public static IntPtr RootWindow(IntPtr window)
|
|
{
|
|
return window == IntPtr.Zero
|
|
? IntPtr.Zero : GetAncestor(window, GetAncestorRoot);
|
|
}
|
|
|
|
public static bool OwnerChainContains(IntPtr window, IntPtr expectedOwner)
|
|
{
|
|
if (window == IntPtr.Zero || expectedOwner == IntPtr.Zero) return false;
|
|
IntPtr expectedRoot = RootWindow(expectedOwner);
|
|
IntPtr current = window;
|
|
for (int depth = 0; depth < 12 && current != IntPtr.Zero; depth++)
|
|
{
|
|
if (current == expectedOwner || RootWindow(current) == expectedRoot) return true;
|
|
current = GetWindow(current, GetWindowOwner);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public static string WindowClassName(IntPtr window)
|
|
{
|
|
StringBuilder value = new StringBuilder(512);
|
|
return window != IntPtr.Zero &&
|
|
GetClassName(window, value, value.Capacity) > 0
|
|
? value.ToString() : String.Empty;
|
|
}
|
|
|
|
public static string WindowTitle(IntPtr window)
|
|
{
|
|
StringBuilder value = new StringBuilder(1024);
|
|
return window != IntPtr.Zero &&
|
|
GetWindowText(window, value, value.Capacity) > 0
|
|
? value.ToString() : String.Empty;
|
|
}
|
|
|
|
public static IntPtr[] VisibleTopLevelWindows()
|
|
{
|
|
List<IntPtr> windows = new List<IntPtr>();
|
|
EnumWindowsCallback callback = delegate(IntPtr window, IntPtr parameter)
|
|
{
|
|
if (window != IntPtr.Zero && IsWindowVisible(window))
|
|
{
|
|
windows.Add(window);
|
|
}
|
|
return true;
|
|
};
|
|
if (!EnumWindows(callback, IntPtr.Zero))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The native top-level window list is unavailable.");
|
|
}
|
|
return windows.ToArray();
|
|
}
|
|
|
|
public static 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);
|
|
AcquireForeground(window, processId, forbiddenPort);
|
|
ClientGeometry baseline = ReadAndAssertClientGeometry(
|
|
window,
|
|
viewportCssWidth,
|
|
viewportCssHeight,
|
|
devicePixelRatio);
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void SendShiftClickRangeThenDeleteAndRestore(
|
|
IntPtr window,
|
|
int processId,
|
|
int forbiddenPort,
|
|
double rangeStartCssX,
|
|
double rangeStartCssY,
|
|
double rangeEndCssX,
|
|
double rangeEndCssY,
|
|
double restoreCssX,
|
|
double restoreCssY,
|
|
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 Shift-range input contract is invalid.");
|
|
}
|
|
|
|
AssertKeyReleased(VirtualKeyLeftButton, "The left mouse button was held before Shift selection.");
|
|
AssertKeyReleased(VirtualKeyShift, "Shift was held before Shift selection.");
|
|
AssertKeyReleased(VirtualKeyDelete, "Delete was held before Shift selection.");
|
|
AssertWindowOwnedByProcess(window, processId);
|
|
AssertNoForbiddenTcp(processId, forbiddenPort);
|
|
AcquireForeground(window, processId, forbiddenPort);
|
|
ClientGeometry baseline = ReadAndAssertClientGeometry(
|
|
window, viewportCssWidth, viewportCssHeight, devicePixelRatio);
|
|
SleepWithWindowGuard(
|
|
500, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
|
|
Point rangeStart = CssPointToScreen(
|
|
baseline, rangeStartCssX, rangeStartCssY, devicePixelRatio);
|
|
Point rangeEnd = CssPointToScreen(
|
|
baseline, rangeEndCssX, rangeEndCssY, devicePixelRatio);
|
|
Point restore = CssPointToScreen(
|
|
baseline, restoreCssX, restoreCssY, devicePixelRatio);
|
|
if ((rangeStart.X == rangeEnd.X && rangeStart.Y == rangeEnd.Y) ||
|
|
(rangeEnd.X == restore.X && rangeEnd.Y == restore.Y))
|
|
{
|
|
throw new InvalidOperationException("The Windows Shift-range points are not distinct.");
|
|
}
|
|
AssertPointOwnedByWindow(window, rangeStart);
|
|
AssertPointOwnedByWindow(window, rangeEnd);
|
|
AssertPointOwnedByWindow(window, restore);
|
|
|
|
bool leftButtonDown = false;
|
|
bool shiftDown = false;
|
|
bool deleteDown = false;
|
|
try
|
|
{
|
|
SendMouseMove(rangeStart.X, rangeStart.Y);
|
|
SleepWithWindowGuard(
|
|
100, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
AssertCursorAt(rangeStart);
|
|
SendMouseButton(MouseLeftDown);
|
|
leftButtonDown = true;
|
|
SleepWithWindowGuard(
|
|
45, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
AssertCursorAt(rangeStart);
|
|
AssertKeyState(
|
|
VirtualKeyLeftButton,
|
|
true,
|
|
"The left mouse button changed during the range-start click.");
|
|
SendMouseButton(MouseLeftUp);
|
|
leftButtonDown = false;
|
|
SleepWithWindowGuard(
|
|
300, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
|
|
SendKeyboard(VirtualKeyShift, false);
|
|
shiftDown = true;
|
|
AssertKeyState(VirtualKeyShift, true, "Shift did not remain held for range selection.");
|
|
SendMouseMove(rangeEnd.X, rangeEnd.Y);
|
|
SleepWithWindowGuard(
|
|
100, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
AssertCursorAt(rangeEnd);
|
|
AssertKeyState(VirtualKeyShift, true, "Shift changed before the range click.");
|
|
SendMouseButton(MouseLeftDown);
|
|
leftButtonDown = true;
|
|
SleepWithWindowGuard(
|
|
45, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
AssertCursorAt(rangeEnd);
|
|
AssertKeyState(VirtualKeyShift, true, "Shift changed during the range click.");
|
|
AssertKeyState(
|
|
VirtualKeyLeftButton,
|
|
true,
|
|
"The left mouse button changed during the range-end click.");
|
|
SendMouseButton(MouseLeftUp);
|
|
leftButtonDown = false;
|
|
SendKeyboard(VirtualKeyShift, true);
|
|
shiftDown = false;
|
|
SleepWithWindowGuard(
|
|
300, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
|
|
SendKeyboard(VirtualKeyDelete, false);
|
|
deleteDown = true;
|
|
SleepWithWindowGuard(
|
|
45, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
AssertKeyState(VirtualKeyDelete, true, "Delete changed during the delete key press.");
|
|
SendKeyboard(VirtualKeyDelete, true);
|
|
deleteDown = false;
|
|
SleepWithWindowGuard(
|
|
650, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
|
|
AssertPointOwnedByWindow(window, restore);
|
|
SendMouseMove(restore.X, restore.Y);
|
|
SleepWithWindowGuard(
|
|
100, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
AssertCursorAt(restore);
|
|
SendMouseButton(MouseLeftDown);
|
|
leftButtonDown = true;
|
|
SleepWithWindowGuard(
|
|
45, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
AssertCursorAt(restore);
|
|
AssertKeyState(
|
|
VirtualKeyLeftButton,
|
|
true,
|
|
"The left mouse button changed during restored selection.");
|
|
SendMouseButton(MouseLeftUp);
|
|
leftButtonDown = false;
|
|
SleepWithWindowGuard(
|
|
300, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
}
|
|
finally
|
|
{
|
|
if (deleteDown) SendKeyboard(VirtualKeyDelete, true);
|
|
if (shiftDown) SendKeyboard(VirtualKeyShift, true);
|
|
if (leftButtonDown) SendMouseButton(MouseLeftUp);
|
|
AssertKeyReleased(VirtualKeyDelete, "Delete remained held after Shift-range cleanup.");
|
|
AssertKeyReleased(VirtualKeyShift, "Shift remained held after Shift-range cleanup.");
|
|
AssertKeyReleased(
|
|
VirtualKeyLeftButton,
|
|
"The left mouse button remained held after Shift-range cleanup.");
|
|
}
|
|
}
|
|
|
|
public static void SendApplicationClick(
|
|
IntPtr window,
|
|
int processId,
|
|
int forbiddenPort,
|
|
double cssX,
|
|
double cssY,
|
|
double viewportCssWidth,
|
|
double viewportCssHeight,
|
|
double devicePixelRatio,
|
|
bool allowForegroundTransitionAfterClick)
|
|
{
|
|
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 click coordinate contract is invalid.");
|
|
}
|
|
|
|
AssertKeyReleased(VirtualKeyLeftButton, "The left mouse button was held before input.");
|
|
AssertWindowOwnedByProcess(window, processId);
|
|
AssertNoForbiddenTcp(processId, forbiddenPort);
|
|
AcquireForeground(window, processId, forbiddenPort);
|
|
ClientGeometry baseline = ReadAndAssertClientGeometry(
|
|
window, viewportCssWidth, viewportCssHeight, devicePixelRatio);
|
|
SleepWithWindowGuard(
|
|
150, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
|
|
Point point = CssPointToScreen(baseline, cssX, cssY, devicePixelRatio);
|
|
AssertPointOwnedByWindow(window, point);
|
|
bool leftButtonDown = false;
|
|
try
|
|
{
|
|
SendMouseMove(point.X, point.Y);
|
|
SleepWithInputGuard(
|
|
100, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline, point, point,
|
|
point, false, false);
|
|
SendMouseButton(MouseLeftDown);
|
|
leftButtonDown = true;
|
|
SleepWithInputGuard(
|
|
45, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline, point, point,
|
|
point, true, false);
|
|
SendMouseButton(MouseLeftUp);
|
|
leftButtonDown = false;
|
|
if (allowForegroundTransitionAfterClick)
|
|
{
|
|
Thread.Sleep(45);
|
|
AssertWindowOwnedByProcess(window, processId);
|
|
AssertNoForbiddenTcp(processId, forbiddenPort);
|
|
}
|
|
else
|
|
{
|
|
SleepWithInputGuard(
|
|
100, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline, point, point,
|
|
point, false, false);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (leftButtonDown)
|
|
{
|
|
SendMouseButton(MouseLeftUp);
|
|
}
|
|
AssertKeyReleased(
|
|
VirtualKeyLeftButton,
|
|
"The left mouse button remained held after click cleanup.");
|
|
}
|
|
}
|
|
|
|
public static void SendApplicationDoubleClick(
|
|
IntPtr window,
|
|
int processId,
|
|
int forbiddenPort,
|
|
double cssX,
|
|
double cssY,
|
|
double viewportCssWidth,
|
|
double viewportCssHeight,
|
|
double devicePixelRatio,
|
|
int interClickDelayMilliseconds)
|
|
{
|
|
if (window == IntPtr.Zero || processId <= 0 || forbiddenPort < 1 ||
|
|
forbiddenPort > 65535 || !IsFinitePositive(viewportCssWidth) ||
|
|
!IsFinitePositive(viewportCssHeight) || !IsFinitePositive(devicePixelRatio) ||
|
|
devicePixelRatio < 0.25 || devicePixelRatio > 5.0 ||
|
|
interClickDelayMilliseconds < 50 || interClickDelayMilliseconds > 250)
|
|
{
|
|
throw new InvalidOperationException("The Windows double-click contract is invalid.");
|
|
}
|
|
|
|
AssertKeyReleased(VirtualKeyLeftButton, "The left mouse button was held before double-click input.");
|
|
AssertWindowOwnedByProcess(window, processId);
|
|
AssertNoForbiddenTcp(processId, forbiddenPort);
|
|
AcquireForeground(window, processId, forbiddenPort);
|
|
ClientGeometry baseline = ReadAndAssertClientGeometry(
|
|
window, viewportCssWidth, viewportCssHeight, devicePixelRatio);
|
|
SleepWithWindowGuard(
|
|
150, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
|
|
Point point = CssPointToScreen(baseline, cssX, cssY, devicePixelRatio);
|
|
AssertPointOwnedByWindow(window, point);
|
|
bool leftButtonDown = false;
|
|
try
|
|
{
|
|
SendMouseMove(point.X, point.Y);
|
|
SleepWithWindowGuard(
|
|
100, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
AssertCursorAt(point);
|
|
for (int click = 0; click < 2; click++)
|
|
{
|
|
SendMouseButton(MouseLeftDown);
|
|
leftButtonDown = true;
|
|
SleepWithWindowGuard(
|
|
45, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
AssertCursorAt(point);
|
|
AssertKeyState(
|
|
VirtualKeyLeftButton,
|
|
true,
|
|
"The left mouse button changed during double-click input.");
|
|
SendMouseButton(MouseLeftUp);
|
|
leftButtonDown = false;
|
|
if (click == 0)
|
|
{
|
|
SleepWithWindowGuard(
|
|
interClickDelayMilliseconds,
|
|
window,
|
|
processId,
|
|
forbiddenPort,
|
|
viewportCssWidth,
|
|
viewportCssHeight,
|
|
devicePixelRatio,
|
|
baseline);
|
|
AssertCursorAt(point);
|
|
}
|
|
}
|
|
SleepWithWindowGuard(
|
|
300, window, processId, forbiddenPort, viewportCssWidth,
|
|
viewportCssHeight, devicePixelRatio, baseline);
|
|
}
|
|
finally
|
|
{
|
|
if (leftButtonDown) SendMouseButton(MouseLeftUp);
|
|
AssertKeyReleased(
|
|
VirtualKeyLeftButton,
|
|
"The left mouse button remained held after double-click cleanup.");
|
|
}
|
|
}
|
|
|
|
public static IntPtr ReadForegroundWindow()
|
|
{
|
|
return GetForegroundWindow();
|
|
}
|
|
|
|
public static void AcquireOwnedForeground(
|
|
IntPtr window,
|
|
IntPtr expectedOwner,
|
|
int processId,
|
|
int forbiddenPort)
|
|
{
|
|
if (!WindowVisible(window) || !WindowEnabled(window) ||
|
|
WindowProcessId(window) != processId ||
|
|
!OwnerChainContains(window, expectedOwner))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The owned background picker identity changed before foreground acquisition.");
|
|
}
|
|
AcquireForeground(window, processId, forbiddenPort);
|
|
if (RootWindow(GetForegroundWindow()) != window)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The exact owned background picker does not own foreground input.");
|
|
}
|
|
}
|
|
|
|
public static void SendBackgroundFunctionKey(
|
|
IntPtr window,
|
|
int processId,
|
|
int forbiddenPort,
|
|
int virtualKey,
|
|
bool allowForegroundTransitionAfterKey)
|
|
{
|
|
ushort key = checked((ushort)virtualKey);
|
|
if (key != VirtualKeyF2 && key != VirtualKeyF3)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Only the closed F2/F3 background shortcut set is allowed.");
|
|
}
|
|
AssertKeyReleased(key, "The background function key was held before input.");
|
|
AssertWindowOwnedByProcess(window, processId);
|
|
AssertNoForbiddenTcp(processId, forbiddenPort);
|
|
AcquireForeground(window, processId, forbiddenPort);
|
|
|
|
bool keyDown = false;
|
|
try
|
|
{
|
|
SendKeyboard(key, false);
|
|
keyDown = true;
|
|
Thread.Sleep(45);
|
|
SendKeyboard(key, true);
|
|
keyDown = false;
|
|
if (allowForegroundTransitionAfterKey)
|
|
{
|
|
Thread.Sleep(45);
|
|
AssertWindowOwnedByProcess(window, processId);
|
|
AssertNoForbiddenTcp(processId, forbiddenPort);
|
|
}
|
|
else
|
|
{
|
|
Thread.Sleep(150);
|
|
AssertWindowOwnedByProcess(window, processId);
|
|
AssertNoForbiddenTcp(processId, forbiddenPort);
|
|
if (GetForegroundWindow() != window)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The application lost foreground during the F3 input.");
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (keyDown) SendKeyboard(key, true);
|
|
AssertKeyReleased(key, "The background function key remained held after input.");
|
|
}
|
|
}
|
|
|
|
public static void SendEscapeToForegroundWindow(IntPtr expectedWindow)
|
|
{
|
|
IntPtr foreground = GetForegroundWindow();
|
|
if (expectedWindow == IntPtr.Zero || foreground == IntPtr.Zero ||
|
|
GetAncestor(foreground, GetAncestorRoot) != expectedWindow)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The exact folder picker does not own foreground keyboard input.");
|
|
}
|
|
|
|
AssertKeyReleased(VirtualKeyEscape, "Escape was held before picker cancellation.");
|
|
bool escapeDown = false;
|
|
try
|
|
{
|
|
SendKeyboard(VirtualKeyEscape, false);
|
|
escapeDown = true;
|
|
Thread.Sleep(45);
|
|
// Escape cancellation may return foreground to the owner while
|
|
// the picker is still being destroyed. Key-up only releases the
|
|
// injected key; the caller separately proves that this exact
|
|
// picker closes and never sends another Escape.
|
|
SendKeyboard(VirtualKeyEscape, true);
|
|
escapeDown = false;
|
|
}
|
|
finally
|
|
{
|
|
if (escapeDown)
|
|
{
|
|
SendKeyboard(VirtualKeyEscape, true);
|
|
}
|
|
AssertKeyReleased(VirtualKeyEscape, "Escape remained held after picker cancellation.");
|
|
}
|
|
}
|
|
|
|
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'
|
|
$ExpectedInstallRelativePath = switch ($BuildFlavor) {
|
|
'Release' {
|
|
'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64'
|
|
break
|
|
}
|
|
'DebugAppX' {
|
|
'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Debug\net8.0-windows10.0.19041.0\win-x64\AppX'
|
|
break
|
|
}
|
|
default { Throw-SafeFailure 'The package build flavor is not closed.' }
|
|
}
|
|
$PackageModeLabel = "Development $BuildFlavor x64"
|
|
$ExpectedInstallLocation = Join-Path $RepositoryRoot $ExpectedInstallRelativePath
|
|
$ExpectedInstallLocation = [IO.Path]::GetFullPath($ExpectedInstallLocation)
|
|
$ConfigurationPath = Join-Path `
|
|
([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) `
|
|
'MBN_STOCK_WEBVIEW\Config\playout.local.json'
|
|
$OperatorSettingsPath = Join-Path `
|
|
([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) `
|
|
'MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json'
|
|
$BackgroundPickerTitle = $Utf8.GetString([Convert]::FromBase64String(
|
|
'64uk7J2MIOyepeuptCDrsLDqsr0g7ISg7YOd'))
|
|
$BackgroundDirectoryName = $Utf8.GetString([Convert]::FromBase64String('67Cw6rK9'))
|
|
$BackgroundNoneLabel = $Utf8.GetString([Convert]::FromBase64String(
|
|
'67Cw6rK97JeG7J2M'))
|
|
$LegacyDefaultBackgroundFileName = $Utf8.GetString(
|
|
[Convert]::FromBase64String('6riw67O4LnZydg=='))
|
|
$ExpectedSavedFixtureGraphicType = $Utf8.GetString(
|
|
[Convert]::FromBase64String('MeyXtO2MkOq4sOuzuA=='))
|
|
$ExpectedSavedFixtureCurrentSubtype = $Utf8.GetString(
|
|
[Convert]::FromBase64String('7ZiE7J6s6rCA'))
|
|
$ExpectedSavedFixtureAfterHoursSubtype = $Utf8.GetString(
|
|
[Convert]::FromBase64String('7Iuc6rCE7Jm464uo7J286rCA'))
|
|
|
|
$script:ApplicationProcess = $null
|
|
$script:ApplicationWasLaunched = $false
|
|
$script:CloseMainWindowCalls = 0
|
|
$script:PrimaryInvokeCalls = 0
|
|
$script:SingleInstanceActivationCalls = 0
|
|
$script:WindowsInputRequestCalls = 0
|
|
$script:WindowsInputAcknowledgementCalls = 0
|
|
$script:BackgroundShortcutRoot = $null
|
|
$script:BackgroundShortcutRootCreated = $false
|
|
$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 Initialize-BackgroundShortcutFixture([string]$InstallLocation) {
|
|
if ($Profile -cne 'DryRunPlayout') {
|
|
return
|
|
}
|
|
if ([IO.File]::Exists($OperatorSettingsPath) -or
|
|
[IO.Directory]::Exists($OperatorSettingsPath)) {
|
|
Throw-SafeFailure (
|
|
'The F2/F3 physical test requires the default package folder layout; ' +
|
|
'runtime folder overrides are present.')
|
|
}
|
|
|
|
if ([IO.File]::Exists($ConfigurationPath)) {
|
|
try {
|
|
$raw = [IO.File]::ReadAllText($ConfigurationPath, $StrictUtf8)
|
|
$configuration = $raw | ConvertFrom-Json -ErrorAction Stop
|
|
}
|
|
catch {
|
|
Throw-SafeFailure 'The F2/F3 physical test could not inspect its DryRun configuration.'
|
|
}
|
|
foreach ($propertyName in @('sceneDirectory', 'legacyBackgroundDirectory')) {
|
|
$matches = @($configuration.PSObject.Properties | Where-Object {
|
|
[string]::Equals(
|
|
$_.Name,
|
|
$propertyName,
|
|
[StringComparison]::OrdinalIgnoreCase)
|
|
})
|
|
if ($matches.Count -gt 1 -or
|
|
($matches.Count -eq 1 -and $null -ne $matches[0].Value)) {
|
|
Throw-SafeFailure (
|
|
'The F2/F3 physical test requires null scene/background folder overrides.')
|
|
}
|
|
}
|
|
}
|
|
|
|
$root = [IO.Path]::GetFullPath((Join-Path $InstallLocation $BackgroundDirectoryName))
|
|
$expectedRoot = [IO.Path]::GetFullPath((Join-Path `
|
|
$ExpectedInstallLocation `
|
|
$BackgroundDirectoryName))
|
|
if (-not (Test-PathEqual $root $expectedRoot) -or [IO.File]::Exists($root)) {
|
|
Throw-SafeFailure 'The exact package-local background test root is unavailable.'
|
|
}
|
|
if (-not [IO.Directory]::Exists($root)) {
|
|
[void][IO.Directory]::CreateDirectory($root)
|
|
$script:BackgroundShortcutRootCreated = $true
|
|
}
|
|
$item = Get-Item -LiteralPath $root -Force
|
|
if (-not $item.PSIsContainer -or
|
|
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
Throw-SafeFailure 'The exact package-local background test root is unsafe.'
|
|
}
|
|
$defaultPath = Join-Path $root $LegacyDefaultBackgroundFileName
|
|
if ([IO.File]::Exists($defaultPath) -or [IO.Directory]::Exists($defaultPath)) {
|
|
Throw-SafeFailure (
|
|
'The F3 fail-closed test requires the external default background asset to be absent.')
|
|
}
|
|
$script:BackgroundShortcutRoot = $root
|
|
}
|
|
|
|
function Remove-BackgroundShortcutFixture {
|
|
if (-not $script:BackgroundShortcutRootCreated -or
|
|
[string]::IsNullOrEmpty([string]$script:BackgroundShortcutRoot)) {
|
|
return
|
|
}
|
|
$root = [IO.Path]::GetFullPath([string]$script:BackgroundShortcutRoot)
|
|
$expectedRoot = [IO.Path]::GetFullPath((Join-Path `
|
|
$ExpectedInstallLocation `
|
|
$BackgroundDirectoryName))
|
|
if (-not (Test-PathEqual $root $expectedRoot) -or
|
|
-not [IO.Directory]::Exists($root)) {
|
|
Throw-SafeFailure 'The temporary background shortcut root identity changed.'
|
|
}
|
|
$item = Get-Item -LiteralPath $root -Force
|
|
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
|
|
@(Get-ChildItem -LiteralPath $root -Force).Count -ne 0) {
|
|
Throw-SafeFailure 'The temporary background shortcut root is no longer empty and was preserved.'
|
|
}
|
|
[IO.Directory]::Delete($root, $false)
|
|
$script:BackgroundShortcutRootCreated = $false
|
|
}
|
|
|
|
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-StreamSha256Hex([IO.Stream]$Stream) {
|
|
$algorithm = [Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
return [BitConverter]::ToString($algorithm.ComputeHash($Stream)).Replace('-', '')
|
|
}
|
|
finally {
|
|
$algorithm.Dispose()
|
|
}
|
|
}
|
|
|
|
function Assert-DebugAppXMatchesLatestPackage([string]$InstallLocation) {
|
|
if ($BuildFlavor -cne 'DebugAppX') {
|
|
return
|
|
}
|
|
|
|
$projectRoot = Join-Path $RepositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp'
|
|
$msix = Join-Path $projectRoot `
|
|
'AppPackages\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Debug_Test\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Debug.msix'
|
|
if (-not [IO.File]::Exists($msix)) {
|
|
Throw-SafeFailure 'The latest Debug MSIX is missing; the registered AppX cannot be proven current.'
|
|
}
|
|
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
$archive = [IO.Compression.ZipFile]::OpenRead($msix)
|
|
try {
|
|
$expectedFiles = @(
|
|
'MBN_STOCK_WEBVIEW.Core.dll',
|
|
'MBN_STOCK_WEBVIEW.Infrastructure.dll',
|
|
'MBN_STOCK_WEBVIEW.LegacyApplication.dll',
|
|
'MBN_STOCK_WEBVIEW.LegacyBridge.dll',
|
|
'MBN_STOCK_WEBVIEW.LegacyParityApp.dll',
|
|
'Web/app.js'
|
|
)
|
|
foreach ($relativePath in $expectedFiles) {
|
|
$entry = $archive.GetEntry($relativePath)
|
|
$installed = Join-Path $InstallLocation $relativePath.Replace('/', '\')
|
|
if ($null -eq $entry -or -not [IO.File]::Exists($installed)) {
|
|
Throw-SafeFailure 'The registered Debug AppX is missing a required current package file.'
|
|
}
|
|
|
|
$entryStream = $entry.Open()
|
|
try {
|
|
$packageHash = Get-StreamSha256Hex $entryStream
|
|
}
|
|
finally {
|
|
$entryStream.Dispose()
|
|
}
|
|
$installedHash = (Get-FileHash -LiteralPath $installed -Algorithm SHA256).Hash
|
|
if ($installedHash -cne $packageHash) {
|
|
Throw-SafeFailure 'The registered Debug AppX is stale relative to the latest Debug MSIX.'
|
|
}
|
|
}
|
|
}
|
|
finally {
|
|
$archive.Dispose()
|
|
}
|
|
|
|
$sourceAppJs = Join-Path $projectRoot 'Web\app.js'
|
|
$installedAppJs = Join-Path $InstallLocation 'Web\app.js'
|
|
if (-not [IO.File]::Exists($sourceAppJs) -or
|
|
(Get-FileHash -LiteralPath $sourceAppJs -Algorithm SHA256).Hash -cne
|
|
(Get-FileHash -LiteralPath $installedAppJs -Algorithm SHA256).Hash) {
|
|
Throw-SafeFailure 'The registered Debug AppX does not contain the current Web application source.'
|
|
}
|
|
}
|
|
|
|
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 $BuildFlavor 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 $BuildFlavor application executable is missing."
|
|
}
|
|
|
|
Assert-DebugAppXMatchesLatestPackage ([string]$package.InstallLocation)
|
|
|
|
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 Get-WindowsInputExchangePath(
|
|
[string]$BasePath,
|
|
[ValidateRange(1, 3)]
|
|
[int]$ExchangeIndex) {
|
|
if ($ExchangeIndex -eq 1) {
|
|
return $BasePath
|
|
}
|
|
$directory = [IO.Path]::GetDirectoryName($BasePath)
|
|
$extension = [IO.Path]::GetExtension($BasePath)
|
|
$name = [IO.Path]::GetFileNameWithoutExtension($BasePath)
|
|
return Join-Path $directory (
|
|
$name + '.exchange-' + $ExchangeIndex.ToString('00') + $extension)
|
|
}
|
|
|
|
function Get-ExpectedWindowsInputExchangeCount(
|
|
[string]$HarnessProfile,
|
|
[string]$HarnessScope) {
|
|
if ($HarnessProfile -ceq 'DryRunPlayout' -or
|
|
$HarnessProfile -ceq 'dry-run-playout') {
|
|
return 3
|
|
}
|
|
if (($HarnessProfile -ceq 'FullUiDb' -or $HarnessProfile -ceq 'full-ui-db') -and
|
|
($HarnessScope -ceq 'PList' -or $HarnessScope -ceq 'plist' -or
|
|
$HarnessScope -ceq 'All' -or $HarnessScope -ceq 'all')) {
|
|
return 2
|
|
}
|
|
return 1
|
|
}
|
|
|
|
function Write-WindowsInputAcknowledgement(
|
|
[string]$Path,
|
|
[int]$ExchangeIndex,
|
|
[string]$Token,
|
|
[string]$Operation,
|
|
[Collections.IDictionary]$InputCounts) {
|
|
$acknowledgement = [ordered]@{
|
|
schemaVersion = 1
|
|
exchangeIndex = $ExchangeIndex
|
|
token = $Token
|
|
result = 'PASS'
|
|
operation = $Operation
|
|
foregroundValidated = $true
|
|
packageIdentityValidated = $true
|
|
inputRetryCount = 0
|
|
}
|
|
foreach ($key in $InputCounts.Keys) {
|
|
$acknowledgement[$key] = $InputCounts[$key]
|
|
}
|
|
$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 ConvertTo-ExactInputPoint(
|
|
[object]$Point,
|
|
[double]$ViewportWidth,
|
|
[double]$ViewportHeight,
|
|
[string]$Label) {
|
|
Assert-ExactJsonPropertyNames $Point @('x', 'y') $Label
|
|
$x = ConvertTo-FiniteNumber $Point.x "$Label X"
|
|
$y = ConvertTo-FiniteNumber $Point.y "$Label Y"
|
|
if ($x -lt 0 -or $x -ge $ViewportWidth -or
|
|
$y -lt 0 -or $y -ge $ViewportHeight) {
|
|
Throw-SafeFailure "$Label is outside the exact package viewport."
|
|
}
|
|
return [pscustomobject]@{ X = $x; Y = $y }
|
|
}
|
|
|
|
function Invoke-ExactPlaylistWindowsInputRequest(
|
|
[object]$Request,
|
|
[Diagnostics.Process]$Application) {
|
|
Assert-ExactJsonPropertyNames $Request @(
|
|
'schemaVersion', 'exchangeIndex', 'token', 'targetUrl', 'operation',
|
|
'sourceRowId', 'targetRowId', 'rangeStartRowId', 'rangeEndRowId',
|
|
'position', 'source', 'target', 'rangeStart', 'rangeEnd',
|
|
'restoreSelection', 'viewport', 'devicePixelRatio', 'beforeOrder',
|
|
'reorderedOrder', 'afterOrder', 'expectedRangeSelectedRowIds',
|
|
'expectedSelectedRowId', 'expectedActiveRowIdAfterHome',
|
|
'expectedFinalActiveRowId', 'expectedFocusedRowId', 'mouseDownCalls',
|
|
'mouseUpCalls', 'homeKeyCalls', 'shiftKeyDownCalls', 'shiftKeyUpCalls',
|
|
'deleteKeyCalls', 'inputRetryCount') 'Playlist Windows input request'
|
|
Assert-ExactJsonPropertyNames $Request.viewport @('width', 'height') `
|
|
'Playlist Windows input viewport'
|
|
|
|
$sourceRowId = [string]$Request.sourceRowId
|
|
$targetRowId = [string]$Request.targetRowId
|
|
$rangeStartRowId = [string]$Request.rangeStartRowId
|
|
$rangeEndRowId = [string]$Request.rangeEndRowId
|
|
$rowIds = @($sourceRowId, $targetRowId, $rangeStartRowId, $rangeEndRowId)
|
|
if ($Request.sourceRowId -isnot [string] -or
|
|
$Request.targetRowId -isnot [string] -or
|
|
$Request.rangeStartRowId -isnot [string] -or
|
|
$Request.rangeEndRowId -isnot [string] -or
|
|
$Request.position -isnot [string] -or
|
|
[string]$Request.position -cne 'after' -or
|
|
$sourceRowId -ceq $targetRowId -or
|
|
$rangeStartRowId -ceq $rangeEndRowId -or
|
|
(@($rowIds | Select-Object -Unique)).Count -ne 4 -or
|
|
$rowIds.Where({ $_ -cnotmatch '\Alegacy-stock-[0-9]{8}\z' }).Count -ne 0 -or
|
|
[string]$Request.expectedSelectedRowId -cne $sourceRowId -or
|
|
[string]$Request.expectedFinalActiveRowId -cne $sourceRowId -or
|
|
[string]$Request.expectedFocusedRowId -cne $sourceRowId -or
|
|
(ConvertTo-FiniteNumber $Request.mouseDownCalls 'Playlist mouse-down count') -ne 4 -or
|
|
(ConvertTo-FiniteNumber $Request.mouseUpCalls 'Playlist mouse-up count') -ne 4 -or
|
|
(ConvertTo-FiniteNumber $Request.homeKeyCalls 'Playlist Home count') -ne 1 -or
|
|
(ConvertTo-FiniteNumber $Request.shiftKeyDownCalls 'Playlist Shift-down count') -ne 1 -or
|
|
(ConvertTo-FiniteNumber $Request.shiftKeyUpCalls 'Playlist Shift-up count') -ne 1 -or
|
|
(ConvertTo-FiniteNumber $Request.deleteKeyCalls 'Playlist Delete count') -ne 1) {
|
|
Throw-SafeFailure 'The playlist Windows input request is outside the exact gesture contract.'
|
|
}
|
|
|
|
$beforeOrder = @($Request.beforeOrder | ForEach-Object { [string]$_ })
|
|
$reorderedOrder = @($Request.reorderedOrder | ForEach-Object { [string]$_ })
|
|
$afterOrder = @($Request.afterOrder | ForEach-Object { [string]$_ })
|
|
$expectedRange = @($Request.expectedRangeSelectedRowIds | ForEach-Object { [string]$_ })
|
|
if ($beforeOrder.Count -ne 5 -or $reorderedOrder.Count -ne 5 -or
|
|
$afterOrder.Count -ne 3 -or
|
|
(@($beforeOrder | Select-Object -Unique)).Count -ne 5 -or
|
|
(@($reorderedOrder | Select-Object -Unique)).Count -ne 5 -or
|
|
(@($afterOrder | Select-Object -Unique)).Count -ne 3 -or
|
|
-not (Test-ExactStringSequence $expectedRange @($rangeStartRowId, $rangeEndRowId))) {
|
|
Throw-SafeFailure 'The playlist Windows input row orders are not the exact five-to-three fixture.'
|
|
}
|
|
foreach ($rowId in $beforeOrder + $reorderedOrder + $afterOrder) {
|
|
if ($rowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z') {
|
|
Throw-SafeFailure 'The playlist Windows input request contains an invalid row identity.'
|
|
}
|
|
}
|
|
|
|
$computedReorder = New-Object 'Collections.Generic.List[string]'
|
|
foreach ($rowId in $beforeOrder) {
|
|
if ($rowId -cne $sourceRowId) { [void]$computedReorder.Add($rowId) }
|
|
}
|
|
$targetIndex = $computedReorder.IndexOf($targetRowId)
|
|
if ($targetIndex -lt 0) {
|
|
Throw-SafeFailure 'The playlist drag target is absent after removing its source.'
|
|
}
|
|
$computedReorder.Insert($targetIndex + 1, $sourceRowId)
|
|
if (-not (Test-ExactStringSequence @($computedReorder) $reorderedOrder) -or
|
|
$reorderedOrder[-2] -cne $rangeStartRowId -or
|
|
$reorderedOrder[-1] -cne $rangeEndRowId -or
|
|
[string]$Request.expectedActiveRowIdAfterHome -cne $reorderedOrder[0]) {
|
|
Throw-SafeFailure 'The playlist reorder/Home/range contract is not exact.'
|
|
}
|
|
$computedAfter = @($reorderedOrder | Where-Object {
|
|
$_ -cne $rangeStartRowId -and $_ -cne $rangeEndRowId
|
|
})
|
|
if (-not (Test-ExactStringSequence $computedAfter $afterOrder)) {
|
|
Throw-SafeFailure 'The playlist Shift-range delete result is not exact.'
|
|
}
|
|
|
|
$viewportWidth = ConvertTo-FiniteNumber $Request.viewport.width `
|
|
'Playlist Windows input viewport width'
|
|
$viewportHeight = ConvertTo-FiniteNumber $Request.viewport.height `
|
|
'Playlist Windows input viewport height'
|
|
$devicePixelRatio = ConvertTo-FiniteNumber $Request.devicePixelRatio `
|
|
'Playlist Windows input device pixel ratio'
|
|
if ($viewportWidth -lt 1 -or $viewportWidth -gt 16384 -or
|
|
$viewportHeight -lt 1 -or $viewportHeight -gt 16384 -or
|
|
$devicePixelRatio -lt 0.25 -or $devicePixelRatio -gt 5) {
|
|
Throw-SafeFailure 'The playlist Windows input viewport contract is invalid.'
|
|
}
|
|
$source = ConvertTo-ExactInputPoint $Request.source $viewportWidth $viewportHeight 'Playlist drag source'
|
|
$target = ConvertTo-ExactInputPoint $Request.target $viewportWidth $viewportHeight 'Playlist drag target'
|
|
$rangeStart = ConvertTo-ExactInputPoint $Request.rangeStart $viewportWidth $viewportHeight 'Playlist range start'
|
|
$rangeEnd = ConvertTo-ExactInputPoint $Request.rangeEnd $viewportWidth $viewportHeight 'Playlist range end'
|
|
$restore = ConvertTo-ExactInputPoint $Request.restoreSelection $viewportWidth $viewportHeight 'Playlist restore selection'
|
|
|
|
[LegacyPackageInputNative]::SendRowHeaderDragAfterThenHome(
|
|
$Application.MainWindowHandle, $Application.Id, 30001,
|
|
$source.X, $source.Y, $target.X, $target.Y,
|
|
$viewportWidth, $viewportHeight, $devicePixelRatio)
|
|
[LegacyPackageInputNative]::SendShiftClickRangeThenDeleteAndRestore(
|
|
$Application.MainWindowHandle, $Application.Id, 30001,
|
|
$rangeStart.X, $rangeStart.Y, $rangeEnd.X, $rangeEnd.Y,
|
|
$restore.X, $restore.Y,
|
|
$viewportWidth, $viewportHeight, $devicePixelRatio)
|
|
}
|
|
|
|
function Invoke-ExactNamedPlaylistDoubleClickRequest(
|
|
[object]$Request,
|
|
[Diagnostics.Process]$Application) {
|
|
Assert-ExactJsonPropertyNames $Request @(
|
|
'schemaVersion', 'exchangeIndex', 'token', 'targetUrl', 'operation',
|
|
'definitionId', 'definitionTitle', 'point', 'viewport',
|
|
'devicePixelRatio', 'expectedPlaylistContent', 'mouseDownCalls',
|
|
'mouseUpCalls', 'doubleClickIntervalMilliseconds',
|
|
'inputRetryCount') 'Named-playlist double-click request'
|
|
Assert-ExactJsonPropertyNames $Request.viewport @('width', 'height') `
|
|
'Named-playlist double-click viewport'
|
|
if ($Request.definitionId -isnot [string] -or
|
|
[string]::IsNullOrEmpty([string]$Request.definitionId) -or
|
|
([string]$Request.definitionId).Length -gt 256 -or
|
|
[string]$Request.definitionId -cmatch '[\x00-\x1F\x7F]' -or
|
|
$Request.definitionTitle -isnot [string] -or
|
|
([string]$Request.definitionTitle).Length -ne 26 -or
|
|
[string]$Request.definitionTitle -cnotmatch
|
|
'\ACDX_P_[0-9]{13}_[0-9a-f]{6}\z' -or
|
|
(ConvertTo-FiniteNumber $Request.mouseDownCalls 'PList double-click mouse-down count') -ne 2 -or
|
|
(ConvertTo-FiniteNumber $Request.mouseUpCalls 'PList double-click mouse-up count') -ne 2 -or
|
|
(ConvertTo-FiniteNumber $Request.doubleClickIntervalMilliseconds `
|
|
'PList double-click interval') -ne 100) {
|
|
Throw-SafeFailure 'The named-playlist double-click identity/count contract is invalid.'
|
|
}
|
|
|
|
$content = @($Request.expectedPlaylistContent)
|
|
if ($content.Count -ne 3) {
|
|
Throw-SafeFailure 'The named-playlist double-click does not preserve exactly three rows.'
|
|
}
|
|
foreach ($row in $content) {
|
|
Assert-ExactJsonPropertyNames $row @(
|
|
'isEnabled', 'marketText', 'stockName', 'graphicType',
|
|
'subtype', 'pageText') 'Named-playlist expected row'
|
|
if ($row.isEnabled -ne $true -or
|
|
$row.marketText -isnot [string] -or
|
|
$row.stockName -isnot [string] -or
|
|
$row.graphicType -isnot [string] -or
|
|
$row.subtype -isnot [string] -or
|
|
$row.pageText -isnot [string] -or
|
|
[string]$row.graphicType -cne $ExpectedSavedFixtureGraphicType) {
|
|
Throw-SafeFailure 'The named-playlist expected row is outside the exact saved fixture.'
|
|
}
|
|
}
|
|
$subtypes = @($content | ForEach-Object { [string]$_.subtype })
|
|
if (-not (Test-ExactStringSequence $subtypes @(
|
|
$ExpectedSavedFixtureCurrentSubtype,
|
|
$ExpectedSavedFixtureCurrentSubtype,
|
|
$ExpectedSavedFixtureAfterHoursSubtype))) {
|
|
Throw-SafeFailure 'The named-playlist expected row order is not the saved three-row fixture.'
|
|
}
|
|
|
|
$viewportWidth = ConvertTo-FiniteNumber $Request.viewport.width `
|
|
'PList double-click viewport width'
|
|
$viewportHeight = ConvertTo-FiniteNumber $Request.viewport.height `
|
|
'PList double-click viewport height'
|
|
$devicePixelRatio = ConvertTo-FiniteNumber $Request.devicePixelRatio `
|
|
'PList double-click device pixel ratio'
|
|
if ($viewportWidth -lt 1 -or $viewportWidth -gt 16384 -or
|
|
$viewportHeight -lt 1 -or $viewportHeight -gt 16384 -or
|
|
$devicePixelRatio -lt 0.25 -or $devicePixelRatio -gt 5) {
|
|
Throw-SafeFailure 'The named-playlist double-click viewport contract is invalid.'
|
|
}
|
|
$point = ConvertTo-ExactInputPoint $Request.point $viewportWidth $viewportHeight `
|
|
'Named-playlist double-click point'
|
|
[LegacyPackageInputNative]::SendApplicationDoubleClick(
|
|
$Application.MainWindowHandle, $Application.Id, 30001,
|
|
$point.X, $point.Y, $viewportWidth, $viewportHeight,
|
|
$devicePixelRatio, 100)
|
|
}
|
|
|
|
function Get-BackgroundPickerWindowHandles {
|
|
$handles = New-Object 'Collections.Generic.HashSet[int64]'
|
|
foreach ($handle in [LegacyPackageInputNative]::VisibleTopLevelWindows()) {
|
|
if ($handle -ne [IntPtr]::Zero) { [void]$handles.Add($handle.ToInt64()) }
|
|
}
|
|
return $handles
|
|
}
|
|
|
|
function Get-OwnedBackgroundPickerCandidates(
|
|
[Collections.Generic.HashSet[int64]]$BaselineHandles,
|
|
[Diagnostics.Process]$Application) {
|
|
$candidates = @()
|
|
foreach ($handle in [LegacyPackageInputNative]::VisibleTopLevelWindows()) {
|
|
try {
|
|
if ($handle -eq [IntPtr]::Zero -or
|
|
$BaselineHandles.Contains($handle.ToInt64()) -or
|
|
-not [LegacyPackageInputNative]::WindowVisible($handle) -or
|
|
-not [LegacyPackageInputNative]::WindowEnabled($handle) -or
|
|
[LegacyPackageInputNative]::WindowProcessId($handle) -ne $Application.Id -or
|
|
-not [LegacyPackageInputNative]::OwnerChainContains(
|
|
$handle,
|
|
$Application.MainWindowHandle) -or
|
|
[LegacyPackageInputNative]::WindowClassName($handle) -cne '#32770' -or
|
|
[LegacyPackageInputNative]::WindowTitle($handle) -cne
|
|
$BackgroundPickerTitle) {
|
|
continue
|
|
}
|
|
$candidates += [pscustomobject]@{
|
|
Handle = $handle
|
|
ProcessId = [LegacyPackageInputNative]::WindowProcessId($handle)
|
|
OwnerLinked = $true
|
|
SameProcess = $true
|
|
ClassName = [LegacyPackageInputNative]::WindowClassName($handle)
|
|
Title = [LegacyPackageInputNative]::WindowTitle($handle)
|
|
IsModal = -not [LegacyPackageInputNative]::WindowEnabled(
|
|
$Application.MainWindowHandle)
|
|
}
|
|
}
|
|
catch {
|
|
# A transient shell window is ignored unless every stable condition
|
|
# is true in one observation.
|
|
}
|
|
}
|
|
return @($candidates)
|
|
}
|
|
|
|
function Wait-UniqueOwnedBackgroundPicker(
|
|
[Collections.Generic.HashSet[int64]]$BaselineHandles,
|
|
[Diagnostics.Process]$Application,
|
|
[string]$ExpectedApplicationPath,
|
|
[datetime]$Deadline) {
|
|
while ([datetime]::UtcNow -lt $Deadline) {
|
|
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
|
|
Assert-NoTornadoConnection $Application.Id
|
|
$candidates = @(Get-OwnedBackgroundPickerCandidates `
|
|
$BaselineHandles `
|
|
$Application)
|
|
if ($candidates.Count -gt 1) {
|
|
Throw-SafeFailure 'More than one newly opened app-owned background picker exists.'
|
|
}
|
|
if ($candidates.Count -eq 1) {
|
|
$picker = $candidates[0]
|
|
if (-not [bool]$picker.IsModal) {
|
|
Throw-SafeFailure 'The exact app-owned background picker is not modal.'
|
|
}
|
|
[LegacyPackageInputNative]::AcquireOwnedForeground(
|
|
$picker.Handle,
|
|
$Application.MainWindowHandle,
|
|
$Application.Id,
|
|
30001)
|
|
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
|
|
Assert-NoTornadoConnection $Application.Id
|
|
$revalidated = @(Get-OwnedBackgroundPickerCandidates `
|
|
$BaselineHandles `
|
|
$Application)
|
|
if ($revalidated.Count -ne 1 -or
|
|
$revalidated[0].Handle -ne $picker.Handle -or
|
|
[LegacyPackageInputNative]::RootWindow(
|
|
[LegacyPackageInputNative]::ReadForegroundWindow()) -ne
|
|
$picker.Handle) {
|
|
Throw-SafeFailure (
|
|
'The exact app-owned background picker identity changed during foreground acquisition.')
|
|
}
|
|
return $picker
|
|
}
|
|
Start-Sleep -Milliseconds 50
|
|
}
|
|
Throw-SafeFailure 'The exact app-owned background picker did not appear in time.'
|
|
}
|
|
|
|
function Wait-BackgroundPickerClosed(
|
|
[IntPtr]$PickerHandle,
|
|
[Diagnostics.Process]$Application,
|
|
[string]$ExpectedApplicationPath,
|
|
[datetime]$Deadline) {
|
|
while ([datetime]::UtcNow -lt $Deadline) {
|
|
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
|
|
Assert-NoTornadoConnection $Application.Id
|
|
if (-not [LegacyPackageInputNative]::WindowExists($PickerHandle) -and
|
|
[LegacyPackageInputNative]::WindowEnabled($Application.MainWindowHandle)) {
|
|
return
|
|
}
|
|
Start-Sleep -Milliseconds 50
|
|
}
|
|
Throw-SafeFailure (
|
|
'The exact background picker was not destroyed after the one Escape input.')
|
|
}
|
|
|
|
function Invoke-ExactBackgroundF2CancelRequest(
|
|
[object]$Request,
|
|
[Diagnostics.Process]$Application,
|
|
[string]$ExpectedApplicationPath) {
|
|
Assert-ExactJsonPropertyNames $Request @(
|
|
'schemaVersion', 'exchangeIndex', 'token', 'targetUrl', 'operation',
|
|
'f2KeyCalls', 'escapeKeyCalls', 'inputRetryCount') `
|
|
'F2 background picker request'
|
|
if ((ConvertTo-FiniteNumber $Request.exchangeIndex 'F2 exchange index') -ne 2 -or
|
|
(ConvertTo-FiniteNumber $Request.f2KeyCalls 'F2 key count') -ne 1 -or
|
|
(ConvertTo-FiniteNumber $Request.escapeKeyCalls 'F2 Escape count') -ne 1 -or
|
|
[string]::IsNullOrEmpty([string]$script:BackgroundShortcutRoot)) {
|
|
Throw-SafeFailure 'The F2 background picker request is outside the exact contract.'
|
|
}
|
|
$defaultPath = Join-Path `
|
|
([string]$script:BackgroundShortcutRoot) `
|
|
$LegacyDefaultBackgroundFileName
|
|
if ([IO.File]::Exists($defaultPath) -or [IO.Directory]::Exists($defaultPath)) {
|
|
Throw-SafeFailure 'The external default background appeared before F2 input.'
|
|
}
|
|
|
|
$baseline = Get-BackgroundPickerWindowHandles
|
|
[LegacyPackageInputNative]::SendBackgroundFunctionKey(
|
|
$Application.MainWindowHandle,
|
|
$Application.Id,
|
|
30001,
|
|
0x71,
|
|
$true)
|
|
$picker = Wait-UniqueOwnedBackgroundPicker `
|
|
$baseline `
|
|
$Application `
|
|
$ExpectedApplicationPath `
|
|
([datetime]::UtcNow.AddSeconds(10))
|
|
[LegacyPackageInputNative]::SendEscapeToForegroundWindow($picker.Handle)
|
|
Wait-BackgroundPickerClosed `
|
|
$picker.Handle `
|
|
$Application `
|
|
$ExpectedApplicationPath `
|
|
([datetime]::UtcNow.AddSeconds(10))
|
|
Start-Sleep -Milliseconds 300
|
|
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
|
|
Assert-NoTornadoConnection $Application.Id
|
|
return [ordered]@{
|
|
f2KeyCalls = 1
|
|
escapeKeyCalls = 1
|
|
pickerUniqueNew = $true
|
|
pickerOwnerLinked = [bool]$picker.OwnerLinked
|
|
pickerSameProcess = [bool]$picker.SameProcess
|
|
pickerClass = [string]$picker.ClassName
|
|
pickerTitle = [string]$picker.Title
|
|
pickerModal = [bool]$picker.IsModal
|
|
pickerClosedAfterEscape = $true
|
|
}
|
|
}
|
|
|
|
function Invoke-ExactBackgroundF3NoneRequest(
|
|
[object]$Request,
|
|
[Diagnostics.Process]$Application,
|
|
[string]$ExpectedApplicationPath) {
|
|
Assert-ExactJsonPropertyNames $Request @(
|
|
'schemaVersion', 'exchangeIndex', 'token', 'targetUrl', 'operation',
|
|
'f3KeyCalls', 'inputRetryCount') 'F3 background-none request'
|
|
if ((ConvertTo-FiniteNumber $Request.exchangeIndex 'F3 exchange index') -ne 3 -or
|
|
(ConvertTo-FiniteNumber $Request.f3KeyCalls 'F3 key count') -ne 1 -or
|
|
[string]::IsNullOrEmpty([string]$script:BackgroundShortcutRoot)) {
|
|
Throw-SafeFailure 'The F3 background-none request is outside the exact contract.'
|
|
}
|
|
$defaultPath = Join-Path `
|
|
([string]$script:BackgroundShortcutRoot) `
|
|
$LegacyDefaultBackgroundFileName
|
|
if ([IO.File]::Exists($defaultPath) -or [IO.Directory]::Exists($defaultPath)) {
|
|
Throw-SafeFailure (
|
|
'The external default background appeared; the one F3 fail-closed input was not sent.')
|
|
}
|
|
[LegacyPackageInputNative]::SendBackgroundFunctionKey(
|
|
$Application.MainWindowHandle,
|
|
$Application.Id,
|
|
30001,
|
|
0x72,
|
|
$false)
|
|
Start-Sleep -Milliseconds 300
|
|
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
|
|
Assert-NoTornadoConnection $Application.Id
|
|
return [ordered]@{ f3KeyCalls = 1 }
|
|
}
|
|
|
|
function Invoke-ExactWindowsInputRequest(
|
|
[string]$RequestPath,
|
|
[string]$AcknowledgementPath,
|
|
[int]$ExpectedExchangeIndex,
|
|
[Diagnostics.Process]$Application,
|
|
[string]$ExpectedApplicationPath) {
|
|
$script:WindowsInputRequestCalls++
|
|
if ($script:WindowsInputRequestCalls -ne $ExpectedExchangeIndex -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
|
|
$token = [string]$request.token
|
|
$schemaVersion = ConvertTo-FiniteNumber $request.schemaVersion 'Windows input schema version'
|
|
if ($request.token -isnot [string] -or
|
|
$request.targetUrl -isnot [string] -or
|
|
$request.operation -isnot [string] -or
|
|
$schemaVersion -ne 1 -or
|
|
(ConvertTo-FiniteNumber $request.exchangeIndex 'Windows input exchange index') -ne
|
|
$ExpectedExchangeIndex -or
|
|
$token -cnotmatch '\A[0-9A-F]{32}\z' -or
|
|
[string]$request.targetUrl -cne 'https://legacy-parity.mbn.local/index.html' -or
|
|
(ConvertTo-FiniteNumber $request.inputRetryCount 'Windows input retry count') -ne 0) {
|
|
Throw-SafeFailure 'The Windows input request does not match the common closed contract.'
|
|
}
|
|
$inputCounts = [ordered]@{}
|
|
$operation = [string]$request.operation
|
|
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
|
|
Assert-NoTornadoConnection $Application.Id
|
|
switch -CaseSensitive ($operation) {
|
|
'row-header-drag-home-shift-range-delete-restore' {
|
|
Invoke-ExactPlaylistWindowsInputRequest `
|
|
$request `
|
|
$Application
|
|
$inputCounts = [ordered]@{
|
|
mouseDownCalls = 4
|
|
mouseUpCalls = 4
|
|
homeKeyCalls = 1
|
|
shiftKeyDownCalls = 1
|
|
shiftKeyUpCalls = 1
|
|
deleteKeyCalls = 1
|
|
}
|
|
break
|
|
}
|
|
'named-playlist-load-double-click' {
|
|
if ($ExpectedExchangeIndex -ne 2) {
|
|
Throw-SafeFailure 'The named-playlist double-click is not the second exchange.'
|
|
}
|
|
Invoke-ExactNamedPlaylistDoubleClickRequest `
|
|
$request `
|
|
$Application
|
|
$inputCounts = [ordered]@{
|
|
mouseDownCalls = 2
|
|
mouseUpCalls = 2
|
|
doubleClickIntervalMilliseconds = 100
|
|
}
|
|
break
|
|
}
|
|
'background-f2-picker-cancel' {
|
|
if ($ExpectedExchangeIndex -ne 2) {
|
|
Throw-SafeFailure 'The F2 background picker is not the second exchange.'
|
|
}
|
|
$inputCounts = Invoke-ExactBackgroundF2CancelRequest `
|
|
$request `
|
|
$Application `
|
|
$ExpectedApplicationPath
|
|
break
|
|
}
|
|
'background-f3-none' {
|
|
if ($ExpectedExchangeIndex -ne 3) {
|
|
Throw-SafeFailure 'The F3 background-none input is not the third exchange.'
|
|
}
|
|
$inputCounts = Invoke-ExactBackgroundF3NoneRequest `
|
|
$request `
|
|
$Application `
|
|
$ExpectedApplicationPath
|
|
break
|
|
}
|
|
default {
|
|
Throw-SafeFailure 'The Windows input operation is outside the closed allowlist.'
|
|
}
|
|
}
|
|
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
|
|
Assert-NoTornadoConnection $Application.Id
|
|
|
|
$script:WindowsInputAcknowledgementCalls++
|
|
if ($script:WindowsInputAcknowledgementCalls -ne $ExpectedExchangeIndex) {
|
|
Throw-SafeFailure 'The Windows input acknowledgement budget changed.'
|
|
}
|
|
$acknowledgementEvidence = Write-WindowsInputAcknowledgement `
|
|
$AcknowledgementPath `
|
|
$ExpectedExchangeIndex `
|
|
$token `
|
|
$operation `
|
|
$inputCounts
|
|
return [pscustomobject]@{
|
|
ExchangeIndex = $ExpectedExchangeIndex
|
|
Operation = $operation
|
|
RequestPath = $RequestPath
|
|
AcknowledgementPath = $AcknowledgementPath
|
|
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,
|
|
[string]$RecoverNamedPlaylistTitle,
|
|
[int]$TimeoutSeconds,
|
|
[Diagnostics.Process]$Application,
|
|
[string]$ExpectedApplicationPath) {
|
|
$rawArguments = @(
|
|
$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)
|
|
)
|
|
if (-not [string]::IsNullOrEmpty($RecoverNamedPlaylistTitle)) {
|
|
$rawArguments += @(
|
|
'--recover-named-playlist-title',
|
|
$RecoverNamedPlaylistTitle)
|
|
}
|
|
$arguments = $rawArguments |
|
|
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
|
|
$expectedWindowsInputExchangeCount = Get-ExpectedWindowsInputExchangeCount `
|
|
$HarnessProfile `
|
|
$HarnessScope
|
|
$nextWindowsInputExchangeIndex = 1
|
|
$windowsInputEvidence = New-Object 'Collections.Generic.List[object]'
|
|
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 ($nextWindowsInputExchangeIndex -le $expectedWindowsInputExchangeCount) {
|
|
$nextRequestPath = Get-WindowsInputExchangePath `
|
|
$WindowsInputRequestPath `
|
|
$nextWindowsInputExchangeIndex
|
|
$nextAcknowledgementPath = Get-WindowsInputExchangePath `
|
|
$WindowsInputAcknowledgementPath `
|
|
$nextWindowsInputExchangeIndex
|
|
}
|
|
if ($nextWindowsInputExchangeIndex -le $expectedWindowsInputExchangeCount -and
|
|
[IO.File]::Exists($nextRequestPath)) {
|
|
$exchangeEvidence = Invoke-ExactWindowsInputRequest `
|
|
$nextRequestPath `
|
|
$nextAcknowledgementPath `
|
|
$nextWindowsInputExchangeIndex `
|
|
$Application `
|
|
$ExpectedApplicationPath
|
|
[void]$windowsInputEvidence.Add($exchangeEvidence)
|
|
$nextWindowsInputExchangeIndex++
|
|
}
|
|
Start-Sleep -Milliseconds 100
|
|
$nodeProcess.Refresh()
|
|
}
|
|
|
|
if ($nextWindowsInputExchangeIndex -ne ($expectedWindowsInputExchangeCount + 1) -or
|
|
$windowsInputEvidence.Count -ne $expectedWindowsInputExchangeCount -or
|
|
$script:WindowsInputRequestCalls -ne $expectedWindowsInputExchangeCount -or
|
|
$script:WindowsInputAcknowledgementCalls -ne $expectedWindowsInputExchangeCount) {
|
|
Throw-SafeFailure 'The required ordered one-shot Windows input exchanges were not completed.'
|
|
}
|
|
for ($exchangeIndex = 1;
|
|
$exchangeIndex -le $expectedWindowsInputExchangeCount;
|
|
$exchangeIndex++) {
|
|
if (-not [IO.File]::Exists((Get-WindowsInputExchangePath `
|
|
$WindowsInputRequestPath `
|
|
$exchangeIndex)) -or
|
|
-not [IO.File]::Exists((Get-WindowsInputExchangePath `
|
|
$WindowsInputAcknowledgementPath `
|
|
$exchangeIndex))) {
|
|
Throw-SafeFailure 'A required Windows input exchange file is missing.'
|
|
}
|
|
}
|
|
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
|
|
# Windows PowerShell 5.1 throws "Argument types do not match" when an
|
|
# array subexpression directly enumerates List[object]. Materialize the
|
|
# completed one-shot evidence before returning it to the sealing phase.
|
|
WindowsInput = @($windowsInputEvidence.ToArray())
|
|
}
|
|
}
|
|
|
|
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 {
|
|
# A packaged full-trust process can appear in the process table a
|
|
# few milliseconds before Path/StartTime become queryable. It has
|
|
# not received any test input yet, so keep the launch gate closed
|
|
# and retry only while it owns no Tornado connection.
|
|
Assert-NoTornadoConnection $process.Id
|
|
Start-Sleep -Milliseconds 50
|
|
continue
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace([string]$path)) {
|
|
Assert-NoTornadoConnection $process.Id
|
|
Start-Sleep -Milliseconds 50
|
|
continue
|
|
}
|
|
|
|
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 {
|
|
# The AppLifecycle redirector can exit within a few milliseconds.
|
|
# Pin and verify its package handle before reading slower Process
|
|
# properties so a legitimate fast redirect is not misclassified as
|
|
# an unknown process after it has already terminated.
|
|
$candidatePackageFullName =
|
|
[LegacyPackageInputNative]::ReadPackageFullName($candidate.Handle)
|
|
$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
|
|
[string]$candidatePackageFullName -cne $PackageFullName -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-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,
|
|
[string]$ExpectedRecoveryTitle,
|
|
[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 }
|
|
'ProgramPlaylistDb' { 'program-playlist-db'; break }
|
|
default { 'read-only' }
|
|
}
|
|
$expectedScope = if ($HarnessProfile -ceq 'FullUiDb') {
|
|
$HarnessScope.ToLowerInvariant()
|
|
}
|
|
else {
|
|
'all'
|
|
}
|
|
$expectedRecoveryRequested = -not [string]::IsNullOrEmpty(
|
|
$ExpectedRecoveryTitle)
|
|
$recoveryEvidence = if ($null -ne $evidence.fullUiDb) {
|
|
$evidence.fullUiDb.namedPlaylistRecovery
|
|
}
|
|
else {
|
|
$null
|
|
}
|
|
$namedPlaylistDeleteWriteCount = @(
|
|
$evidence.safety.approvedDatabaseWriteMessages |
|
|
Where-Object {
|
|
[string]$_.type -ceq 'delete-selected-named-playlist'
|
|
}).Count
|
|
$sealedRecoveryMatchCount = if ($expectedRecoveryRequested) {
|
|
@($evidence.finalSnapshot.state.namedPlaylist.definitions |
|
|
Where-Object {
|
|
[string]$_.title -ceq $ExpectedRecoveryTitle
|
|
}).Count
|
|
}
|
|
else {
|
|
0
|
|
}
|
|
$namedPlaylistRecoveryContractOk = if (-not $expectedRecoveryRequested) {
|
|
$null -eq $recoveryEvidence
|
|
}
|
|
elseif ($null -eq $recoveryEvidence -or
|
|
[string]$recoveryEvidence.targetTitle -cne $ExpectedRecoveryTitle -or
|
|
([string]$recoveryEvidence.targetTitle).Length -ne 26 -or
|
|
[string]$recoveryEvidence.targetTitle -cnotmatch
|
|
'^CDX_P_[0-9]{13}_[0-9a-f]{6}$' -or
|
|
$recoveryEvidence.freshListVerified -ne $true -or
|
|
$recoveryEvidence.exactAbsenceVerified -ne $true -or
|
|
$sealedRecoveryMatchCount -ne 0) {
|
|
$false
|
|
}
|
|
elseif ([string]$recoveryEvidence.result -ceq 'alreadyAbsent') {
|
|
[int]$recoveryEvidence.exactMatchCount -eq 0 -and
|
|
[int]$recoveryEvidence.writeCount -eq 0 -and
|
|
[string]::IsNullOrEmpty(
|
|
[string]$recoveryEvidence.mutationOutcome) -and
|
|
$namedPlaylistDeleteWriteCount -eq 1
|
|
}
|
|
elseif ([string]$recoveryEvidence.result -ceq 'deleted') {
|
|
[int]$recoveryEvidence.exactMatchCount -eq 1 -and
|
|
[int]$recoveryEvidence.writeCount -eq 1 -and
|
|
@('committedFresh', 'committedOptimistic') -ccontains
|
|
[string]$recoveryEvidence.mutationOutcome -and
|
|
$namedPlaylistDeleteWriteCount -eq 2
|
|
}
|
|
else {
|
|
$false
|
|
}
|
|
$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
|
|
}
|
|
elseif ($HarnessProfile -ceq 'ProgramPlaylistDb') {
|
|
$evidence.safety.databaseWriteIntentIssued -eq $true -and
|
|
@($evidence.safety.approvedDatabaseWriteMessages).Count -eq 3 -and
|
|
@($evidence.safety.approvedDatabaseWriteMessages | Where-Object {
|
|
[string]$_.type -ceq 'create-named-playlist'
|
|
}).Count -eq 1 -and
|
|
@($evidence.safety.approvedDatabaseWriteMessages | Where-Object {
|
|
[string]$_.type -ceq 'save-current-named-playlist-to'
|
|
}).Count -eq 1 -and
|
|
@($evidence.safety.approvedDatabaseWriteMessages | Where-Object {
|
|
[string]$_.type -ceq 'delete-selected-named-playlist'
|
|
}).Count -eq 1 -and
|
|
$null -eq $evidence.fullUiDb -and
|
|
[string]$evidence.programPlaylistDb.result -ceq 'PASS' -and
|
|
[string]$evidence.programPlaylistDb.title -ceq
|
|
[string]$evidence.fixture.namedPlaylistTitle -and
|
|
([string]$evidence.programPlaylistDb.title).Length -eq 26 -and
|
|
[string]$evidence.programPlaylistDb.title -cmatch
|
|
'^CDX_P_[0-9]{13}_[0-9a-f]{6}$' -and
|
|
-not [string]::IsNullOrWhiteSpace(
|
|
[string]$evidence.programPlaylistDb.definitionId) -and
|
|
-not [string]::IsNullOrWhiteSpace(
|
|
[string]$evidence.programPlaylistDb.saveDefinitionId) -and
|
|
@($evidence.safety.approvedDatabaseWriteMessages | Where-Object {
|
|
[string]$_.type -ceq 'create-named-playlist' -and
|
|
[string]$_.title -ceq
|
|
[string]$evidence.programPlaylistDb.title -and
|
|
-not [string]::IsNullOrWhiteSpace([string]$_.requestId)
|
|
}).Count -eq 1 -and
|
|
@($evidence.safety.approvedDatabaseWriteMessages | Where-Object {
|
|
[string]$_.type -ceq 'save-current-named-playlist-to' -and
|
|
[string]$_.definitionId -ceq
|
|
[string]$evidence.programPlaylistDb.saveDefinitionId
|
|
}).Count -eq 1 -and
|
|
@($evidence.safety.approvedDatabaseWriteMessages | Where-Object {
|
|
[string]$_.type -ceq 'delete-selected-named-playlist' -and
|
|
-not [string]::IsNullOrWhiteSpace([string]$_.requestId)
|
|
}).Count -eq 1 -and
|
|
[int]$evidence.programPlaylistDb.rowCount -eq 3 -and
|
|
[int]$evidence.programPlaylistDb.createWriteCount -eq 1 -and
|
|
[int]$evidence.programPlaylistDb.saveWriteCount -eq 1 -and
|
|
[int]$evidence.programPlaylistDb.deleteWriteCount -eq 1 -and
|
|
$evidence.programPlaylistDb.freshDefinitionReadbackVerified -eq $true -and
|
|
$evidence.programPlaylistDb.exactAbsenceVerified -eq $true -and
|
|
[string]$evidence.programPlaylistDb.finalPhase -ceq 'idle' -and
|
|
@($evidence.finalSnapshot.state.namedPlaylist.definitions |
|
|
Where-Object {
|
|
[string]$_.title -ceq
|
|
[string]$evidence.programPlaylistDb.title
|
|
}).Count -eq 0
|
|
}
|
|
else {
|
|
$evidence.safety.databaseWriteIntentIssued -eq $false -and
|
|
@($evidence.safety.approvedDatabaseWriteMessages).Count -eq 0 -and
|
|
$null -eq $evidence.fullUiDb -and
|
|
$null -eq $evidence.programPlaylistDb
|
|
}
|
|
$playoutIntentContractOk = if ($HarnessProfile -ceq 'DryRunPlayout') {
|
|
$dryRunProgramContractOk =
|
|
$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
|
|
-not [string]::IsNullOrWhiteSpace(
|
|
[string]$evidence.dryRunPlayout.skippedEntryId) -and
|
|
[string]$evidence.dryRunPlayout.stagedEntryId -ceq
|
|
[string]$evidence.dryRunPlayout.skippedEntryId -and
|
|
[string]$evidence.dryRunPlayout.selectedEntryId -cne
|
|
[string]$evidence.dryRunPlayout.nextEntryId -and
|
|
[string]$evidence.dryRunPlayout.stagedEntryId -cne
|
|
[string]$evidence.dryRunPlayout.selectedEntryId -and
|
|
[string]$evidence.dryRunPlayout.skippedEntryId -cne
|
|
[string]$evidence.dryRunPlayout.nextEntryId -and
|
|
-not [string]::IsNullOrWhiteSpace(
|
|
[string]$evidence.dryRunPlayout.tailAppendedEntryId) -and
|
|
[string]$evidence.dryRunPlayout.tailAppendedEntryId -cne
|
|
[string]$evidence.dryRunPlayout.selectedEntryId -and
|
|
[string]$evidence.dryRunPlayout.tailAppendedEntryId -cne
|
|
[string]$evidence.dryRunPlayout.nextEntryId -and
|
|
[int]$evidence.dryRunPlayout.playlistCountAfterDoubleClick -eq
|
|
([int]$evidence.dryRunPlayout.playlistCountBeforeSelection + 1) -and
|
|
[int]$evidence.dryRunPlayout.programCutSelection -ge 0 -and
|
|
[int]$evidence.dryRunPlayout.programDragParity.cutPhysicalIndex -ge 0 -and
|
|
-not [string]::IsNullOrWhiteSpace(
|
|
[string]$evidence.dryRunPlayout.programDragParity.appendedEntryId) -and
|
|
[string]$evidence.dryRunPlayout.programDragParity.appendedEntryId -cne
|
|
[string]$evidence.dryRunPlayout.tailAppendedEntryId -and
|
|
[string]$evidence.dryRunPlayout.programDragParity.appendedEntryId -cne
|
|
[string]$evidence.dryRunPlayout.selectedEntryId -and
|
|
[string]$evidence.dryRunPlayout.programDragParity.appendedEntryId -cne
|
|
[string]$evidence.dryRunPlayout.nextEntryId -and
|
|
[string]$evidence.dryRunPlayout.programDragParity.appendedEntryId -cne
|
|
[string]$evidence.dryRunPlayout.skippedEntryId -and
|
|
[int]$evidence.dryRunPlayout.programDragParity.appendIntentCount -eq 1 -and
|
|
[int]$evidence.dryRunPlayout.programDragParity.cleanupDeleteIntentCount -eq 1 -and
|
|
$evidence.dryRunPlayout.programDragParity.onAirEntryPreserved -eq $true -and
|
|
[string]$evidence.dryRunPlayout.programEnableParity.currentEntryId -ceq
|
|
[string]$evidence.dryRunPlayout.selectedEntryId -and
|
|
[string]$evidence.dryRunPlayout.programEnableParity.skippedEntryId -ceq
|
|
[string]$evidence.dryRunPlayout.skippedEntryId -and
|
|
[string]$evidence.dryRunPlayout.programEnableParity.nextEntryId -ceq
|
|
[string]$evidence.dryRunPlayout.nextEntryId -and
|
|
[int]$evidence.dryRunPlayout.programEnableParity.futureRowCount -ge 3 -and
|
|
$evidence.dryRunPlayout.programEnableParity.mouseCheckboxDisabled -eq $true -and
|
|
[int]$evidence.dryRunPlayout.programEnableParity.setAllIntentCount -eq 2 -and
|
|
$evidence.dryRunPlayout.programEnableParity.setAllAppliesEveryRow -eq $true -and
|
|
[int]$evidence.dryRunPlayout.programEnableParity.toggleActiveIntentCount -eq 1 -and
|
|
$evidence.dryRunPlayout.programEnableParity.endOfPlaylistObserved -eq $true -and
|
|
$evidence.dryRunPlayout.programEnableParity.playlistNextRestored -eq $true
|
|
$dryRunBackgroundContractOk =
|
|
[int]$evidence.dryRunPlayout.backgroundShortcutParity.f2IntentCount -eq 1 -and
|
|
[int]$evidence.dryRunPlayout.backgroundShortcutParity.f2KeyCalls -eq 1 -and
|
|
[int]$evidence.dryRunPlayout.backgroundShortcutParity.escapeKeyCalls -eq 1 -and
|
|
$evidence.dryRunPlayout.backgroundShortcutParity.pickerOwnedAndCancelled -eq $true -and
|
|
[int]$evidence.dryRunPlayout.backgroundShortcutParity.f3IntentCount -eq 1 -and
|
|
[int]$evidence.dryRunPlayout.backgroundShortcutParity.f3KeyCalls -eq 1 -and
|
|
$evidence.dryRunPlayout.backgroundShortcutParity.defaultAssetAbsentFailClosed -eq $true -and
|
|
$evidence.dryRunPlayout.backgroundShortcutParity.finalBackgroundEnabled -eq $false -and
|
|
$evidence.dryRunPlayout.backgroundShortcutParity.finalBackgroundNoneChecked -eq $true -and
|
|
[string]$evidence.dryRunPlayout.backgroundShortcutParity.finalPhase -ceq 'idle' -and
|
|
$evidence.finalSnapshot.state.playout.backgroundEnabled -eq $false -and
|
|
[string]$evidence.finalSnapshot.state.playout.backgroundFileName -ceq '' -and
|
|
$evidence.finalSnapshot.dom.backgroundNoneChecked -eq $true -and
|
|
[string]$evidence.finalSnapshot.dom.backgroundNameValue -ceq
|
|
$BackgroundNoneLabel
|
|
$dryRunKeyContractOk =
|
|
[int]$evidence.dryRunPlayout.shortcutParity.f8IntentCount -eq 1 -and
|
|
[int]$evidence.dryRunPlayout.shortcutParity.escapeIntentCount -eq 1 -and
|
|
[string]$evidence.dryRunPlayout.shortcutParity.finalPhase -ceq 'idle'
|
|
$dryRunMessageContractOk =
|
|
@($evidence.safety.observedOutboundMessageTypes | Where-Object {
|
|
[string]$_ -ceq 'take-in'
|
|
}).Count -eq 1 -and
|
|
@($evidence.safety.observedOutboundMessageTypes | Where-Object {
|
|
[string]$_ -ceq 'next-playout'
|
|
}).Count -eq 1 -and
|
|
@($evidence.safety.observedOutboundMessageTypes | Where-Object {
|
|
[string]$_ -ceq 'take-out'
|
|
}).Count -eq 1 -and
|
|
@($evidence.safety.observedOutboundMessageTypes | Where-Object {
|
|
[string]$_ -ceq 'set-all-playlist-enabled'
|
|
}).Count -eq 2 -and
|
|
@($evidence.safety.observedOutboundMessageTypes | Where-Object {
|
|
[string]$_ -ceq 'toggle-active-playlist-enabled'
|
|
}).Count -eq 1 -and
|
|
@($evidence.safety.observedOutboundMessageTypes | Where-Object {
|
|
[string]$_ -ceq 'choose-background'
|
|
}).Count -eq 1 -and
|
|
@($evidence.safety.observedOutboundMessageTypes | Where-Object {
|
|
[string]$_ -ceq 'toggle-background'
|
|
}).Count -eq 1
|
|
$dryRunShortcutContractOk =
|
|
$dryRunBackgroundContractOk -and
|
|
$dryRunKeyContractOk -and
|
|
$dryRunMessageContractOk
|
|
$dryRunProgramContractOk -and $dryRunShortcutContractOk
|
|
}
|
|
elseif ($HarnessProfile -ceq 'ProgramPlaylistDb') {
|
|
$evidence.safety.playoutIntentIssued -eq $true -and
|
|
$null -eq $evidence.dryRunPlayout -and
|
|
[string]$evidence.programPlaylistDb.result -ceq 'PASS' -and
|
|
-not [string]::IsNullOrWhiteSpace(
|
|
[string]$evidence.programPlaylistDb.currentEntryId) -and
|
|
@($evidence.safety.observedOutboundMessageTypes | Where-Object {
|
|
[string]$_ -ceq 'take-in'
|
|
}).Count -eq 1 -and
|
|
@($evidence.safety.observedOutboundMessageTypes | Where-Object {
|
|
[string]$_ -ceq 'take-out'
|
|
}).Count -eq 1 -and
|
|
@($evidence.safety.observedOutboundMessageTypes | Where-Object {
|
|
[string]$_ -ceq 'next-playout'
|
|
}).Count -eq 0 -and
|
|
@($evidence.safety.observedOutboundMessageTypes | Where-Object {
|
|
[string]$_ -ceq 'prepare-playout'
|
|
}).Count -eq 0
|
|
}
|
|
else {
|
|
$evidence.safety.playoutIntentIssued -eq $false -and
|
|
$null -eq $evidence.dryRunPlayout -and
|
|
$null -eq $evidence.programPlaylistDb
|
|
}
|
|
$screenshotPathOk =
|
|
-not [string]::IsNullOrWhiteSpace([string]$evidence.screenshot.path) -and
|
|
(Test-PathEqual ([string]$evidence.screenshot.path) $ScreenshotPath)
|
|
$safetyChecks = [ordered]@{
|
|
schema = [int]$evidence.schemaVersion -eq 1
|
|
profile = [string]$evidence.profile -ceq $expectedProfile
|
|
scope = [string]$evidence.scope -ceq $expectedScope
|
|
result = $safeResult
|
|
warnings = -not ($result -ceq 'PASS_WITH_WARNING' -and $warnings.Count -eq 0)
|
|
error = $null -eq $evidence.error
|
|
expectedUrl = [string]$evidence.target.expectedUrl -ceq
|
|
'https://legacy-parity.mbn.local/index.html'
|
|
port = [int]$evidence.target.port -eq $ExpectedPort
|
|
timeout = [int]$evidence.target.timeoutMilliseconds -eq
|
|
($ExpectedTimeoutSeconds * 1000)
|
|
actualUrl = [string]$evidence.target.url -ceq
|
|
'https://legacy-parity.mbn.local/index.html'
|
|
mode = [string]$evidence.safety.requiredMode -ceq 'dryRun'
|
|
phase = [string]$evidence.safety.requiredPhase -ceq 'idle'
|
|
appClose = $evidence.safety.appCloseRequested -eq $false
|
|
playout = [bool]$playoutIntentContractOk
|
|
databaseWrite = [bool]$databaseWriteContractOk
|
|
namedPlaylistRecovery = [bool]$namedPlaylistRecoveryContractOk
|
|
externalCancellation = $evidence.safety.externalCancellationRequested -eq $false
|
|
blockedOutbound = @($evidence.safety.blockedOutboundMessages).Count -eq 0
|
|
stateViolations = @($evidence.safety.stateViolations).Count -eq 0
|
|
screenshotPath = $screenshotPathOk
|
|
}
|
|
$failedSafetyChecks = @($safetyChecks.GetEnumerator() | Where-Object {
|
|
$_.Value -ne $true
|
|
} | ForEach-Object { $_.Key })
|
|
if ($failedSafetyChecks.Count -ne 0) {
|
|
$playoutDiagnostic = if ($failedSafetyChecks -ccontains 'playout') {
|
|
$playoutValueType = if ($null -eq $playoutIntentContractOk) {
|
|
'null'
|
|
}
|
|
else {
|
|
$playoutIntentContractOk.GetType().FullName
|
|
}
|
|
" Playout value type: $playoutValueType; value: " +
|
|
"'$([string]$playoutIntentContractOk)'." +
|
|
$(if ($HarnessProfile -ceq 'DryRunPlayout') {
|
|
" Program contract: '$([string]$dryRunProgramContractOk)';" +
|
|
" background contract: '$([string]$dryRunBackgroundContractOk)';" +
|
|
" key contract: '$([string]$dryRunKeyContractOk)';" +
|
|
" message contract: '$([string]$dryRunMessageContractOk)'."
|
|
}
|
|
else {
|
|
''
|
|
})
|
|
}
|
|
else {
|
|
''
|
|
}
|
|
Throw-SafeFailure (
|
|
'The real input evidence does not satisfy the DryRun safety contract. ' +
|
|
'Failed checks: ' + ($failedSafetyChecks -join ', ') + '.' +
|
|
$playoutDiagnostic)
|
|
}
|
|
|
|
if ($HarnessProfile -ceq 'ReadOnly' -or
|
|
$HarnessProfile -ceq 'DryRunPlayout' -or
|
|
$HarnessProfile -ceq 'ProgramPlaylistDb') {
|
|
$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', 'move-selected-playlist-rows',
|
|
'reorder-playlist-rows',
|
|
'select-playlist-boundary', 'delete-selected-playlist-rows',
|
|
'set-playlist-enabled',
|
|
'refresh-named-playlists')
|
|
if ($HarnessProfile -ceq 'DryRunPlayout') {
|
|
$allowedOutbound += @(
|
|
'choose-background', 'toggle-background',
|
|
'take-in', 'next-playout', 'take-out',
|
|
'set-all-playlist-enabled',
|
|
'toggle-active-playlist-enabled')
|
|
}
|
|
elseif ($HarnessProfile -ceq 'ProgramPlaylistDb') {
|
|
$allowedOutbound += @(
|
|
'select-named-playlist',
|
|
'create-named-playlist',
|
|
'save-current-named-playlist-to',
|
|
'delete-selected-named-playlist',
|
|
'dismiss-dialog',
|
|
'take-in', '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.'
|
|
}
|
|
|
|
$expectedExchangeCount = Get-ExpectedWindowsInputExchangeCount `
|
|
$HarnessProfile `
|
|
$HarnessScope
|
|
$sealedExchanges = @($evidence.windowsInput.exchanges)
|
|
if ([bool]$evidence.windowsInput.required -ne $true -or
|
|
[string]$evidence.windowsInput.result -cne 'PASS' -or
|
|
[int]$evidence.windowsInput.expectedExchangeCount -ne $expectedExchangeCount -or
|
|
[int]$evidence.windowsInput.completedExchangeCount -ne $expectedExchangeCount -or
|
|
$sealedExchanges.Count -ne $expectedExchangeCount -or
|
|
-not (Test-PathEqual ([string]$evidence.windowsInput.requestPath) `
|
|
$WindowsInputRequestPath) -or
|
|
-not (Test-PathEqual ([string]$evidence.windowsInput.acknowledgementPath) `
|
|
$WindowsInputAcknowledgementPath)) {
|
|
Throw-SafeFailure 'The sealed Windows input exchange count/path contract is not exact.'
|
|
}
|
|
$request = $null
|
|
for ($exchangeIndex = 1;
|
|
$exchangeIndex -le $expectedExchangeCount;
|
|
$exchangeIndex++) {
|
|
$requestPath = Get-WindowsInputExchangePath `
|
|
$WindowsInputRequestPath `
|
|
$exchangeIndex
|
|
$acknowledgementPath = Get-WindowsInputExchangePath `
|
|
$WindowsInputAcknowledgementPath `
|
|
$exchangeIndex
|
|
$requestEvidence = Read-SmallJsonEvidenceFile `
|
|
$requestPath `
|
|
"Final Windows input request $exchangeIndex"
|
|
$acknowledgementEvidence = Read-SmallJsonEvidenceFile `
|
|
$acknowledgementPath `
|
|
"Final Windows input acknowledgement $exchangeIndex"
|
|
$sealedExchange = $sealedExchanges[$exchangeIndex - 1]
|
|
if ([int]$sealedExchange.exchangeIndex -ne $exchangeIndex -or
|
|
[string]$sealedExchange.operation -cne
|
|
[string]$requestEvidence.Value.operation -or
|
|
[string]$sealedExchange.result -cne 'PASS' -or
|
|
-not (Test-PathEqual ([string]$sealedExchange.requestPath) $requestPath) -or
|
|
-not (Test-PathEqual `
|
|
([string]$sealedExchange.acknowledgementPath) `
|
|
$acknowledgementPath) -or
|
|
[string]$sealedExchange.requestSha256 -cne $requestEvidence.Sha256 -or
|
|
[string]$sealedExchange.acknowledgementSha256 -cne
|
|
$acknowledgementEvidence.Sha256) {
|
|
Throw-SafeFailure 'A sealed Windows input exchange does not match its files.'
|
|
}
|
|
if ($exchangeIndex -eq 1) {
|
|
$request = $requestEvidence.Value
|
|
if ([string]$request.operation -cne
|
|
'row-header-drag-home-shift-range-delete-restore' -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
|
|
-not (Test-ExactStringSequence `
|
|
@($evidence.windowsInput.shiftRangeDeletedRowIds) `
|
|
@($request.expectedRangeSelectedRowIds)) -or
|
|
[string]$evidence.windowsInput.activeRowIdAfterHome -cne
|
|
[string]$request.expectedActiveRowIdAfterHome -or
|
|
[string]$evidence.windowsInput.finalActiveRowId -cne
|
|
[string]$request.expectedFinalActiveRowId) {
|
|
Throw-SafeFailure 'The sealed playlist Windows input evidence is not exact.'
|
|
}
|
|
}
|
|
elseif ($HarnessProfile -ceq 'DryRunPlayout' -and
|
|
$exchangeIndex -eq 2) {
|
|
if ([string]$requestEvidence.Value.operation -cne
|
|
'background-f2-picker-cancel' -or
|
|
[int]$requestEvidence.Value.f2KeyCalls -ne 1 -or
|
|
[int]$requestEvidence.Value.escapeKeyCalls -ne 1 -or
|
|
[int]$acknowledgementEvidence.Value.f2KeyCalls -ne 1 -or
|
|
[int]$acknowledgementEvidence.Value.escapeKeyCalls -ne 1 -or
|
|
$acknowledgementEvidence.Value.pickerUniqueNew -ne $true -or
|
|
$acknowledgementEvidence.Value.pickerOwnerLinked -ne $true -or
|
|
$acknowledgementEvidence.Value.pickerSameProcess -ne $true -or
|
|
[string]$acknowledgementEvidence.Value.pickerClass -cne '#32770' -or
|
|
[string]$acknowledgementEvidence.Value.pickerTitle -cne
|
|
$BackgroundPickerTitle -or
|
|
$acknowledgementEvidence.Value.pickerModal -ne $true -or
|
|
$acknowledgementEvidence.Value.pickerClosedAfterEscape -ne $true -or
|
|
$evidence.dryRunPlayout.backgroundShortcutParity.pickerOwnedAndCancelled -ne
|
|
$true) {
|
|
Throw-SafeFailure 'The sealed physical F2 picker cancellation evidence is not exact.'
|
|
}
|
|
}
|
|
elseif ($HarnessProfile -ceq 'DryRunPlayout' -and
|
|
$exchangeIndex -eq 3) {
|
|
if ([string]$requestEvidence.Value.operation -cne
|
|
'background-f3-none' -or
|
|
[int]$requestEvidence.Value.f3KeyCalls -ne 1 -or
|
|
[int]$acknowledgementEvidence.Value.f3KeyCalls -ne 1 -or
|
|
$evidence.dryRunPlayout.backgroundShortcutParity.defaultAssetAbsentFailClosed -ne
|
|
$true -or
|
|
$evidence.dryRunPlayout.backgroundShortcutParity.finalBackgroundEnabled -ne
|
|
$false -or
|
|
$evidence.dryRunPlayout.backgroundShortcutParity.finalBackgroundNoneChecked -ne
|
|
$true) {
|
|
Throw-SafeFailure 'The sealed physical F3 background-none evidence is not exact.'
|
|
}
|
|
}
|
|
elseif ([string]$requestEvidence.Value.operation -cne
|
|
'named-playlist-load-double-click' -or
|
|
[string]$requestEvidence.Value.definitionTitle -cne
|
|
[string]$evidence.fixture.namedPlaylistTitle -or
|
|
@($requestEvidence.Value.expectedPlaylistContent).Count -ne 3 -or
|
|
@($evidence.fullUiDb.databaseChecks | Where-Object {
|
|
[string]$_.name -ceq
|
|
'PList physical double-click fresh readback' -and
|
|
$_.details.contentRestored -eq $true -and
|
|
$_.details.startedUnselected -eq $true -and
|
|
[int]$_.details.selectionIntentCount -eq 1 -and
|
|
[int]$_.details.loadIntentCount -eq 1 -and
|
|
[string]$_.details.input -ceq 'Windows SendInput double-click'
|
|
}).Count -ne 1) {
|
|
Throw-SafeFailure 'The sealed PList physical double-click evidence is not exact.'
|
|
}
|
|
}
|
|
|
|
$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]$_ })
|
|
$expectedFinalOrder = @($expectedAfterOrder)
|
|
if ($HarnessProfile -ceq 'DryRunPlayout') {
|
|
# DryRun deliberately proves that a PROGRAM cut double-click appends one
|
|
# safe tail row. Preserve the sealed Windows drag order as the prefix and
|
|
# require that exact new opaque row id at the tail after TAKE OUT.
|
|
$expectedFinalOrder += @(
|
|
[string]$evidence.dryRunPlayout.tailAppendedEntryId)
|
|
}
|
|
$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' -or
|
|
$HarnessProfile -ceq 'ProgramPlaylistDb') -and (
|
|
-not (Test-ExactStringSequence $statePlaylistIds $expectedFinalOrder) -or
|
|
-not (Test-ExactStringSequence $domPlaylistIds $expectedFinalOrder) -or
|
|
-not (Test-ExactStringSequence $selectedStateIds @([string]$request.sourceRowId)) -or
|
|
-not (Test-ExactStringSequence $selectedDomIds @([string]$request.sourceRowId)) -or
|
|
-not (Test-ExactStringSequence `
|
|
$activeStateIds `
|
|
@([string]$request.expectedFinalActiveRowId)) -or
|
|
-not (Test-ExactStringSequence `
|
|
$activeDomIds `
|
|
@([string]$request.expectedFinalActiveRowId)) -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.'
|
|
}
|
|
}
|
|
|
|
if ($DefinitionsOnly) {
|
|
return
|
|
}
|
|
|
|
$recoveryRequested = -not [string]::IsNullOrEmpty($RecoverNamedPlaylistTitle)
|
|
if ($recoveryRequested -and
|
|
($RecoverNamedPlaylistTitle.Length -ne 26 -or
|
|
$RecoverNamedPlaylistTitle -cnotmatch '^CDX_P_[0-9]{13}_[0-9a-f]{6}$')) {
|
|
Throw-SafeFailure 'RecoverNamedPlaylistTitle must be one exact generated CDX_P fixture title.'
|
|
}
|
|
if ($recoveryRequested -and
|
|
($Profile -cne 'FullUiDb' -or
|
|
($FullUiDbScope -cne 'PList' -and $FullUiDbScope -cne 'All'))) {
|
|
Throw-SafeFailure 'Named-playlist recovery is allowed only for FullUiDb PList or All.'
|
|
}
|
|
|
|
$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')
|
|
$ExpectedWindowsInputExchangeCount = Get-ExpectedWindowsInputExchangeCount `
|
|
$Profile `
|
|
$FullUiDbScope
|
|
$WindowsInputRequestPaths = @(1..$ExpectedWindowsInputExchangeCount | ForEach-Object {
|
|
Get-WindowsInputExchangePath $WindowsInputRequest $_
|
|
})
|
|
$WindowsInputAcknowledgementPaths = @(1..$ExpectedWindowsInputExchangeCount |
|
|
ForEach-Object {
|
|
Get-WindowsInputExchangePath $WindowsInputAcknowledgement $_
|
|
})
|
|
$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) +
|
|
$WindowsInputRequestPaths + $WindowsInputAcknowledgementPaths
|
|
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'
|
|
for ($exchangeIndex = 1;
|
|
$exchangeIndex -le $ExpectedWindowsInputExchangeCount;
|
|
$exchangeIndex++) {
|
|
Assert-OutputPathAvailable $WindowsInputRequestPaths[$exchangeIndex - 1] `
|
|
"Windows input request $exchangeIndex"
|
|
Assert-OutputPathAvailable $WindowsInputAcknowledgementPaths[$exchangeIndex - 1] `
|
|
"Windows input acknowledgement $exchangeIndex"
|
|
}
|
|
Assert-LocalConfigurationIsDryRun
|
|
Assert-NoUnsafeEnvironment
|
|
$registration = Get-ExactDevelopmentPackage
|
|
Assert-NoExistingApplication
|
|
Assert-PortIsFree $Port
|
|
Initialize-BackgroundShortcutFixture $registration.InstallLocation
|
|
|
|
$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'
|
|
}
|
|
elseif ($Profile -ceq 'ProgramPlaylistDb') {
|
|
'program-playlist-db'
|
|
}
|
|
else {
|
|
'read-only'
|
|
}) `
|
|
$(if ($Profile -ceq 'FullUiDb') { $FullUiDbScope.ToLowerInvariant() } else { 'all' }) `
|
|
$RecoverNamedPlaylistTitle `
|
|
$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
|
|
(Get-Item -LiteralPath $Output).Length -le 0 -or
|
|
(Get-Item -LiteralPath $Screenshot).Length -le 0 -or
|
|
@($WindowsInputRequestPaths + $WindowsInputAcknowledgementPaths |
|
|
Where-Object {
|
|
-not [IO.File]::Exists($_) -or
|
|
(Get-Item -LiteralPath $_).Length -le 0
|
|
}).Count -ne 0) {
|
|
Throw-SafeFailure 'The real input evidence files are missing or empty.'
|
|
}
|
|
$harnessEvidence = Read-AndAssertHarnessEvidence `
|
|
$Output `
|
|
$Screenshot `
|
|
$WindowsInputRequest `
|
|
$WindowsInputAcknowledgement `
|
|
$Profile `
|
|
$FullUiDbScope `
|
|
$RecoverNamedPlaylistTitle `
|
|
$Port `
|
|
$HarnessTimeoutSeconds
|
|
$wrapperWindowsInput = @($harnessRun.WindowsInput)
|
|
$sealedWindowsInput = @($harnessEvidence.windowsInput.exchanges)
|
|
if ($wrapperWindowsInput.Count -ne $ExpectedWindowsInputExchangeCount -or
|
|
$sealedWindowsInput.Count -ne $ExpectedWindowsInputExchangeCount) {
|
|
Throw-SafeFailure 'The wrapper and harness Windows input exchange counts differ.'
|
|
}
|
|
for ($exchangeIndex = 1;
|
|
$exchangeIndex -le $ExpectedWindowsInputExchangeCount;
|
|
$exchangeIndex++) {
|
|
$wrapperExchange = $wrapperWindowsInput[$exchangeIndex - 1]
|
|
$sealedExchange = $sealedWindowsInput[$exchangeIndex - 1]
|
|
if ([int]$wrapperExchange.ExchangeIndex -ne $exchangeIndex -or
|
|
[int]$sealedExchange.exchangeIndex -ne $exchangeIndex -or
|
|
[string]$wrapperExchange.Operation -cne [string]$sealedExchange.operation -or
|
|
[string]$wrapperExchange.RequestSha256 -cne
|
|
[string]$sealedExchange.requestSha256 -or
|
|
[string]$wrapperExchange.AcknowledgementSha256 -cne
|
|
[string]$sealedExchange.acknowledgementSha256) {
|
|
Throw-SafeFailure 'The wrapper and harness Windows input exchange hashes differ.'
|
|
}
|
|
}
|
|
|
|
Assert-ExactApplicationStillRunning $script:ApplicationProcess $registration.Executable
|
|
Assert-NoTornadoConnection $script:ApplicationProcess.Id
|
|
|
|
if ($Profile -cne 'FullUiDb' -and $Profile -cne 'ProgramPlaylistDb') {
|
|
$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.'
|
|
}
|
|
Remove-BackgroundShortcutFixture
|
|
|
|
$script:Phase = 'complete'
|
|
[pscustomobject]@{
|
|
result = [string]$harnessEvidence.result
|
|
packageFullName = $PackageFullName
|
|
packageMode = $PackageModeLabel
|
|
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]$wrapperWindowsInput[0].RequestSha256
|
|
windowsInputAcknowledgement = $WindowsInputAcknowledgement
|
|
windowsInputAcknowledgementSha256 = `
|
|
[string]$wrapperWindowsInput[0].AcknowledgementSha256
|
|
windowsInputExchanges = @($wrapperWindowsInput)
|
|
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.')
|
|
}
|
|
|
|
if ($script:BackgroundShortcutRootCreated) {
|
|
Remove-BackgroundShortcutFixture
|
|
}
|
|
|
|
throw
|
|
}
|