| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278 |
- // 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 <nlohmann/json.hpp>
- #include <stdexcept>
- #include <string>
- #include <thread>
- #include <vector>
- 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>();
- }
- 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>();
- }
- 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<std::string>();
- }
- 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<std::string>();
- }
- bool is_displayed() const {
- auto res = cli_->Get(url("/displayed"));
- if (!res || res->status != 200) { return false; }
- return json::parse(res->body)["value"].get<bool>();
- }
- };
- // ─── 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<std::string>();
- }
- // Fallback: try "ELEMENT" (older protocol)
- if (value.contains("ELEMENT")) {
- return value["ELEMENT"].get<std::string>();
- }
- 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<std::string>();
- }
- ~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>();
- }
- 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<std::string>();
- }
- // ─── 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<Element> 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<Element> 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<Element> 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<std::string>(); }
- 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<std::string>();
- }
- };
- } // namespace webdriver
|