Skip to content

Commit

Permalink
Merge pull request #16 from FlaUI/appium-new-command-timeout
Browse files Browse the repository at this point in the history
Add new session timeout feature
  • Loading branch information
aristotelos authored Apr 18, 2024
2 parents cee32df + 4a727d0 commit fef5489
Show file tree
Hide file tree
Showing 21 changed files with 302 additions and 32 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
120 changes: 119 additions & 1 deletion src/FlaUI.WebDriver.UITests/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ public void NewSession_AppNotExists_ReturnsError()
Assert.That(newSession, Throws.TypeOf<WebDriverArgumentException>().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<WebDriverArgumentException>().With.Message.EqualTo("Capability appium:app must be a string"));
}

[Test]
public void NewSession_AppTopLevelWindow_IsSupported()
{
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -125,6 +139,33 @@ public void NewSession_AppTopLevelWindowTitleMatchNotFound_ReturnsError()
Assert.That(newSession, Throws.TypeOf<WebDriverArgumentException>().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<WebDriverArgumentException>().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<WebDriverArgumentException>().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)
Expand All @@ -136,6 +177,20 @@ public void NewSession_AppTopLevelWindowInvalidFormat_ReturnsError(string appTop
Assert.That(newSession, Throws.TypeOf<WebDriverArgumentException>().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<WebDriverArgumentException>().With.Message.EqualTo("Capability appium:appTopLevelWindow must be a string"));
}

[Test]
public void GetTitle_Default_IsSupported()
{
Expand All @@ -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<WebDriverException>().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<WebDriverException>().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<WebDriverArgumentException>().With.Message.EqualTo("Capability appium:newCommandTimeout must be a number"));
}
}
}
5 changes: 3 additions & 2 deletions src/FlaUI.WebDriver.UITests/WebDriverFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/FlaUI.WebDriver/Controllers/ActionsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ private Session GetSession(string sessionId)
{
throw WebDriverResponseException.SessionNotFound(sessionId);
}
session.SetLastCommandTimeToNow();
return session;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/FlaUI.WebDriver/Controllers/ElementController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ private Session GetSession(string sessionId)
{
throw WebDriverResponseException.SessionNotFound(sessionId);
}
session.SetLastCommandTimeToNow();
return session;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/FlaUI.WebDriver/Controllers/ExecuteController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ private Session GetSession(string sessionId)
{
throw WebDriverResponseException.SessionNotFound(sessionId);
}
session.SetLastCommandTimeToNow();
return session;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/FlaUI.WebDriver/Controllers/FindElementsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ private Session GetSession(string sessionId)
{
throw WebDriverResponseException.SessionNotFound(sessionId);
}
session.SetLastCommandTimeToNow();
return session;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/FlaUI.WebDriver/Controllers/ScreenshotController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ private Session GetSession(string sessionId)
{
throw WebDriverResponseException.SessionNotFound(sessionId);
}
session.SetLastCommandTimeToNow();
return session;
}
}
Expand Down
60 changes: 50 additions & 10 deletions src/FlaUI.WebDriver/Controllers/SessionController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,7 +30,7 @@ public async Task<ActionResult> 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;
Expand All @@ -42,15 +43,15 @@ public async Task<ActionResult> 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"))
Expand All @@ -69,12 +70,12 @@ public async Task<ActionResult> 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);
Expand All @@ -84,6 +85,10 @@ public async Task<ActionResult> 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()
Expand All @@ -93,6 +98,40 @@ public async Task<ActionResult> CreateNewSession([FromBody] CreateSessionRequest
}));
}

private static bool TryGetStringCapability(Dictionary<string, JsonElement> 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<string, JsonElement> 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;
Expand Down Expand Up @@ -139,14 +178,14 @@ private static Process GetProcessByMainWindowHandle(string appTopLevelWindowStri
return process;
}

private static IEnumerable<Dictionary<string, string>> GetPossibleCapabilities(CreateSessionRequest request)
private static IEnumerable<Dictionary<string, JsonElement>> GetPossibleCapabilities(CreateSessionRequest request)
{
var requiredCapabilities = request.Capabilities.AlwaysMatch ?? new Dictionary<string, string>();
var allFirstMatchCapabilities = request.Capabilities.FirstMatch ?? new List<Dictionary<string, string>>(new[] { new Dictionary<string, string>() });
var requiredCapabilities = request.Capabilities.AlwaysMatch ?? new Dictionary<string, JsonElement>();
var allFirstMatchCapabilities = request.Capabilities.FirstMatch ?? new List<Dictionary<string, JsonElement>>(new[] { new Dictionary<string, JsonElement>() });
return allFirstMatchCapabilities.Select(firstMatchCapabilities => MergeCapabilities(firstMatchCapabilities, requiredCapabilities));
}

private static Dictionary<string, string> MergeCapabilities(Dictionary<string, string> firstMatchCapabilities, Dictionary<string, string> requiredCapabilities)
private static Dictionary<string, JsonElement> MergeCapabilities(Dictionary<string, JsonElement> firstMatchCapabilities, Dictionary<string, JsonElement> requiredCapabilities)
{
var duplicateKeys = firstMatchCapabilities.Keys.Intersect(requiredCapabilities.Keys);
if (duplicateKeys.Any())
Expand Down Expand Up @@ -183,6 +222,7 @@ private Session GetSession(string sessionId)
{
throw WebDriverResponseException.SessionNotFound(sessionId);
}
session.SetLastCommandTimeToNow();
return session;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/FlaUI.WebDriver/Controllers/TimeoutsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ private Session GetSession(string sessionId)
{
throw WebDriverResponseException.SessionNotFound(sessionId);
}
session.SetLastCommandTimeToNow();
return session;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/FlaUI.WebDriver/Controllers/WindowController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ private Session GetSession(string sessionId)
{
throw WebDriverResponseException.SessionNotFound(sessionId);
}
session.SetLastCommandTimeToNow();
return session;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/FlaUI.WebDriver/ISessionRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ public interface ISessionRepository
void Add(Session session);
void Delete(Session session);
Session? FindById(string sessionId);
List<Session> FindTimedOut();
}
}
5 changes: 3 additions & 2 deletions src/FlaUI.WebDriver/Models/Capabilities.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
using System.Collections.Generic;
using System.Text.Json;

namespace FlaUI.WebDriver.Models
{
public class Capabilities
{
public Dictionary<string, string>? AlwaysMatch { get; set; }
public List<Dictionary<string, string>>? FirstMatch { get; set; }
public Dictionary<string, JsonElement>? AlwaysMatch { get; set; }
public List<Dictionary<string, JsonElement>>? FirstMatch { get; set; }
}
}
Loading

0 comments on commit fef5489

Please sign in to comment.