Serenity Operating System
1/*
2 * Copyright (c) 2022, Tim Flynn <trflynn89@serenityos.org>
3 *
4 * SPDX-License-Identifier: BSD-2-Clause
5 */
6
7#include <AK/Debug.h>
8#include <AK/JsonArray.h>
9#include <AK/JsonObject.h>
10#include <AK/JsonValue.h>
11#include <AK/Optional.h>
12#include <LibWeb/Loader/ResourceLoader.h>
13#include <LibWeb/WebDriver/Capabilities.h>
14#include <LibWeb/WebDriver/TimeoutsConfiguration.h>
15
16namespace Web::WebDriver {
17
18// https://w3c.github.io/webdriver/#dfn-deserialize-as-a-page-load-strategy
19static Response deserialize_as_a_page_load_strategy(JsonValue value)
20{
21 // 1. If value is not a string return an error with error code invalid argument.
22 if (!value.is_string())
23 return Error::from_code(ErrorCode::InvalidArgument, "Capability pageLoadStrategy must be a string"sv);
24
25 // 2. If there is no entry in the table of page load strategies with keyword value return an error with error code invalid argument.
26 if (!value.as_string().is_one_of("none"sv, "eager"sv, "normal"sv))
27 return Error::from_code(ErrorCode::InvalidArgument, "Invalid pageLoadStrategy capability"sv);
28
29 // 3. Return success with data value.
30 return value;
31}
32
33// https://w3c.github.io/webdriver/#dfn-deserialize-as-an-unhandled-prompt-behavior
34static Response deserialize_as_an_unhandled_prompt_behavior(JsonValue value)
35{
36 // 1. If value is not a string return an error with error code invalid argument.
37 if (!value.is_string())
38 return Error::from_code(ErrorCode::InvalidArgument, "Capability unhandledPromptBehavior must be a string"sv);
39
40 // 2. If value is not present as a keyword in the known prompt handling approaches table return an error with error code invalid argument.
41 if (!value.as_string().is_one_of("dismiss"sv, "accept"sv, "dismiss and notify"sv, "accept and notify"sv, "ignore"sv))
42 return Error::from_code(ErrorCode::InvalidArgument, "Invalid pageLoadStrategy capability"sv);
43
44 // 3. Return success with data value.
45 return value;
46}
47
48static Response deserialize_as_ladybird_options(JsonValue value)
49{
50 if (!value.is_object())
51 return Error::from_code(ErrorCode::InvalidArgument, "Extension capability serenity:ladybird must be an object"sv);
52
53 auto const& object = value.as_object();
54
55 if (auto headless = object.get("headless"sv); headless.has_value() && !headless->is_bool())
56 return Error::from_code(ErrorCode::InvalidArgument, "Extension capability serenity:ladybird/headless must be a boolean"sv);
57
58 return value;
59}
60
61static JsonObject default_ladybird_options()
62{
63 JsonObject options;
64 options.set("headless"sv, false);
65
66 return options;
67}
68
69// https://w3c.github.io/webdriver/#dfn-validate-capabilities
70static ErrorOr<JsonObject, Error> validate_capabilities(JsonValue const& capability)
71{
72 // 1. If capability is not a JSON Object return an error with error code invalid argument.
73 if (!capability.is_object())
74 return Error::from_code(ErrorCode::InvalidArgument, "Capability is not an Object"sv);
75
76 // 2. Let result be an empty JSON Object.
77 JsonObject result;
78
79 // 3. For each enumerable own property in capability, run the following substeps:
80 TRY(capability.as_object().try_for_each_member([&](auto const& name, auto const& value) -> ErrorOr<void, Error> {
81 // a. Let name be the name of the property.
82 // b. Let value be the result of getting a property named name from capability.
83
84 // c. Run the substeps of the first matching condition:
85 JsonValue deserialized;
86
87 // -> value is null
88 if (value.is_null()) {
89 // Let deserialized be set to null.
90 }
91
92 // -> name equals "acceptInsecureCerts"
93 else if (name == "acceptInsecureCerts"sv) {
94 // If value is not a boolean return an error with error code invalid argument. Otherwise, let deserialized be set to value
95 if (!value.is_bool())
96 return Error::from_code(ErrorCode::InvalidArgument, "Capability acceptInsecureCerts must be a boolean"sv);
97 deserialized = value;
98 }
99
100 // -> name equals "browserName"
101 // -> name equals "browserVersion"
102 // -> name equals "platformName"
103 else if (name.is_one_of("browserName"sv, "browser_version"sv, "platformName"sv)) {
104 // If value is not a string return an error with error code invalid argument. Otherwise, let deserialized be set to value.
105 if (!value.is_string())
106 return Error::from_code(ErrorCode::InvalidArgument, DeprecatedString::formatted("Capability {} must be a string", name));
107 deserialized = value;
108 }
109
110 // -> name equals "pageLoadStrategy"
111 else if (name == "pageLoadStrategy"sv) {
112 // Let deserialized be the result of trying to deserialize as a page load strategy with argument value.
113 deserialized = TRY(deserialize_as_a_page_load_strategy(value));
114 }
115
116 // FIXME: -> name equals "proxy"
117 // FIXME: Let deserialized be the result of trying to deserialize as a proxy with argument value.
118
119 // -> name equals "strictFileInteractability"
120 else if (name == "strictFileInteractability"sv) {
121 // If value is not a boolean return an error with error code invalid argument. Otherwise, let deserialized be set to value
122 if (!value.is_bool())
123 return Error::from_code(ErrorCode::InvalidArgument, "Capability strictFileInteractability must be a boolean"sv);
124 deserialized = value;
125 }
126
127 // -> name equals "timeouts"
128 else if (name == "timeouts"sv) {
129 // Let deserialized be the result of trying to JSON deserialize as a timeouts configuration the value.
130 auto timeouts = TRY(json_deserialize_as_a_timeouts_configuration(value));
131 deserialized = JsonValue { timeouts_object(timeouts) };
132 }
133
134 // -> name equals "unhandledPromptBehavior"
135 else if (name == "unhandledPromptBehavior"sv) {
136 // Let deserialized be the result of trying to deserialize as an unhandled prompt behavior with argument value.
137 deserialized = TRY(deserialize_as_an_unhandled_prompt_behavior(value));
138 }
139
140 // FIXME: -> name is the name of an additional WebDriver capability
141 // FIXME: Let deserialized be the result of trying to run the additional capability deserialization algorithm for the extension capability corresponding to name, with argument value.
142
143 // -> name is the key of an extension capability
144 // If name is known to the implementation, let deserialized be the result of trying to deserialize value in an implementation-specific way. Otherwise, let deserialized be set to value.
145 else if (name == "serenity:ladybird"sv) {
146 deserialized = TRY(deserialize_as_ladybird_options(value));
147 }
148
149 // -> The remote end is an endpoint node
150 else {
151 // Return an error with error code invalid argument.
152 return Error::from_code(ErrorCode::InvalidArgument, DeprecatedString::formatted("Unrecognized capability: {}", name));
153 }
154
155 // d. If deserialized is not null, set a property on result with name name and value deserialized.
156 if (!deserialized.is_null())
157 result.set(name, move(deserialized));
158
159 return {};
160 }));
161
162 // 4. Return success with data result.
163 return result;
164}
165
166// https://w3c.github.io/webdriver/#dfn-merging-capabilities
167static ErrorOr<JsonObject, Error> merge_capabilities(JsonObject const& primary, Optional<JsonObject const&> const& secondary)
168{
169 // 1. Let result be a new JSON Object.
170 JsonObject result;
171
172 // 2. For each enumerable own property in primary, run the following substeps:
173 primary.for_each_member([&](auto const& name, auto const& value) {
174 // a. Let name be the name of the property.
175 // b. Let value be the result of getting a property named name from primary.
176
177 // c. Set a property on result with name name and value value.
178 result.set(name, value);
179 });
180
181 // 3. If secondary is undefined, return result.
182 if (!secondary.has_value())
183 return result;
184
185 // 4. For each enumerable own property in secondary, run the following substeps:
186 TRY(secondary->try_for_each_member([&](auto const& name, auto const& value) -> ErrorOr<void, Error> {
187 // a. Let name be the name of the property.
188 // b. Let value be the result of getting a property named name from secondary.
189
190 // c. Let primary value be the result of getting the property name from primary.
191 auto primary_value = primary.get(name);
192
193 // d. If primary value is not undefined, return an error with error code invalid argument.
194 if (primary_value.has_value())
195 return Error::from_code(ErrorCode::InvalidArgument, DeprecatedString::formatted("Unable to merge capability {}", name));
196
197 // e. Set a property on result with name name and value value.
198 result.set(name, value);
199 return {};
200 }));
201
202 // 5. Return result.
203 return result;
204}
205
206static bool matches_browser_version(StringView requested_version, StringView required_version)
207{
208 // FIXME: Handle relative (>, >=, <. <=) comparisons. For now, require an exact match.
209 return requested_version == required_version;
210}
211
212static bool matches_platform_name(StringView requested_platform_name, StringView required_platform_name)
213{
214 if (requested_platform_name == required_platform_name)
215 return true;
216
217 // The following platform names are in common usage with well-understood semantics and, when matching capabilities, greatest interoperability can be achieved by honoring them as valid synonyms for well-known Operating Systems:
218 // "linux" Any server or desktop system based upon the Linux kernel.
219 // "mac" Any version of Apple’s macOS.
220 // "windows" Any version of Microsoft Windows, including desktop and mobile versions.
221 // This list is not exhaustive.
222
223 // NOTE: Of the synonyms listed in the spec, the only one that differs for us is macOS.
224 // Further, we are allowed to handle synonyms for SerenityOS.
225 if (requested_platform_name == "mac"sv && required_platform_name == "macos"sv)
226 return true;
227 if (requested_platform_name == "serenity"sv && required_platform_name == "serenityos"sv)
228 return true;
229 return false;
230}
231
232// https://w3c.github.io/webdriver/#dfn-matching-capabilities
233static JsonValue match_capabilities(JsonObject const& capabilities)
234{
235 static auto browser_name = StringView { BROWSER_NAME, strlen(BROWSER_NAME) }.to_lowercase_string();
236 static auto platform_name = StringView { OS_STRING, strlen(OS_STRING) }.to_lowercase_string();
237
238 // 1. Let matched capabilities be a JSON Object with the following entries:
239 JsonObject matched_capabilities;
240 // "browserName"
241 // ASCII Lowercase name of the user agent as a string.
242 matched_capabilities.set("browserName"sv, browser_name);
243 // "browserVersion"
244 // The user agent version, as a string.
245 matched_capabilities.set("browserVersion"sv, BROWSER_VERSION);
246 // "platformName"
247 // ASCII Lowercase name of the current platform as a string.
248 matched_capabilities.set("platformName"sv, platform_name);
249 // "acceptInsecureCerts"
250 // Boolean initially set to false, indicating the session will not implicitly trust untrusted or self-signed TLS certificates on navigation.
251 matched_capabilities.set("acceptInsecureCerts"sv, false);
252 // "strictFileInteractability"
253 // Boolean initially set to false, indicating that interactability checks will be applied to <input type=file>.
254 matched_capabilities.set("strictFileInteractability"sv, false);
255 // "setWindowRect"
256 // Boolean indicating whether the remote end supports all of the resizing and positioning commands.
257 matched_capabilities.set("setWindowRect"sv, true);
258
259 // 2. Optionally add extension capabilities as entries to matched capabilities. The values of these may be elided, and there is no requirement that all extension capabilities be added.
260 matched_capabilities.set("serenity:ladybird"sv, default_ladybird_options());
261
262 // 3. For each name and value corresponding to capability’s own properties:
263 auto result = capabilities.try_for_each_member([&](auto const& name, auto const& value) -> ErrorOr<void> {
264 // a. Let match value equal value.
265
266 // b. Run the substeps of the first matching name:
267 // -> "browserName"
268 if (name == "browserName"sv) {
269 // If value is not a string equal to the "browserName" entry in matched capabilities, return success with data null.
270 if (value.as_string() != matched_capabilities.get_deprecated_string(name).value())
271 return AK::Error::from_string_view("browserName"sv);
272 }
273 // -> "browserVersion"
274 else if (name == "browserVersion"sv) {
275 // Compare value to the "browserVersion" entry in matched capabilities using an implementation-defined comparison algorithm. The comparison is to accept a value that places constraints on the version using the "<", "<=", ">", and ">=" operators.
276 // If the two values do not match, return success with data null.
277 if (!matches_browser_version(value.as_string(), matched_capabilities.get_deprecated_string(name).value()))
278 return AK::Error::from_string_view("browserVersion"sv);
279 }
280 // -> "platformName"
281 else if (name == "platformName"sv) {
282 // If value is not a string equal to the "platformName" entry in matched capabilities, return success with data null.
283 if (!matches_platform_name(value.as_string(), matched_capabilities.get_deprecated_string(name).value()))
284 return AK::Error::from_string_view("platformName"sv);
285 }
286 // -> "acceptInsecureCerts"
287 else if (name == "acceptInsecureCerts"sv) {
288 // If value is true and the endpoint node does not support insecure TLS certificates, return success with data null.
289 if (value.as_bool())
290 return AK::Error::from_string_view("acceptInsecureCerts"sv);
291 }
292 // -> "proxy"
293 else if (name == "proxy"sv) {
294 // FIXME: If the endpoint node does not allow the proxy it uses to be configured, or if the proxy configuration defined in value is not one that passes the endpoint node’s implementation-specific validity checks, return success with data null.
295 }
296 // -> Otherwise
297 else {
298 // FIXME: If name is the name of an additional WebDriver capability which defines a matched capability serialization algorithm, let match value be the result of running the matched capability serialization algorithm for capability name with argument value.
299 // FIXME: Otherwise, if name is the key of an extension capability, let match value be the result of trying implementation-specific steps to match on name with value. If the match is not successful, return success with data null.
300 }
301
302 // c. Set a property on matched capabilities with name name and value match value.
303 matched_capabilities.set(name, value);
304 return {};
305 });
306
307 if (result.is_error()) {
308 dbgln_if(WEBDRIVER_DEBUG, "Failed to match capability: {}", result.error());
309 return JsonValue {};
310 }
311
312 // 4. Return success with data matched capabilities.
313 return matched_capabilities;
314}
315
316// https://w3c.github.io/webdriver/#dfn-capabilities-processing
317Response process_capabilities(JsonValue const& parameters)
318{
319 if (!parameters.is_object())
320 return Error::from_code(ErrorCode::InvalidArgument, "Session parameters is not an object"sv);
321
322 // 1. Let capabilities request be the result of getting the property "capabilities" from parameters.
323 // a. If capabilities request is not a JSON Object, return error with error code invalid argument.
324 auto maybe_capabilities_request = parameters.as_object().get_object("capabilities"sv);
325 if (!maybe_capabilities_request.has_value())
326 return Error::from_code(ErrorCode::InvalidArgument, "Capabilities is not an object"sv);
327
328 auto const& capabilities_request = maybe_capabilities_request.value();
329
330 // 2. Let required capabilities be the result of getting the property "alwaysMatch" from capabilities request.
331 // a. If required capabilities is undefined, set the value to an empty JSON Object.
332 JsonObject required_capabilities;
333
334 if (auto capability = capabilities_request.get("alwaysMatch"sv); capability.has_value()) {
335 // b. Let required capabilities be the result of trying to validate capabilities with argument required capabilities.
336 required_capabilities = TRY(validate_capabilities(*capability));
337 }
338
339 // 3. Let all first match capabilities be the result of getting the property "firstMatch" from capabilities request.
340 JsonArray all_first_match_capabilities;
341
342 if (auto capabilities = capabilities_request.get("firstMatch"sv); capabilities.has_value()) {
343 // b. If all first match capabilities is not a JSON List with one or more entries, return error with error code invalid argument.
344 if (!capabilities->is_array() || capabilities->as_array().is_empty())
345 return Error::from_code(ErrorCode::InvalidArgument, "Capability firstMatch must be an array with at least one entry"sv);
346
347 all_first_match_capabilities = capabilities->as_array();
348 } else {
349 // a. If all first match capabilities is undefined, set the value to a JSON List with a single entry of an empty JSON Object.
350 all_first_match_capabilities.append(JsonObject {});
351 }
352
353 // 4. Let validated first match capabilities be an empty JSON List.
354 JsonArray validated_first_match_capabilities;
355 validated_first_match_capabilities.ensure_capacity(all_first_match_capabilities.size());
356
357 // 5. For each first match capabilities corresponding to an indexed property in all first match capabilities:
358 TRY(all_first_match_capabilities.try_for_each([&](auto const& first_match_capabilities) -> ErrorOr<void, Error> {
359 // a. Let validated capabilities be the result of trying to validate capabilities with argument first match capabilities.
360 auto validated_capabilities = TRY(validate_capabilities(first_match_capabilities));
361
362 // b. Append validated capabilities to validated first match capabilities.
363 validated_first_match_capabilities.append(move(validated_capabilities));
364 return {};
365 }));
366
367 // 6. Let merged capabilities be an empty List.
368 JsonArray merged_capabilities;
369 merged_capabilities.ensure_capacity(validated_first_match_capabilities.size());
370
371 // 7. For each first match capabilities corresponding to an indexed property in validated first match capabilities:
372 TRY(validated_first_match_capabilities.try_for_each([&](auto const& first_match_capabilities) -> ErrorOr<void, Error> {
373 // a. Let merged be the result of trying to merge capabilities with required capabilities and first match capabilities as arguments.
374 auto merged = TRY(merge_capabilities(required_capabilities, first_match_capabilities.as_object()));
375
376 // b. Append merged to merged capabilities.
377 merged_capabilities.append(move(merged));
378 return {};
379 }));
380
381 // 8. For each capabilities corresponding to an indexed property in merged capabilities:
382 for (auto const& capabilities : merged_capabilities.values()) {
383 // a. Let matched capabilities be the result of trying to match capabilities with capabilities as an argument.
384 auto matched_capabilities = match_capabilities(capabilities.as_object());
385
386 // b. If matched capabilities is not null, return success with data matched capabilities.
387 if (!matched_capabilities.is_null())
388 return matched_capabilities;
389 }
390
391 // 9. Return success with data null.
392 return JsonValue {};
393}
394
395LadybirdOptions::LadybirdOptions(JsonObject const& capabilities)
396{
397 auto options = capabilities.get_object("serenity:ladybird"sv);
398 if (!options.has_value())
399 return;
400
401 auto headless = options->get_bool("headless"sv);
402 if (headless.has_value())
403 this->headless = headless.value();
404}
405
406}