// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using Microsoft.Testing.Extensions.VideoRecorder.Resources;
using Microsoft.Testing.Platform.CommandLine;
using Microsoft.Testing.Platform.Configurations;
using Microsoft.Testing.Platform.Extensions;
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.Extensions.OutputDevice;
using Microsoft.Testing.Platform.Extensions.TestHost;
using Microsoft.Testing.Platform.Helpers;
using Microsoft.Testing.Platform.Logging;
using Microsoft.Testing.Platform.Messages;
using Microsoft.Testing.Platform.OutputDevice;
using Microsoft.Testing.Platform.Services;
using Microsoft.Testing.Platform.TestHost;
namespace Microsoft.Testing.Extensions.VideoRecorder;
///
/// Wires the into the platform when recording is enabled via
/// --capture-video. The screen is recorded continuously as a rolling sequence of segments;
/// at the end of the run the per-test clips (or the chaptered session video) are cut from those
/// segments using each test's timing. This avoids the race a per-test recorder has — data consumers
/// are invoked asynchronously, so by the time a test's InProgress message is observed the
/// test may already be finishing.
///
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[UnsupportedOSPlatform("wasi")]
internal sealed partial class VideoRecorderSessionHandler :
IDataConsumer,
ITestSessionLifetimeHandler,
IDataProducer,
IOutputDeviceDataProducer
{
private static readonly char[] InvalidFileNameChars = Path.GetInvalidFileNameChars();
private readonly bool _enabled;
private readonly IVideoRecorder? _recorder;
private readonly VideoRecorderOptions _options;
private readonly VideoRecorderPersistenceMode _persistMode;
private readonly VideoCaptureGranularity _granularity;
private readonly IMessageBus _messageBus;
private readonly IOutputDevice _outputDevice;
private readonly IClock _clock;
private readonly ILogger _logger;
#if NET9_0_OR_GREATER
private readonly Lock _stateGate = new();
#else
private readonly object _stateGate = new();
#endif
private readonly List _testRecords = [];
private readonly Dictionary _inFlight = [];
// A persistent stack keeps both failure insertion and pruning snapshots O(1).
private FailedWindow? _failedWindows;
private SessionUid? _sessionUid;
private bool _anyTestFailed;
private int _bufferDropWarned;
public VideoRecorderSessionHandler(
VideoRecorderOptions options,
IConfiguration configuration,
ICommandLineOptions commandLineOptions,
IMessageBus messageBus,
IOutputDevice outputDevice,
IClock clock,
ILogger logger)
: this(options, configuration, commandLineOptions, messageBus, outputDevice, clock, logger, recorder: null)
{
}
internal VideoRecorderSessionHandler(
VideoRecorderOptions options,
IConfiguration configuration,
ICommandLineOptions commandLineOptions,
IMessageBus messageBus,
IOutputDevice outputDevice,
IClock clock,
ILogger logger,
IVideoRecorder? recorder)
{
_messageBus = messageBus;
_outputDevice = outputDevice;
_clock = clock;
_logger = logger;
_options = options;
_enabled = commandLineOptions.IsOptionSet(VideoRecorderCommandLineProvider.EnableOptionName);
if (_enabled)
{
ApplyCommandLineOverrides(options, commandLineOptions);
}
_persistMode = options.PersistMode;
_granularity = options.Granularity;
if (!_enabled)
{
return;
}
string outputDirectory = options.OutputDirectory
?? Path.Combine(configuration.GetTestResultDirectory(), "VideoRecordings");
_recorder = recorder ?? new FfmpegVideoRecorder(
options,
outputDirectory,
clock,
log: message => logger.LogTrace(message),
warn: message => logger.LogWarning(message));
}
public string Uid => nameof(VideoRecorderSessionHandler);
public string Version => ExtensionVersion.DefaultSemVer;
public string DisplayName => VideoRecorderResources.ExtensionDisplayName;
public string Description => VideoRecorderResources.ExtensionDescription;
public Type[] DataTypesConsumed => [typeof(TestNodeUpdateMessage)];
public Type[] DataTypesProduced => [typeof(SessionFileArtifact)];
public Task IsEnabledAsync() => Task.FromResult(_enabled);
public async Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext)
{
CancellationToken cancellationToken = testSessionContext.CancellationToken;
cancellationToken.ThrowIfCancellationRequested();
_sessionUid = testSessionContext.SessionUid;
if (_recorder is null)
{
return;
}
if (!_recorder.IsAvailable)
{
await _outputDevice.DisplayAsync(this, new WarningMessageOutputDeviceData(VideoRecorderResources.FfmpegNotFound), cancellationToken).ConfigureAwait(false);
return;
}
// Record continuously for the whole session regardless of granularity; both per-test clips
// and the session video are cut from this single recording afterwards.
_recorder.Start();
if (_recorder.RecordingStartUtc is null)
{
return;
}
string hint = _granularity == VideoCaptureGranularity.PerSession
? VideoRecorderResources.HintPerSession
: VideoRecorderResources.HintPerTest;
string ready = string.Format(
CultureInfo.CurrentCulture,
VideoRecorderResources.RecorderReady,
_recorder.FfmpegPath,
_granularity,
_persistMode,
hint);
await _outputDevice.DisplayAsync(this, new FormattedTextOutputDeviceData(ready), cancellationToken).ConfigureAwait(false);
}
public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext)
{
if (_recorder is null || !_recorder.IsAvailable)
{
return;
}
CancellationToken cancellationToken = testSessionContext.CancellationToken;
// The platform drains the data-consumer queue before invoking session-finishing handlers,
// so every test's terminal message has already been recorded into _testRecords.
await _recorder.StopAsync(cancellationToken).ConfigureAwait(false);
try
{
await ProduceVideosAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
DeleteDirectoryQuietly(_recorder.SegmentDirectory);
}
}
private sealed class FailedWindow
{
public FailedWindow(double startSeconds, double endSeconds, FailedWindow? next)
{
StartSeconds = startSeconds;
EndSeconds = endSeconds;
Next = next;
}
public double StartSeconds { get; }
public double EndSeconds { get; }
public FailedWindow? Next { get; }
}
}