From ad6a4d442f801fb323475ee4583ea64436e70c7e Mon Sep 17 00:00:00 2001 From: aristotelos Date: Wed, 17 Apr 2024 10:46:14 +0200 Subject: [PATCH 1/2] Add new session timeout feature Cleanup unused sessions after the `appium:newCommandTimeout` interval (default 60 seconds). --- README.md | 1 + src/FlaUI.WebDriver.UITests/SessionTests.cs | 51 ++++++++++++++ .../WebDriverFixture.cs | 5 +- .../Controllers/ActionsController.cs | 1 + .../Controllers/ElementController.cs | 1 + .../Controllers/ExecuteController.cs | 1 + .../Controllers/FindElementsController.cs | 1 + .../Controllers/ScreenshotController.cs | 1 + .../Controllers/SessionController.cs | 25 ++++--- .../Controllers/TimeoutsController.cs | 1 + .../Controllers/WindowController.cs | 1 + src/FlaUI.WebDriver/ISessionRepository.cs | 1 + src/FlaUI.WebDriver/Models/Capabilities.cs | 5 +- .../Models/CreateSessionResponse.cs | 3 +- src/FlaUI.WebDriver/Program.cs | 4 ++ .../Properties/launchSettings.json | 30 ++++---- src/FlaUI.WebDriver/Session.cs | 10 +++ src/FlaUI.WebDriver/SessionCleanupOptions.cs | 9 +++ src/FlaUI.WebDriver/SessionCleanupService.cs | 69 +++++++++++++++++++ src/FlaUI.WebDriver/SessionRepository.cs | 5 ++ src/FlaUI.WebDriver/appsettings.json | 5 +- 21 files changed, 199 insertions(+), 31 deletions(-) create mode 100644 src/FlaUI.WebDriver/SessionCleanupOptions.cs create mode 100644 src/FlaUI.WebDriver/SessionCleanupService.cs diff --git a/README.md b/README.md index d016d2c..8560a52 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 b925a97..37db5c9 100644 --- a/src/FlaUI.WebDriver.UITests/SessionTests.cs +++ b/src/FlaUI.WebDriver.UITests/SessionTests.cs @@ -135,5 +135,56 @@ 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); + } } } 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 3645ab0..9deab7d 100644 --- a/src/FlaUI.WebDriver/Controllers/SessionController.cs +++ b/src/FlaUI.WebDriver/Controllers/SessionController.cs @@ -29,7 +29,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; @@ -44,16 +44,16 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest } if (capabilities.TryGetValue("appium:app", out var appPath)) { - if (appPath == "Root") + if (appPath.GetString() == "Root") { app = null; } else { - capabilities.TryGetValue("appium:appArguments", out var appArguments); + bool hasArguments = capabilities.TryGetValue("appium:appArguments", out var appArguments); try { - var processStartInfo = new ProcessStartInfo(appPath, appArguments ?? ""); + var processStartInfo = new ProcessStartInfo(appPath.GetString()!, hasArguments ? appArguments.GetString()! : ""); app = Core.Application.Launch(processStartInfo); } catch(Exception e) @@ -64,12 +64,12 @@ public async Task CreateNewSession([FromBody] CreateSessionRequest } else if(capabilities.TryGetValue("appium:appTopLevelWindow", out var appTopLevelWindowString)) { - Process process = GetProcessByMainWindowHandle(appTopLevelWindowString); + Process process = GetProcessByMainWindowHandle(appTopLevelWindowString.GetString()!); app = Core.Application.Attach(process); } else if (capabilities.TryGetValue("appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch)) { - Process? process = GetProcessByMainWindowTitle(appTopLevelWindowTitleMatch); + Process? process = GetProcessByMainWindowTitle(appTopLevelWindowTitleMatch.GetString()!); app = Core.Application.Attach(process); } else @@ -77,6 +77,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(capabilities.TryGetValue("appium:newCommandTimeout", out var newCommandTimeout)) + { + session.NewCommandTimeout = TimeSpan.FromSeconds(newCommandTimeout.GetDouble()); + } _sessionRepository.Add(session); _logger.LogInformation("Created session with ID {SessionId} and capabilities {Capabilities}", session.SessionId, capabilities); return await Task.FromResult(WebDriverResult.Success(new CreateSessionResponse() @@ -132,14 +136,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()) @@ -176,6 +180,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 + } } From 4a727d073624eca8469841b682a627a5e03648b3 Mon Sep 17 00:00:00 2001 From: aristotelos Date: Thu, 18 Apr 2024 08:56:00 +0200 Subject: [PATCH 2/2] Improve capability validation --- src/FlaUI.WebDriver.UITests/SessionTests.cs | 69 ++++++++++++++++++- .../Controllers/SessionController.cs | 62 +++++++++++++---- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/src/FlaUI.WebDriver.UITests/SessionTests.cs b/src/FlaUI.WebDriver.UITests/SessionTests.cs index 759c067..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() { @@ -197,5 +252,17 @@ public void NewCommandTimeout_NotExpired_DoesNotEndSession() 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/Controllers/SessionController.cs b/src/FlaUI.WebDriver/Controllers/SessionController.cs index 7e9ce56..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; @@ -42,25 +43,24 @@ 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.GetString() == "Root") + if (appPath == "Root") { app = null; } else - { - bool hasArguments = capabilities.TryGetValue("appium:appArguments", out var appArgumentsValue); - var appArguments = hasArguments ? appArgumentsValue.GetString()! : ""; + { + TryGetStringCapability(capabilities, "appium:appArguments", out var appArguments); try { - if (appPath.GetString()!.EndsWith("!App")) + if (appPath.EndsWith("!App")) { - app = Core.Application.LaunchStoreApp(appPath.GetString()!, appArguments); + app = Core.Application.LaunchStoreApp(appPath, appArguments); } else { - var processStartInfo = new ProcessStartInfo(appPath.GetString()!, appArguments); + var processStartInfo = new ProcessStartInfo(appPath, appArguments ?? ""); app = Core.Application.Launch(processStartInfo); } } @@ -70,14 +70,14 @@ 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.GetString()!); + 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.GetString()!); + Process? process = GetProcessByMainWindowTitle(appTopLevelWindowTitleMatch); app = Core.Application.Attach(process); } else @@ -85,9 +85,9 @@ 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(capabilities.TryGetValue("appium:newCommandTimeout", out var newCommandTimeout)) + if(TryGetNumberCapability(capabilities, "appium:newCommandTimeout", out var newCommandTimeout)) { - session.NewCommandTimeout = TimeSpan.FromSeconds(newCommandTimeout.GetDouble()); + session.NewCommandTimeout = TimeSpan.FromSeconds(newCommandTimeout); } _sessionRepository.Add(session); _logger.LogInformation("Created session with ID {SessionId} and capabilities {Capabilities}", session.SessionId, capabilities); @@ -98,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;