feat: harden isolated Tornado test workflow

This commit is contained in:
2026-07-10 11:27:40 +09:00
parent 5a8c7028dc
commit fc932b27f6
36 changed files with 3919 additions and 226 deletions

View File

@@ -0,0 +1,564 @@
using System.Collections;
using System.Text.Json;
using System.Text.Json.Serialization;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.PlayoutSmoke;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class IsolatedTestCommandTests : IDisposable
{
private readonly TemporarySceneDirectory _scenes =
TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
[Fact]
public void Preflight_ValidSequenceBuildsExactBasenameCuesAndDisablesReconnect()
{
using var environment = ClearedPlayoutEnvironment();
using var config = CreateConfig(ValidOptions());
var plan = IsolatedTestCommandPreflight.Create(Invocation(
SmokeCommandKind.TestSequence,
config.Path));
Assert.Equal("5001.t2s", plan.PrepareCue!.SceneFile);
Assert.Equal("5001", plan.PrepareCue.SceneName);
Assert.Equal("5006.t2s", plan.NextCue!.SceneFile);
Assert.Equal("5006", plan.NextCue.SceneName);
Assert.Equal(TimeSpan.FromSeconds(1), plan.ObservationDuration);
Assert.False(plan.Options.ReconnectEnabled);
Assert.Equal(0, plan.Options.MaximumReconnectAttempts);
}
[Fact]
public void Preflight_TestConnectReusesTestConfigurationGatesWithoutScenes()
{
using var environment = ClearedPlayoutEnvironment();
using var config = CreateConfig(ValidOptions());
var plan = IsolatedTestCommandPreflight.Create(new SmokeCommandInvocation(
SmokeCommandKind.TestConnect,
config.Path));
Assert.Null(plan.PrepareCue);
Assert.Null(plan.NextCue);
Assert.Equal(TimeSpan.Zero, plan.ObservationDuration);
Assert.Equal(PlayoutMode.Test, plan.Options.Mode);
}
[Fact]
public void Preflight_RejectsAnyPlayoutEnvironmentOverrideBeforeReadingConfiguration()
{
using var environment = ClearedPlayoutEnvironment()
.Set("MBN_STOCK_PLAYOUT_FUTURE_OVERRIDE", "sensitive-value");
var exception = Assert.Throws<IsolatedTestPreflightException>(() =>
IsolatedTestCommandPreflight.Create(Invocation(
SmokeCommandKind.TestSequence,
"C:\\sensitive\\missing.local.json")));
Assert.Equal("environment-override-present", exception.ErrorCode);
Assert.DoesNotContain("sensitive", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Preflight_RejectsRelativeOrMissingConfigWithoutReflectingPath()
{
using var environment = ClearedPlayoutEnvironment();
const string unsafePath = ".\\private\\playout.local.json";
var exception = Assert.Throws<IsolatedTestPreflightException>(() =>
IsolatedTestCommandPreflight.Create(Invocation(
SmokeCommandKind.TestSequence,
unsafePath)));
Assert.Equal("config-file-rejected", exception.ErrorCode);
Assert.DoesNotContain(unsafePath, exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(PlayoutMode.DryRun, false)]
[InlineData(PlayoutMode.Live, false)]
[InlineData(PlayoutMode.Test, true)]
public void Preflight_RejectsAnythingThatCouldEnableLiveOutput(
PlayoutMode mode,
bool trustedLiveOutputEnabled)
{
using var environment = ClearedPlayoutEnvironment();
var options = ValidOptions();
options.Mode = mode;
options.TrustedLiveOutputEnabled = trustedLiveOutputEnabled;
using var config = CreateConfig(options);
var exception = Assert.Throws<IsolatedTestPreflightException>(() =>
IsolatedTestCommandPreflight.Create(Invocation(
SmokeCommandKind.TestSequence,
config.Path)));
Assert.Equal("test-mode-required", exception.ErrorCode);
}
[Fact]
public void Preflight_ReusesAllowlistAndExistingFileValidationBeforeEngineCreation()
{
using var environment = ClearedPlayoutEnvironment();
var options = ValidOptions();
options.TestSceneAllowlist = ["5001"];
using var config = CreateConfig(options);
var exception = Assert.Throws<IsolatedTestPreflightException>(() =>
IsolatedTestCommandPreflight.Create(Invocation(
SmokeCommandKind.TestSequence,
config.Path)));
Assert.Equal("safety-gate-rejected", exception.ErrorCode);
Assert.DoesNotContain("5006", exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(_scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Executor_SuccessRunsExactSequenceWithObservationAndAllScopeTakeOut()
{
var engine = new RecordingPlayoutEngine();
var delays = new List<TimeSpan>();
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestSequence),
_ => engine,
(delay, _) =>
{
delays.Add(delay);
return Task.CompletedTask;
});
Assert.Equal(
[
"Connect",
"Prepare",
"TakeIn",
"Next",
"TakeOut:All",
"Disconnect",
"Dispose"
], engine.Calls);
Assert.Equal([TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)], delays);
Assert.True(report.Completed);
Assert.True(report.ComActivationAttempted);
Assert.False(report.OutcomeUnknown);
Assert.Equal(6, report.Steps.Count);
Assert.True(report.Cleanup.AdapterDisposed);
}
[Fact]
public async Task Executor_TestConnectNeverIssuesSceneOrOutputMutation()
{
var engine = new RecordingPlayoutEngine();
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestConnect),
_ => engine,
(_, _) => throw new InvalidOperationException("No observation is allowed."));
Assert.Equal(["Connect", "Disconnect", "Dispose"], engine.Calls);
Assert.True(report.Completed);
Assert.True(report.ComActivationAttempted);
}
[Fact]
public async Task Executor_ConnectUnavailableDoesNotGuessWhetherComWasAttempted()
{
var engine = new RecordingPlayoutEngine()
.Return(PlayoutOperation.Connect, PlayoutResultCode.Unavailable);
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestConnect),
_ => engine,
(_, _) => Task.CompletedTask);
Assert.Null(report.ComActivationAttempted);
Assert.Equal(["Connect", "Disconnect", "Dispose"], engine.Calls);
}
[Fact]
public async Task Executor_UnknownConnectOutcomeReportsUnknownActivationAndSkipsDisconnect()
{
var engine = new RecordingPlayoutEngine()
.Return(PlayoutOperation.Connect, PlayoutResultCode.OutcomeUnknown);
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestConnect),
_ => engine,
(_, _) => Task.CompletedTask);
Assert.Null(report.ComActivationAttempted);
Assert.True(report.OutcomeUnknown);
Assert.Equal(["Connect", "Quarantine", "Dispose"], engine.Calls);
Assert.True(report.Cleanup.QuarantineCompleted);
}
[Fact]
public async Task Executor_EngineCreationFailureReportsNoComActivationAttempt()
{
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestConnect),
_ => throw new InvalidOperationException("sensitive factory failure"),
(_, _) => Task.CompletedTask);
Assert.False(report.ConnectRequestIssued);
Assert.False(report.ComActivationAttempted);
Assert.False(report.Completed);
Assert.False(report.OutcomeUnknown);
}
[Fact]
public async Task Executor_IntermediateFailureStopsPlaybackAndPerformsDisconnectCleanup()
{
var engine = new RecordingPlayoutEngine()
.Return(PlayoutOperation.Prepare, PlayoutResultCode.Failed);
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestSequence),
_ => engine,
(_, _) => Task.CompletedTask);
Assert.Equal(["Connect", "Prepare", "Disconnect", "Dispose"], engine.Calls);
Assert.False(report.Completed);
Assert.Equal("Prepare", report.StoppedAfter);
Assert.Contains(report.Steps, step =>
step.Phase == "cleanup" && step.Operation == "Disconnect");
}
[Fact]
public async Task Executor_OutcomeUnknownNeverAttemptsAutomaticDisconnectOrLaterCommand()
{
var engine = new RecordingPlayoutEngine()
.Return(PlayoutOperation.TakeIn, PlayoutResultCode.OutcomeUnknown);
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestSequence),
_ => engine,
(_, _) => Task.CompletedTask);
Assert.Equal(["Connect", "Prepare", "TakeIn", "Quarantine", "Dispose"], engine.Calls);
Assert.True(report.OutcomeUnknown);
Assert.True(report.OutputMayBeActive);
Assert.False(report.Cleanup.DisconnectAttempted);
Assert.True(report.Cleanup.AdapterDisposed);
}
[Fact]
public async Task Executor_QuarantineFailureSkipsDisposeToAvoidImplicitDisconnect()
{
var engine = new RecordingPlayoutEngine
{
ThrowOnQuarantine = true
}.Return(PlayoutOperation.TakeIn, PlayoutResultCode.OutcomeUnknown);
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestSequence),
_ => engine,
(_, _) => Task.CompletedTask);
Assert.Equal(["Connect", "Prepare", "TakeIn", "Quarantine"], engine.Calls);
Assert.True(report.Cleanup.QuarantineAttempted);
Assert.False(report.Cleanup.QuarantineCompleted);
Assert.False(report.Cleanup.AdapterDisposed);
Assert.False(report.Cleanup.DisconnectAttempted);
}
[Fact]
public async Task Executor_CancelledObservationStopsBeforeNextWithoutMarkingComOutcomeUnknown()
{
using var cancellation = new CancellationTokenSource();
var engine = new RecordingPlayoutEngine();
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestSequence),
_ => engine,
(_, token) =>
{
cancellation.Cancel();
return Task.FromCanceled(token);
},
cancellation.Token);
Assert.Equal(
["Connect", "Prepare", "TakeIn", "TakeOut:All", "Disconnect", "Dispose"],
engine.Calls);
Assert.Equal("Observation", report.StoppedAfter);
Assert.False(report.OutcomeUnknown);
Assert.False(report.OutputMayBeActive);
Assert.False(report.Completed);
Assert.Contains(report.Steps, step =>
step.Phase == "cleanup" && step.Operation == "TakeOut");
}
[Fact]
public async Task Executor_UnknownTakeOutCleanupSkipsAutomaticDisconnect()
{
using var cancellation = new CancellationTokenSource();
var engine = new RecordingPlayoutEngine()
.Return(PlayoutOperation.TakeOut, PlayoutResultCode.OutcomeUnknown);
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestSequence),
_ => engine,
(_, token) =>
{
cancellation.Cancel();
return Task.FromCanceled(token);
},
cancellation.Token);
Assert.Equal(
["Connect", "Prepare", "TakeIn", "TakeOut:All", "Quarantine", "Dispose"],
engine.Calls);
Assert.True(report.OutcomeUnknown);
Assert.True(report.OutputMayBeActive);
Assert.False(report.Cleanup.DisconnectAttempted);
}
[Theory]
[InlineData(PlayoutResultCode.Failed)]
[InlineData(PlayoutResultCode.Unavailable)]
[InlineData(PlayoutResultCode.Cancelled)]
[InlineData(PlayoutResultCode.Rejected)]
public async Task Executor_NonSuccessfulCleanupTakeOutReportsPossibleActiveOutput(
PlayoutResultCode takeOutCode)
{
using var cancellation = new CancellationTokenSource();
var engine = new RecordingPlayoutEngine()
.Return(PlayoutOperation.TakeOut, takeOutCode);
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestSequence),
_ => engine,
(_, token) =>
{
cancellation.Cancel();
return Task.FromCanceled(token);
},
cancellation.Token);
Assert.Equal(
["Connect", "Prepare", "TakeIn", "TakeOut:All", "Quarantine", "Dispose"],
engine.Calls);
Assert.True(report.OutcomeUnknown);
Assert.True(report.OutputMayBeActive);
Assert.False(report.Cleanup.DisconnectAttempted);
}
[Theory]
[InlineData(PlayoutResultCode.Failed)]
[InlineData(PlayoutResultCode.Unavailable)]
[InlineData(PlayoutResultCode.Cancelled)]
[InlineData(PlayoutResultCode.Rejected)]
public async Task Executor_NonSuccessfulSequenceTakeOutReportsPossibleActiveOutput(
PlayoutResultCode takeOutCode)
{
var engine = new RecordingPlayoutEngine()
.Return(PlayoutOperation.TakeOut, takeOutCode);
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestSequence),
_ => engine,
(_, _) => Task.CompletedTask);
Assert.Equal(
["Connect", "Prepare", "TakeIn", "Next", "TakeOut:All", "Quarantine", "Dispose"],
engine.Calls);
Assert.True(report.OutcomeUnknown);
Assert.True(report.OutputMayBeActive);
Assert.False(report.Cleanup.DisconnectAttempted);
}
[Fact]
public async Task Executor_ObservationFailureStillPerformsTakeOutCleanup()
{
var engine = new RecordingPlayoutEngine();
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestSequence),
_ => engine,
(_, _) => Task.FromException(new InvalidOperationException("fake observation failure")));
Assert.Equal(
["Connect", "Prepare", "TakeIn", "TakeOut:All", "Disconnect", "Dispose"],
engine.Calls);
Assert.Equal("Observation", report.StoppedAfter);
Assert.False(report.OutcomeUnknown);
Assert.False(report.OutputMayBeActive);
Assert.True(report.Cleanup.DisconnectAttempted);
}
[Fact]
public async Task Executor_ReportDoesNotContainEngineMessagesPathsOrSceneCodes()
{
var engine = new RecordingPlayoutEngine("C:\\private\\5001.t2s HRESULT 0x80004005");
var report = await IsolatedTestCommandExecutor.ExecuteAsync(
ExecutionPlan(SmokeCommandKind.TestSequence),
_ => engine,
(_, _) => Task.CompletedTask);
var json = JsonSerializer.Serialize(report);
Assert.DoesNotContain("private", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("5001", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("HRESULT", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("0x80004005", json, StringComparison.OrdinalIgnoreCase);
}
public void Dispose() => _scenes.Dispose();
private IsolatedTestCommandPlan ExecutionPlan(SmokeCommandKind kind) => new(
kind,
ValidOptions(),
kind == SmokeCommandKind.TestSequence ? new PlayoutCue("5001.t2s", "5001") : null,
kind == SmokeCommandKind.TestSequence ? new PlayoutCue("5006.t2s", "5006") : null,
kind == SmokeCommandKind.TestSequence ? TimeSpan.FromSeconds(1) : TimeSpan.Zero);
private SmokeCommandInvocation Invocation(SmokeCommandKind kind, string configPath) => new(
kind,
configPath,
"5001",
"5006",
1000);
private PlayoutOptions ValidOptions() => new()
{
Mode = PlayoutMode.Test,
Host = "127.0.0.1",
Port = 30001,
TcpMode = 1,
ClientPort = 0,
OutputChannel = 9,
LayoutIndex = 10,
SceneDirectory = _scenes.Path,
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
TestSceneAllowlist = ["5001", "5006"],
TrustedLiveOutputEnabled = false,
ReconnectEnabled = true,
MaximumReconnectAttempts = 3
};
private static TemporaryJsonFile CreateConfig(PlayoutOptions options)
{
var serializerOptions = new JsonSerializerOptions();
serializerOptions.Converters.Add(new JsonStringEnumConverter());
return TemporaryJsonFile.Create(JsonSerializer.Serialize(options, serializerOptions));
}
private static EnvironmentScope ClearedPlayoutEnvironment()
{
var result = new EnvironmentScope();
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
{
if (entry.Key is string name &&
name.StartsWith(
IsolatedTestCommandPreflight.EnvironmentVariablePrefix,
StringComparison.OrdinalIgnoreCase))
{
result.Set(name, null);
}
}
return result;
}
private sealed class RecordingPlayoutEngine : IPlayoutEngine
{
private readonly Dictionary<PlayoutOperation, PlayoutResultCode> _codes = [];
private readonly string _message;
public RecordingPlayoutEngine(string message = "safe")
{
_message = message;
Status = new PlayoutStatus(
PlayoutMode.Test,
PlayoutConnectionState.Disconnected,
false,
true,
false,
false,
null,
null,
"safe",
DateTimeOffset.UnixEpoch,
1);
}
public List<string> Calls { get; } = [];
public bool ThrowOnQuarantine { get; init; }
public PlayoutStatus Status { get; }
public event EventHandler<PlayoutStatusChangedEventArgs>? StatusChanged
{
add { }
remove { }
}
public RecordingPlayoutEngine Return(
PlayoutOperation operation,
PlayoutResultCode code)
{
_codes[operation] = code;
return this;
}
public Task<PlayoutResult> ConnectAsync(CancellationToken cancellationToken = default) =>
Result(PlayoutOperation.Connect, "Connect");
public Task<PlayoutResult> DisconnectAsync(CancellationToken cancellationToken = default) =>
Result(PlayoutOperation.Disconnect, "Disconnect");
public Task<PlayoutResult> PrepareAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default) =>
Result(PlayoutOperation.Prepare, "Prepare");
public Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default) =>
Result(PlayoutOperation.TakeIn, "TakeIn");
public Task<PlayoutResult> NextAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default) =>
Result(PlayoutOperation.Next, "Next");
public Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default) =>
Result(PlayoutOperation.TakeOut, $"TakeOut:{scope}");
public ValueTask DisposeAsync()
{
Calls.Add("Dispose");
return ValueTask.CompletedTask;
}
public ValueTask QuarantineAsync()
{
Calls.Add("Quarantine");
if (ThrowOnQuarantine)
{
throw new InvalidOperationException("fake quarantine failure");
}
return ValueTask.CompletedTask;
}
private Task<PlayoutResult> Result(PlayoutOperation operation, string call)
{
Calls.Add(call);
var code = _codes.GetValueOrDefault(operation, PlayoutResultCode.Success);
return Task.FromResult(new PlayoutResult(
operation,
code,
false,
_message,
DateTimeOffset.UnixEpoch));
}
}
}