Skip to content

Commit

Permalink
Better capability matching errors
Browse files Browse the repository at this point in the history
Use very specific error messages to indicate capability mismatches.
  • Loading branch information
aristotelos committed May 15, 2024
1 parent bb9eaf0 commit d0d426d
Show file tree
Hide file tree
Showing 3 changed files with 157 additions and 94 deletions.
16 changes: 8 additions & 8 deletions src/FlaUI.WebDriver.UITests/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,30 @@ public void NewSession_PlatformNameMissing_ReturnsError()

var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, emptyOptions);

Assert.That(newSession, Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("Required capabilities did not match. Capability `platformName` with value `windows` is required, capability 'appium:automationName' with value `FlaUI` is required, and one of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability (SessionNotCreated)"));
Assert.That(newSession, Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("Missing capability 'platformName' with value 'windows' (SessionNotCreated)"));
}

[Test]
public void NewSession_AutomationNameMissing_ReturnsError()
{
var emptyOptions = FlaUIDriverOptions.Empty();
emptyOptions.AddAdditionalOption("appium:platformName", "windows");
emptyOptions.PlatformName = "Windows";

var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, emptyOptions);

Assert.That(newSession, Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("Required capabilities did not match. Capability `platformName` with value `windows` is required, capability 'appium:automationName' with value `FlaUI` is required, and one of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability (SessionNotCreated)"));
Assert.That(newSession, Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("Missing capability 'appium:automationName' with value 'flaui' (SessionNotCreated)"));
}

[Test]
public void NewSession_AllAppCapabilitiesMissing_ReturnsError()
{
var emptyOptions = FlaUIDriverOptions.Empty();
emptyOptions.AddAdditionalOption("appium:platformName", "windows");
emptyOptions.PlatformName = "Windows";
emptyOptions.AddAdditionalOption("appium:automationName", "windows");

var newSession = () => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, emptyOptions);

Assert.That(newSession, Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("Required capabilities did not match. Capability `platformName` with value `windows` is required, capability 'appium:automationName' with value `FlaUI` is required, and one of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability (SessionNotCreated)"));
Assert.That(newSession, Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("Missing capability 'appium:automationName' with value 'flaui' (SessionNotCreated)"));
}

[Test]
Expand Down Expand Up @@ -115,7 +115,7 @@ public void NewSession_NotSupportedCapability_Throws()
driverOptions.AddAdditionalOption("unknown:unknown", "value");

Assert.That(() => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions),
Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("Required capabilities did not match. Capability `platformName` with value `windows` is required, capability 'appium:automationName' with value `FlaUI` is required, and one of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability (SessionNotCreated)"));
Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("The following capabilities could not be matched: 'unknown:unknown' (SessionNotCreated)"));
}

[Test]
Expand Down Expand Up @@ -158,7 +158,7 @@ public void NewSession_AppTopLevelWindowZero_ReturnsError()
Assert.That(newSession, Throws.TypeOf<WebDriverArgumentException>().With.Message.EqualTo("Capability appium:appTopLevelWindow '0x0' should not be zero"));
}

[Ignore("Sometimes multiple processes are left open")]
[Explicit("Sometimes multiple processes are left open")]
[TestCase("FlaUI WPF Test App")]
[TestCase("FlaUI WPF .*")]
public void NewSession_AppTopLevelWindowTitleMatch_IsSupported(string match)
Expand All @@ -176,7 +176,7 @@ public void NewSession_AppTopLevelWindowTitleMatch_IsSupported(string match)
Assert.That(testAppProcess.Process.HasExited, Is.False);
}

[Test, Ignore("Sometimes multiple processes are left open")]
[Test, Explicit("Sometimes multiple processes are left open")]
public void NewSession_AppTopLevelWindowTitleMatchMultipleMatching_ReturnsError()
{
using var testAppProcess = new TestAppProcess();
Expand Down
141 changes: 55 additions & 86 deletions src/FlaUI.WebDriver/Controllers/SessionController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,35 @@ public SessionController(ILogger<SessionController> logger, ISessionRepository s
[HttpPost]
public async Task<ActionResult> CreateNewSession([FromBody] CreateSessionRequest request)
{
var possibleCapabilities = GetPossibleCapabilities(request);
IDictionary<string, JsonElement>? matchedCapabilities = null;
IEnumerable<IDictionary<string, JsonElement>> matchingCapabilities = possibleCapabilities
.Where(capabilities => IsMatchingCapabilitySet(capabilities, out matchedCapabilities))
var mergedCapabilities = GetMergedCapabilities(request);
MergedCapabilities? matchedCapabilities = null;
IEnumerable<MergedCapabilities> matchingCapabilities = mergedCapabilities
.Where(capabilities => TryMatchCapabilities(capabilities, out matchedCapabilities, out _))
.Select(capabillities => matchedCapabilities!);

Core.Application? app;
var isAppOwnedBySession = false;
var capabilities = matchingCapabilities.FirstOrDefault();
if (capabilities == null)
{
var mismatchIndications = mergedCapabilities
.Select(capabilities => GetMismatchIndication(capabilities));

return WebDriverResult.Error(new ErrorResponse
{
ErrorCode = "session not created",
Message = "Required capabilities did not match. Capability `platformName` with value `windows` is required, capability 'appium:automationName' with value `FlaUI` is required, and one of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability"
Message = string.Join("; ", mismatchIndications)
});
}
if (TryGetStringCapability(capabilities, "appium:app", out var appPath))
if (capabilities.TryGetStringCapability("appium:app", out var appPath))
{
if (appPath == "Root")
{
app = null;
}
else
{
TryGetStringCapability(capabilities, "appium:appArguments", out var appArguments);
capabilities.TryGetStringCapability("appium:appArguments", out var appArguments);
try
{
if (appPath.EndsWith("!App"))
Expand All @@ -58,7 +61,7 @@ public async Task<ActionResult> CreateNewSession([FromBody] CreateSessionRequest
else
{
var processStartInfo = new ProcessStartInfo(appPath, appArguments ?? "");
if(TryGetStringCapability(capabilities, "appium:appWorkingDir", out var appWorkingDir))
if(capabilities.TryGetStringCapability("appium:appWorkingDir", out var appWorkingDir))
{
processStartInfo.WorkingDirectory = appWorkingDir;
}
Expand All @@ -73,12 +76,12 @@ public async Task<ActionResult> CreateNewSession([FromBody] CreateSessionRequest

isAppOwnedBySession = true;
}
else if (TryGetStringCapability(capabilities, "appium:appTopLevelWindow", out var appTopLevelWindowString))
else if (capabilities.TryGetStringCapability("appium:appTopLevelWindow", out var appTopLevelWindowString))
{
Process process = GetProcessByMainWindowHandle(appTopLevelWindowString);
app = Core.Application.Attach(process);
}
else if (TryGetStringCapability(capabilities, "appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch))
else if (capabilities.TryGetStringCapability("appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch))
{
Process? process = GetProcessByMainWindowTitle(appTopLevelWindowTitleMatch);
app = Core.Application.Attach(process);
Expand All @@ -88,128 +91,106 @@ 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, isAppOwnedBySession);
if(TryGetNumberCapability(capabilities, "appium:newCommandTimeout", out var newCommandTimeout))
if(capabilities.TryGetNumberCapability("appium:newCommandTimeout", out var newCommandTimeout))
{
session.NewCommandTimeout = TimeSpan.FromSeconds(newCommandTimeout);
}
if (capabilities.TryGetValue("timeouts", out var valueJson))
if (capabilities.TryGetCapability<TimeoutsConfiguration>("timeouts", out var timeoutsConfiguration))
{
var timeoutsConfiguration = JsonSerializer.Deserialize<TimeoutsConfiguration>(valueJson);
if (timeoutsConfiguration == null)
{
throw WebDriverResponseException.InvalidArgument("Could not deserialize timeouts capability");
}
session.TimeoutsConfiguration = timeoutsConfiguration;
session.TimeoutsConfiguration = timeoutsConfiguration!;
}
_sessionRepository.Add(session);
_logger.LogInformation("Created session with ID {SessionId} and capabilities {Capabilities}", session.SessionId, capabilities);
return await Task.FromResult(WebDriverResult.Success(new CreateSessionResponse()
{
SessionId = session.SessionId,
Capabilities = capabilities
Capabilities = capabilities.Capabilities
}));
}

private bool IsMatchingCapabilitySet(IDictionary<string, JsonElement> capabilities, out IDictionary<string, JsonElement> matchedCapabilities)
private string? GetMismatchIndication(MergedCapabilities capabilities)
{
TryMatchCapabilities(capabilities, out _, out var mismatchIndication);
return mismatchIndication;
}

private bool TryMatchCapabilities(MergedCapabilities capabilities, [MaybeNullWhen(false)] out MergedCapabilities matchedCapabilities, [MaybeNullWhen(true)] out string? mismatchIndication)
{
matchedCapabilities = new Dictionary<string, JsonElement>();
if (TryGetStringCapability(capabilities, "platformName", out var platformName)
matchedCapabilities = new MergedCapabilities(new Dictionary<string, JsonElement>());
if (capabilities.TryGetStringCapability("platformName", out var platformName)
&& platformName.ToLowerInvariant() == "windows")
{
matchedCapabilities.Add("platformName", capabilities["platformName"]);
matchedCapabilities.Copy("platformName", capabilities);
}
else
{
mismatchIndication = "Missing capability 'platformName' with value 'windows'";
return false;
}

if (TryGetStringCapability(capabilities, "appium:automationName", out var automationName)
if (capabilities.TryGetStringCapability("appium:automationName", out var automationName)
&& automationName.ToLowerInvariant() == "flaui")
{
matchedCapabilities.Add("appium:automationName", capabilities["appium:automationName"]);
matchedCapabilities.Copy("appium:automationName", capabilities);
}
else
{
mismatchIndication = "Missing capability 'appium:automationName' with value 'flaui'";
return false;
}

if (TryGetStringCapability(capabilities, "appium:app", out var appPath))
if (capabilities.TryGetStringCapability("appium:app", out var appPath))
{
matchedCapabilities.Add("appium:app", capabilities["appium:app"]);
matchedCapabilities.Copy("appium:app", capabilities);

if (appPath != "Root")
{
if(capabilities.ContainsKey("appium:appArguments"))
if (capabilities.Contains("appium:appArguments"))
{
matchedCapabilities.Add("appium:appArguments", capabilities["appium:appArguments"]);
matchedCapabilities.Copy("appium:appArguments", capabilities);
}
if (!appPath.EndsWith("!App"))
{
if (capabilities.ContainsKey("appium:appWorkingDir"))
if (capabilities.Contains("appium:appWorkingDir"))
{
matchedCapabilities.Add("appium:appWorkingDir", capabilities["appium:appWorkingDir"]);
matchedCapabilities.Copy("appium:appWorkingDir", capabilities);
}
}
}
}
else if (capabilities.ContainsKey("appium:appTopLevelWindow"))
else if (capabilities.Contains("appium:appTopLevelWindow"))
{
matchedCapabilities.Add("appium:appTopLevelWindow", capabilities["appium:appTopLevelWindow"]);
matchedCapabilities.Copy("appium:appTopLevelWindow", capabilities);
}
else if (capabilities.ContainsKey("appium:appTopLevelWindowTitleMatch"))
{
matchedCapabilities.Add("appium:appTopLevelWindowTitleMatch", capabilities["appium:appTopLevelWindowTitleMatch"]);
else if (capabilities.Contains("appium:appTopLevelWindowTitleMatch"))
{
matchedCapabilities.Copy("appium:appTopLevelWindowTitleMatch", capabilities);
}
else
{
mismatchIndication = "One of 'appium:app', 'appium:appTopLevelWindow' or 'appium:appTopLevelWindowTitleMatch' should be specified";
return false;
}

if (capabilities.ContainsKey("appium:newCommandTimeout"))
if (capabilities.Contains("appium:newCommandTimeout"))
{
matchedCapabilities.Add("appium:newCommandTimeout", capabilities["appium:newCommandTimeout"]); ;
matchedCapabilities.Copy("appium:newCommandTimeout", capabilities); ;
}

if (capabilities.ContainsKey("timeouts"))
if (capabilities.Contains("timeouts"))
{
matchedCapabilities.Add("timeouts", capabilities["timeouts"]);
matchedCapabilities.Copy("timeouts", capabilities);
}

return matchedCapabilities.Count == capabilities.Count;
}

private static bool TryGetStringCapability(IDictionary<string, JsonElement> capabilities, string key, [MaybeNullWhen(false)] out string value)
{
if(capabilities.TryGetValue(key, out var valueJson))
var notMatchedCapabilities = capabilities.Capabilities.Keys.Except(matchedCapabilities.Capabilities.Keys);
if (notMatchedCapabilities.Any())
{
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(IDictionary<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;
mismatchIndication = $"The following capabilities could not be matched: '{string.Join("', '", notMatchedCapabilities)}'";
return false;
}

value = default;
return false;
mismatchIndication = null;
return true;
}

private static Process GetProcessByMainWindowTitle(string appTopLevelWindowTitleMatch)
Expand Down Expand Up @@ -258,23 +239,11 @@ private static Process GetProcessByMainWindowHandle(string appTopLevelWindowStri
return process;
}

private static IEnumerable<IDictionary<string, JsonElement>> GetPossibleCapabilities(CreateSessionRequest request)
private static IEnumerable<MergedCapabilities> GetMergedCapabilities(CreateSessionRequest request)
{
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 IDictionary<string, JsonElement> MergeCapabilities(IDictionary<string, JsonElement> firstMatchCapabilities, IDictionary<string, JsonElement> requiredCapabilities)
{
var duplicateKeys = firstMatchCapabilities.Keys.Intersect(requiredCapabilities.Keys);
if (duplicateKeys.Any())
{
throw WebDriverResponseException.InvalidArgument($"Capabilities cannot be merged because there are duplicate capabilities: {string.Join(", ", duplicateKeys)}");
}

return firstMatchCapabilities.Concat(requiredCapabilities)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
return allFirstMatchCapabilities.Select(firstMatchCapabilities => new MergedCapabilities(firstMatchCapabilities, requiredCapabilities));
}

[HttpDelete("{sessionId}")]
Expand Down
Loading

0 comments on commit d0d426d

Please sign in to comment.