// webdriver.h — Thin W3C WebDriver client using cpp-httplib + nlohmann/json. // SPDX-License-Identifier: MIT // // Usage: // webdriver::Session session; // starts headless Firefox via geckodriver // session.navigate("http://localhost:8080"); // auto el = session.css("h1"); // assert(el.text() == "Hello!"); // // session destructor closes the browser #pragma once #include "httplib.h" #include #include #include #include #include namespace webdriver { using json = nlohmann::json; // ─── Errors ────────────────────────────────────────────────── class Error : public std::runtime_error { public: using std::runtime_error::runtime_error; }; // ─── Forward declarations ──────────────────────────────────── class Session; // ─── Element ───────────────────────────────────────────────── class Element { friend class Session; httplib::Client *cli_; std::string session_id_; std::string element_id_; public: Element(httplib::Client *cli, const std::string &session_id, const std::string &element_id) : cli_(cli), session_id_(session_id), element_id_(element_id) {} std::string url(const std::string &suffix = "") const { return "/session/" + session_id_ + "/element/" + element_id_ + suffix; } public: std::string text() const { auto res = cli_->Get(url("/text")); if (!res || res->status != 200) { throw Error("Failed to get element text"); } return json::parse(res->body)["value"].get(); } std::string attribute(const std::string &name) const { auto res = cli_->Get(url("/attribute/" + name)); if (!res || res->status != 200) { return ""; } auto val = json::parse(res->body)["value"]; return val.is_null() ? "" : val.get(); } std::string property(const std::string &name) const { auto res = cli_->Get(url("/property/" + name)); if (!res || res->status != 200) { return ""; } auto val = json::parse(res->body)["value"]; return val.is_null() ? "" : val.get(); } void click() const { auto res = cli_->Post(url("/click"), "{}", "application/json"); if (!res || res->status != 200) { throw Error("Failed to click element"); } } void send_keys(const std::string &keys) const { json body = {{"text", keys}}; auto res = cli_->Post(url("/value"), body.dump(), "application/json"); if (!res || res->status != 200) { throw Error("Failed to send keys to element"); } } void clear() const { auto res = cli_->Post(url("/clear"), "{}", "application/json"); if (!res || res->status != 200) { throw Error("Failed to clear element"); } } std::string tag_name() const { auto res = cli_->Get(url("/name")); if (!res || res->status != 200) { throw Error("Failed to get tag name"); } return json::parse(res->body)["value"].get(); } bool is_displayed() const { auto res = cli_->Get(url("/displayed")); if (!res || res->status != 200) { return false; } return json::parse(res->body)["value"].get(); } }; // ─── Session ───────────────────────────────────────────────── class Session { httplib::Client cli_; std::string session_id_; // W3C WebDriver uses this key for element references static constexpr const char *ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf"; std::string extract_element_id(const json &value) const { if (value.contains(ELEMENT_KEY)) { return value[ELEMENT_KEY].get(); } // Fallback: try "ELEMENT" (older protocol) if (value.contains("ELEMENT")) { return value["ELEMENT"].get(); } throw Error("No element identifier in response: " + value.dump()); } std::string url(const std::string &suffix) const { return "/session/" + session_id_ + suffix; } public: explicit Session(const std::string &host = "127.0.0.1", int port = 4444) : cli_(host, port) { cli_.set_read_timeout(std::chrono::seconds(30)); cli_.set_connection_timeout(std::chrono::seconds(5)); json caps = { {"capabilities", {{"alwaysMatch", {{"moz:firefoxOptions", {{"args", json::array({"-headless"})}}}}}}}}; auto res = cli_.Post("/session", caps.dump(), "application/json"); if (!res) { throw Error("Cannot connect to geckodriver"); } if (res->status != 200) { throw Error("Failed to create session: " + res->body); } auto body = json::parse(res->body); session_id_ = body["value"]["sessionId"].get(); } ~Session() { try { cli_.Delete(url("")); } catch (...) {} } // Non-copyable, non-movable (owns a session) Session(const Session &) = delete; Session &operator=(const Session &) = delete; // ─── Navigation ────────────────────────────────────────── void navigate(const std::string &nav_url) { json body = {{"url", nav_url}}; auto res = cli_.Post(url("/url"), body.dump(), "application/json"); if (!res || res->status != 200) { throw Error("Failed to navigate to: " + nav_url); } } std::string title() { auto res = cli_.Get(url("/title")); if (!res || res->status != 200) { throw Error("Failed to get title"); } return json::parse(res->body)["value"].get(); } std::string current_url() { auto res = cli_.Get(url("/url")); if (!res || res->status != 200) { throw Error("Failed to get current URL"); } return json::parse(res->body)["value"].get(); } // ─── Find elements ────────────────────────────────────── Element find(const std::string &using_, const std::string &value) { json body = {{"using", using_}, {"value", value}}; auto res = cli_.Post(url("/element"), body.dump(), "application/json"); if (!res || res->status != 200) { throw Error("Element not found: " + using_ + "=" + value); } auto eid = extract_element_id(json::parse(res->body)["value"]); return Element(&cli_, session_id_, eid); } std::vector find_all(const std::string &using_, const std::string &value) { json body = {{"using", using_}, {"value", value}}; auto res = cli_.Post(url("/elements"), body.dump(), "application/json"); if (!res || res->status != 200) { return {}; } std::vector elements; for (auto &v : json::parse(res->body)["value"]) { elements.emplace_back(&cli_, session_id_, extract_element_id(v)); } return elements; } // Convenience: find by CSS selector Element css(const std::string &selector) { return find("css selector", selector); } std::vector css_all(const std::string &selector) { return find_all("css selector", selector); } // ─── Wait ──────────────────────────────────────────────── // Poll for an element until it appears or timeout Element wait_for(const std::string &selector, int timeout_ms = 5000) { auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); while (std::chrono::steady_clock::now() < deadline) { try { return css(selector); } catch (...) { std::this_thread::sleep_for(std::chrono::milliseconds(200)); } } throw Error("Timeout waiting for element: " + selector); } // Wait until a JS expression returns truthy bool wait_until(const std::string &script, int timeout_ms = 5000) { auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); while (std::chrono::steady_clock::now() < deadline) { auto result = execute_script(script); if (result != "null" && result != "false" && result != "" && result != "0" && result != "undefined") { return true; } std::this_thread::sleep_for(std::chrono::milliseconds(200)); } return false; } // ─── Execute script ───────────────────────────────────── std::string execute_script(const std::string &script, const json &args = json::array()) { json body = {{"script", script}, {"args", args}}; auto res = cli_.Post(url("/execute/sync"), body.dump(), "application/json"); if (!res || res->status != 200) { throw Error("Failed to execute script: " + script); } auto val = json::parse(res->body)["value"]; if (val.is_null()) { return "null"; } if (val.is_string()) { return val.get(); } return val.dump(); } // ─── Page source ──────────────────────────────────────── std::string page_source() { auto res = cli_.Get(url("/source")); if (!res || res->status != 200) { throw Error("Failed to get page source"); } return json::parse(res->body)["value"].get(); } }; } // namespace webdriver