feat: verify Tornado PGM KTAP connection

This commit is contained in:
2026-07-10 14:28:45 +09:00
parent b113830c14
commit 930424b752
22 changed files with 3474 additions and 67 deletions

View File

@@ -333,6 +333,15 @@ internal static class IsolatedTestCommandExecutor
}
outputMayBeActive = false;
// K3D output methods complete asynchronously through event callbacks.
// The current adapter does not consume those callbacks yet, so keep
// the connection alive for the same bounded observation window after
// TAKE OUT before requesting Disconnect.
if (!await ObserveAsync().ConfigureAwait(false))
{
stoppedAfter = "TakeOutObservation";
break;
}
}
disconnectAttempted = true;

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout.Registration;
using MBN_STOCK_WEBVIEW.PlayoutSmoke;
@@ -26,6 +27,8 @@ return parsed.Invocation.Kind switch
SmokeCommandKind.TestPlan => TestPlan(parsed.Invocation),
SmokeCommandKind.TestConnect or SmokeCommandKind.TestSequence =>
await RunIsolatedTestCommandAsync(parsed.Invocation, cancellation.Token),
SmokeCommandKind.PgmConnectDiagnostic =>
await RunPgmConnectDiagnosticAsync(parsed.Invocation, cancellation.Token),
_ => Usage("unknown-command")
};
@@ -221,6 +224,104 @@ static async Task<int> RunIsolatedTestCommandAsync(
return report.OutcomeUnknown ? 5 : 4;
}
static async Task<int> RunPgmConnectDiagnosticAsync(
SmokeCommandInvocation invocation,
CancellationToken cancellationToken)
{
if (IsolatedTestCommandPreflight.HasPlayoutEnvironmentOverride(
Environment.GetEnvironmentVariables()))
{
WritePgmDiagnosticFailure("environment-override-present");
return 65;
}
var request = new PgmConnectDiagnosticRequest(
invocation.Host ?? string.Empty,
invocation.Port ?? 0,
invocation.ExpectedPgmWindowTitle ?? string.Empty);
PgmConnectDiagnosticReport report;
try
{
report = await new PgmConnectDiagnosticRunner()
.ExecuteAsync(request, cancellationToken)
.ConfigureAwait(false);
}
catch
{
WritePgmDiagnosticFailure("internal-diagnostic-failure");
return 70;
}
WriteJson(new
{
command = "pgm-connect-diagnostic",
report.RequestValidated,
report.ProcessGatePassed,
report.ListenerOwnershipConfirmed,
report.RegistrationReady,
report.ConnectRequestIssued,
report.ComActivationAttempted,
lastKtapConnectState = ToWireValue(report.LastKtapConnectState),
report.KtapConnectAttempted,
report.KtapConnectAccepted,
report.KtapHelloObserved,
report.NetworkMonitoringRecordExpected,
report.NetworkMonitoringCheckRequired,
report.NetworkMonitoringVerified,
report.DisconnectAttempted,
report.DisconnectCode,
report.Completed,
report.OutcomeUnknown,
report.RenderCommandSurfaceExposed,
report.RenderCommandAttempted,
report.AutomaticReconnectEnabled,
report.ErrorCode
});
if (report.Completed && !report.OutcomeUnknown)
{
return 0;
}
return report.OutcomeUnknown ? 5 : 4;
}
static void WritePgmDiagnosticFailure(string errorCode) =>
WriteJson(new
{
command = "pgm-connect-diagnostic",
requestValidated = false,
processGatePassed = false,
listenerOwnershipConfirmed = false,
registrationReady = false,
connectRequestIssued = false,
comActivationAttempted = false,
lastKtapConnectState = "not-attempted",
ktapConnectAttempted = false,
ktapConnectAccepted = false,
ktapHelloObserved = (bool?)null,
networkMonitoringRecordExpected = false,
networkMonitoringCheckRequired = false,
networkMonitoringVerified = (bool?)null,
disconnectAttempted = false,
disconnectCode = "not-attempted",
completed = false,
outcomeUnknown = false,
renderCommandSurfaceExposed = false,
renderCommandAttempted = false,
automaticReconnectEnabled = false,
errorCode
});
static string ToWireValue(PlayoutKtapConnectState state) => state switch
{
PlayoutKtapConnectState.NotAttempted => "not-attempted",
PlayoutKtapConnectState.Attempted => "attempted",
PlayoutKtapConnectState.AcceptedUnconfirmed => "accepted-unconfirmed",
PlayoutKtapConnectState.Failed => "failed",
_ => "not-attempted"
};
static void WritePreflightFailure(string command, string errorCode) =>
WriteJson(new
{
@@ -265,12 +366,14 @@ static int Usage(string? errorCode)
errorCode = errorCode ?? "invalid-command-line"
});
Console.Error.WriteLine(
"Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run|--test-plan|--test-connect|--test-sequence]");
"Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run|--test-plan|--test-connect|--test-sequence|--pgm-connect-diagnostic]");
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.");
"--test-connect and --test-sequence may request render-capable COM after all Test safety gates pass.");
Console.Error.WriteLine(
$"--pgm-connect-diagnostic requires {SmokeCommandLine.PgmConnectAcknowledgementFlag}, --host, --port and --expected-pgm-window-title; it exposes no render command.");
return 64;
}

View File

@@ -7,7 +7,8 @@ internal enum SmokeCommandKind
DryRun,
TestPlan,
TestConnect,
TestSequence
TestSequence,
PgmConnectDiagnostic
}
internal sealed record SmokeCommandInvocation(
@@ -15,7 +16,10 @@ internal sealed record SmokeCommandInvocation(
string? ConfigPath = null,
string? PrepareSceneCode = null,
string? NextSceneCode = null,
int? ObservationMilliseconds = null);
int? ObservationMilliseconds = null,
string? Host = null,
int? Port = null,
string? ExpectedPgmWindowTitle = null);
internal sealed record SmokeCommandParseResult(
bool IsValid,
@@ -26,6 +30,8 @@ internal static class SmokeCommandLine
{
internal const string AcknowledgementFlag =
"--acknowledge-isolated-non-program-test-output";
internal const string PgmConnectAcknowledgementFlag =
"--i-understand-this-will-contact-current-pgm-tornado-via-ktap-connect-and-disconnect-only";
public static SmokeCommandParseResult Parse(IReadOnlyList<string> arguments)
{
@@ -50,6 +56,7 @@ internal static class SmokeCommandLine
"--test-plan" => SmokeCommandKind.TestPlan,
"--test-connect" => SmokeCommandKind.TestConnect,
"--test-sequence" => SmokeCommandKind.TestSequence,
"--pgm-connect-diagnostic" => SmokeCommandKind.PgmConnectDiagnostic,
_ => SmokeCommandKind.Invalid
};
if (kind == SmokeCommandKind.Invalid)
@@ -61,6 +68,9 @@ internal static class SmokeCommandLine
string? prepareSceneCode = null;
string? nextSceneCode = null;
int? observationMilliseconds = null;
string? host = null;
int? port = null;
string? expectedPgmWindowTitle = null;
var acknowledged = false;
for (var index = 1; index < arguments.Count; index++)
@@ -68,7 +78,8 @@ internal static class SmokeCommandLine
var argument = arguments[index];
switch (argument)
{
case AcknowledgementFlag:
case AcknowledgementFlag when kind != SmokeCommandKind.PgmConnectDiagnostic:
case PgmConnectAcknowledgementFlag when kind == SmokeCommandKind.PgmConnectDiagnostic:
if (acknowledged)
{
return Invalid("duplicate-option");
@@ -77,7 +88,54 @@ internal static class SmokeCommandLine
acknowledged = true;
break;
case "--config":
case "--host" when kind == SmokeCommandKind.PgmConnectDiagnostic:
if (host is not null)
{
return Invalid("duplicate-option");
}
if (!TryReadValue(arguments, ref index, out host))
{
return Invalid("missing-option-value");
}
break;
case "--port" when kind == SmokeCommandKind.PgmConnectDiagnostic:
if (port is not null)
{
return Invalid("duplicate-option");
}
if (!TryReadValue(arguments, ref index, out var portText) ||
!int.TryParse(
portText,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out var parsedPort) ||
parsedPort is < 1 or > 65_535)
{
return Invalid("invalid-port");
}
port = parsedPort;
break;
case "--expected-pgm-window-title"
when kind == SmokeCommandKind.PgmConnectDiagnostic:
if (expectedPgmWindowTitle is not null)
{
return Invalid("duplicate-option");
}
if (!TryReadValue(arguments, ref index, out expectedPgmWindowTitle))
{
return Invalid("missing-option-value");
}
break;
case "--config" when kind != SmokeCommandKind.PgmConnectDiagnostic:
if (configPath is not null)
{
return Invalid("duplicate-option");
@@ -146,6 +204,22 @@ internal static class SmokeCommandLine
return Invalid("missing-acknowledgement");
}
if (kind == SmokeCommandKind.PgmConnectDiagnostic)
{
if (string.IsNullOrWhiteSpace(host) ||
port is null ||
string.IsNullOrWhiteSpace(expectedPgmWindowTitle))
{
return Invalid("missing-required-option");
}
return Valid(new SmokeCommandInvocation(
kind,
Host: host,
Port: port,
ExpectedPgmWindowTitle: expectedPgmWindowTitle));
}
if (string.IsNullOrWhiteSpace(configPath))
{
return Invalid("missing-required-option");
@@ -177,7 +251,10 @@ internal static class SmokeCommandLine
configPath,
prepareSceneCode,
nextSceneCode,
observationMilliseconds));
observationMilliseconds,
host,
port,
expectedPgmWindowTitle));
}
internal static bool IsSafeSceneCode(string value)