Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new session timeout feature #16

Merged
merged 3 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading