diff --git a/meta/plugins/wpt.py b/meta/plugins/wpt.py index 5827dcf5..147209d3 100644 --- a/meta/plugins/wpt.py +++ b/meta/plugins/wpt.py @@ -46,9 +46,9 @@ def _(args: WptArgs): cmd = [ "./wpt", "run", - "paper_muncher", + "vaev", "--webdriver-binary", - "paper_muncher_webdriver", + "vaev-webdriver", "--test-type=reftest", "--no-fail-on-unexpected", ] + args.args diff --git a/project.lock b/project.lock index 217c83a4..608a04db 100644 --- a/project.lock +++ b/project.lock @@ -6,13 +6,18 @@ "git": "https://github.com/cute-engineering/cat.git", "tag": "v0.11.0" }, + "cute-engineering/ce-bootfs": { + "commit": "87ad7796d29b6e36adebc7810b69c9b8dd4d0208", + "git": "https://github.com/cute-engineering/ce-bootfs.git", + "tag": "main" + }, "cute-engineering/ce-heap": { "commit": "918dd7962dbcd703a883c9fcf658b496f9e3502e", "git": "https://github.com/cute-engineering/ce-heap.git", "tag": "v1.1.0" }, "cute-engineering/ce-libc": { - "commit": "d5703dc5442bb9460eb7a99368c9c8b71791d629", + "commit": "3ed3147c168046594a92d7862feed99f5c558ac2", "git": "https://github.com/cute-engineering/ce-libc.git", "tag": "v1.1.1" }, @@ -42,12 +47,12 @@ "tag": "main" }, "skift-org/hideo": { - "commit": "e2be582bd8b168d9541ae19b6105f741c5dd7716", + "commit": "eb9ef17d03ee0abff1e142c467d3b6e04ca1564f", "git": "https://github.com/skift-org/hideo.git", "tag": "main" }, "skift-org/karm": { - "commit": "93c3aaf3959abbeb05176b8d64ecbf80dfa2ac0a", + "commit": "6ce43866d37365b2954b127b8a34e7e091c6d2ad", "git": "https://github.com/skift-org/karm.git", "tag": "main" } diff --git a/src/vaev-engine/dom/element.cpp b/src/vaev-engine/dom/element.cpp index 19d51ef9..9738677c 100644 --- a/src/vaev-engine/dom/element.cpp +++ b/src/vaev-engine/dom/element.cpp @@ -144,6 +144,25 @@ export struct Element : Node { return sb.take(); } + // MARK: Element ----------------------------------------------------------- + + // https://html.spec.whatwg.org/multipage/syntax.html#void-elements + bool isVoidElement() const { + return qualifiedName == Html::AREA_TAG or + qualifiedName == Html::BASE_TAG or + qualifiedName == Html::BR_TAG or + qualifiedName == Html::COL_TAG or + qualifiedName == Html::EMBED_TAG or + qualifiedName == Html::HR_TAG or + qualifiedName == Html::IMG_TAG or + qualifiedName == Html::INPUT_TAG or + qualifiedName == Html::LINK_TAG or + qualifiedName == Html::META_TAG or + qualifiedName == Html::SOURCE_TAG or + qualifiedName == Html::TRACK_TAG or + qualifiedName == Html::WBR_TAG; + } + // MARK: Pseudo Elements --------------------------------------------------- void clearPseudoElement() { diff --git a/src/vaev-engine/dom/mod.cpp b/src/vaev-engine/dom/mod.cpp index 83c2cb3f..44604ff4 100644 --- a/src/vaev-engine/dom/mod.cpp +++ b/src/vaev-engine/dom/mod.cpp @@ -13,3 +13,4 @@ export import :dom.text; export import :dom.tokenList; export import :dom.tree; export import :dom.window; +export import :dom.serialisation; diff --git a/src/vaev-engine/dom/names.cpp b/src/vaev-engine/dom/names.cpp index 12f7662f..e2c8623a 100644 --- a/src/vaev-engine/dom/names.cpp +++ b/src/vaev-engine/dom/names.cpp @@ -94,6 +94,7 @@ export Symbol qualifiedAttrNameCased(Str name) { if (eqCi(Str(#VALUE), name)) \ return Symbol::from(Str(#VALUE)); #include "defs/ns-svg-attr-names.inc" + #undef ATTR return Symbol::from(name); } @@ -103,6 +104,7 @@ export Symbol qualifiedTagNameCased(Str name) { if (eqCi(Str(#VALUE), name)) \ return Symbol::from(Str(#VALUE)); #include "defs/ns-svg-tag-names.inc" + #undef TAG return Symbol::from(name); } @@ -127,6 +129,10 @@ namespace Dom { void Dom::QualifiedName::repr(Io::Emit& e) const { Str displayNamespace = ns.str(); + // NOTE: If current node is an element in the HTML namespace, the MathML namespace, + // or the SVG namespace, then let tagname be current node's local name. + // Otherwise, let tagname be current node's qualified name. + // SEE: 13.3.5.2 https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments if (ns == Html::NAMESPACE) { displayNamespace = "html"; } else if (ns == Svg::NAMESPACE) { @@ -134,6 +140,7 @@ void Dom::QualifiedName::repr(Io::Emit& e) const { } else if (ns == MathMl::NAMESPACE) { displayNamespace = "mathml"; } + e("{}:{}", displayNamespace, name); } diff --git a/src/vaev-engine/dom/serialisation.cpp b/src/vaev-engine/dom/serialisation.cpp new file mode 100644 index 00000000..ee013e19 --- /dev/null +++ b/src/vaev-engine/dom/serialisation.cpp @@ -0,0 +1,189 @@ +export module Vaev.Engine:dom.serialisation; + +import Karm.Core; +import Karm.Gc; + +import :dom.element; +import :dom.comment; +import :dom.document; +import :dom.documentType; + +using namespace Karm; + +namespace Vaev::Dom { + +// MARK: Escaping -------------------------------------------------------------- +// https://html.spec.whatwg.org/multipage/parsing.html#escapingString + +export void escapeString(Io::Emit& e, Io::SScan& s, bool attributeMode = false) { + while (not s.ended()) { + auto r = s.next(); + // Replace any occurrence of the "&" character by the string "&". + if (r == '&') + e("&"); + + // Replace any occurrences of the U+00A0 NO-BREAK SPACE character by the string " ". + else if (r == U'\xA0') + e(" "); + + // Replace any occurrences of the "<" character by the string "<". + else if (r == '<') + e("<"); + + // Replace any occurrences of the ">" character by the string ">". + else if (r == '>') + e(">"); + + // If the algorithm was invoked in the attribute mode, then replace any occurrences of the """ character by the string """. + else if (attributeMode and r == '"') + e("""); + + else + e(r); + } +} + +export void escapeString(Io::Emit& e, Str str, bool attributeMode = false) { + Io::SScan s{str}; + escapeString(e, s, attributeMode); +} + +// MARK: Serialize ------------------------------------------------------------- +// https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments + +// https://html.spec.whatwg.org/multipage/parsing.html#serializes-as-void +bool _serializeAsVoid(Gc::Ref node) { + auto el = node->is(); + if (not el) + return false; + + // For the purposes of the following algorithm, an element serializes as void + // if its element type is one of the void elements, or is basefont, bgsound, frame, keygen, or param. + return el->isVoidElement() or + el->qualifiedName == Html::BASEFONT_TAG or + el->qualifiedName == Html::BGSOUND_TAG or + el->qualifiedName == Html::FRAME_TAG or + el->qualifiedName == Html::KEYGEN_TAG or + el->qualifiedName == Html::PARAM_TAG; +} + +// https://html.spec.whatwg.org/multipage/parsing.html#html-fragment-serialisation-algorithm +export void serializeHtmlFragment(Gc::Ref node, Io::Emit& e) { + // 1. If the node serializes as void, then return the empty string. + if (_serializeAsVoid(node)) + return; + + // 3. If the node is a template element, then let the node instead be the template element's template contents (a DocumentFragment node). + // TODO: We don't support DocumentFragment + + // 4. If current node is a shadow host, then: + // 1. Let shadow be current node's shadow root. + // 2. If serializableShadowRoots is true and shadow’s serializable is true, or shadowRoots contains shadow, then: + // 1. Append "". + // TODO: We don't have shadow dom support + + // 5. For each child node of the node, in tree order: + // 1. Let current node be the child node being processed. + for (auto currentNode : node->iterChildren()) { + // 2. Append the appropriate string: + // If current node is an Element: + if (auto el = currentNode->is()) { + // - Determine tagname: if in HTML, MathML, or SVG namespace, tagname is local name; otherwise qualified name. + // - Append "<" followed by tagname. + e("<{}", el->qualifiedName); + + // - If current node has an is value not present as an attribute, append ' is=""'. + if (auto isValue = el->getAttribute(Html::IS_ATTR)) { + e(" is=\""); + escapeString(e, isValue.unwrap(), true); + e("\""); + } + // - For each attribute: + for (auto& [name, attr] : el->attributes.iterUnordered()) { + if (name == Html::IS_ATTR) + continue; + // Append space, attribute’s serialized name, "=", quote, escaped value, quote. + e(" {}=\"", name); + escapeString(e, attr->value, true); + e("\""); + } + // - Append ">". + e(">"); + + // - If current node serializes as void, continue. + if (_serializeAsVoid(currentNode)) + continue; + + // - Append the value of running this algorithm on current node + serializeHtmlFragment(currentNode, e); + + // then "". + e("", el->qualifiedName); + } + + // If current node is a Text node: + else if (auto text = currentNode->is()) { + auto parent = text->parentNode(); + // - If its parent is style, script, xmp, iframe, noembed, noframes, plaintext, or (if scripting enabled) noscript, + if (auto parentElement = parent->is(); + parentElement and + (parentElement->qualifiedName == Html::STYLE_TAG or + parentElement->qualifiedName == Html::SCRIPT_TAG or + parentElement->qualifiedName == Html::XMP_TAG or + parentElement->qualifiedName == Html::IFRAME_TAG or + parentElement->qualifiedName == Html::NOEMBED_TAG or + parentElement->qualifiedName == Html::NOFRAMES_TAG or + parentElement->qualifiedName == Html::PLAINTEXT_TAG or + parentElement->qualifiedName == Html::NOSCRIPT_TAG)) { + // append text literally. + e(text->data()); + } + // - Otherwise append escaped text. + else { + escapeString(e, text->data()); + } + } + + // If current node is a Comment: + else if (auto comment = currentNode->is()) { + // - Append "". + e("", comment->data()); + } + + // If current node is a ProcessingInstruction: + // - Append "". + // TODO: We don't support ProcessingInstruction + + // If current node is a DocumentType: + else if (auto doctype = currentNode->is()) { + // - Append "". + e("", doctype->name); + } + } +} + +export String serializeHtmlFragment(Gc::Ref node) { + // 1. If the node serializes as void, then return the empty string. + if (_serializeAsVoid(node)) + return ""s; + + // 2. Let s be a string, and initialize it to the empty string. + Io::StringWriter sw; + Io::Emit e{sw}; + + serializeHtmlFragment(node, e); + + // 6. Return s. + return sw.take(); +} + +} // namespace Vaev::Dom diff --git a/src/vaev-engine/dom/window.cpp b/src/vaev-engine/dom/window.cpp index 0d601f2a..80f376d2 100644 --- a/src/vaev-engine/dom/window.cpp +++ b/src/vaev-engine/dom/window.cpp @@ -58,10 +58,16 @@ export struct Window { } else { co_return Error::invalidInput("unsupported intent"); } + invalidateRender(); co_return Ok(); } + [[clang::coro_wrapper]] + Async::Task<> refreshAsync() { + return loadLocationAsync(document()->url()); + } + Ref::Url location() const { return _document.upgrade()->url(); } @@ -73,8 +79,7 @@ export struct Window { Driver::RenderResult& ensureRender() { if (_render) return *_render; - Vec2Au viewportSize = {_media.width, _media.height}; - _render = Driver::render(_document.upgrade(), _media, {.small = viewportSize}); + _render = Driver::render(_document.upgrade(), _media, {.small = _media.viewportSize()}); return *_render; } diff --git a/src/vaev-engine/style/media.cpp b/src/vaev-engine/style/media.cpp index 64914015..f4041d57 100644 --- a/src/vaev-engine/style/media.cpp +++ b/src/vaev-engine/style/media.cpp @@ -274,6 +274,10 @@ export struct Media { .deviceAspectRatio = settings.paper.width / settings.paper.height, }; } + + Vec2Au viewportSize() const { + return {width, height}; + } }; // MARK: Media Features -------------------------------------------------------- diff --git a/src/vaev-engine/style/specified.cpp b/src/vaev-engine/style/specified.cpp index 14bd1535..78bcf4ed 100644 --- a/src/vaev-engine/style/specified.cpp +++ b/src/vaev-engine/style/specified.cpp @@ -61,7 +61,7 @@ export struct SpecifiedValues { Integer order; AlignProps aligns; Display display; - f16 opacity; + f32 opacity; // Small Field Float float_ = Float::NONE; diff --git a/src/vaev-webdriver/driver.cpp b/src/vaev-webdriver/driver.cpp new file mode 100644 index 00000000..fa744881 --- /dev/null +++ b/src/vaev-webdriver/driver.cpp @@ -0,0 +1,289 @@ +module; + +#include + +export module Vaev.Webdriver:driver; + +import Karm.Core; +import Karm.Http; +import Karm.Ref; +import Karm.Image; +import Karm.Crypto; +import Karm.Sys; +import Vaev.Engine; + +import :protocol; + +using namespace Karm; + +namespace Vaev::WebDriver { + +export struct Session { + Ref::Uuid uuid; + Map> windows; + Ref::Uuid current; + TimeoutConfiguration timeouts; + + // https://www.w3.org/TR/webdriver2/#dfn-current-browsing-context + Res> currentBrowsingContext() { + auto maybeWindow = windows.tryGet(current); + if (not maybeWindow) + return Error::invalidInput("no current browsing context"); + return Ok(maybeWindow.take()); + } +}; + +// https://www.w3.org/TR/webdriver2/#endpoints +export struct WebDriver { + Map> _sessions; + + // MARK: 7. Capabilities --------------------------------------------------- + + // MARK: 8. Sessions ------------------------------------------------------- + // https://www.w3.org/TR/webdriver2/#sessions + + Res> getSession(Ref::Uuid sessionId) { + auto maybeSession = _sessions.tryGet(sessionId); + if (not maybeSession) + return Error::invalidInput("invalid session uuid"); + return Ok(maybeSession.take()); + } + + // https://www.w3.org/TR/webdriver2/#new-session + Res newSession() { + auto sessionId = try$(Ref::Uuid::v4()); + auto windowHandle = try$(Ref::Uuid::v4()); + auto session = makeRc(sessionId); + session->windows.put(windowHandle, Dom::Window::create()); + session->current = windowHandle; + _sessions.put(sessionId, session); + return Ok(sessionId); + } + + // https://www.w3.org/TR/webdriver2/#delete-session + Res<> deleteSession(Ref::Uuid sessionId) { + _sessions.del(sessionId); + return Ok(); + } + + // https://www.w3.org/TR/webdriver2/#status + Res status() { + return Ok(ReadinessState{ + .ready = true, + .message = wholesome(Sys::now().val()), + }); + } + + // MARK: 9. Timeout -------------------------------------------------------- + // https://www.w3.org/TR/webdriver2/#timeouts + + // https://www.w3.org/TR/webdriver2/#set-timeouts + Res<> setTimeouts(Ref::Uuid sessionId, TimeoutConfiguration timeouts) { + auto session = try$(getSession(sessionId)); + session->timeouts = timeouts; + return Ok(); + } + + // https://www.w3.org/TR/webdriver2/#get-timeouts + Res getTimeouts(Ref::Uuid sessionId) { + auto session = try$(getSession(sessionId)); + return Ok(session->timeouts); + } + + // MARK: 10. Navigation ---------------------------------------------------- + // https://www.w3.org/TR/webdriver2/#navigation + + // https://www.w3.org/TR/webdriver2/#navigate-to + Async::Task<> navigateTo(Ref::Uuid sessionId, Ref::Url url) { + auto session = co_try$(getSession(sessionId)); + auto window = co_try$(session->currentBrowsingContext()); + co_return co_await window->loadLocationAsync(url); + } + + // https://www.w3.org/TR/webdriver2/#get-current-url + Res getCurrentUrl(Ref::Uuid sessionId) { + auto session = try$(getSession(sessionId)); + auto window = try$(session->currentBrowsingContext()); + return Ok(window->location()); + } + + // https://www.w3.org/TR/webdriver2/#refresh + Async::Task<> refreshAsync(Ref::Uuid sessionId) { + auto session = co_try$(getSession(sessionId)); + auto window = co_try$(session->currentBrowsingContext()); + co_return co_await window->refreshAsync(); + } + + // https://www.w3.org/TR/webdriver2/#get-title + Res getTitle(Ref::Uuid sessionId) { + auto session = try$(getSession(sessionId)); + auto window = try$(session->currentBrowsingContext()); + + // 3. Let title be the session's current top-level browsing context's active document's title. + return Ok(window->document().upgrade()->title()); + } + + // MARK: 11. Context ------------------------------------------------------- + // https://www.w3.org/TR/webdriver2/#contexts + + // https://www.w3.org/TR/webdriver2/#get-window-handle + Res getWindowHandle(Ref::Uuid sessionId) { + auto session = try$(getSession(sessionId)); + return Ok(session->current); + } + + // https://www.w3.org/TR/webdriver2/#close-window + Res<> closeWindow(Ref::Uuid sessionId) { + auto session = try$(getSession(sessionId)); + if (not session->windows.del(session->current)) + return Error::invalidInput("browsing context no longer open"); + return Ok(); + } + + // https://www.w3.org/TR/webdriver2/#switch-to-window + Res<> switchToWindow(Ref::Uuid sessionId, Ref::Uuid windowHandle) { + auto session = try$(getSession(sessionId)); + if (not session->windows.has(windowHandle)) + return Error::invalidInput("no such window"); + session->current = windowHandle; + return Ok(); + } + + // https://www.w3.org/TR/webdriver2/#get-window-handles + Res> getWindowHandles(Ref::Uuid sessionId) { + auto session = try$(getSession(sessionId)); + return Ok(session->windows.keys()); + } + + // https://www.w3.org/TR/webdriver2/#new-window + Res newWindow(Ref::Uuid sessionId) { + auto session = try$(getSession(sessionId)); + auto windowHandle = try$(Ref::Uuid::v4()); + auto window = Dom::Window::create(); + session->windows.put(windowHandle, window); + session->current = windowHandle; + return Ok(windowHandle); + } + + // MARK: 11.8 Resizing and positioning windows ----------------------------- + // https://www.w3.org/TR/webdriver2/#resizing-and-positioning-windows + + // https://www.w3.org/TR/webdriver2/#get-window-rect + Res getWindowRect(Ref::Uuid sessionId) { + auto session = try$(getSession(sessionId)); + auto window = try$(session->currentBrowsingContext()); + return Ok(window->_media.viewportSize()); + } + + // https://www.w3.org/TR/webdriver2/#set-window-rect + Res<> setWindowRect(Ref::Uuid sessionId, RectAu rect) { + auto session = try$(getSession(sessionId)); + auto window = try$(session->currentBrowsingContext()); + window->changeViewport(rect.size()); + return Ok(); + } + + // https://www.w3.org/TR/webdriver2/#maximize-window + Res<> maximizeWindow() { + return Error::unsupported("unsupported operation"); + } + + // https://www.w3.org/TR/webdriver2/#minimize-window + Res<> minimizeWindow() { + return Error::unsupported("unsupported operation"); + } + + // https://www.w3.org/TR/webdriver2/#fullscreen-window + Res<> fullscreenWindow() { + return Error::unsupported("unsupported operation"); + } + + // MARK: 13. Document ------------------------------------------------------ + // https://www.w3.org/TR/webdriver2/#document + + // https://www.w3.org/TR/webdriver2/#get-page-source + Res getPageSource(Ref::Uuid sessionId) { + auto session = try$(getSession(sessionId)); + auto window = try$(session->currentBrowsingContext()); + return Ok(Dom::serializeHtmlFragment(window->document().upgrade())); + } + + // https://www.w3.org/TR/webdriver2/#execute-script + Res executeScript([[maybe_unused]] Ref::Uuid sessionId, Str body) { + // FIXME: We don't support javascript yet, let's pretend we are + + // https://github.com/web-platform-tests/wpt/blob/bbfc05f2af01d92e2c5af0f8a37b580e233f48f1/tools/wptrunner/wptrunner/executors/executorwebdriver.py#L1070 + if (contains(body, R"js(return [window.outerWidth - window.innerWidth, + window.outerHeight - window.innerHeight];")js"s)) { + return Ok(Serde::Array{0, 0}); + } + + // https://github.com/web-platform-tests/wpt/blob/master/tools/wptrunner/wptrunner/executors/runner.js + else if (contains(body, "document.title = 'MainThread'"s)) { + return Ok(Serde::Array{}); + } + + // https://github.com/web-platform-tests/wpt/blob/master/tools/wptrunner/wptrunner/executors/test-wait.js + else if (contains(body, R"js(const initialized = !!window.__wptrunner_url;)js"s)) { + // FIXME: not 100% sure + return Ok(Serde::Array{"complete"s, "complete"s, Serde::Array{}}); + } + + else + return Error::unsupported("unsupported operation"); + } + + // MARK: 17. Screen capture ------------------------------------------------ + // https://www.w3.org/TR/webdriver2/#screen-capture + + // https://www.w3.org/TR/webdriver2/#take-screenshot + Res takeScreenshot(Ref::Uuid sessionId) { + auto session = try$(getSession(sessionId)); + auto window = try$(session->currentBrowsingContext()); + auto scene = window->render(); + auto data = try$( + Karm::Image::save( + scene, + window->_media.viewportSize().cast(), + { + // NOSPEC: Should be PUBLIC_PNG but we don't support PNG encoding yet + .format = Ref::Uti::PUBLIC_BMP, + } + ) + ); + + return Ok(Crypto::base64Encode(data)); + } + + // MARK: 18. Print --------------------------------------------------------- + // https://www.w3.org/TR/webdriver2/#print + + // https://www.w3.org/TR/webdriver2/#print-page + Res printPage(Ref::Uuid sessionId, PrintSettings settings = {}) { + auto session = try$(getSession(sessionId)); + auto window = try$(session->currentBrowsingContext()); + + auto printer = try$( + Print::PdfPrinter::create( + Ref::Uti::PUBLIC_PDF + ) + ); + + window->print(settings.toNative()) | forEach([&](Print::Page& page) { + page.print( + *printer, + {.showBackgroundGraphics = true} + ); + }); + + Io::BufferWriter bw; + try$(printer->write(bw)); + return Ok(Crypto::base64Encode(bw.bytes())); + } +}; + +export Rc createWebDriver() { + return makeRc(); +} + +} // namespace Vaev::WebDriver diff --git a/src/vaev-webdriver/main.cpp b/src/vaev-webdriver/main.cpp new file mode 100644 index 00000000..bd482b6f --- /dev/null +++ b/src/vaev-webdriver/main.cpp @@ -0,0 +1,34 @@ +#include +#include + +import Karm.Core; +import Karm.Http; +import Karm.Cli; +import Vaev.Webdriver; + +using namespace Karm; + +Async::Task<> entryPointAsync(Sys::Context& ctx) { + auto portOption = Cli::option('p', "port"s, "TCP port to listen to (default: 4444)."s, 4444); + Cli::Section serverSection = {"Server Options"s, {portOption}}; + + Cli::Command cmd{ + "vaev-webdriver"s, + "Webdriver protocol implementation for vaev."s, + {serverSection} + }; + + co_trya$(cmd.execAsync(ctx)); + if (not cmd) + co_return Ok(); + + auto webdriver = Vaev::WebDriver::createWebDriver(); + auto service = Vaev::WebDriver::createService(webdriver); + co_return co_await Http::serveAsync( + service, + { + .name = "Vaev WebDriver"s, + .addr = Sys::Ip4::localhost(portOption.value()), + } + ); +} \ No newline at end of file diff --git a/src/vaev-webdriver/manifest.json b/src/vaev-webdriver/manifest.json new file mode 100644 index 00000000..048c9141 --- /dev/null +++ b/src/vaev-webdriver/manifest.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schemas.cute.engineering/stable/cutekit.manifest.component.v1", + "id": "vaev-webdriver", + "type": "exe", + "requires": [ + "vaev-engine", + "karm-cli" + ] +} diff --git a/src/vaev-webdriver/mod.cpp b/src/vaev-webdriver/mod.cpp new file mode 100644 index 00000000..ef14c6ff --- /dev/null +++ b/src/vaev-webdriver/mod.cpp @@ -0,0 +1,5 @@ +export module Vaev.Webdriver; + +export import :protocol; +export import :service; +export import :driver; diff --git a/src/vaev-webdriver/protocol.cpp b/src/vaev-webdriver/protocol.cpp new file mode 100644 index 00000000..7cee016f --- /dev/null +++ b/src/vaev-webdriver/protocol.cpp @@ -0,0 +1,92 @@ +module; + +#include + +export module Vaev.Webdriver:protocol; + +import Karm.Core; +import Karm.Math; +import Karm.Print; +import Karm.Http; + +using namespace Karm; + +namespace Vaev::WebDriver { + +// MARK: 6. Protocol ----------------------------------------------------------- +// https://www.w3.org/TR/webdriver2/#protocol + +Async::Task<> _sendSuccessAsync(Rc resp, Serde::Value data = NONE) { + co_trya$(resp->writeHeaderAsync(Http::Code::OK)); + co_trya$(resp->writeJsonAsync(Serde::Object{ + {"value"s, data}, + })); + co_return Ok(); +} + +Async::Task<> _sendErrorAsync(Rc resp, Error err, Serde::Value data = {}) { + co_trya$(resp->writeHeaderAsync(Http::Code::BAD_REQUEST)); + co_trya$(resp->writeJsonAsync(Serde::Object{ + {"error"s, Str{err.msg()}}, + {"data"s, data}, + })); + co_return Ok(); +} + +// MARK: Readiness State ------------------------------------------------------- +// https://www.w3.org/TR/webdriver2/#dfn-readiness-state + +struct ReadinessState { + bool ready; + String message = ""s; +}; + +// MARK: 9. Timeouts ----------------------------------------------------------- +// https://www.w3.org/TR/webdriver2/#timeouts + +// https://www.w3.org/TR/webdriver2/#dfn-timeouts-configuration +struct TimeoutConfiguration { + Duration script = Duration::fromMSecs(30000); + Duration pageLoad = Duration::fromMSecs(300000); + Duration implicit = Duration::fromMSecs(0); +}; + +// MARK: 18. Print ------------------------------------------------------------- + +struct PrintSettings { + Print::Orientation orientation = Print::Orientation::PORTRAIT; + f64 scale = 1.0; + bool background = false; + bool shrinkToFit = true; + Math::Vec2f paper = { + 21.59, + 27.94 + }; + Math::Insetsf margins = 1.0; + Vec pageRanges{}; + + static PrintSettings defaults() { + return {}; + } + + Print::Settings toNative() const { + return { + .paper = { + .name = "custom"s, + .width = paper.width * 10 * Print::UNIT, + .height = paper.height * 10 * Print::UNIT, + }, + .margins = Math::Insetsf{ + margins.top * 10 * Print::UNIT, + margins.end * 10 * Print::UNIT, + margins.bottom * 10 * Print::UNIT, + margins.start * 10 * Print::UNIT, + }, + .orientation = orientation, + .scale = scale, + .backgroundGraphics = background, + }; + } +}; + +} // namespace Vaev::WebDriver diff --git a/src/vaev-webdriver/service.cpp b/src/vaev-webdriver/service.cpp new file mode 100644 index 00000000..e4f66795 --- /dev/null +++ b/src/vaev-webdriver/service.cpp @@ -0,0 +1,542 @@ +module; + +#include + +export module Vaev.Webdriver:service; + +import Karm.Core; +import Karm.Http; +import Karm.Logger; + +import :driver; + +using namespace Karm; + +namespace Vaev::WebDriver { + +export Rc createService(Rc webdriver) { + auto service = makeRc(); + + service->get("/", [](Rc, Rc resp) -> Async::Task<> { + co_trya$(resp->writeStrAsync(R"html( + + + +

Vaev WebDriver

+ + + )html"s)); + co_return Ok(); + }); + + // MARK: 8. Sessions ------------------------------------------------------- + // https://www.w3.org/TR/webdriver2/#sessions + + // https://www.w3.org/TR/webdriver2/#new-session + service->post( + "/session", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + auto data = co_trya$(req->readJsonAsync()); + + auto maybeSessionId = webdriver->newSession(); + if (not maybeSessionId) + co_return co_await _sendErrorAsync(resp, maybeSessionId.none()); + + Serde::Object body{ + {"sessionId"s, maybeSessionId.unwrap().unparsed()}, + {"capabilities"s, data.getOr("capabilities"s, NONE)}, + }; + co_trya$(_sendSuccessAsync(resp, std::move(body))); + + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#delete-session + service->delete_( + "/session/{sessionId}", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + auto result = webdriver->deleteSession(sessionId); + + if (not result) + co_return co_await _sendErrorAsync(resp, result.none()); + + co_trya$(_sendSuccessAsync(resp)); + + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#status + service->get( + "/status", + [webdriver](Rc, Rc resp) mutable -> Async::Task<> { + auto maybeStatus = webdriver->status(); + if (not maybeStatus) + co_return co_await _sendErrorAsync(resp, maybeStatus.none()); + + Serde::Object body{ + {"ready"s, maybeStatus.unwrap().ready}, + {"message"s, maybeStatus.unwrap().message}, + }; + co_trya$(_sendSuccessAsync(resp, std::move(body))); + + co_return Ok(); + } + ); + + // MARK: 9. Timeout -------------------------------------------------------- + // https://www.w3.org/TR/webdriver2/#timeouts + + // https://www.w3.org/TR/webdriver2/#get-timeouts + service->get( + "/session/{sessionId}/timeouts", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + auto maybeTimeouts = webdriver->getTimeouts(sessionId); + if (not maybeTimeouts) + co_return co_await _sendErrorAsync(resp, maybeTimeouts.none()); + + Serde::Object serialized{ + {"script"s, maybeTimeouts.unwrap().script.toMSecs()}, + {"pageLoad"s, maybeTimeouts.unwrap().pageLoad.toMSecs()}, + {"implicit"s, maybeTimeouts.unwrap().implicit.toMSecs()}, + }; + co_trya$(_sendSuccessAsync(resp, std::move(serialized))); + + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#set-timeouts + service->post( + "/session/{sessionId}/timeouts", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + Serde::Value timeouts = co_trya$(req->readJsonAsync()); + TimeoutConfiguration configuration{}; + + if (auto value = timeouts.getOr("script", NONE); value.isInt()) { + configuration.script = Duration::fromMSecs(value.asInt()); + } + if (auto value = timeouts.getOr("pageLoad", NONE); value.isInt()) { + configuration.pageLoad = Duration::fromMSecs(value.asInt()); + } + if (auto value = timeouts.getOr("implicit", NONE); value.isInt()) { + configuration.implicit = Duration::fromMSecs(value.asInt()); + } + + auto result = webdriver->setTimeouts(sessionId, configuration); + if (not result) + co_return co_await _sendErrorAsync(resp, result.none()); + + co_trya$(_sendSuccessAsync(resp)); + co_return Ok(); + } + ); + + // MARK: 10. Navigation ---------------------------------------------------- + // https://www.w3.org/TR/webdriver2/#navigation + + // https://www.w3.org/TR/webdriver2/#navigate-to + service->post( + "/session/{sessionId}/url", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + Serde::Value parameters = co_trya$(req->readJsonAsync()); + + Ref::Url url; + if (auto value = parameters.getOr("url", NONE); value.isStr()) { + url = Ref::Url::parse(value.asStr()); + } else { + co_return co_await _sendErrorAsync(resp, Error::invalidInput("missing url key")); + } + + auto result = co_await webdriver->navigateTo(sessionId, url); + if (not result) + co_return co_await _sendErrorAsync(resp, result.none()); + + co_trya$(_sendSuccessAsync(resp)); + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#get-current-url + service->get( + "/session/{sessionId}/url", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + auto maybeUrl = webdriver->getCurrentUrl(sessionId); + if (not maybeUrl) + co_return co_await _sendErrorAsync(resp, maybeUrl.none()); + + co_trya$(_sendSuccessAsync(resp, maybeUrl.unwrap().str())); + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#refresh + service->post( + "/session/{sessionId}/refresh", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + auto result = co_await webdriver->refreshAsync(sessionId); + if (not result) + co_return co_await _sendErrorAsync(resp, result.none()); + co_trya$(_sendSuccessAsync(resp)); + + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#get-title + service->get( + "/session/{sessionId}/title", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + auto maybeTitle = webdriver->getTitle(sessionId); + if (not maybeTitle) + co_return co_await _sendErrorAsync(resp, maybeTitle.none()); + + co_trya$(_sendSuccessAsync(resp, maybeTitle.unwrap().str())); + co_return Ok(); + } + ); + + // MARK: 11. Context ------------------------------------------------------- + // https://www.w3.org/TR/webdriver2/#contexts + + // https://www.w3.org/TR/webdriver2/#get-window-handle + service->get( + "/session/{sessionId}/window", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + auto maybeWindowHandle = webdriver->getWindowHandle(sessionId); + if (not maybeWindowHandle) + co_return co_await _sendErrorAsync(resp, maybeWindowHandle.none()); + + co_trya$(_sendSuccessAsync(resp, maybeWindowHandle.unwrap().unparsed())); + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#close-window + service->delete_( + "/session/{sessionId}/window", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + auto result = webdriver->closeWindow(sessionId); + if (not result) + co_return co_await _sendErrorAsync(resp, result.none()); + + co_trya$(_sendSuccessAsync(resp)); + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#switch-to-window + service->post( + "/session/{sessionId}/window", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + Serde::Value parameters = co_trya$(req->readJsonAsync()); + Ref::Uuid handle; + if (auto value = parameters.getOr("handle", NONE); value.isStr()) { + handle = co_try$(Ref::Uuid::parse(value.asStr())); + } else { + co_return co_await _sendErrorAsync(resp, Error::invalidInput("missing handle key")); + } + + auto result = webdriver->switchToWindow(sessionId, handle); + if (not result) + co_return co_await _sendErrorAsync(resp, result.none()); + + co_trya$(_sendSuccessAsync(resp)); + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#get-window-handles + service->get( + "/session/{sessionId}/window/handles", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + auto maybeHandles = webdriver->getWindowHandles(sessionId); + if (not maybeHandles) + co_return co_await _sendErrorAsync(resp, maybeHandles.none()); + + Serde::Array handles; + for (auto& h : maybeHandles.unwrap()) + handles.pushBack(h.unparsed()); + co_trya$(_sendSuccessAsync(resp, std::move(handles))); + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#new-window + service->post( + "/session/{sessionId}/window/new", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + auto maybeWindowHandle = webdriver->newWindow(sessionId); + if (not maybeWindowHandle) + co_return co_await _sendErrorAsync(resp, maybeWindowHandle.none()); + + Serde::Object result{ + {"handle"s, maybeWindowHandle.take().unparsed()}, + {"type"s, "window"s}, + }; + co_trya$(_sendSuccessAsync(resp, std::move(result))); + + co_return Ok(); + } + ); + + // MARK: 11.8 Resizing and positioning windows ----------------------------- + // https://www.w3.org/TR/webdriver2/#resizing-and-positioning-windows + + // https://www.w3.org/TR/webdriver2/#get-window-rect + service->get( + "/session/{sessionId}/window/rect", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + auto maybeRect = webdriver->getWindowRect(sessionId); + if (not maybeRect) + co_return co_await _sendErrorAsync(resp, maybeRect.none()); + + Serde::Object result{ + {"x"s, maybeRect.unwrap().x.cast()}, + {"y"s, maybeRect.unwrap().y.cast()}, + {"width"s, maybeRect.unwrap().width.cast()}, + {"height"s, maybeRect.unwrap().height.cast()}, + }; + co_trya$(_sendSuccessAsync(resp, std::move(result))); + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#set-window-rect + service->post( + "/session/{sessionId}/window/rect", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + Serde::Value parameters = co_trya$(req->readJsonAsync()); + + RectAu rect; + + if (auto value = parameters.getOr("width", NONE); value.isInt()) + rect.width = Au(value.asInt()); + + if (auto value = parameters.getOr("height", NONE); value.isInt()) + rect.height = Au(value.asInt()); + + if (auto value = parameters.getOr("x", NONE); value.isInt()) + rect.x = Au(value.asInt()); + + if (auto value = parameters.getOr("y", NONE); value.isInt()) + rect.y = Au(value.asInt()); + + auto result = webdriver->setWindowRect(sessionId, rect); + if (not result) + co_return co_await _sendErrorAsync(resp, result.none()); + + co_trya$(_sendSuccessAsync(resp)); + + co_return Ok(); + } + ); + + // MARK: 13. Document ------------------------------------------------------ + // https://www.w3.org/TR/webdriver2/#document + + // https://www.w3.org/TR/webdriver2/#get-page-source + service->get( + "/session/{sessionId}/source", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + auto maybeSource = webdriver->getPageSource(sessionId); + if (not maybeSource) + co_return co_await _sendErrorAsync(resp, maybeSource.none()); + + co_trya$(_sendSuccessAsync(resp, maybeSource.take())); + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#execute-script + service->post( + "/session/{sessionId}/execute/sync", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + Serde::Value parameters = co_trya$(req->readJsonAsync()); + String script; + if (auto value = parameters.getOr("script", NONE); value.isStr()) { + script = value.asStr(); + } else { + co_return co_await _sendErrorAsync(resp, Error::invalidInput("missing script key")); + } + + auto scriptResult = webdriver->executeScript(sessionId, script); + if (not scriptResult) + co_return co_await _sendErrorAsync(resp, scriptResult.none()); + + co_trya$(_sendSuccessAsync(resp, scriptResult.take())); + co_return Ok(); + } + ); + + // https://www.w3.org/TR/webdriver2/#execute-async-script + service->post( + "/session/{sessionId}/execute/async", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + Serde::Value parameters = co_trya$(req->readJsonAsync()); + String script; + if (auto value = parameters.getOr("script", NONE); value.isStr()) { + script = value.asStr(); + } else { + co_return co_await _sendErrorAsync(resp, Error::invalidInput("missing script key")); + } + + auto scriptResult = webdriver->executeScript(sessionId, script); + if (not scriptResult) + co_return co_await _sendErrorAsync(resp, scriptResult.none()); + + co_trya$(_sendSuccessAsync(resp, scriptResult.take())); + co_return Ok(); + } + ); + + // MARK: 17. Screen capture ------------------------------------------------ + // https://www.w3.org/TR/webdriver2/#screen-capture + + // https://www.w3.org/TR/webdriver2/#take-screenshot + service->post( + "/session/{sessionId}/screenshot", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + auto maybeScreenshot = webdriver->takeScreenshot(sessionId); + if (not maybeScreenshot) + co_return co_await _sendErrorAsync(resp, maybeScreenshot.none()); + + co_trya$(_sendSuccessAsync(resp, maybeScreenshot.take())); + co_return Ok(); + } + ); + + // MARK: 18. Print --------------------------------------------------------- + // https://www.w3.org/TR/webdriver2/#print + + // https://www.w3.org/TR/webdriver2/#print-page + service->post( + "/session/{sessionId}/print", + [webdriver](Rc req, Rc resp) mutable -> Async::Task<> { + Ref::Uuid sessionId = co_try$(Ref::Uuid::parse(co_try$(req->routeParams.tryGet("sessionId"s)))); + + Serde::Value parameters = co_trya$(req->readJsonAsync()); + + // Let orientation be the result of getting a property with default named "orientation" and with default "portrait" from parameters. + auto orientation = parameters.getOr("orientation", "portrait"s); + // If orientation is not a String or does not have one of the values "landscape" or "portrait", return error with error code invalid argument. + if (not orientation.isStr() or (orientation.asStr() != "landscape"s and orientation.asStr() != "portrait"s)) { + co_trya$(_sendErrorAsync(resp, Error::invalidInput("invalid argument"))); + co_return Ok(); + } + + // Let scale be the result of getting a property with default named "scale" and with default 1 from parameters. + auto scale = parameters.getOr("scale", 1); + + // If scale is not a Number, or is less than 0.1 or greater than 2 return error with error code invalid argument. + // TODO + + // Let background be the result of getting a property with default named "background" and with default false from parameters. + auto background = parameters.getOr("background", false); + + // If background is not a Boolean return error with error code invalid argument. + // TODO + + // Let page be the result of getting a property with default named "page" and with a default of an empty Object from parameters. + auto page = parameters.getOr("page", Serde::Object{}); + + // Let pageWidth be the result of getting a property with default named "width" and with a default of 21.59 from page. + auto pageWidth = page.getOr("width", 21.59); + + // Let pageHeight be the result of getting a property with default named "height" and with a default of 27.94 from page. + auto pageHeight = page.getOr("height", 27.94); + + // If either of pageWidth or pageHeight is not a Number, or is less than (2.54 / 72), return error with error code invalid argument. + // TODO + + // Let margin be the result of getting a property with default named "margin" and with a default of an empty Object from parameters. + auto margin = parameters.getOr("margin", Serde::Object{}); + + // Let marginTop be the result of getting a property with default named "top" and with a default of 1 from margin. + auto marginTop = margin.getOr("top", 1); + + // Let marginBottom be the result of getting a property with default named "bottom" and with a default of 1 from margin. + auto marginBottom = margin.getOr("bottom", 1); + + // Let marginLeft be the result of getting a property with default named "left" and with a default of 1 from margin. + auto marginLeft = margin.getOr("left", 1); + + // Let marginRight be the result of getting a property with default named "right" and with a default of 1 from margin. + auto marginRight = margin.getOr("right", 1); + + // If any of marginTop, marginBottom, marginLeft, or marginRight is not a Number, or is less then 0, return error with error code invalid argument. + // TODO + + // Let shrinkToFit be the result of getting a property with default named "shrinkToFit" and with default true from parameters. + auto shrinkToFit = parameters.getOr("shrinkToFit", true); + + // If shrinkToFit is not a Boolean return error with error code invalid argument. + // TODO + + // Let pageRanges be the result of getting a property with default named "pageRanges" from parameters with default of an empty Array. + // TODO + + // If pageRanges is not an Array return error with error code invalid argument. + // TODO + + PrintSettings settings{ + .orientation = orientation.asStr() == "portrait" ? Print::Orientation::PORTRAIT : Print::Orientation::LANDSCAPE, + .scale = scale.asFloat(), + .background = background.asBool(), + .paper = { + pageWidth.asFloat(), + pageHeight.asFloat(), + }, + .margins = { + marginTop.asFloat(), + marginRight.asFloat(), + marginBottom.asFloat(), + marginLeft.asFloat(), + }, + }; + + auto maybePdf = webdriver->printPage(sessionId, settings); + if (not maybePdf) + co_return co_await _sendErrorAsync(resp, maybePdf.none()); + + co_trya$(_sendSuccessAsync(resp, maybePdf.take())); + co_return Ok(); + } + ); + + return service; +} + +} // namespace Vaev::WebDriver