Skip to content

Commit d0d426d

Browse files
committed
Better capability matching errors
Use very specific error messages to indicate capability mismatches.
1 parent bb9eaf0 commit d0d426d

3 files changed

Lines changed: 157 additions & 94 deletions

File tree

src/FlaUI.WebDriver.UITests/SessionTests.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,30 +17,30 @@ public void NewSession_PlatformNameMissing_ReturnsError()
1717

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

20-
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)"));
20+
Assert.That(newSession, Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("Missing capability 'platformName' with value 'windows' (SessionNotCreated)"));
2121
}
2222

2323
[Test]
2424
public void NewSession_AutomationNameMissing_ReturnsError()
2525
{
2626
var emptyOptions = FlaUIDriverOptions.Empty();
27-
emptyOptions.AddAdditionalOption("appium:platformName", "windows");
27+
emptyOptions.PlatformName = "Windows";
2828

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

31-
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)"));
31+
Assert.That(newSession, Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("Missing capability 'appium:automationName' with value 'flaui' (SessionNotCreated)"));
3232
}
3333

3434
[Test]
3535
public void NewSession_AllAppCapabilitiesMissing_ReturnsError()
3636
{
3737
var emptyOptions = FlaUIDriverOptions.Empty();
38-
emptyOptions.AddAdditionalOption("appium:platformName", "windows");
38+
emptyOptions.PlatformName = "Windows";
3939
emptyOptions.AddAdditionalOption("appium:automationName", "windows");
4040

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

43-
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)"));
43+
Assert.That(newSession, Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("Missing capability 'appium:automationName' with value 'flaui' (SessionNotCreated)"));
4444
}
4545

4646
[Test]
@@ -115,7 +115,7 @@ public void NewSession_NotSupportedCapability_Throws()
115115
driverOptions.AddAdditionalOption("unknown:unknown", "value");
116116

117117
Assert.That(() => new RemoteWebDriver(WebDriverFixture.WebDriverUrl, driverOptions),
118-
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)"));
118+
Throws.TypeOf<InvalidOperationException>().With.Message.EqualTo("The following capabilities could not be matched: 'unknown:unknown' (SessionNotCreated)"));
119119
}
120120

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

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

179-
[Test, Ignore("Sometimes multiple processes are left open")]
179+
[Test, Explicit("Sometimes multiple processes are left open")]
180180
public void NewSession_AppTopLevelWindowTitleMatchMultipleMatching_ReturnsError()
181181
{
182182
using var testAppProcess = new TestAppProcess();

src/FlaUI.WebDriver/Controllers/SessionController.cs

Lines changed: 55 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -23,32 +23,35 @@ public SessionController(ILogger<SessionController> logger, ISessionRepository s
2323
[HttpPost]
2424
public async Task<ActionResult> CreateNewSession([FromBody] CreateSessionRequest request)
2525
{
26-
var possibleCapabilities = GetPossibleCapabilities(request);
27-
IDictionary<string, JsonElement>? matchedCapabilities = null;
28-
IEnumerable<IDictionary<string, JsonElement>> matchingCapabilities = possibleCapabilities
29-
.Where(capabilities => IsMatchingCapabilitySet(capabilities, out matchedCapabilities))
26+
var mergedCapabilities = GetMergedCapabilities(request);
27+
MergedCapabilities? matchedCapabilities = null;
28+
IEnumerable<MergedCapabilities> matchingCapabilities = mergedCapabilities
29+
.Where(capabilities => TryMatchCapabilities(capabilities, out matchedCapabilities, out _))
3030
.Select(capabillities => matchedCapabilities!);
3131

3232
Core.Application? app;
3333
var isAppOwnedBySession = false;
3434
var capabilities = matchingCapabilities.FirstOrDefault();
3535
if (capabilities == null)
3636
{
37+
var mismatchIndications = mergedCapabilities
38+
.Select(capabilities => GetMismatchIndication(capabilities));
39+
3740
return WebDriverResult.Error(new ErrorResponse
3841
{
3942
ErrorCode = "session not created",
40-
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"
43+
Message = string.Join("; ", mismatchIndications)
4144
});
4245
}
43-
if (TryGetStringCapability(capabilities, "appium:app", out var appPath))
46+
if (capabilities.TryGetStringCapability("appium:app", out var appPath))
4447
{
4548
if (appPath == "Root")
4649
{
4750
app = null;
4851
}
4952
else
5053
{
51-
TryGetStringCapability(capabilities, "appium:appArguments", out var appArguments);
54+
capabilities.TryGetStringCapability("appium:appArguments", out var appArguments);
5255
try
5356
{
5457
if (appPath.EndsWith("!App"))
@@ -58,7 +61,7 @@ public async Task<ActionResult> CreateNewSession([FromBody] CreateSessionRequest
5861
else
5962
{
6063
var processStartInfo = new ProcessStartInfo(appPath, appArguments ?? "");
61-
if(TryGetStringCapability(capabilities, "appium:appWorkingDir", out var appWorkingDir))
64+
if(capabilities.TryGetStringCapability("appium:appWorkingDir", out var appWorkingDir))
6265
{
6366
processStartInfo.WorkingDirectory = appWorkingDir;
6467
}
@@ -73,12 +76,12 @@ public async Task<ActionResult> CreateNewSession([FromBody] CreateSessionRequest
7376

7477
isAppOwnedBySession = true;
7578
}
76-
else if (TryGetStringCapability(capabilities, "appium:appTopLevelWindow", out var appTopLevelWindowString))
79+
else if (capabilities.TryGetStringCapability("appium:appTopLevelWindow", out var appTopLevelWindowString))
7780
{
7881
Process process = GetProcessByMainWindowHandle(appTopLevelWindowString);
7982
app = Core.Application.Attach(process);
8083
}
81-
else if (TryGetStringCapability(capabilities, "appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch))
84+
else if (capabilities.TryGetStringCapability("appium:appTopLevelWindowTitleMatch", out var appTopLevelWindowTitleMatch))
8285
{
8386
Process? process = GetProcessByMainWindowTitle(appTopLevelWindowTitleMatch);
8487
app = Core.Application.Attach(process);
@@ -88,128 +91,106 @@ public async Task<ActionResult> CreateNewSession([FromBody] CreateSessionRequest
8891
throw WebDriverResponseException.InvalidArgument("One of appium:app, appium:appTopLevelWindow or appium:appTopLevelWindowTitleMatch must be passed as a capability");
8992
}
9093
var session = new Session(app, isAppOwnedBySession);
91-
if(TryGetNumberCapability(capabilities, "appium:newCommandTimeout", out var newCommandTimeout))
94+
if(capabilities.TryGetNumberCapability("appium:newCommandTimeout", out var newCommandTimeout))
9295
{
9396
session.NewCommandTimeout = TimeSpan.FromSeconds(newCommandTimeout);
9497
}
95-
if (capabilities.TryGetValue("timeouts", out var valueJson))
98+
if (capabilities.TryGetCapability<TimeoutsConfiguration>("timeouts", out var timeoutsConfiguration))
9699
{
97-
var timeoutsConfiguration = JsonSerializer.Deserialize<TimeoutsConfiguration>(valueJson);
98-
if (timeoutsConfiguration == null)
99-
{
100-
throw WebDriverResponseException.InvalidArgument("Could not deserialize timeouts capability");
101-
}
102-
session.TimeoutsConfiguration = timeoutsConfiguration;
100+
session.TimeoutsConfiguration = timeoutsConfiguration!;
103101
}
104102
_sessionRepository.Add(session);
105103
_logger.LogInformation("Created session with ID {SessionId} and capabilities {Capabilities}", session.SessionId, capabilities);
106104
return await Task.FromResult(WebDriverResult.Success(new CreateSessionResponse()
107105
{
108106
SessionId = session.SessionId,
109-
Capabilities = capabilities
107+
Capabilities = capabilities.Capabilities
110108
}));
111109
}
112110

113-
private bool IsMatchingCapabilitySet(IDictionary<string, JsonElement> capabilities, out IDictionary<string, JsonElement> matchedCapabilities)
111+
private string? GetMismatchIndication(MergedCapabilities capabilities)
112+
{
113+
TryMatchCapabilities(capabilities, out _, out var mismatchIndication);
114+
return mismatchIndication;
115+
}
116+
117+
private bool TryMatchCapabilities(MergedCapabilities capabilities, [MaybeNullWhen(false)] out MergedCapabilities matchedCapabilities, [MaybeNullWhen(true)] out string? mismatchIndication)
114118
{
115-
matchedCapabilities = new Dictionary<string, JsonElement>();
116-
if (TryGetStringCapability(capabilities, "platformName", out var platformName)
119+
matchedCapabilities = new MergedCapabilities(new Dictionary<string, JsonElement>());
120+
if (capabilities.TryGetStringCapability("platformName", out var platformName)
117121
&& platformName.ToLowerInvariant() == "windows")
118122
{
119-
matchedCapabilities.Add("platformName", capabilities["platformName"]);
123+
matchedCapabilities.Copy("platformName", capabilities);
120124
}
121125
else
122126
{
127+
mismatchIndication = "Missing capability 'platformName' with value 'windows'";
123128
return false;
124129
}
125130

126-
if (TryGetStringCapability(capabilities, "appium:automationName", out var automationName)
131+
if (capabilities.TryGetStringCapability("appium:automationName", out var automationName)
127132
&& automationName.ToLowerInvariant() == "flaui")
128133
{
129-
matchedCapabilities.Add("appium:automationName", capabilities["appium:automationName"]);
134+
matchedCapabilities.Copy("appium:automationName", capabilities);
130135
}
131136
else
132137
{
138+
mismatchIndication = "Missing capability 'appium:automationName' with value 'flaui'";
133139
return false;
134140
}
135141

136-
if (TryGetStringCapability(capabilities, "appium:app", out var appPath))
142+
if (capabilities.TryGetStringCapability("appium:app", out var appPath))
137143
{
138-
matchedCapabilities.Add("appium:app", capabilities["appium:app"]);
144+
matchedCapabilities.Copy("appium:app", capabilities);
139145

140146
if (appPath != "Root")
141147
{
142-
if(capabilities.ContainsKey("appium:appArguments"))
148+
if (capabilities.Contains("appium:appArguments"))
143149
{
144-
matchedCapabilities.Add("appium:appArguments", capabilities["appium:appArguments"]);
150+
matchedCapabilities.Copy("appium:appArguments", capabilities);
145151
}
146152
if (!appPath.EndsWith("!App"))
147153
{
148-
if (capabilities.ContainsKey("appium:appWorkingDir"))
154+
if (capabilities.Contains("appium:appWorkingDir"))
149155
{
150-
matchedCapabilities.Add("appium:appWorkingDir", capabilities["appium:appWorkingDir"]);
156+
matchedCapabilities.Copy("appium:appWorkingDir", capabilities);
151157
}
152158
}
153159
}
154160
}
155-
else if (capabilities.ContainsKey("appium:appTopLevelWindow"))
161+
else if (capabilities.Contains("appium:appTopLevelWindow"))
156162
{
157-
matchedCapabilities.Add("appium:appTopLevelWindow", capabilities["appium:appTopLevelWindow"]);
163+
matchedCapabilities.Copy("appium:appTopLevelWindow", capabilities);
158164
}
159-
else if (capabilities.ContainsKey("appium:appTopLevelWindowTitleMatch"))
160-
{
161-
matchedCapabilities.Add("appium:appTopLevelWindowTitleMatch", capabilities["appium:appTopLevelWindowTitleMatch"]);
165+
else if (capabilities.Contains("appium:appTopLevelWindowTitleMatch"))
166+
{
167+
matchedCapabilities.Copy("appium:appTopLevelWindowTitleMatch", capabilities);
162168
}
163169
else
164170
{
171+
mismatchIndication = "One of 'appium:app', 'appium:appTopLevelWindow' or 'appium:appTopLevelWindowTitleMatch' should be specified";
165172
return false;
166173
}
167174

168-
if (capabilities.ContainsKey("appium:newCommandTimeout"))
175+
if (capabilities.Contains("appium:newCommandTimeout"))
169176
{
170-
matchedCapabilities.Add("appium:newCommandTimeout", capabilities["appium:newCommandTimeout"]); ;
177+
matchedCapabilities.Copy("appium:newCommandTimeout", capabilities); ;
171178
}
172179

173-
if (capabilities.ContainsKey("timeouts"))
180+
if (capabilities.Contains("timeouts"))
174181
{
175-
matchedCapabilities.Add("timeouts", capabilities["timeouts"]);
182+
matchedCapabilities.Copy("timeouts", capabilities);
176183
}
177184

178-
return matchedCapabilities.Count == capabilities.Count;
179-
}
180-
181-
private static bool TryGetStringCapability(IDictionary<string, JsonElement> capabilities, string key, [MaybeNullWhen(false)] out string value)
182-
{
183-
if(capabilities.TryGetValue(key, out var valueJson))
185+
var notMatchedCapabilities = capabilities.Capabilities.Keys.Except(matchedCapabilities.Capabilities.Keys);
186+
if (notMatchedCapabilities.Any())
184187
{
185-
if(valueJson.ValueKind != JsonValueKind.String)
186-
{
187-
throw WebDriverResponseException.InvalidArgument($"Capability {key} must be a string");
188-
}
189-
190-
value = valueJson.GetString();
191-
return value != null;
192-
}
193-
194-
value = null;
195-
return false;
196-
}
197-
198-
private static bool TryGetNumberCapability(IDictionary<string, JsonElement> capabilities, string key, out double value)
199-
{
200-
if (capabilities.TryGetValue(key, out var valueJson))
201-
{
202-
if (valueJson.ValueKind != JsonValueKind.Number)
203-
{
204-
throw WebDriverResponseException.InvalidArgument($"Capability {key} must be a number");
205-
}
206-
207-
value = valueJson.GetDouble();
208-
return true;
188+
mismatchIndication = $"The following capabilities could not be matched: '{string.Join("', '", notMatchedCapabilities)}'";
189+
return false;
209190
}
210191

211-
value = default;
212-
return false;
192+
mismatchIndication = null;
193+
return true;
213194
}
214195

215196
private static Process GetProcessByMainWindowTitle(string appTopLevelWindowTitleMatch)
@@ -258,23 +239,11 @@ private static Process GetProcessByMainWindowHandle(string appTopLevelWindowStri
258239
return process;
259240
}
260241

261-
private static IEnumerable<IDictionary<string, JsonElement>> GetPossibleCapabilities(CreateSessionRequest request)
242+
private static IEnumerable<MergedCapabilities> GetMergedCapabilities(CreateSessionRequest request)
262243
{
263244
var requiredCapabilities = request.Capabilities.AlwaysMatch ?? new Dictionary<string, JsonElement>();
264245
var allFirstMatchCapabilities = request.Capabilities.FirstMatch ?? new List<Dictionary<string, JsonElement>>(new[] { new Dictionary<string, JsonElement>() });
265-
return allFirstMatchCapabilities.Select(firstMatchCapabilities => MergeCapabilities(firstMatchCapabilities, requiredCapabilities));
266-
}
267-
268-
private static IDictionary<string, JsonElement> MergeCapabilities(IDictionary<string, JsonElement> firstMatchCapabilities, IDictionary<string, JsonElement> requiredCapabilities)
269-
{
270-
var duplicateKeys = firstMatchCapabilities.Keys.Intersect(requiredCapabilities.Keys);
271-
if (duplicateKeys.Any())
272-
{
273-
throw WebDriverResponseException.InvalidArgument($"Capabilities cannot be merged because there are duplicate capabilities: {string.Join(", ", duplicateKeys)}");
274-
}
275-
276-
return firstMatchCapabilities.Concat(requiredCapabilities)
277-
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
246+
return allFirstMatchCapabilities.Select(firstMatchCapabilities => new MergedCapabilities(firstMatchCapabilities, requiredCapabilities));
278247
}
279248

280249
[HttpDelete("{sessionId}")]

0 commit comments

Comments
 (0)