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