feat: harden isolated Tornado test workflow
This commit is contained in:
491
tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs
Normal file
491
tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs
Normal file
@@ -0,0 +1,491 @@
|
||||
using System.Collections;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.PlayoutSmoke;
|
||||
|
||||
internal sealed record IsolatedTestCommandPlan(
|
||||
SmokeCommandKind Kind,
|
||||
PlayoutOptions Options,
|
||||
PlayoutCue? PrepareCue,
|
||||
PlayoutCue? NextCue,
|
||||
TimeSpan ObservationDuration);
|
||||
|
||||
internal sealed class IsolatedTestPreflightException : Exception
|
||||
{
|
||||
public IsolatedTestPreflightException(string errorCode)
|
||||
: base("The isolated Test command was rejected before engine creation.")
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public string ErrorCode { get; }
|
||||
}
|
||||
|
||||
internal static class IsolatedTestCommandPreflight
|
||||
{
|
||||
internal const string EnvironmentVariablePrefix = "MBN_STOCK_PLAYOUT_";
|
||||
|
||||
public static IsolatedTestCommandPlan Create(SmokeCommandInvocation invocation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(invocation);
|
||||
if (invocation.Kind is not (
|
||||
SmokeCommandKind.TestPlan or
|
||||
SmokeCommandKind.TestConnect or
|
||||
SmokeCommandKind.TestSequence))
|
||||
{
|
||||
throw Rejected("invalid-test-command");
|
||||
}
|
||||
|
||||
if (HasPlayoutEnvironmentOverride(Environment.GetEnvironmentVariables()))
|
||||
{
|
||||
throw Rejected("environment-override-present");
|
||||
}
|
||||
|
||||
var configPath = ValidateConfigPath(invocation.ConfigPath);
|
||||
PlayoutOptions options;
|
||||
try
|
||||
{
|
||||
options = PlayoutOptionsLoader.LoadFileOnly(configPath);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is PlayoutConfigurationException or
|
||||
ArgumentException or
|
||||
IOException or
|
||||
UnauthorizedAccessException)
|
||||
{
|
||||
throw Rejected("configuration-rejected");
|
||||
}
|
||||
|
||||
if (options.Mode != PlayoutMode.Test || options.TrustedLiveOutputEnabled)
|
||||
{
|
||||
throw Rejected("test-mode-required");
|
||||
}
|
||||
|
||||
// This one-shot diagnostic never reconnects or replays a command. The runtime
|
||||
// still checks process eligibility before every operation.
|
||||
options.ReconnectEnabled = false;
|
||||
options.MaximumReconnectAttempts = 0;
|
||||
|
||||
PlayoutCue? prepareCue = null;
|
||||
PlayoutCue? nextCue = null;
|
||||
if (invocation.Kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence)
|
||||
{
|
||||
if (invocation.PrepareSceneCode is null || invocation.NextSceneCode is null ||
|
||||
invocation.ObservationMilliseconds is not (>= 1_000 and <= 30_000) ||
|
||||
!SmokeCommandLine.IsSafeSceneCode(invocation.PrepareSceneCode) ||
|
||||
!SmokeCommandLine.IsSafeSceneCode(invocation.NextSceneCode))
|
||||
{
|
||||
throw Rejected("scene-code-rejected");
|
||||
}
|
||||
|
||||
prepareCue = CreateCue(invocation.PrepareSceneCode);
|
||||
nextCue = CreateCue(invocation.NextSceneCode);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PlayoutEngineFactory.ValidateIsolatedTestCommand(
|
||||
options,
|
||||
prepareCue is null ? [] : [prepareCue, nextCue!]);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is PlayoutConfigurationException or ArgumentException)
|
||||
{
|
||||
throw Rejected("safety-gate-rejected");
|
||||
}
|
||||
|
||||
return new IsolatedTestCommandPlan(
|
||||
invocation.Kind,
|
||||
options,
|
||||
prepareCue,
|
||||
nextCue,
|
||||
TimeSpan.FromMilliseconds(invocation.ObservationMilliseconds ?? 0));
|
||||
}
|
||||
|
||||
internal static bool HasPlayoutEnvironmentOverride(IDictionary environment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
foreach (var key in environment.Keys)
|
||||
{
|
||||
if (key is string name &&
|
||||
name.StartsWith(EnvironmentVariablePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ValidateConfigPath(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || !Path.IsPathFullyQualified(value))
|
||||
{
|
||||
throw Rejected("config-file-rejected");
|
||||
}
|
||||
|
||||
string fullPath;
|
||||
FileAttributes attributes;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(value);
|
||||
var root = Path.GetPathRoot(fullPath);
|
||||
if (string.IsNullOrEmpty(root) ||
|
||||
fullPath.StartsWith("\\\\", StringComparison.Ordinal) ||
|
||||
new DriveInfo(root).DriveType != DriveType.Fixed)
|
||||
{
|
||||
throw Rejected("config-file-rejected");
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw Rejected("config-file-rejected");
|
||||
}
|
||||
|
||||
attributes = File.GetAttributes(fullPath);
|
||||
}
|
||||
catch (IsolatedTestPreflightException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or
|
||||
IOException or
|
||||
NotSupportedException or
|
||||
System.Security.SecurityException or
|
||||
UnauthorizedAccessException)
|
||||
{
|
||||
throw Rejected("config-file-rejected");
|
||||
}
|
||||
|
||||
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
|
||||
{
|
||||
throw Rejected("config-file-rejected");
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private static PlayoutCue CreateCue(string sceneCode) =>
|
||||
new($"{sceneCode}.t2s", sceneCode);
|
||||
|
||||
private static IsolatedTestPreflightException Rejected(string errorCode) => new(errorCode);
|
||||
}
|
||||
|
||||
internal sealed record SafeCommandStep(
|
||||
string Phase,
|
||||
string Operation,
|
||||
string Code);
|
||||
|
||||
internal sealed record SafeCleanupReport(
|
||||
bool DisconnectAttempted,
|
||||
string? DisconnectCode,
|
||||
bool QuarantineAttempted,
|
||||
bool QuarantineCompleted,
|
||||
bool AdapterDisposed);
|
||||
|
||||
internal sealed record SafeTestCommandReport(
|
||||
string Command,
|
||||
string Mode,
|
||||
bool ConnectRequestIssued,
|
||||
bool? ComActivationAttempted,
|
||||
bool Completed,
|
||||
bool OutcomeUnknown,
|
||||
bool OutputMayBeActive,
|
||||
string? StoppedAfter,
|
||||
IReadOnlyList<SafeCommandStep> Steps,
|
||||
SafeCleanupReport Cleanup);
|
||||
|
||||
internal static class IsolatedTestCommandExecutor
|
||||
{
|
||||
public static Task<SafeTestCommandReport> ExecuteAsync(
|
||||
IsolatedTestCommandPlan plan,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteAsync(
|
||||
plan,
|
||||
PlayoutEngineFactory.Create,
|
||||
static (delay, token) => Task.Delay(delay, token),
|
||||
cancellationToken);
|
||||
|
||||
internal static async Task<SafeTestCommandReport> ExecuteAsync(
|
||||
IsolatedTestCommandPlan plan,
|
||||
Func<PlayoutOptions, IPlayoutEngine> engineFactory,
|
||||
Func<TimeSpan, CancellationToken, Task> observationDelay,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(engineFactory);
|
||||
ArgumentNullException.ThrowIfNull(observationDelay);
|
||||
if (plan.Kind is not (SmokeCommandKind.TestConnect or SmokeCommandKind.TestSequence))
|
||||
{
|
||||
throw new ArgumentException("The plan is not executable.", nameof(plan));
|
||||
}
|
||||
|
||||
var steps = new List<SafeCommandStep>();
|
||||
IPlayoutEngine? engine = null;
|
||||
var connectRequestIssued = false;
|
||||
var connectResultReceived = false;
|
||||
bool? comActivationAttempted = false;
|
||||
var completed = false;
|
||||
var outcomeUnknown = false;
|
||||
string? stoppedAfter = null;
|
||||
var disconnectAttempted = false;
|
||||
string? disconnectCode = null;
|
||||
var adapterDisposed = false;
|
||||
var quarantineAttempted = false;
|
||||
var quarantineCompleted = false;
|
||||
var normalDisconnectCompleted = false;
|
||||
var outputMayBeActive = false;
|
||||
var takeOutAttempted = false;
|
||||
|
||||
try
|
||||
{
|
||||
engine = engineFactory(plan.Options);
|
||||
do
|
||||
{
|
||||
connectRequestIssued = true;
|
||||
var connect = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
connectResultReceived = true;
|
||||
AddStep(steps, "command", connect);
|
||||
comActivationAttempted = InferComActivationAttempt(connect.Code);
|
||||
if (!connect.IsSuccess)
|
||||
{
|
||||
stoppedAfter = connect.Operation.ToString();
|
||||
outcomeUnknown = IsOutcomeUnknown(connect);
|
||||
break;
|
||||
}
|
||||
|
||||
if (plan.Kind == SmokeCommandKind.TestSequence)
|
||||
{
|
||||
var prepare = await engine.PrepareAsync(plan.PrepareCue!, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
AddStep(steps, "command", prepare);
|
||||
if (!prepare.IsSuccess)
|
||||
{
|
||||
stoppedAfter = prepare.Operation.ToString();
|
||||
outcomeUnknown = IsOutcomeUnknown(prepare);
|
||||
break;
|
||||
}
|
||||
|
||||
// Once TAKE IN is issued, an exception or ambiguous result can mean
|
||||
// the Test output became active even though no success was observed.
|
||||
outputMayBeActive = true;
|
||||
var takeIn = await engine.TakeInAsync(cancellationToken).ConfigureAwait(false);
|
||||
AddStep(steps, "command", takeIn);
|
||||
if (!takeIn.IsSuccess)
|
||||
{
|
||||
stoppedAfter = takeIn.Operation.ToString();
|
||||
outcomeUnknown = IsOutcomeUnknown(takeIn);
|
||||
if (!outcomeUnknown)
|
||||
{
|
||||
outputMayBeActive = false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (!await ObserveAsync().ConfigureAwait(false))
|
||||
{
|
||||
stoppedAfter = "Observation";
|
||||
break;
|
||||
}
|
||||
|
||||
var next = await engine.NextAsync(plan.NextCue!, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
AddStep(steps, "command", next);
|
||||
if (!next.IsSuccess)
|
||||
{
|
||||
stoppedAfter = next.Operation.ToString();
|
||||
outcomeUnknown = IsOutcomeUnknown(next);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!await ObserveAsync().ConfigureAwait(false))
|
||||
{
|
||||
stoppedAfter = "Observation";
|
||||
break;
|
||||
}
|
||||
|
||||
takeOutAttempted = true;
|
||||
var takeOut = await engine.TakeOutAsync(
|
||||
PlayoutTakeOutScope.All,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
AddStep(steps, "command", takeOut);
|
||||
if (!takeOut.IsSuccess)
|
||||
{
|
||||
stoppedAfter = takeOut.Operation.ToString();
|
||||
// TAKE IN succeeded, so every non-successful TAKE OUT leaves the
|
||||
// physical Test output state unsafe to infer from its result code.
|
||||
outcomeUnknown = true;
|
||||
break;
|
||||
}
|
||||
|
||||
outputMayBeActive = false;
|
||||
}
|
||||
|
||||
disconnectAttempted = true;
|
||||
var disconnect = await engine.DisconnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
disconnectCode = disconnect.Code.ToString();
|
||||
AddStep(steps, "command", disconnect);
|
||||
normalDisconnectCompleted = true;
|
||||
if (!disconnect.IsSuccess)
|
||||
{
|
||||
stoppedAfter = disconnect.Operation.ToString();
|
||||
outcomeUnknown = IsOutcomeUnknown(disconnect);
|
||||
break;
|
||||
}
|
||||
|
||||
completed = true;
|
||||
}
|
||||
while (false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
stoppedAfter ??= connectRequestIssued ? "Internal" : "EngineCreation";
|
||||
if (!connectResultReceived)
|
||||
{
|
||||
comActivationAttempted = connectRequestIssued ? null : false;
|
||||
}
|
||||
|
||||
outcomeUnknown = connectRequestIssued;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (engine is not null)
|
||||
{
|
||||
if (outputMayBeActive && !takeOutAttempted && !outcomeUnknown)
|
||||
{
|
||||
takeOutAttempted = true;
|
||||
try
|
||||
{
|
||||
var cleanupTakeOut = await engine.TakeOutAsync(
|
||||
PlayoutTakeOutScope.All,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
AddStep(steps, "cleanup", cleanupTakeOut);
|
||||
if (cleanupTakeOut.IsSuccess)
|
||||
{
|
||||
outputMayBeActive = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
outcomeUnknown = true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
outcomeUnknown = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!normalDisconnectCompleted && !outcomeUnknown)
|
||||
{
|
||||
disconnectAttempted = true;
|
||||
try
|
||||
{
|
||||
var cleanup = await engine.DisconnectAsync(CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
disconnectCode = cleanup.Code.ToString();
|
||||
AddStep(steps, "cleanup", cleanup);
|
||||
outcomeUnknown |= IsOutcomeUnknown(cleanup);
|
||||
}
|
||||
catch
|
||||
{
|
||||
disconnectCode = "Failed";
|
||||
outcomeUnknown = true;
|
||||
}
|
||||
}
|
||||
|
||||
var safeToDispose = true;
|
||||
if (outcomeUnknown)
|
||||
{
|
||||
quarantineAttempted = true;
|
||||
try
|
||||
{
|
||||
await engine.QuarantineAsync().ConfigureAwait(false);
|
||||
quarantineCompleted = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A normal Dispose may issue SDK Disconnect. If quarantine could
|
||||
// not first detach the session, leaking locally is safer than an
|
||||
// unrequested control call against an unknown physical output.
|
||||
safeToDispose = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (safeToDispose)
|
||||
{
|
||||
try
|
||||
{
|
||||
await engine.DisposeAsync().ConfigureAwait(false);
|
||||
adapterDisposed = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
outcomeUnknown = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BuildReport();
|
||||
|
||||
async Task<bool> ObserveAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await observationDelay(plan.ObservationDuration, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Observation is local timing only. Its failure does not make a COM
|
||||
// command ambiguous, so retain the bounded TAKE OUT cleanup path.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
SafeTestCommandReport BuildReport() => new(
|
||||
plan.Kind == SmokeCommandKind.TestConnect ? "test-connect" : "test-sequence",
|
||||
"Test",
|
||||
connectRequestIssued,
|
||||
comActivationAttempted,
|
||||
completed,
|
||||
outcomeUnknown,
|
||||
outputMayBeActive,
|
||||
stoppedAfter,
|
||||
steps.AsReadOnly(),
|
||||
new SafeCleanupReport(
|
||||
disconnectAttempted,
|
||||
disconnectCode,
|
||||
quarantineAttempted,
|
||||
quarantineCompleted,
|
||||
adapterDisposed));
|
||||
}
|
||||
|
||||
private static void AddStep(
|
||||
ICollection<SafeCommandStep> steps,
|
||||
string phase,
|
||||
PlayoutResult result) =>
|
||||
steps.Add(new SafeCommandStep(
|
||||
phase,
|
||||
result.Operation.ToString(),
|
||||
result.Code.ToString()));
|
||||
|
||||
private static bool IsOutcomeUnknown(PlayoutResult result) =>
|
||||
result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut;
|
||||
|
||||
private static bool? InferComActivationAttempt(PlayoutResultCode code) => code switch
|
||||
{
|
||||
PlayoutResultCode.Success or PlayoutResultCode.Failed => true,
|
||||
PlayoutResultCode.Rejected => false,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -4,14 +4,29 @@ using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
using MBN_STOCK_WEBVIEW.PlayoutSmoke;
|
||||
|
||||
var command = args.Length == 0 ? "--probe" : args[0].ToLowerInvariant();
|
||||
|
||||
return command switch
|
||||
var parsed = SmokeCommandLine.Parse(args);
|
||||
if (!parsed.IsValid)
|
||||
{
|
||||
"--probe" => Probe(),
|
||||
"--dry-run" => await DryRunAsync(),
|
||||
_ => Usage()
|
||||
return Usage(parsed.ErrorCode);
|
||||
}
|
||||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
cancellation.Cancel();
|
||||
};
|
||||
|
||||
return parsed.Invocation.Kind switch
|
||||
{
|
||||
SmokeCommandKind.Probe => Probe(),
|
||||
SmokeCommandKind.DryRun => await DryRunAsync(),
|
||||
SmokeCommandKind.TestPlan => TestPlan(parsed.Invocation),
|
||||
SmokeCommandKind.TestConnect or SmokeCommandKind.TestSequence =>
|
||||
await RunIsolatedTestCommandAsync(parsed.Invocation, cancellation.Token),
|
||||
_ => Usage("unknown-command")
|
||||
};
|
||||
|
||||
static int Probe()
|
||||
@@ -42,8 +57,7 @@ static int Probe()
|
||||
|
||||
tornadoProcessCount++;
|
||||
var title = process.MainWindowTitle;
|
||||
if (title.Contains("PGM", StringComparison.OrdinalIgnoreCase) ||
|
||||
title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase))
|
||||
if (SmokeCommandLine.IsUnsafeTornadoWindowTitle(title))
|
||||
{
|
||||
programWindowDetected = true;
|
||||
}
|
||||
@@ -58,9 +72,12 @@ static int Probe()
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
WriteJson(new
|
||||
{
|
||||
command = "probe",
|
||||
architecture = Environment.Is64BitProcess ? "x64" : "x86",
|
||||
connectRequestIssued = false,
|
||||
comActivationAttempted = false,
|
||||
runtimeRegistration = new
|
||||
{
|
||||
ready = runtimeRegistration.IsReady,
|
||||
@@ -76,9 +93,9 @@ static int Probe()
|
||||
programWindowDetected
|
||||
},
|
||||
safety = programWindowDetected
|
||||
? "PROGRAM window detected; no COM activation was attempted."
|
||||
? "PROGRAM or ambiguous Tornado window detected; no COM activation was attempted."
|
||||
: "Read-only probe complete; no COM activation was attempted."
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
});
|
||||
|
||||
return runtimeRegistration.IsReady && Environment.Is64BitProcess
|
||||
? 0
|
||||
@@ -101,24 +118,124 @@ static async Task<int> DryRunAsync()
|
||||
await engine.DisconnectAsync()
|
||||
};
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
WriteJson(new
|
||||
{
|
||||
command = "dry-run",
|
||||
mode = engine.Status.Mode.ToString(),
|
||||
state = engine.Status.State.ToString(),
|
||||
connectRequestIssued = false,
|
||||
comActivationAttempted = false,
|
||||
commands = results.Select(result => new
|
||||
{
|
||||
operation = result.Operation.ToString(),
|
||||
code = result.Code.ToString(),
|
||||
message = result.Message
|
||||
})
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
});
|
||||
|
||||
return results.All(result => result.IsSuccess) ? 0 : 3;
|
||||
}
|
||||
|
||||
static int Usage()
|
||||
static int TestPlan(SmokeCommandInvocation invocation)
|
||||
{
|
||||
Console.Error.WriteLine("Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run]");
|
||||
Console.Error.WriteLine("This tool never enables Test or Live mode and never activates K3D COM in dry-run.");
|
||||
try
|
||||
{
|
||||
var plan = IsolatedTestCommandPreflight.Create(invocation);
|
||||
WriteJson(new
|
||||
{
|
||||
command = "test-plan",
|
||||
mode = "Test",
|
||||
preflightValidated = true,
|
||||
runtimeProcessGateChecked = false,
|
||||
connectRequestIssued = false,
|
||||
comActivationAttempted = false,
|
||||
sceneCount = plan.PrepareCue is null ? 0 : 2,
|
||||
automaticReconnectEnabled = plan.Options.ReconnectEnabled,
|
||||
safety = "Configuration and scene assets validated only; no engine or COM was created."
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
catch (IsolatedTestPreflightException exception)
|
||||
{
|
||||
WritePreflightFailure("test-plan", exception.ErrorCode);
|
||||
return 65;
|
||||
}
|
||||
catch
|
||||
{
|
||||
WritePreflightFailure("test-plan", "internal-preflight-failure");
|
||||
return 70;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<int> RunIsolatedTestCommandAsync(
|
||||
SmokeCommandInvocation invocation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var command = invocation.Kind == SmokeCommandKind.TestConnect
|
||||
? "test-connect"
|
||||
: "test-sequence";
|
||||
IsolatedTestCommandPlan plan;
|
||||
try
|
||||
{
|
||||
plan = IsolatedTestCommandPreflight.Create(invocation);
|
||||
}
|
||||
catch (IsolatedTestPreflightException exception)
|
||||
{
|
||||
WritePreflightFailure(command, exception.ErrorCode);
|
||||
return 65;
|
||||
}
|
||||
catch
|
||||
{
|
||||
WritePreflightFailure(command, "internal-preflight-failure");
|
||||
return 70;
|
||||
}
|
||||
|
||||
var report = await IsolatedTestCommandExecutor.ExecuteAsync(plan, cancellationToken);
|
||||
WriteJson(report);
|
||||
if (report.Completed && !report.OutcomeUnknown)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return report.OutcomeUnknown ? 5 : 4;
|
||||
}
|
||||
|
||||
static void WritePreflightFailure(string command, string errorCode) =>
|
||||
WriteJson(new
|
||||
{
|
||||
command,
|
||||
mode = "Test",
|
||||
preflightValidated = false,
|
||||
runtimeProcessGateChecked = false,
|
||||
connectRequestIssued = false,
|
||||
comActivationAttempted = false,
|
||||
errorCode,
|
||||
safety = "Rejected before engine creation; no COM activation was attempted."
|
||||
});
|
||||
|
||||
static void WriteJson<T>(T value) =>
|
||||
Console.WriteLine(JsonSerializer.Serialize(value, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
}));
|
||||
|
||||
static int Usage(string? errorCode)
|
||||
{
|
||||
WriteJson(new
|
||||
{
|
||||
command = "invalid",
|
||||
connectRequestIssued = false,
|
||||
comActivationAttempted = false,
|
||||
errorCode = errorCode ?? "invalid-command-line"
|
||||
});
|
||||
Console.Error.WriteLine(
|
||||
"Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run|--test-plan|--test-connect|--test-sequence]");
|
||||
Console.Error.WriteLine(
|
||||
$"Test commands require {SmokeCommandLine.AcknowledgementFlag} and --config <absolute-path>.");
|
||||
Console.Error.WriteLine(
|
||||
"--test-plan and --test-sequence also require --prepare <scene-code> --next <scene-code> --observe-ms <1000..30000>.");
|
||||
Console.Error.WriteLine(
|
||||
"Only --test-connect and --test-sequence may request COM, after all Test safety gates pass.");
|
||||
return 64;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Playout.Tests")]
|
||||
257
tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/SmokeCommandLine.cs
Normal file
257
tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/SmokeCommandLine.cs
Normal file
@@ -0,0 +1,257 @@
|
||||
namespace MBN_STOCK_WEBVIEW.PlayoutSmoke;
|
||||
|
||||
internal enum SmokeCommandKind
|
||||
{
|
||||
Invalid,
|
||||
Probe,
|
||||
DryRun,
|
||||
TestPlan,
|
||||
TestConnect,
|
||||
TestSequence
|
||||
}
|
||||
|
||||
internal sealed record SmokeCommandInvocation(
|
||||
SmokeCommandKind Kind,
|
||||
string? ConfigPath = null,
|
||||
string? PrepareSceneCode = null,
|
||||
string? NextSceneCode = null,
|
||||
int? ObservationMilliseconds = null);
|
||||
|
||||
internal sealed record SmokeCommandParseResult(
|
||||
bool IsValid,
|
||||
SmokeCommandInvocation Invocation,
|
||||
string? ErrorCode = null);
|
||||
|
||||
internal static class SmokeCommandLine
|
||||
{
|
||||
internal const string AcknowledgementFlag =
|
||||
"--acknowledge-isolated-non-program-test-output";
|
||||
|
||||
public static SmokeCommandParseResult Parse(IReadOnlyList<string> arguments)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(arguments);
|
||||
if (arguments.Count == 0)
|
||||
{
|
||||
return Valid(new SmokeCommandInvocation(SmokeCommandKind.Probe));
|
||||
}
|
||||
|
||||
if (arguments.Count == 1)
|
||||
{
|
||||
return arguments[0].ToLowerInvariant() switch
|
||||
{
|
||||
"--probe" => Valid(new SmokeCommandInvocation(SmokeCommandKind.Probe)),
|
||||
"--dry-run" => Valid(new SmokeCommandInvocation(SmokeCommandKind.DryRun)),
|
||||
_ => Invalid("unknown-command")
|
||||
};
|
||||
}
|
||||
|
||||
var kind = arguments[0].ToLowerInvariant() switch
|
||||
{
|
||||
"--test-plan" => SmokeCommandKind.TestPlan,
|
||||
"--test-connect" => SmokeCommandKind.TestConnect,
|
||||
"--test-sequence" => SmokeCommandKind.TestSequence,
|
||||
_ => SmokeCommandKind.Invalid
|
||||
};
|
||||
if (kind == SmokeCommandKind.Invalid)
|
||||
{
|
||||
return Invalid("unknown-command");
|
||||
}
|
||||
|
||||
string? configPath = null;
|
||||
string? prepareSceneCode = null;
|
||||
string? nextSceneCode = null;
|
||||
int? observationMilliseconds = null;
|
||||
var acknowledged = false;
|
||||
|
||||
for (var index = 1; index < arguments.Count; index++)
|
||||
{
|
||||
var argument = arguments[index];
|
||||
switch (argument)
|
||||
{
|
||||
case AcknowledgementFlag:
|
||||
if (acknowledged)
|
||||
{
|
||||
return Invalid("duplicate-option");
|
||||
}
|
||||
|
||||
acknowledged = true;
|
||||
break;
|
||||
|
||||
case "--config":
|
||||
if (configPath is not null)
|
||||
{
|
||||
return Invalid("duplicate-option");
|
||||
}
|
||||
|
||||
if (!TryReadValue(arguments, ref index, out configPath))
|
||||
{
|
||||
return Invalid("missing-option-value");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "--prepare" when kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence:
|
||||
if (prepareSceneCode is not null)
|
||||
{
|
||||
return Invalid("duplicate-option");
|
||||
}
|
||||
|
||||
if (!TryReadValue(arguments, ref index, out prepareSceneCode))
|
||||
{
|
||||
return Invalid("missing-option-value");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "--next" when kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence:
|
||||
if (nextSceneCode is not null)
|
||||
{
|
||||
return Invalid("duplicate-option");
|
||||
}
|
||||
|
||||
if (!TryReadValue(arguments, ref index, out nextSceneCode))
|
||||
{
|
||||
return Invalid("missing-option-value");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "--observe-ms" when kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence:
|
||||
if (observationMilliseconds is not null)
|
||||
{
|
||||
return Invalid("duplicate-option");
|
||||
}
|
||||
|
||||
if (!TryReadValue(arguments, ref index, out var observationText) ||
|
||||
!int.TryParse(
|
||||
observationText,
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var observationValue) ||
|
||||
observationValue is < 1_000 or > 30_000)
|
||||
{
|
||||
return Invalid("invalid-observation-duration");
|
||||
}
|
||||
|
||||
observationMilliseconds = observationValue;
|
||||
break;
|
||||
|
||||
default:
|
||||
return Invalid("unknown-option");
|
||||
}
|
||||
}
|
||||
|
||||
if (!acknowledged)
|
||||
{
|
||||
return Invalid("missing-acknowledgement");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configPath))
|
||||
{
|
||||
return Invalid("missing-required-option");
|
||||
}
|
||||
|
||||
if (kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence)
|
||||
{
|
||||
if (prepareSceneCode is null || nextSceneCode is null || observationMilliseconds is null)
|
||||
{
|
||||
return Invalid("missing-required-option");
|
||||
}
|
||||
|
||||
if (!IsSafeSceneCode(prepareSceneCode) || !IsSafeSceneCode(nextSceneCode))
|
||||
{
|
||||
return Invalid("unsafe-scene-code");
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
prepareSceneCode,
|
||||
nextSceneCode,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Invalid("duplicate-scene-code");
|
||||
}
|
||||
}
|
||||
|
||||
return Valid(new SmokeCommandInvocation(
|
||||
kind,
|
||||
configPath,
|
||||
prepareSceneCode,
|
||||
nextSceneCode,
|
||||
observationMilliseconds));
|
||||
}
|
||||
|
||||
internal static bool IsSafeSceneCode(string value)
|
||||
{
|
||||
if (value.Length is < 1 or > 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var character in value)
|
||||
{
|
||||
if (!((character >= 'A' && character <= 'Z') ||
|
||||
(character >= 'a' && character <= 'z') ||
|
||||
(character >= '0' && character <= '9') ||
|
||||
character is '_' or '-'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool IsUnsafeTornadoWindowTitle(string? title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title) ||
|
||||
title.Contains("PGM", StringComparison.OrdinalIgnoreCase) ||
|
||||
title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
const string marker = "TEST";
|
||||
for (var start = 0; start <= title.Length - marker.Length; start++)
|
||||
{
|
||||
if (!title.AsSpan(start, marker.Length).Equals(
|
||||
marker.AsSpan(),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var beforeIsBoundary = start == 0 || !char.IsLetterOrDigit(title[start - 1]);
|
||||
var after = start + marker.Length;
|
||||
var afterIsBoundary = after == title.Length || !char.IsLetterOrDigit(title[after]);
|
||||
if (beforeIsBoundary && afterIsBoundary)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadValue(
|
||||
IReadOnlyList<string> arguments,
|
||||
ref int index,
|
||||
out string? value)
|
||||
{
|
||||
if (index + 1 >= arguments.Count ||
|
||||
string.IsNullOrWhiteSpace(arguments[index + 1]) ||
|
||||
arguments[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = arguments[++index];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static SmokeCommandParseResult Valid(SmokeCommandInvocation invocation) =>
|
||||
new(true, invocation);
|
||||
|
||||
private static SmokeCommandParseResult Invalid(string errorCode) =>
|
||||
new(false, new SmokeCommandInvocation(SmokeCommandKind.Invalid), errorCode);
|
||||
}
|
||||
Reference in New Issue
Block a user