mirror of
https://github.com/LadybirdBrowser/ladybird.git
synced 2024-12-04 05:20:30 +00:00
WebDriver: Add new WebDriver service
WebDriver aims to implement the WebDriver specification found at https://w3c.github.io/webdriver/webdriver-spec.html . It's an HTTP server that can create Browser sessions and control them. Co-authored-by: Florent Castelli <florent.castelli@gmail.com>
This commit is contained in:
parent
8c0f1da9f7
commit
80603f141a
Notes:
sideshowbarker
2024-07-17 10:54:57 +09:00
Author: https://github.com/AtkinsSJ Commit: https://github.com/SerenityOS/serenity/commit/80603f141a Pull-request: https://github.com/SerenityOS/serenity/pull/15504 Reviewed-by: https://github.com/ADKaster Reviewed-by: https://github.com/linusg
12 changed files with 915 additions and 0 deletions
|
@ -494,6 +494,10 @@
|
|||
#cmakedefine01 WASM_VALIDATOR_DEBUG
|
||||
#endif
|
||||
|
||||
#ifndef WEBDRIVER_DEBUG
|
||||
#cmakedefine01 WEBDRIVER_DEBUG
|
||||
#endif
|
||||
|
||||
#ifndef WEBGL_CONTEXT_DEBUG
|
||||
#cmakedefine01 WEBGL_CONTEXT_DEBUG
|
||||
#endif
|
||||
|
|
|
@ -211,6 +211,7 @@ set(WAITQUEUE_DEBUG ON)
|
|||
set(WASM_BINPARSER_DEBUG ON)
|
||||
set(WASM_TRACE_DEBUG ON)
|
||||
set(WASM_VALIDATOR_DEBUG ON)
|
||||
set(WEBDRIVER_DEBUG ON)
|
||||
set(WEBGL_CONTEXT_DEBUG ON)
|
||||
set(WEBSERVER_DEBUG ON)
|
||||
set(WEB_WORKER_DEBUG ON)
|
||||
|
|
|
@ -26,5 +26,6 @@ if (SERENITYOS)
|
|||
add_subdirectory(Taskbar)
|
||||
add_subdirectory(TelnetServer)
|
||||
add_subdirectory(WebContent)
|
||||
add_subdirectory(WebDriver)
|
||||
add_subdirectory(WindowServer)
|
||||
endif()
|
||||
|
|
26
Userland/Services/WebDriver/BrowserConnection.cpp
Normal file
26
Userland/Services/WebDriver/BrowserConnection.cpp
Normal file
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2022, Florent Castelli <florent.castelli@gmail.com>
|
||||
* Copyright (c) 2022, Sam Atkins <atkinssj@serenityos.org>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#include "BrowserConnection.h"
|
||||
#include "Client.h"
|
||||
|
||||
namespace WebDriver {
|
||||
|
||||
BrowserConnection::BrowserConnection(NonnullOwnPtr<Core::Stream::LocalSocket> socket, NonnullRefPtr<Client> client, unsigned session_id)
|
||||
: IPC::ConnectionFromClient<WebDriverSessionClientEndpoint, WebDriverSessionServerEndpoint>(*this, move(socket), 1)
|
||||
, m_client(move(client))
|
||||
, m_session_id(session_id)
|
||||
{
|
||||
}
|
||||
|
||||
void BrowserConnection::die()
|
||||
{
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Session {} was closed remotely. Shutting down...", m_session_id);
|
||||
m_client->close_session(m_session_id);
|
||||
}
|
||||
|
||||
}
|
34
Userland/Services/WebDriver/BrowserConnection.h
Normal file
34
Userland/Services/WebDriver/BrowserConnection.h
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (c) 2022, Florent Castelli <florent.castelli@gmail.com>
|
||||
* Copyright (c) 2022, Sam Atkins <atkinssj@serenityos.org>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <AK/Format.h>
|
||||
#include <Applications/Browser/WebDriverSessionClientEndpoint.h>
|
||||
#include <Applications/Browser/WebDriverSessionServerEndpoint.h>
|
||||
#include <LibGUI/Application.h>
|
||||
#include <LibIPC/ConnectionFromClient.h>
|
||||
#include <LibIPC/Encoder.h>
|
||||
|
||||
namespace WebDriver {
|
||||
|
||||
class Client;
|
||||
|
||||
class BrowserConnection
|
||||
: public IPC::ConnectionFromClient<WebDriverSessionClientEndpoint, WebDriverSessionServerEndpoint> {
|
||||
C_OBJECT_ABSTRACT(BrowserConnection)
|
||||
public:
|
||||
BrowserConnection(NonnullOwnPtr<Core::Stream::LocalSocket> socket, NonnullRefPtr<Client> client, unsigned session_id);
|
||||
|
||||
virtual void die() override;
|
||||
|
||||
private:
|
||||
NonnullRefPtr<Client> m_client;
|
||||
unsigned m_session_id { 0 };
|
||||
};
|
||||
|
||||
}
|
19
Userland/Services/WebDriver/CMakeLists.txt
Normal file
19
Userland/Services/WebDriver/CMakeLists.txt
Normal file
|
@ -0,0 +1,19 @@
|
|||
serenity_component(
|
||||
WebDriver
|
||||
TARGETS WebDriver
|
||||
)
|
||||
|
||||
set(SOURCES
|
||||
BrowserConnection.cpp
|
||||
Client.cpp
|
||||
Session.cpp
|
||||
main.cpp
|
||||
)
|
||||
|
||||
set(GENERATED_SOURCES
|
||||
../../Applications/Browser/WebDriverSessionClientEndpoint.h
|
||||
../../Applications/Browser/WebDriverSessionServerEndpoint.h
|
||||
)
|
||||
|
||||
serenity_bin(WebDriver)
|
||||
target_link_libraries(WebDriver LibCore LibHTTP LibMain LibIPC)
|
442
Userland/Services/WebDriver/Client.cpp
Normal file
442
Userland/Services/WebDriver/Client.cpp
Normal file
|
@ -0,0 +1,442 @@
|
|||
/*
|
||||
* Copyright (c) 2022, Florent Castelli <florent.castelli@gmail.com>
|
||||
* Copyright (c) 2022, Sam Atkins <atkinssj@serenityos.org>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#include "Client.h"
|
||||
#include "Session.h"
|
||||
#include <AK/Debug.h>
|
||||
#include <AK/JsonParser.h>
|
||||
#include <LibCore/DateTime.h>
|
||||
#include <LibCore/MemoryStream.h>
|
||||
#include <LibHTTP/HttpRequest.h>
|
||||
#include <LibHTTP/HttpResponse.h>
|
||||
|
||||
namespace WebDriver {
|
||||
|
||||
Atomic<unsigned> Client::s_next_session_id;
|
||||
NonnullOwnPtrVector<Session> Client::s_sessions;
|
||||
Vector<Client::Route> Client::s_routes = {
|
||||
{ HTTP::HttpRequest::Method::POST, { "session" }, &Client::handle_post_session },
|
||||
{ HTTP::HttpRequest::Method::DELETE, { "session", ":session_id" }, &Client::handle_delete_session },
|
||||
{ HTTP::HttpRequest::Method::GET, { "status" }, &Client::handle_get_status },
|
||||
{ HTTP::HttpRequest::Method::POST, { "session", ":session_id", "url" }, &Client::handle_post_url },
|
||||
{ HTTP::HttpRequest::Method::GET, { "session", ":session_id", "url" }, &Client::handle_get_url },
|
||||
{ HTTP::HttpRequest::Method::GET, { "session", ":session_id", "title" }, &Client::handle_get_title },
|
||||
{ HTTP::HttpRequest::Method::DELETE, { "session", ":session_id", "window" }, &Client::handle_delete_window },
|
||||
};
|
||||
|
||||
Client::Client(NonnullOwnPtr<Core::Stream::BufferedTCPSocket> socket, Core::Object* parent)
|
||||
: Core::Object(parent)
|
||||
, m_socket(move(socket))
|
||||
{
|
||||
}
|
||||
|
||||
void Client::die()
|
||||
{
|
||||
m_socket->close();
|
||||
deferred_invoke([this] { remove_from_parent(); });
|
||||
}
|
||||
|
||||
void Client::start()
|
||||
{
|
||||
m_socket->on_ready_to_read = [this] {
|
||||
StringBuilder builder;
|
||||
|
||||
// FIXME: All this should be moved to LibHTTP and be made spec compliant
|
||||
auto maybe_buffer = ByteBuffer::create_uninitialized(m_socket->buffer_size());
|
||||
if (maybe_buffer.is_error()) {
|
||||
warnln("Could not create buffer for client: {}", maybe_buffer.error());
|
||||
die();
|
||||
return;
|
||||
}
|
||||
|
||||
auto buffer = maybe_buffer.release_value();
|
||||
for (;;) {
|
||||
auto maybe_can_read = m_socket->can_read_without_blocking();
|
||||
if (maybe_can_read.is_error()) {
|
||||
warnln("Failed to get the blocking status for the socket: {}", maybe_can_read.error());
|
||||
die();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!maybe_can_read.value())
|
||||
break;
|
||||
|
||||
auto maybe_data = m_socket->read(buffer);
|
||||
if (maybe_data.is_error()) {
|
||||
warnln("Failed to read data from the request: {}", maybe_data.error());
|
||||
die();
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_socket->is_eof()) {
|
||||
die();
|
||||
break;
|
||||
}
|
||||
|
||||
builder.append(StringView(maybe_data.value()));
|
||||
}
|
||||
|
||||
auto request = builder.to_byte_buffer();
|
||||
auto http_request_or_error = HTTP::HttpRequest::from_raw_request(request);
|
||||
if (!http_request_or_error.has_value())
|
||||
return;
|
||||
|
||||
auto http_request = http_request_or_error.release_value();
|
||||
|
||||
auto body_or_error = read_body_as_json(http_request);
|
||||
if (body_or_error.is_error()) {
|
||||
warnln("Failed to read the request body: {}", body_or_error.error());
|
||||
die();
|
||||
return;
|
||||
}
|
||||
|
||||
auto maybe_did_handle = handle_request(http_request, body_or_error.value());
|
||||
if (maybe_did_handle.is_error()) {
|
||||
warnln("Failed to handle the request: {}", maybe_did_handle.error());
|
||||
}
|
||||
|
||||
die();
|
||||
};
|
||||
}
|
||||
|
||||
ErrorOr<JsonValue> Client::read_body_as_json(HTTP::HttpRequest const& request)
|
||||
{
|
||||
// If we received a multipart body here, this would fail badly.
|
||||
unsigned content_length = 0;
|
||||
for (auto const& header : request.headers()) {
|
||||
if (header.name.equals_ignoring_case("Content-Length"sv)) {
|
||||
content_length = header.value.to_int(TrimWhitespace::Yes).value_or(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!content_length)
|
||||
return JsonValue();
|
||||
|
||||
// FIXME: Check the Content-Type is actually application/json
|
||||
JsonParser json_parser(request.body());
|
||||
return json_parser.parse();
|
||||
}
|
||||
|
||||
ErrorOr<bool> Client::handle_request(HTTP::HttpRequest const& request, JsonValue const& body)
|
||||
{
|
||||
if constexpr (WEBDRIVER_DEBUG) {
|
||||
dbgln("Got HTTP request: {} {}", request.method_name(), request.resource());
|
||||
if (!body.is_null())
|
||||
dbgln("Body: {}", body.to_string());
|
||||
}
|
||||
|
||||
auto routing_result_match = match_route(request.method(), request.resource());
|
||||
if (routing_result_match.is_error()) {
|
||||
auto error = routing_result_match.release_error();
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Failed to match route: {}", error);
|
||||
TRY(send_error_response(error, request));
|
||||
return false;
|
||||
}
|
||||
|
||||
auto routing_result = routing_result_match.release_value();
|
||||
auto result = (this->*routing_result.handler)(routing_result.parameters, body);
|
||||
if (result.is_error()) {
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Error in calling route handler: {}", result.error());
|
||||
TRY(send_error_response(result.release_error(), request));
|
||||
return false;
|
||||
}
|
||||
|
||||
auto object = result.release_value();
|
||||
TRY(send_response(object.to_string(), request));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// https://w3c.github.io/webdriver/#dfn-send-a-response
|
||||
ErrorOr<void> Client::send_response(StringView content, HTTP::HttpRequest const& request)
|
||||
{
|
||||
// FIXME: Implement to spec.
|
||||
|
||||
StringBuilder builder;
|
||||
builder.append("HTTP/1.0 200 OK\r\n"sv);
|
||||
builder.append("Server: WebDriver (SerenityOS)\r\n"sv);
|
||||
builder.append("X-Frame-Options: SAMEORIGIN\r\n"sv);
|
||||
builder.append("X-Content-Type-Options: nosniff\r\n"sv);
|
||||
builder.append("Pragma: no-cache\r\n"sv);
|
||||
builder.append("Content-Type: application/json; charset=utf-8\r\n"sv);
|
||||
builder.appendff("Content-Length: {}\r\n", content.length());
|
||||
builder.append("\r\n"sv);
|
||||
|
||||
auto builder_contents = builder.to_byte_buffer();
|
||||
TRY(m_socket->write(builder_contents));
|
||||
TRY(m_socket->write(content.bytes()));
|
||||
log_response(200, request);
|
||||
|
||||
auto keep_alive = false;
|
||||
if (auto it = request.headers().find_if([](auto& header) { return header.name.equals_ignoring_case("Connection"sv); }); !it.is_end()) {
|
||||
if (it->value.trim_whitespace().equals_ignoring_case("keep-alive"sv))
|
||||
keep_alive = true;
|
||||
}
|
||||
if (!keep_alive)
|
||||
m_socket->close();
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
// https://w3c.github.io/webdriver/#dfn-send-an-error
|
||||
ErrorOr<void> Client::send_error_response(HttpError const& error, HTTP::HttpRequest const& request)
|
||||
{
|
||||
// FIXME: Implement to spec.
|
||||
|
||||
dbgln("send_error_response: {} {}: {}", error.http_status, error.error, error.message);
|
||||
auto reason_phrase = HTTP::HttpResponse::reason_phrase_for_code(error.http_status);
|
||||
|
||||
auto result = JsonObject();
|
||||
result.set("error", error.error);
|
||||
result.set("message", error.message);
|
||||
result.set("stacktrace", "");
|
||||
|
||||
StringBuilder content_builder;
|
||||
result.serialize(content_builder);
|
||||
|
||||
StringBuilder header_builder;
|
||||
header_builder.appendff("HTTP/1.0 {} ", error.http_status);
|
||||
header_builder.append(reason_phrase);
|
||||
header_builder.append("\r\n"sv);
|
||||
header_builder.append("Content-Type: application/json; charset=UTF-8\r\n"sv);
|
||||
header_builder.appendff("Content-Length: {}\r\n", content_builder.length());
|
||||
header_builder.append("\r\n"sv);
|
||||
TRY(m_socket->write(header_builder.to_byte_buffer()));
|
||||
TRY(m_socket->write(content_builder.to_byte_buffer()));
|
||||
|
||||
log_response(error.http_status, request);
|
||||
return {};
|
||||
}
|
||||
|
||||
void Client::log_response(unsigned code, HTTP::HttpRequest const& request)
|
||||
{
|
||||
outln("{} :: {:03d} :: {} {}", Core::DateTime::now().to_string(), code, request.method_name(), request.resource());
|
||||
}
|
||||
|
||||
// https://w3c.github.io/webdriver/#dfn-match-a-request
|
||||
ErrorOr<Client::RoutingResult, HttpError> Client::match_route(HTTP::HttpRequest::Method method, String resource)
|
||||
{
|
||||
// FIXME: Implement to spec.
|
||||
|
||||
dbgln_if(WEBDRIVER_DEBUG, "match_route({}, {})", HTTP::to_string(method), resource);
|
||||
|
||||
// https://w3c.github.io/webdriver/webdriver-spec.html#routing-requests
|
||||
if (!resource.starts_with(m_prefix))
|
||||
return HttpError { 404, "unknown command", "The resource doesn't start with the prefix." };
|
||||
|
||||
Vector<StringView> resource_split = resource.substring_view(m_prefix.length()).split_view('/', true);
|
||||
Vector<StringView> parameters;
|
||||
|
||||
bool matched_path = false;
|
||||
|
||||
for (auto const& route : Client::s_routes) {
|
||||
dbgln_if(WEBDRIVER_DEBUG, "- Checking {} {}", HTTP::to_string(route.method), String::join("/"sv, route.path));
|
||||
if (resource_split.size() != route.path.size()) {
|
||||
dbgln_if(WEBDRIVER_DEBUG, "-> Discarding: Wrong length");
|
||||
continue;
|
||||
}
|
||||
|
||||
bool match = true;
|
||||
for (size_t i = 0; i < route.path.size(); ++i) {
|
||||
if (route.path[i].starts_with(':')) {
|
||||
parameters.append(resource_split[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (route.path[i] != resource_split[i]) {
|
||||
match = false;
|
||||
parameters.clear();
|
||||
dbgln_if(WEBDRIVER_DEBUG, "-> Discarding: Part `{}` does not match `{}`", route.path[i], resource_split[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match && route.method == method) {
|
||||
dbgln_if(WEBDRIVER_DEBUG, "-> Matched! :^)");
|
||||
return RoutingResult { route.handler, parameters };
|
||||
}
|
||||
matched_path = true;
|
||||
}
|
||||
|
||||
// Matched a path, but didn't match a known method
|
||||
if (matched_path) {
|
||||
dbgln_if(WEBDRIVER_DEBUG, "- A path matched, but method didn't. :^(");
|
||||
return HttpError { 405, "unknown method", "The command matched a known URL but did not match a method for that URL." };
|
||||
}
|
||||
|
||||
// Didn't have any match
|
||||
dbgln_if(WEBDRIVER_DEBUG, "- No matches. :^(");
|
||||
return HttpError { 404, "unknown command", "The command was not recognized." };
|
||||
}
|
||||
|
||||
ErrorOr<Session*, HttpError> Client::find_session_with_id(StringView session_id)
|
||||
{
|
||||
auto session_id_or_error = session_id.to_uint<>();
|
||||
if (!session_id_or_error.has_value())
|
||||
return HttpError { 404, "invalid session id", "Invalid session id" };
|
||||
|
||||
for (auto& session : Client::s_sessions) {
|
||||
if (session.session_id() == session_id_or_error.value())
|
||||
return &session;
|
||||
}
|
||||
return HttpError { 404, "invalid session id", "Invalid session id" };
|
||||
}
|
||||
|
||||
void Client::close_session(unsigned session_id)
|
||||
{
|
||||
bool found = Client::s_sessions.remove_first_matching([&](auto const& it) {
|
||||
return it->session_id() == session_id;
|
||||
});
|
||||
|
||||
if (found)
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Shut down session {}", session_id);
|
||||
else
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Unable to shut down session {}: Not found", session_id);
|
||||
}
|
||||
|
||||
JsonValue Client::make_json_value(JsonValue const& value)
|
||||
{
|
||||
JsonObject result;
|
||||
result.set("value", value);
|
||||
return result;
|
||||
}
|
||||
|
||||
// POST /session https://w3c.github.io/webdriver/#dfn-new-sessions
|
||||
ErrorOr<JsonValue, HttpError> Client::handle_post_session(Vector<StringView>, JsonValue const&)
|
||||
{
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Handling POST /session");
|
||||
|
||||
// FIXME: 1. If the maximum active sessions is equal to the length of the list of active sessions,
|
||||
// return error with error code session not created.
|
||||
|
||||
// FIXME: 2. If the remote end is an intermediary node, take implementation-defined steps that either
|
||||
// result in returning an error with error code session not created, or in returning a
|
||||
// success with data that is isomorphic to that returned by remote ends according to the
|
||||
// rest of this algorithm. If an error is not returned, the intermediary node must retain a
|
||||
// reference to the session created on the upstream node as the associated session such
|
||||
// that commands may be forwarded to this associated session on subsequent commands.
|
||||
|
||||
// FIXME: 3. If the maximum active sessions is equal to the length of the list of active sessions,
|
||||
// return error with error code session not created.
|
||||
|
||||
// FIXME: 4. Let capabilities be the result of trying to process capabilities with parameters as an argument.
|
||||
|
||||
// FIXME: 5. If capabilities’s is null, return error with error code session not created.
|
||||
|
||||
// 6. Let session id be the result of generating a UUID.
|
||||
// FIXME: Actually create a UUID.
|
||||
auto session_id = Client::s_next_session_id++;
|
||||
|
||||
// 7. Let session be a new session with the session ID of session id.
|
||||
NonnullOwnPtr<Session> session = make<Session>(session_id, *this);
|
||||
auto start_result = session->start();
|
||||
if (start_result.is_error()) {
|
||||
return HttpError { 500, "Failed to start session", start_result.error().string_literal() };
|
||||
}
|
||||
|
||||
// FIXME: 8. Set the current session to session.
|
||||
|
||||
// FIXME: 9. Run any WebDriver new session algorithm defined in external specifications,
|
||||
// with arguments session and capabilities.
|
||||
|
||||
// 10. Append session to active sessions.
|
||||
Client::s_sessions.append(move(session));
|
||||
|
||||
// 11. Let body be a JSON Object initialized with:
|
||||
JsonObject body;
|
||||
// "sessionId"
|
||||
// session id
|
||||
body.set("sessionId", String::number(session_id));
|
||||
// FIXME: "capabilities"
|
||||
// capabilities
|
||||
|
||||
// FIXME: 12. Initialize the following from capabilities:
|
||||
// NOTE: See spec for steps
|
||||
|
||||
// FIXME: 13. Set the webdriver-active flag to true.
|
||||
|
||||
// FIXME: 14. Set the current top-level browsing context for session with the top-level browsing context
|
||||
// of the UA’s current browsing context.
|
||||
|
||||
// FIXME: 15. Set the request queue to a new queue.
|
||||
|
||||
// 16. Return success with data body.
|
||||
return make_json_value(body);
|
||||
}
|
||||
|
||||
// DELETE /session/{session id} https://w3c.github.io/webdriver/#dfn-delete-session
|
||||
ErrorOr<JsonValue, HttpError> Client::handle_delete_session(Vector<StringView> parameters, JsonValue const&)
|
||||
{
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Handling DELETE /session/<session_id>");
|
||||
|
||||
// 1. If the current session is an active session, try to close the session.
|
||||
Session* session = TRY(find_session_with_id(parameters[0]));
|
||||
|
||||
auto stop_result = session->stop();
|
||||
if (stop_result.is_error()) {
|
||||
return HttpError { 500, "unsupported operation", stop_result.error().string_literal() };
|
||||
}
|
||||
|
||||
// 2. Return success with data null.
|
||||
return make_json_value(JsonValue());
|
||||
}
|
||||
|
||||
// GET /status https://w3c.github.io/webdriver/#dfn-status
|
||||
ErrorOr<JsonValue, HttpError> Client::handle_get_status(Vector<StringView>, JsonValue const&)
|
||||
{
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Handling GET /status");
|
||||
|
||||
// FIXME: Implement the spec steps
|
||||
return HttpError { 400, "", "" };
|
||||
}
|
||||
|
||||
// POST /session/{session id}/url https://w3c.github.io/webdriver/#dfn-navigate-to
|
||||
ErrorOr<JsonValue, HttpError> Client::handle_post_url(Vector<StringView> parameters, JsonValue const& payload)
|
||||
{
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Handling POST /session/<session_id>/url");
|
||||
Session* session = TRY(find_session_with_id(parameters[0]));
|
||||
|
||||
// NOTE: Spec steps handled in Session::post_url().
|
||||
auto result = TRY(session->post_url(payload));
|
||||
return make_json_value(result);
|
||||
}
|
||||
|
||||
// GET /session/{session id}/url https://w3c.github.io/webdriver/#dfn-get-current-url
|
||||
ErrorOr<JsonValue, HttpError> Client::handle_get_url(Vector<StringView>, JsonValue const&)
|
||||
{
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Handling GET /session/<session_id>/url");
|
||||
|
||||
// FIXME: Implement the spec steps
|
||||
return HttpError { 400, "", "" };
|
||||
}
|
||||
|
||||
// GET /session/{session id}/title https://w3c.github.io/webdriver/#dfn-get-title
|
||||
ErrorOr<JsonValue, HttpError> Client::handle_get_title(Vector<StringView> parameters, JsonValue const&)
|
||||
{
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Handling GET /session/<session_id>/title");
|
||||
Session* session = TRY(find_session_with_id(parameters[0]));
|
||||
|
||||
// NOTE: Spec steps handled in Session::get_title().
|
||||
auto result = TRY(session->get_title());
|
||||
|
||||
return make_json_value(result);
|
||||
}
|
||||
|
||||
// DELETE /session/{session id}/window https://w3c.github.io/webdriver/#dfn-close-window
|
||||
ErrorOr<JsonValue, HttpError> Client::handle_delete_window(Vector<StringView> parameters, JsonValue const&)
|
||||
{
|
||||
dbgln_if(WEBDRIVER_DEBUG, "Handling DELETE /session/<session_id>/window");
|
||||
Session* session = TRY(find_session_with_id(parameters[0]));
|
||||
|
||||
// NOTE: Spec steps handled in Session::delete_window().
|
||||
TRY(unwrap_result(session->delete_window()));
|
||||
|
||||
return make_json_value(JsonValue());
|
||||
}
|
||||
|
||||
}
|
91
Userland/Services/WebDriver/Client.h
Normal file
91
Userland/Services/WebDriver/Client.h
Normal file
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* Copyright (c) 2022, Florent Castelli <florent.castelli@gmail.com>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <AK/NonnullOwnPtrVector.h>
|
||||
#include <LibCore/Object.h>
|
||||
#include <LibCore/Stream.h>
|
||||
#include <LibHTTP/Forward.h>
|
||||
#include <LibHTTP/HttpRequest.h>
|
||||
#include <WebDriver/HttpError.h>
|
||||
#include <WebDriver/Session.h>
|
||||
|
||||
namespace WebDriver {
|
||||
|
||||
class Client final : public Core::Object {
|
||||
C_OBJECT(Client);
|
||||
|
||||
public:
|
||||
void start();
|
||||
void close_session(unsigned session_id);
|
||||
|
||||
private:
|
||||
Client(NonnullOwnPtr<Core::Stream::BufferedTCPSocket>, Core::Object* parent);
|
||||
|
||||
ErrorOr<JsonValue> read_body_as_json(HTTP::HttpRequest const&);
|
||||
ErrorOr<bool> handle_request(HTTP::HttpRequest const&, JsonValue const& body);
|
||||
ErrorOr<void> send_response(StringView content, HTTP::HttpRequest const&);
|
||||
ErrorOr<void> send_error_response(HttpError const& error, HTTP::HttpRequest const&);
|
||||
void die();
|
||||
void log_response(unsigned code, HTTP::HttpRequest const&);
|
||||
|
||||
using RouteHandler = ErrorOr<JsonValue, HttpError> (Client::*)(Vector<StringView>, JsonValue const&);
|
||||
struct Route {
|
||||
HTTP::HttpRequest::Method method;
|
||||
Vector<String> path;
|
||||
RouteHandler handler;
|
||||
};
|
||||
|
||||
struct RoutingResult {
|
||||
RouteHandler handler;
|
||||
Vector<StringView> parameters;
|
||||
};
|
||||
|
||||
ErrorOr<RoutingResult, HttpError> match_route(HTTP::HttpRequest::Method method, String resource);
|
||||
ErrorOr<JsonValue, HttpError> handle_post_session(Vector<StringView>, JsonValue const& payload);
|
||||
ErrorOr<JsonValue, HttpError> handle_delete_session(Vector<StringView>, JsonValue const& payload);
|
||||
ErrorOr<JsonValue, HttpError> handle_get_status(Vector<StringView>, JsonValue const& payload);
|
||||
ErrorOr<JsonValue, HttpError> handle_post_url(Vector<StringView>, JsonValue const& payload);
|
||||
ErrorOr<JsonValue, HttpError> handle_get_url(Vector<StringView>, JsonValue const& payload);
|
||||
ErrorOr<JsonValue, HttpError> handle_get_title(Vector<StringView>, JsonValue const& payload);
|
||||
ErrorOr<JsonValue, HttpError> handle_delete_window(Vector<StringView>, JsonValue const& payload);
|
||||
|
||||
ErrorOr<Session*, HttpError> find_session_with_id(StringView session_id);
|
||||
JsonValue make_json_value(JsonValue const&);
|
||||
|
||||
template<typename T>
|
||||
static ErrorOr<T, HttpError> unwrap_result(ErrorOr<T, Variant<HttpError, Error>> result)
|
||||
{
|
||||
if (result.is_error()) {
|
||||
Variant<HttpError, Error> error = result.release_error();
|
||||
if (error.has<HttpError>())
|
||||
return error.get<HttpError>();
|
||||
return HttpError { 500, "unsupported operation", error.get<Error>().string_literal() };
|
||||
}
|
||||
|
||||
return result.release_value();
|
||||
}
|
||||
static ErrorOr<void, HttpError> unwrap_result(ErrorOr<void, Variant<HttpError, Error>> result)
|
||||
{
|
||||
if (result.is_error()) {
|
||||
Variant<HttpError, Error> error = result.release_error();
|
||||
if (error.has<HttpError>())
|
||||
return error.get<HttpError>();
|
||||
return HttpError { 500, "unsupported operation", error.get<Error>().string_literal() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
NonnullOwnPtr<Core::Stream::BufferedTCPSocket> m_socket;
|
||||
static Vector<Route> s_routes;
|
||||
String m_prefix = "/";
|
||||
|
||||
static NonnullOwnPtrVector<Session> s_sessions;
|
||||
static Atomic<unsigned> s_next_session_id;
|
||||
};
|
||||
|
||||
}
|
28
Userland/Services/WebDriver/HttpError.h
Normal file
28
Userland/Services/WebDriver/HttpError.h
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 2022, Florent Castelli <florent.castelli@gmail.com>
|
||||
* Copyright (c) 2022, Sam Atkins <atkinssj@serenityos.org>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <AK/String.h>
|
||||
|
||||
namespace WebDriver {
|
||||
|
||||
struct HttpError {
|
||||
unsigned http_status;
|
||||
String error;
|
||||
String message;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
template<>
|
||||
struct AK::Formatter<WebDriver::HttpError> : Formatter<StringView> {
|
||||
ErrorOr<void> format(FormatBuilder& builder, WebDriver::HttpError const& error)
|
||||
{
|
||||
return Formatter<StringView>::format(builder, String::formatted("Error {}, {}: {}", error.http_status, error.error, error.message));
|
||||
}
|
||||
};
|
144
Userland/Services/WebDriver/Session.cpp
Normal file
144
Userland/Services/WebDriver/Session.cpp
Normal file
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Copyright (c) 2022, Florent Castelli <florent.castelli@gmail.com>
|
||||
* Copyright (c) 2022, Sam Atkins <atkinssj@serenityos.org>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#include "Session.h"
|
||||
#include "BrowserConnection.h"
|
||||
#include "Client.h"
|
||||
#include <LibCore/LocalServer.h>
|
||||
#include <LibCore/Stream.h>
|
||||
#include <LibCore/System.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace WebDriver {
|
||||
|
||||
Session::Session(unsigned session_id, NonnullRefPtr<Client> client)
|
||||
: m_client(move(client))
|
||||
, m_id(session_id)
|
||||
{
|
||||
}
|
||||
|
||||
Session::~Session()
|
||||
{
|
||||
if (m_started) {
|
||||
auto error = stop();
|
||||
if (error.is_error()) {
|
||||
warnln("Failed to stop session {}: {}", m_id, error.error());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ErrorOr<void> Session::start()
|
||||
{
|
||||
auto socket_path = String::formatted("/tmp/browser_webdriver_{}_{}", getpid(), m_id);
|
||||
dbgln("Listening for WebDriver connection on {}", socket_path);
|
||||
|
||||
// FIXME: Use Core::LocalServer
|
||||
struct sockaddr_un addr;
|
||||
int listen_socket = TRY(Core::System::socket(AF_UNIX, SOCK_STREAM, 0));
|
||||
::memset(&addr, 0, sizeof(struct sockaddr_un));
|
||||
addr.sun_family = AF_UNIX;
|
||||
::strncpy(addr.sun_path, socket_path.characters(), sizeof(addr.sun_path) - 1);
|
||||
|
||||
TRY(Core::System::bind(listen_socket, (const struct sockaddr*)&addr, sizeof(struct sockaddr_un)));
|
||||
TRY(Core::System::listen(listen_socket, 1));
|
||||
|
||||
char const* argv[] = { "/bin/Browser", "--webdriver", socket_path.characters(), nullptr };
|
||||
TRY(Core::System::posix_spawn("/bin/Browser"sv, nullptr, nullptr, const_cast<char**>(argv), environ));
|
||||
|
||||
int data_socket = TRY(Core::System::accept(listen_socket, nullptr, nullptr));
|
||||
auto socket = TRY(Core::Stream::LocalSocket::adopt_fd(data_socket));
|
||||
TRY(socket->set_blocking(true));
|
||||
m_browser_connection = TRY(adopt_nonnull_ref_or_enomem(new (nothrow) BrowserConnection(move(socket), m_client, session_id())));
|
||||
dbgln("Browser is connected");
|
||||
|
||||
m_started = true;
|
||||
m_windows.set("main", make<Session::Window>("main", true));
|
||||
m_current_window_handle = "main";
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
ErrorOr<void> Session::stop()
|
||||
{
|
||||
m_browser_connection->async_quit();
|
||||
return {};
|
||||
}
|
||||
|
||||
// DELETE /session/{session id}/window https://w3c.github.io/webdriver/#dfn-close-window
|
||||
ErrorOr<void, Variant<HttpError, Error>> Session::delete_window()
|
||||
{
|
||||
// 1. If the current top-level browsing context is no longer open, return error with error code no such window.
|
||||
auto current_window = get_window_object();
|
||||
if (!current_window.has_value())
|
||||
return Variant<HttpError, Error>(HttpError { 400, "no such window", "Window not found" });
|
||||
|
||||
// 2. Close the current top-level browsing context.
|
||||
m_windows.remove(m_current_window_handle);
|
||||
|
||||
// 3. If there are no more open top-level browsing contexts, then close the session.
|
||||
if (m_windows.is_empty()) {
|
||||
auto result = stop();
|
||||
if (result.is_error()) {
|
||||
return Variant<HttpError, Error>(result.release_error());
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
// POST /session/{session id}/url https://w3c.github.io/webdriver/#dfn-navigate-to
|
||||
ErrorOr<JsonValue, HttpError> Session::post_url(JsonValue const& payload)
|
||||
{
|
||||
// 1. If the current top-level browsing context is no longer open, return error with error code no such window.
|
||||
auto current_window = get_window_object();
|
||||
if (!current_window.has_value())
|
||||
return HttpError { 400, "no such window", "Window not found" };
|
||||
|
||||
// FIXME 2. Handle any user prompts and return its value if it is an error.
|
||||
|
||||
// 3. If the url property is missing from the parameters argument or it is not a string, return error with error code invalid argument.
|
||||
if (!payload.is_object() || !payload.as_object().has_string("url"sv)) {
|
||||
return HttpError { 400, "invalid argument", "Payload doesn't have a string url" };
|
||||
}
|
||||
|
||||
// 4. Let url be the result of getting a property named url from the parameters argument.
|
||||
URL url(payload.as_object().get_ptr("url"sv)->as_string());
|
||||
|
||||
// FIXME: 5. If url is not an absolute URL or an absolute URL with fragment, return error with error code invalid argument. [URL]
|
||||
|
||||
// 6. Let url be the result of getting a property named url from the parameters argument.
|
||||
// Duplicate step?
|
||||
|
||||
// 7. Navigate the current top-level browsing context to url.
|
||||
m_browser_connection->async_set_url(url);
|
||||
|
||||
// FIXME: 8. Run the post-navigation checks and return its value if it is an error.
|
||||
|
||||
// FIXME: 9. Wait for navigation to complete and return its value if it is an error.
|
||||
|
||||
// FIXME: 10. Set the current browsing context to the current top-level browsing context.
|
||||
|
||||
// 11. Return success with data null.
|
||||
return JsonValue();
|
||||
}
|
||||
|
||||
// GET /session/{session id}/title https://w3c.github.io/webdriver/#dfn-get-title
|
||||
ErrorOr<JsonValue, HttpError> Session::get_title()
|
||||
{
|
||||
// 1. If the current top-level browsing context is no longer open, return error with error code no such window.
|
||||
auto current_window = get_window_object();
|
||||
if (!current_window.has_value())
|
||||
return HttpError { 400, "no such window", "Window not found" };
|
||||
|
||||
// FIXME: 2. Handle any user prompts and return its value if it is an error.
|
||||
|
||||
// 3. Let title be the initial value of the title IDL attribute of the current top-level browsing context's active document.
|
||||
// 4. Return success with data title.
|
||||
return JsonValue(m_browser_connection->get_title());
|
||||
}
|
||||
|
||||
}
|
50
Userland/Services/WebDriver/Session.h
Normal file
50
Userland/Services/WebDriver/Session.h
Normal file
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright (c) 2022, Florent Castelli <florent.castelli@gmail.com>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <AK/Error.h>
|
||||
#include <AK/JsonValue.h>
|
||||
#include <AK/RefPtr.h>
|
||||
#include <WebDriver/BrowserConnection.h>
|
||||
#include <WebDriver/HttpError.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace WebDriver {
|
||||
|
||||
class Session {
|
||||
public:
|
||||
Session(unsigned session_id, NonnullRefPtr<Client> client);
|
||||
~Session();
|
||||
|
||||
unsigned session_id() const { return m_id; }
|
||||
|
||||
struct Window {
|
||||
String handle;
|
||||
bool is_open;
|
||||
};
|
||||
|
||||
HashMap<String, NonnullOwnPtr<Window>>& get_window_handles() { return m_windows; }
|
||||
Optional<Window*> get_window_object() { return m_windows.get(m_current_window_handle); }
|
||||
String get_window() { return m_current_window_handle; }
|
||||
|
||||
ErrorOr<void> start();
|
||||
ErrorOr<void> stop();
|
||||
ErrorOr<void, Variant<HttpError, Error>> delete_window();
|
||||
ErrorOr<JsonValue, HttpError> post_url(JsonValue const& url);
|
||||
ErrorOr<JsonValue, HttpError> get_title();
|
||||
|
||||
private:
|
||||
NonnullRefPtr<Client> m_client;
|
||||
bool m_started { false };
|
||||
unsigned m_id { 0 };
|
||||
HashMap<String, NonnullOwnPtr<Window>> m_windows;
|
||||
String m_current_window_handle;
|
||||
RefPtr<Core::LocalServer> m_local_server;
|
||||
RefPtr<BrowserConnection> m_browser_connection;
|
||||
};
|
||||
|
||||
}
|
75
Userland/Services/WebDriver/main.cpp
Normal file
75
Userland/Services/WebDriver/main.cpp
Normal file
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright (c) 2022, Florent Castelli <florent.castelli@gmail.com>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#include <LibCore/ArgsParser.h>
|
||||
#include <LibCore/EventLoop.h>
|
||||
#include <LibCore/System.h>
|
||||
#include <LibCore/TCPServer.h>
|
||||
#include <LibMain/Main.h>
|
||||
#include <WebDriver/Client.h>
|
||||
|
||||
ErrorOr<int> serenity_main(Main::Arguments arguments)
|
||||
{
|
||||
String default_listen_address = "0.0.0.0";
|
||||
u16 default_port = 8000;
|
||||
|
||||
String listen_address = default_listen_address;
|
||||
int port = default_port;
|
||||
|
||||
Core::ArgsParser args_parser;
|
||||
args_parser.add_option(listen_address, "IP address to listen on", "listen-address", 'l', "listen_address");
|
||||
args_parser.add_option(port, "Port to listen on", "port", 'p', "port");
|
||||
args_parser.parse(arguments);
|
||||
|
||||
auto ipv4_address = IPv4Address::from_string(listen_address);
|
||||
if (!ipv4_address.has_value()) {
|
||||
warnln("Invalid listen address: {}", listen_address);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ((u16)port != port) {
|
||||
warnln("Invalid port number: {}", port);
|
||||
return 1;
|
||||
}
|
||||
|
||||
TRY(Core::System::pledge("stdio accept rpath inet unix proc exec fattr"));
|
||||
|
||||
Core::EventLoop loop;
|
||||
|
||||
auto server = TRY(Core::TCPServer::try_create());
|
||||
|
||||
server->on_ready_to_accept = [&] {
|
||||
auto maybe_client_socket = server->accept();
|
||||
if (maybe_client_socket.is_error()) {
|
||||
warnln("Failed to accept the client: {}", maybe_client_socket.error());
|
||||
return;
|
||||
}
|
||||
|
||||
auto maybe_buffered_socket = Core::Stream::BufferedTCPSocket::create(maybe_client_socket.release_value());
|
||||
if (maybe_buffered_socket.is_error()) {
|
||||
warnln("Could not obtain a buffered socket for the client: {}", maybe_buffered_socket.error());
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: Propagate errors
|
||||
MUST(maybe_buffered_socket.value()->set_blocking(true));
|
||||
auto client = WebDriver::Client::construct(maybe_buffered_socket.release_value(), server);
|
||||
client->start();
|
||||
};
|
||||
|
||||
TRY(server->listen(ipv4_address.value(), port));
|
||||
|
||||
outln("Listening on {}:{}", ipv4_address.value(), port);
|
||||
|
||||
TRY(Core::System::unveil("/bin/Browser", "rx"));
|
||||
TRY(Core::System::unveil("/etc/timezone", "r"));
|
||||
TRY(Core::System::unveil("/res/icons", "r"));
|
||||
TRY(Core::System::unveil("/tmp", "rwc"));
|
||||
TRY(Core::System::unveil(nullptr, nullptr));
|
||||
|
||||
TRY(Core::System::pledge("stdio accept rpath unix proc exec fattr"));
|
||||
return loop.exec();
|
||||
}
|
Loading…
Reference in a new issue