diff --git a/README.md b/README.md index 78d4b3f..3bb289d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The following capabilities are supported: | appium:appArguments | Application arguments string, for example `/?`. | | | appium:appTopLevelWindow | The hexadecimal handle of an existing application top level window to attach to, for example `0x12345` (should be of string type). Either this capability, `appTopLevelWindowTitleMatch` or `app` must be provided on session startup. | `0xC0B46` | | appium:appTopLevelWindowTitleMatch | The title of an existing application top level window to attach to, for example `My App Window Title` (should be of string type). Either this capability, `appTopLevelWindow` or `app` must be provided on session startup. | `My App Window Title` or `My App Window Title - .*` | +| appium:newCommandTimeout | The number of seconds the to wait for clients to send commands before deciding that the client has gone away and the session should shut down. Default one minute (60). | `120` | ## Getting Started diff --git a/src/FlaUI.WebDriver.UITests/SessionTests.cs b/src/FlaUI.WebDriver.UITests/SessionTests.cs index 86503ac..85f71e3 100644 --- a/src/FlaUI.WebDriver.UITests/SessionTests.cs +++ b/src/FlaUI.WebDriver.UITests/SessionTests.cs @@ -40,6 +40,20 @@ public void NewSession_AppNotExists_ReturnsError() Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Starting app 'C:\\NotExisting.exe' with arguments '' threw an exception: An error occurred trying to start process 'C:\\NotExisting.exe' with working directory '.'. The system cannot find the file specified.")); } + [TestCase(123)] + [TestCase(false)] + public void NewSession_AppNotAString_Throws(object value) + { + var driverOptions = new FlaUIDriverOptions() + { + PlatformName = "Windows" + }; + driverOptions.AddAdditionalOption("appium:app", value); + + Assert.That(() => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions), + Throws.TypeOf().With.Message.EqualTo("Capability appium:app must be a string")); + } + [Test] public void NewSession_AppTopLevelWindow_IsSupported() { @@ -91,7 +105,7 @@ public void NewSession_AppTopLevelWindowTitleMatch_IsSupported(string match) } [Test, Ignore("Sometimes multiple processes are left open")] - public void NewSession_MultipleMatchingAppTopLevelWindowTitleMatch_ReturnsError() + public void NewSession_AppTopLevelWindowTitleMatchMultipleMatching_ReturnsError() { using var testAppProcess = new TestAppProcess(); using var testAppProcess1 = new TestAppProcess(); @@ -125,6 +139,33 @@ public void NewSession_AppTopLevelWindowTitleMatchNotFound_ReturnsError() Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo("Process with main window title matching 'FlaUI Not Existing' could not be found")); } + [TestCase(123)] + [TestCase(false)] + public void NewSession_AppTopLevelWindowTitleMatchNotAString_Throws(object value) + { + var driverOptions = new FlaUIDriverOptions() + { + PlatformName = "Windows" + }; + driverOptions.AddAdditionalOption("appium:appTopLevelWindowTitleMatch", value); + + Assert.That(() => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions), + Throws.TypeOf().With.Message.EqualTo("Capability appium:appTopLevelWindowTitleMatch must be a string")); + } + + [TestCase("(invalid")] + public void NewSession_AppTopLevelWindowTitleMatchInvalidRegex_Throws(string value) + { + var driverOptions = new FlaUIDriverOptions() + { + PlatformName = "Windows" + }; + driverOptions.AddAdditionalOption("appium:appTopLevelWindowTitleMatch", value); + + Assert.That(() => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions), + Throws.TypeOf().With.Message.EqualTo("Capability appium:appTopLevelWindowTitleMatch '(invalid' is not a valid regular expression: Invalid pattern '(invalid' at offset 8. Not enough )'s.")); + } + [TestCase("")] [TestCase("FlaUI")] public void NewSession_AppTopLevelWindowInvalidFormat_ReturnsError(string appTopLevelWindowString) @@ -136,6 +177,20 @@ public void NewSession_AppTopLevelWindowInvalidFormat_ReturnsError(string appTop Assert.That(newSession, Throws.TypeOf().With.Message.EqualTo($"Capability appium:appTopLevelWindow '{appTopLevelWindowString}' is not a valid hexadecimal string")); } + [TestCase(123)] + [TestCase(false)] + public void NewSession_AppTopLevelWindowNotAString_ReturnsError(object value) + { + var driverOptions = new FlaUIDriverOptions() + { + PlatformName = "Windows" + }; + driverOptions.AddAdditionalOption("appium:appTopLevelWindow", value); + + Assert.That(() => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions), + Throws.TypeOf().With.Message.EqualTo("Capability appium:appTopLevelWindow must be a string")); + } + [Test] public void GetTitle_Default_IsSupported() { @@ -146,5 +201,68 @@ public void GetTitle_Default_IsSupported() Assert.That(title, Is.EqualTo("FlaUI WPF Test App")); } + + [Test, Explicit("Takes too long (one minute)")] + public void NewCommandTimeout_DefaultValue_OneMinute() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + System.Threading.Thread.Sleep(TimeSpan.FromSeconds(60) + WebDriverFixture.SessionCleanupInterval*2); + + Assert.That(() => driver.Title, Throws.TypeOf().With.Message.Matches("No active session with ID '.*'")); + } + + [Test] + public void NewCommandTimeout_Expired_EndsSession() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + driverOptions.AddAdditionalOption("appium:newCommandTimeout", 1); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + System.Threading.Thread.Sleep(TimeSpan.FromSeconds(1) + WebDriverFixture.SessionCleanupInterval * 2); + + Assert.That(() => driver.Title, Throws.TypeOf().With.Message.Matches("No active session with ID '.*'")); + } + + [Test] + public void NewCommandTimeout_ReceivedCommandsBeforeExpiry_DoesNotEndSession() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + driverOptions.AddAdditionalOption("appium:newCommandTimeout", WebDriverFixture.SessionCleanupInterval.TotalSeconds * 4); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + System.Threading.Thread.Sleep(WebDriverFixture.SessionCleanupInterval * 2); + _ = driver.Title; + System.Threading.Thread.Sleep(WebDriverFixture.SessionCleanupInterval * 2); + _ = driver.Title; + System.Threading.Thread.Sleep(WebDriverFixture.SessionCleanupInterval * 2); + + Assert.That(() => driver.Title, Throws.Nothing); + } + + [Test] + public void NewCommandTimeout_NotExpired_DoesNotEndSession() + { + var driverOptions = FlaUIDriverOptions.TestApp(); + driverOptions.AddAdditionalOption("appium:newCommandTimeout", 240); + using var driver = new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions); + + System.Threading.Thread.Sleep(WebDriverFixture.SessionCleanupInterval * 2); + + Assert.That(() => driver.Title, Throws.Nothing); + } + + [TestCase("123")] + [TestCase(false)] + [TestCase("not a number")] + public void NewCommandTimeout_InvalidValue_Throws(object value) + { + var driverOptions = FlaUIDriverOptions.TestApp(); + driverOptions.AddAdditionalOption("appium:newCommandTimeout", value); + + Assert.That(() => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions), + Throws.TypeOf().With.Message.EqualTo("Capability appium:newCommandTimeout must be a number")); + } } } diff --git a/src/FlaUI.WebDriver.UITests/WebDriverFixture.cs b/src/FlaUI.WebDriver.UITests/WebDriverFixture.cs index 9599ae5..d8fb2c2 100644 --- a/src/FlaUI.WebDriver.UITests/WebDriverFixture.cs +++ b/src/FlaUI.WebDriver.UITests/WebDriverFixture.cs @@ -9,7 +9,8 @@ namespace FlaUI.WebDriver.UITests [SetUpFixture] public class WebDriverFixture { - public static readonly Uri WebDriverUrl = new Uri("http://localhost:9723/"); + public static readonly Uri WebDriverUrl = new Uri("http://localhost:4723/"); + public static readonly TimeSpan SessionCleanupInterval = TimeSpan.FromSeconds(1); private Process _webDriverProcess; @@ -20,7 +21,7 @@ public void Setup() Directory.SetCurrentDirectory(assemblyDir); string webDriverPath = Path.Combine(Directory.GetCurrentDirectory(), "FlaUI.WebDriver.exe"); - var webDriverArguments = $"--urls={WebDriverUrl}"; + var webDriverArguments = $"--urls={WebDriverUrl} --SessionCleanup:SchedulingIntervalSeconds={SessionCleanupInterval.TotalSeconds}"; var webDriverProcessStartInfo = new ProcessStartInfo(webDriverPath, webDriverArguments) { RedirectStandardError = true, diff --git a/src/FlaUI.WebDriver/Controllers/ActionsController.cs b/src/FlaUI.WebDriver/Controllers/ActionsController.cs index d7202e0..a0c7c35 100644 --- a/src/FlaUI.WebDriver/Controllers/ActionsController.cs +++ b/src/FlaUI.WebDriver/Controllers/ActionsController.cs @@ -333,6 +333,7 @@ private Session GetSession(string sessionId) { throw WebDriverResponseException.SessionNotFound(sessionId); } + session.SetLastCommandTimeToNow(); return session; } } diff --git a/src/FlaUI.WebDriver/Controllers/ElementController.cs b/src/FlaUI.WebDriver/Controllers/ElementController.cs index 6e9d5a7..022bef7 100644 --- a/src/FlaUI.WebDriver/Controllers/ElementController.cs +++ b/src/FlaUI.WebDriver/Controllers/ElementController.cs @@ -219,6 +219,7 @@ private Session GetSession(string sessionId) { throw WebDriverResponseException.SessionNotFound(sessionId); } + session.SetLastCommandTimeToNow(); return session; } } diff --git a/src/FlaUI.WebDriver/Controllers/ExecuteController.cs b/src/FlaUI.WebDriver/Controllers/ExecuteController.cs index 36495f6..610d89b 100644 --- a/src/FlaUI.WebDriver/Controllers/ExecuteController.cs +++ b/src/FlaUI.WebDriver/Controllers/ExecuteController.cs @@ -78,6 +78,7 @@ private Session GetSession(string sessionId) { throw WebDriverResponseException.SessionNotFound(sessionId); } + session.SetLastCommandTimeToNow(); return session; } } diff --git a/src/FlaUI.WebDriver/Controllers/FindElementsController.cs b/src/FlaUI.WebDriver/Controllers/FindElementsController.cs index 9c675bb..940885a 100644 --- a/src/FlaUI.WebDriver/Controllers/FindElementsController.cs +++ b/src/FlaUI.WebDriver/Controllers/FindElementsController.cs @@ -210,6 +210,7 @@ private Session GetSession(string sessionId) { throw WebDriverResponseException.SessionNotFound(sessionId); } + session.SetLastCommandTimeToNow(); return session; } } diff --git a/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs b/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs index 9fd70a2..6f45aa4 100644 --- a/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs +++ b/src/FlaUI.WebDriver/Controllers/ScreenshotController.cs @@ -75,6 +75,7 @@ private Session GetSession(string sessionId) { throw WebDriverResponseException.SessionNotFound(sessionId); } + session.SetLastCommandTimeToNow(); return session; } } diff --git a/src/FlaUI.WebDriver/Controllers/SessionController.cs b/src/FlaUI.WebDriver/Controllers/SessionController.cs index a0bc3a4..ee7583e 100644 --- a/src/FlaUI.WebDriver/Controllers/SessionController.cs +++ b/src/FlaUI.WebDriver/Controllers/SessionController.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; using System.Text.RegularExpressions; @@ -29,7 +30,7 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest { var possibleCapabilities = GetPossibleCapabilities(request); var matchingCapabilities = possibleCapabilities.Where( - capabilities => capabilities.TryGetValue("platformName", out var platformName) && platformName.ToLowerInvariant() == "windows" + capabilities => capabilities.TryGetValue("platformName", out var platformName) && platformName.GetString()?.ToLowerInvariant() == "windows" ); Core.Application? app; @@ -42,15 +43,15 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest Message = "Required capabilities did not match. Capability `platformName` with value `windows` is required" }); } - if (capabilities.TryGetValue("appium:app", out var appPath)) + if (TryGetStringCapability(capabilities, "appium:app", out var appPath)) { if (appPath == "Root") { app = null; } else - { - capabilities.TryGetValue("appium:appArguments", out var appArguments); + { + TryGetStringCapability(capabilities, "appium:appArguments", out var appArguments); try { if (appPath.EndsWith("!App")) @@ -69,12 +70,12 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest } } } - else if(capabilities.TryGetValue("appium:appTopLevelWindow", out var appTopLevelWindowString)) + else if (TryGetStringCapability(capabilities, "appium:appTopLevelWindow", out var appTopLevelWindowString)) { Process process = GetProcessByMainWindowHandle(appTopLevelWindowString); app = Core.Application.Attach(process); } - else if (capabilities.TryGetValue("appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch)) + else if (TryGetStringCapability(capabilities, "appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch)) { Process? process = GetProcessByMainWindowTitle(appTopLevelWindowTitleMatch); app = Core.Application.Attach(process); @@ -84,6 +85,10 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest throw WebDriverResponseException.InvalidArgument("One of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability"); } var session = new Session(app); + if(TryGetNumberCapability(capabilities, "appium:newCommandTimeout", out var newCommandTimeout)) + { + session.NewCommandTimeout = TimeSpan.FromSeconds(newCommandTimeout); + } _sessionRepository.Add(session); _logger.LogInformation("Created session with ID {SessionId} and capabilities {Capabilities}", session.SessionId, capabilities); return await Task.FromResult(WebDriverResult.Success(new CreateSessionResponse() @@ -93,6 +98,40 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest })); } + private static bool TryGetStringCapability(Dictionary capabilities, string key, [MaybeNullWhen(false)] out string value) + { + if(capabilities.TryGetValue(key, out var valueJson)) + { + if(valueJson.ValueKind != JsonValueKind.String) + { + throw WebDriverResponseException.InvalidArgument($"Capability {key} must be a string"); + } + + value = valueJson.GetString(); + return value != null; + } + + value = null; + return false; + } + + private static bool TryGetNumberCapability(Dictionary capabilities, string key, out double value) + { + if (capabilities.TryGetValue(key, out var valueJson)) + { + if (valueJson.ValueKind != JsonValueKind.Number) + { + throw WebDriverResponseException.InvalidArgument($"Capability {key} must be a number"); + } + + value = valueJson.GetDouble(); + return true; + } + + value = default; + return false; + } + private static Process GetProcessByMainWindowTitle(string appTopLevelWindowTitleMatch) { Regex appMainWindowTitleRegex; @@ -139,14 +178,14 @@ private static Process GetProcessByMainWindowHandle(string appTopLevelWindowStri return process; } - private static IEnumerable> GetPossibleCapabilities(CreateSessionRequest request) + private static IEnumerable> GetPossibleCapabilities(CreateSessionRequest request) { - var requiredCapabilities = request.Capabilities.AlwaysMatch ?? new Dictionary(); - var allFirstMatchCapabilities = request.Capabilities.FirstMatch ?? new List>(new[] { new Dictionary() }); + var requiredCapabilities = request.Capabilities.AlwaysMatch ?? new Dictionary(); + var allFirstMatchCapabilities = request.Capabilities.FirstMatch ?? new List>(new[] { new Dictionary() }); return allFirstMatchCapabilities.Select(firstMatchCapabilities => MergeCapabilities(firstMatchCapabilities, requiredCapabilities)); } - private static Dictionary MergeCapabilities(Dictionary firstMatchCapabilities, Dictionary requiredCapabilities) + private static Dictionary MergeCapabilities(Dictionary firstMatchCapabilities, Dictionary requiredCapabilities) { var duplicateKeys = firstMatchCapabilities.Keys.Intersect(requiredCapabilities.Keys); if (duplicateKeys.Any()) @@ -183,6 +222,7 @@ private Session GetSession(string sessionId) { throw WebDriverResponseException.SessionNotFound(sessionId); } + session.SetLastCommandTimeToNow(); return session; } } diff --git a/src/FlaUI.WebDriver/Controllers/TimeoutsController.cs b/src/FlaUI.WebDriver/Controllers/TimeoutsController.cs index f189b78..78f6b24 100644 --- a/src/FlaUI.WebDriver/Controllers/TimeoutsController.cs +++ b/src/FlaUI.WebDriver/Controllers/TimeoutsController.cs @@ -42,6 +42,7 @@ private Session GetSession(string sessionId) { throw WebDriverResponseException.SessionNotFound(sessionId); } + session.SetLastCommandTimeToNow(); return session; } } diff --git a/src/FlaUI.WebDriver/Controllers/WindowController.cs b/src/FlaUI.WebDriver/Controllers/WindowController.cs index 6a9e66d..49569fe 100644 --- a/src/FlaUI.WebDriver/Controllers/WindowController.cs +++ b/src/FlaUI.WebDriver/Controllers/WindowController.cs @@ -172,6 +172,7 @@ private Session GetSession(string sessionId) { throw WebDriverResponseException.SessionNotFound(sessionId); } + session.SetLastCommandTimeToNow(); return session; } } diff --git a/src/FlaUI.WebDriver/ISessionRepository.cs b/src/FlaUI.WebDriver/ISessionRepository.cs index 32881e0..e90b0ab 100644 --- a/src/FlaUI.WebDriver/ISessionRepository.cs +++ b/src/FlaUI.WebDriver/ISessionRepository.cs @@ -5,5 +5,6 @@ public interface ISessionRepository void Add(Session session); void Delete(Session session); Session? FindById(string sessionId); + List FindTimedOut(); } } diff --git a/src/FlaUI.WebDriver/Models/Capabilities.cs b/src/FlaUI.WebDriver/Models/Capabilities.cs index b5c3954..c020fe7 100644 --- a/src/FlaUI.WebDriver/Models/Capabilities.cs +++ b/src/FlaUI.WebDriver/Models/Capabilities.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; +using System.Text.Json; namespace FlaUI.WebDriver.Models { public class Capabilities { - public Dictionary? AlwaysMatch { get; set; } - public List>? FirstMatch { get; set; } + public Dictionary? AlwaysMatch { get; set; } + public List>? FirstMatch { get; set; } } } diff --git a/src/FlaUI.WebDriver/Models/CreateSessionResponse.cs b/src/FlaUI.WebDriver/Models/CreateSessionResponse.cs index ffe0eaf..3dacf7e 100644 --- a/src/FlaUI.WebDriver/Models/CreateSessionResponse.cs +++ b/src/FlaUI.WebDriver/Models/CreateSessionResponse.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; +using System.Text.Json; namespace FlaUI.WebDriver.Models { public class CreateSessionResponse { public string SessionId { get; set; } = null!; - public Dictionary Capabilities { get; set; } = new Dictionary(); + public Dictionary Capabilities { get; set; } = new Dictionary(); } } diff --git a/src/FlaUI.WebDriver/Program.cs b/src/FlaUI.WebDriver/Program.cs index 4ef1cd6..4cbba6c 100644 --- a/src/FlaUI.WebDriver/Program.cs +++ b/src/FlaUI.WebDriver/Program.cs @@ -15,6 +15,10 @@ c.SwaggerDoc("v1", new OpenApiInfo { Title = "FlaUI.WebDriver", Version = "v1" }); }); +builder.Services.Configure( + builder.Configuration.GetSection(SessionCleanupOptions.OptionsSectionName)); +builder.Services.AddHostedService(); + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/src/FlaUI.WebDriver/Properties/launchSettings.json b/src/FlaUI.WebDriver/Properties/launchSettings.json index 4aeed20..c122417 100644 --- a/src/FlaUI.WebDriver/Properties/launchSettings.json +++ b/src/FlaUI.WebDriver/Properties/launchSettings.json @@ -1,23 +1,14 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:26539", - "sslPort": 0 - } - }, +{ "profiles": { "FlaUI.WebDriver": { "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, + "commandLineArgs": "--urls=http://localhost:4723/", "launchUrl": "swagger", - "applicationUrl": "http://localhost:4723", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - } + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:4723" }, "IIS Express": { "commandName": "IISExpress", @@ -27,5 +18,14 @@ "ASPNETCORE_ENVIRONMENT": "Development" } } + }, + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:26539", + "sslPort": 0 + } } -} +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/Session.cs b/src/FlaUI.WebDriver/Session.cs index c715a23..834a21a 100644 --- a/src/FlaUI.WebDriver/Session.cs +++ b/src/FlaUI.WebDriver/Session.cs @@ -66,6 +66,16 @@ public string CurrentWindowHandle } } + public bool IsTimedOut => (DateTime.UtcNow - LastNewCommandTimeUtc) > NewCommandTimeout; + + public TimeSpan NewCommandTimeout { get; internal set; } = TimeSpan.FromSeconds(60); + public DateTime LastNewCommandTimeUtc { get; internal set; } = DateTime.UtcNow; + + public void SetLastCommandTimeToNow() + { + LastNewCommandTimeUtc = DateTime.UtcNow; + } + public KnownElement GetOrAddKnownElement(AutomationElement element) { var result = KnownElementsByElementReference.Values.FirstOrDefault(knownElement => knownElement.Element.Equals(element)); diff --git a/src/FlaUI.WebDriver/SessionCleanupOptions.cs b/src/FlaUI.WebDriver/SessionCleanupOptions.cs new file mode 100644 index 0000000..98f3ec2 --- /dev/null +++ b/src/FlaUI.WebDriver/SessionCleanupOptions.cs @@ -0,0 +1,9 @@ +namespace FlaUI.WebDriver +{ + public class SessionCleanupOptions + { + public const string OptionsSectionName = "SessionCleanup"; + + public double SchedulingIntervalSeconds { get; set; } = 60; + } +} \ No newline at end of file diff --git a/src/FlaUI.WebDriver/SessionCleanupService.cs b/src/FlaUI.WebDriver/SessionCleanupService.cs new file mode 100644 index 0000000..47bdb9b --- /dev/null +++ b/src/FlaUI.WebDriver/SessionCleanupService.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Options; + +namespace FlaUI.WebDriver +{ + public class SessionCleanupService : IHostedService, IDisposable + { + private readonly ILogger _logger; + private Timer? _timer = null; + + public IServiceProvider Services { get; } + public IOptions Options { get; } + + public SessionCleanupService(IServiceProvider services, IOptions options, ILogger logger) + { + Services = services; + Options = options; + _logger = logger; + } + + public Task StartAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Session cleanup service running every {SchedulingIntervalSeconds} seconds", Options.Value.SchedulingIntervalSeconds); + + _timer = new Timer(DoWork, null, TimeSpan.FromSeconds(Options.Value.SchedulingIntervalSeconds), TimeSpan.FromSeconds(Options.Value.SchedulingIntervalSeconds)); + + return Task.CompletedTask; + } + + private void DoWork(object? state) + { + using (var scope = Services.CreateScope()) + { + var sessionRepository = + scope.ServiceProvider + .GetRequiredService(); + + var timedOutSessions = sessionRepository.FindTimedOut(); + if(timedOutSessions.Count > 0) + { + _logger.LogInformation("Session cleanup service cleaning up {Count} sessions that did not receive commands in their specified new command timeout interval", timedOutSessions.Count); + + foreach (Session session in timedOutSessions) + { + sessionRepository.Delete(session); + session.Dispose(); + } + } + else + { + _logger.LogInformation("Session cleanup service did not find sessions to cleanup"); + } + } + } + + public Task StopAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Session cleanup service is stopping"); + + _timer?.Change(Timeout.Infinite, 0); + + return Task.CompletedTask; + } + + public void Dispose() + { + _timer?.Dispose(); + } + } +} diff --git a/src/FlaUI.WebDriver/SessionRepository.cs b/src/FlaUI.WebDriver/SessionRepository.cs index fb0e146..ba258c4 100644 --- a/src/FlaUI.WebDriver/SessionRepository.cs +++ b/src/FlaUI.WebDriver/SessionRepository.cs @@ -21,5 +21,10 @@ public void Delete(Session session) { Sessions.Remove(session); } + + public List FindTimedOut() + { + return Sessions.Where(session => session.IsTimedOut).ToList(); + } } } diff --git a/src/FlaUI.WebDriver/appsettings.json b/src/FlaUI.WebDriver/appsettings.json index 10f68b8..5c19971 100644 --- a/src/FlaUI.WebDriver/appsettings.json +++ b/src/FlaUI.WebDriver/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "SessionCleanup": { + "ScheduledIntervalSeconds": 60 + } }