diff options
-rw-r--r-- | cmake/Functions.cmake | 4 | ||||
-rw-r--r-- | cmake/License.cmake | 9 | ||||
-rw-r--r-- | src/core/CMakeLists.txt | 22 | ||||
-rw-r--r-- | src/core/api/configure.cmake | 9 | ||||
-rw-r--r-- | src/core/configure/BUILD.root.gn.in | 10 | ||||
-rw-r--r-- | src/core/doc/src/qtwebengine-features.qdoc | 63 | ||||
-rw-r--r-- | src/core/tools/webenginedriver/CMakeLists.txt | 57 | ||||
-rw-r--r-- | tests/auto/core/CMakeLists.txt | 4 | ||||
-rw-r--r-- | tests/auto/core/webenginedriver/CMakeLists.txt | 15 | ||||
-rw-r--r-- | tests/auto/core/webenginedriver/browser/CMakeLists.txt | 13 | ||||
-rw-r--r-- | tests/auto/core/webenginedriver/browser/main.cpp | 21 | ||||
-rw-r--r-- | tests/auto/core/webenginedriver/resources/input.html | 5 | ||||
-rw-r--r-- | tests/auto/core/webenginedriver/test/CMakeLists.txt | 26 | ||||
-rw-r--r-- | tests/auto/core/webenginedriver/tst_webenginedriver.cpp | 631 |
14 files changed, 888 insertions, 1 deletions
diff --git a/cmake/Functions.cmake b/cmake/Functions.cmake index 3371e5ff5..481657cff 100644 --- a/cmake/Functions.cmake +++ b/cmake/Functions.cmake @@ -1405,7 +1405,8 @@ endfunction() function(add_code_attributions_target) cmake_parse_arguments(PARSE_ARGV 0 arg "" - "TARGET;OUTPUT;GN_TARGET;FILE_TEMPLATE;ENTRY_TEMPLATE;BUILDDIR" "" + "TARGET;OUTPUT;GN_TARGET;FILE_TEMPLATE;ENTRY_TEMPLATE;BUILDDIR" + "EXTRA_THIRD_PARTY_DIRS" ) _qt_internal_validate_all_args_are_parsed(arg) get_filename_component(fileTemplate ${arg_FILE_TEMPLATE} ABSOLUTE) @@ -1417,6 +1418,7 @@ function(add_code_attributions_target) -DFILE_TEMPLATE=${fileTemplate} -DENTRY_TEMPLATE=${entryTemplate} -DGN_TARGET=${arg_GN_TARGET} + -DEXTRA_THIRD_PARTY_DIRS="${arg_EXTRA_THIRD_PARTY_DIRS}" -DBUILDDIR=${arg_BUILDDIR} -DOUTPUT=${arg_OUTPUT} -DPython3_EXECUTABLE=${Python3_EXECUTABLE} diff --git a/cmake/License.cmake b/cmake/License.cmake index dc1e286f1..2427ad679 100644 --- a/cmake/License.cmake +++ b/cmake/License.cmake @@ -19,6 +19,14 @@ if(NOT Python3_EXECUTABLE) find_package(Python3 3.6 REQUIRED) endif() +set(extraThirdPartyDirs "") +if(NOT "${EXTRA_THIRD_PARTY_DIRS}" STREQUAL "") + string(REPLACE " " ";" dirList ${EXTRA_THIRD_PARTY_DIRS}) + foreach(dir ${dirList}) + string(CONCAT extraThirdPartyDirs ${extraThirdPartyDirs}"${dir}",) + endforeach() +endif() + execute_process( COMMAND ${Python3_EXECUTABLE} ${LICENSE_SCRIPT} --file-template ${FILE_TEMPLATE} @@ -26,6 +34,7 @@ execute_process( --gn-binary ${Gn_EXECUTABLE} --gn-target ${GN_TARGET} --gn-out-dir ${BUILDDIR} + --extra-third-party-dirs=[${extraThirdPartyDirs}] credits ${OUTPUT} WORKING_DIRECTORY ${BUILDDIR} RESULT_VARIABLE gnResult diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index b70d10c1b..05410e4c6 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -14,6 +14,7 @@ endif() set(buildDir "${CMAKE_CURRENT_BINARY_DIR}") add_subdirectory(api) +add_subdirectory(tools/webenginedriver) add_subdirectory(tools/qwebengine_convert_dict) ## @@ -410,6 +411,10 @@ foreach(arch ${archs}) ARGS use_embedded_config CONDITION QT_FEATURE_webengine_embedded_build ) + extend_gn_list(gnArgArg + ARGS enable_webenginedriver + CONDITION QT_FEATURE_webenginedriver + ) if(LINUX) list(APPEND gnArgArg @@ -634,6 +639,23 @@ if(QT_FEATURE_webengine_spellchecker AND NOT CMAKE_CROSSCOMPILING) endif() ## +# WEBENGINEDRIVER +## + +if(QT_FEATURE_webenginedriver) + add_ninja_command( + TARGET webenginedriver_group + OUTPUT ${WEBENGINEDRIVER_EXECUTABLE} + BUILDDIR ${buildDir}/$<CONFIG>/${arch} + MODULE core + ) + add_custom_target(webenginedriver + DEPENDS + ${buildDir}/$<CONFIG>/${arch}/${WEBENGINEDRIVER_EXECUTABLE}) + add_dependencies(run_core_NinjaDone webenginedriver) +endif() + +## # CHROMIUM UPDATE ## diff --git a/src/core/api/configure.cmake b/src/core/api/configure.cmake index afef27a74..563410409 100644 --- a/src/core/api/configure.cmake +++ b/src/core/api/configure.cmake @@ -172,6 +172,14 @@ qt_feature("webengine-vaapi" PRIVATE # hardware accelerated encoding requires bundled libvpx CONDITION LINUX AND NOT QT_FEATURE_webengine_system_libvpx ) +list(LENGTH CMAKE_OSX_ARCHITECTURES osx_arch_count) +qt_feature("webenginedriver" PUBLIC + SECTION "WebEngine" + LABEL "Build WebEngineDriver" + PURPOSE "Enables WebEngineDriver build" + CONDITION NOT CMAKE_CROSSCOMPILING + AND NOT (CMAKE_OSX_ARCHITECTURES AND osx_arch_count GREATER 1) +) # internal testing feature qt_feature("webengine-system-poppler" PRIVATE LABEL "popler" @@ -216,6 +224,7 @@ qt_configure_add_summary_entry( CONDITION LINUX ) qt_configure_add_summary_entry(ARGS "webengine-v8-context-snapshot") +qt_configure_add_summary_entry(ARGS "webenginedriver") qt_configure_end_summary_section() # end of "Qt WebEngineCore" section if(CMAKE_CROSSCOMPILING) check_thumb(armThumb) diff --git a/src/core/configure/BUILD.root.gn.in b/src/core/configure/BUILD.root.gn.in index cdd02b66e..872ee4583 100644 --- a/src/core/configure/BUILD.root.gn.in +++ b/src/core/configure/BUILD.root.gn.in @@ -81,6 +81,7 @@ config("QtWebEngineCore_config") { declare_args() { use_embedded_config = false + enable_webenginedriver = true } config("embedded_config") { @@ -757,3 +758,12 @@ if (enable_spellcheck) { ] } } + +if (enable_webenginedriver) { + group("webenginedriver_group") { + testonly = true + deps = [ + "//chrome/test/chromedriver:chromedriver_server", + ] + } +} diff --git a/src/core/doc/src/qtwebengine-features.qdoc b/src/core/doc/src/qtwebengine-features.qdoc index b4befe48e..531fe4518 100644 --- a/src/core/doc/src/qtwebengine-features.qdoc +++ b/src/core/doc/src/qtwebengine-features.qdoc @@ -12,6 +12,7 @@ \list \li \l{Audio and Video Codecs} + \li \l{WebEngineDriver} \li \l{Chromium DevTools} \li \l{Client Certificates} \li \l{Custom Schemes} @@ -76,6 +77,68 @@ codecs, open source implementations, such as \l{OpenH264 Project Homepage} {OpenH264}, are available. + \section1 WebEngineDriver + + With WebEngineDriver, you can automate the testing of web sites across browsers. + WebEngineDriver is based on ChromeDriver and can be used the same way. + For more information about ChromeDriver and its use, visit + \l {https://chromedriver.chromium.org/}{ChromeDriver user site}. + + WebEngineDriver has slight modifications compared to ChromeDriver to be able to connect to + \QWE based browsers. It is compatible with \QWE example browsers, such as + \l {WebEngine Widgets Simple Browser Example}{Simple Browser} or + \l{WebEngine Quick Nano Browser}{Nano Browser}. + + The browser automation is scripted through a WebDriver client like the + \l {https://www.selenium.dev/}{Selenium WebDriver}. + For example, WebEngineDriver can be used with the Python lanugage bindings of + Selenium WebDriver: + + \code + from selenium import webdriver + from selenium.webdriver.chrome.service import Service + + service = Service(executable_path='QTDIR/libexec/webenginedriver') + options = webdriver.ChromeOptions() + options.binary_location = 'path/to/browser_binary' + + driver = webdriver.Chrome(service=service, options=options) + driver.get("http://www.google.com/") + driver.quit() + \endcode + + In this example, + \list + \li \c executable_path has to be set to the WebEngineDriver's binary path + \li \c QTDIR is the directory where Qt is installed + \li \c options.binary_location has to be set to the browser's binary path + \endlist + + \note On Windows: \c executable_path='QTDIR/bin/webenginedriver.exe' + + Before executing the script, the \c QTWEBENGINE_REMOTE_DEBUGGING environment variable has to + be set. Its value is a port number what is used by both the browser and WebEngineDriver to + communicate with each other. + \badcode + export QTWEBENGINE_REMOTE_DEBUGGING=12345 + \endcode + + By executing, the script opens the specified web browser and loads the Google web site. + + WebEngineDriver can be also attached to an already running browser if it was started with the + remote debugging port set. \c options.debugger_address has to be set to the remote debugging + address in the script: + + \code + options.debugger_address = 'localhost:12345' + \endcode + + In this case, \c options.binary_location should not be set because the browser is already + running. The environment variable \c QTWEBENGINE_REMOTE_DEBUGGING is not used by the + WebEngineDriver if \c options.debugger_address is set. + + \note WebEngineDriver must be built with the same version of Chromium as \QWE is using. + \section1 Chromium DevTools The Chromium DevTools provide the ability to inspect and debug layout and diff --git a/src/core/tools/webenginedriver/CMakeLists.txt b/src/core/tools/webenginedriver/CMakeLists.txt new file mode 100644 index 000000000..b20311980 --- /dev/null +++ b/src/core/tools/webenginedriver/CMakeLists.txt @@ -0,0 +1,57 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +if(WIN32) + set(WEBENGINEDRIVER_EXECUTABLE webenginedriver.exe) +else() + set(WEBENGINEDRIVER_EXECUTABLE webenginedriver) +endif() +set(WEBENGINEDRIVER_EXECUTABLE ${WEBENGINEDRIVER_EXECUTABLE} PARENT_SCOPE) + +if(QT_FEATURE_webenginedriver) + get_install_config(config) + get_architectures(archs) + list(GET archs 0 arch) + + ## + # DOCS + ## + add_code_attributions_target( + TARGET generate_webenginedriver_attributions + OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/webenginedriver_attributions.qdoc + GN_TARGET //chrome/test/chromedriver:chromedriver_server + EXTRA_THIRD_PARTY_DIRS + third_party/selenium-atoms/sizzle + third_party/selenium-atoms/wgxpath + third_party/selenium-atoms/closure-lib + FILE_TEMPLATE ../../doc/about_credits.tmpl + ENTRY_TEMPLATE ../../doc/about_credits_entry.tmpl + BUILDDIR ${buildDir}/${config}/${arch} + ) + add_dependencies(generate_webenginedriver_attributions run_core_GnDone) + add_dependencies(prepare_docs_WebEngineCore generate_webenginedriver_attributions) + + ## + # INSTALL + ## + install( + PROGRAMS ${buildDir}/${config}/${arch}/${WEBENGINEDRIVER_EXECUTABLE} + CONFIGURATIONS ${config} + RUNTIME DESTINATION "${INSTALL_LIBEXECDIR}" + ) + if(NOT QT_WILL_INSTALL) + add_custom_target(copy-webenginedriver + ALL + DEPENDS ${QT_BUILD_DIR}/${INSTALL_LIBEXECDIR}/${WEBENGINEDRIVER_EXECUTABLE} + ) + add_custom_command( + OUTPUT ${QT_BUILD_DIR}/${INSTALL_LIBEXECDIR}/${WEBENGINEDRIVER_EXECUTABLE} + COMMAND ${CMAKE_COMMAND} -E copy ${buildDir}/${config}/${arch}/${WEBENGINEDRIVER_EXECUTABLE} + ${QT_BUILD_DIR}/${INSTALL_LIBEXECDIR} + DEPENDS + WebEngineCore + ${buildDir}/${config}/${arch}/${WEBENGINEDRIVER_EXECUTABLE} + USES_TERMINAL + ) + endif() +endif() diff --git a/tests/auto/core/CMakeLists.txt b/tests/auto/core/CMakeLists.txt index 4ed41d947..5908756b4 100644 --- a/tests/auto/core/CMakeLists.txt +++ b/tests/auto/core/CMakeLists.txt @@ -19,3 +19,7 @@ if(QT_FEATURE_ssl) add_subdirectory(qwebengineclientcertificatestore) add_subdirectory(certificateerror) endif() + +if(QT_FEATURE_webenginedriver) + add_subdirectory(webenginedriver) +endif() diff --git a/tests/auto/core/webenginedriver/CMakeLists.txt b/tests/auto/core/webenginedriver/CMakeLists.txt new file mode 100644 index 000000000..c8cf8b3ab --- /dev/null +++ b/tests/auto/core/webenginedriver/CMakeLists.txt @@ -0,0 +1,15 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +if(NOT QT_BUILD_STANDALONE_TESTS AND NOT QT_BUILDING_QT) + cmake_minimum_required(VERSION 3.16) + project(tst_webenginedriver LANGUAGES CXX) + find_package(Qt6BuildInternals REQUIRED COMPONENTS STANDALONE_TEST) +endif() + +add_subdirectory(test) +add_subdirectory(browser) + +add_dependencies(tst_webenginedriver + testbrowser +) diff --git a/tests/auto/core/webenginedriver/browser/CMakeLists.txt b/tests/auto/core/webenginedriver/browser/CMakeLists.txt new file mode 100644 index 000000000..25e162e7b --- /dev/null +++ b/tests/auto/core/webenginedriver/browser/CMakeLists.txt @@ -0,0 +1,13 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +qt_internal_add_executable(testbrowser + SOURCES + main.cpp + LIBRARIES + Qt::Core + Qt::Gui + Qt::WebEngineWidgets + OUTPUT_DIRECTORY + ${CMAKE_CURRENT_BINARY_DIR}/.. +) diff --git a/tests/auto/core/webenginedriver/browser/main.cpp b/tests/auto/core/webenginedriver/browser/main.cpp new file mode 100644 index 000000000..4b8f3513f --- /dev/null +++ b/tests/auto/core/webenginedriver/browser/main.cpp @@ -0,0 +1,21 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +#include <QtWebEngineCore/qwebenginepage.h> +#include <QtWebEngineWidgets/qwebengineview.h> +#include <QtWidgets/qapplication.h> + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + + QWebEngineView view; + QObject::connect(view.page(), &QWebEnginePage::windowCloseRequested, &app, &QApplication::quit); + QObject::connect(&app, &QApplication::aboutToQuit, + []() { fprintf(stderr, "Test browser is about to quit.\n"); }); + + view.resize(100, 100); + view.show(); + + return app.exec(); +} diff --git a/tests/auto/core/webenginedriver/resources/input.html b/tests/auto/core/webenginedriver/resources/input.html new file mode 100644 index 000000000..c21458350 --- /dev/null +++ b/tests/auto/core/webenginedriver/resources/input.html @@ -0,0 +1,5 @@ +<html> +<body> + <input type="text" id="text_input"> +</body> +</html> diff --git a/tests/auto/core/webenginedriver/test/CMakeLists.txt b/tests/auto/core/webenginedriver/test/CMakeLists.txt new file mode 100644 index 000000000..041bf955b --- /dev/null +++ b/tests/auto/core/webenginedriver/test/CMakeLists.txt @@ -0,0 +1,26 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +include(../../../util/util.cmake) + +qt_internal_add_test(tst_webenginedriver + SOURCES + ../tst_webenginedriver.cpp + LIBRARIES + Qt::Network + Qt::WebEngineCore + Qt::WebEngineWidgets + Test::Util + OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/.." +) + +set(tst_webenginedriver_resource_files + "../resources/input.html" +) + +qt_internal_add_resource(tst_webenginedriver "tst_webenginedriver" + PREFIX + "/" + FILES + ${tst_webenginedriver_resource_files} +) diff --git a/tests/auto/core/webenginedriver/tst_webenginedriver.cpp b/tests/auto/core/webenginedriver/tst_webenginedriver.cpp new file mode 100644 index 000000000..cd3098b25 --- /dev/null +++ b/tests/auto/core/webenginedriver/tst_webenginedriver.cpp @@ -0,0 +1,631 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +#include <QtTest/QtTest> + +#include <QtCore/qjsondocument.h> +#include <QtCore/qlibraryinfo.h> +#include <QtCore/qobject.h> +#include <QtCore/qprocess.h> +#include <QtGui/qimage.h> +#include <QtNetwork/qnetworkaccessmanager.h> +#include <QtNetwork/qnetworkreply.h> +#include <QtNetwork/qnetworkrequest.h> +#include <QtWebEngineCore/qtwebenginecoreglobal.h> +#include <QtWebEngineCore/qwebenginepage.h> +#include <QtWebEngineCore/qwebengineprofile.h> +#include <QtWebEngineWidgets/qwebengineview.h> + +#include <widgetutil.h> + +#define REMOTE_DEBUGGING_PORT 12345 +#define WEBENGINEDRIVER_PORT 9515 + +class DriverServer : public QObject +{ + Q_OBJECT + +public: + DriverServer(QProcessEnvironment processEnvironment = {}, QStringList processArguments = {}) + : m_serverURL(QUrl( + QLatin1String("http://localhost:%1").arg(QString::number(WEBENGINEDRIVER_PORT)))) + { + QString driverPath = QLibraryInfo::path(QLibraryInfo::LibraryExecutablesPath) + + QLatin1String("/webenginedriver"); +#if defined(Q_OS_WIN) + driverPath += QLatin1String(".exe"); +#endif + m_process.setProgram(driverPath); + + if (processArguments.isEmpty()) + processArguments + << QLatin1String("--port=%1").arg(QString::number(WEBENGINEDRIVER_PORT)); + m_process.setArguments(processArguments); + + if (!processEnvironment.isEmpty()) + m_process.setProcessEnvironment(processEnvironment); + + connect(&m_process, &QProcess::errorOccurred, [this](QProcess::ProcessError error) { + qWarning() << "WebEngineDriver error occurred:" << error; + dumpConsoleMessages(); + }); + + connect(&m_process, &QProcess::readyReadStandardError, [this]() { + QProcess::ProcessChannel tmp = m_process.readChannel(); + m_process.setReadChannel(QProcess::StandardError); + while (m_process.canReadLine()) { + QString line = QString::fromUtf8(m_process.readLine()); + if (line.endsWith(QLatin1Char('\n'))) + line.truncate(line.size() - 1); + m_stderr << line; + } + m_process.setReadChannel(tmp); + }); + + connect(&m_process, &QProcess::readyReadStandardOutput, [this]() { + QProcess::ProcessChannel tmp = m_process.readChannel(); + m_process.setReadChannel(QProcess::StandardOutput); + while (m_process.canReadLine()) { + QString line = QString::fromUtf8(m_process.readLine()); + if (line.endsWith(QLatin1Char('\n'))) + line.truncate(line.size() - 1); + m_stdout << line; + } + m_process.setReadChannel(tmp); + }); + } + + ~DriverServer() + { + if (m_process.state() != QProcess::Running) + return; + + if (!m_sessionId.isEmpty()) + deleteSession(); + + shutdown(); + } + + bool start() + { + if (!QFileInfo::exists(m_process.program())) { + qWarning() << "WebEngineDriver executable not found:" << m_process.program(); + return false; + } + + connect(&m_process, &QProcess::finished, + [this](int exitCode, QProcess::ExitStatus exitStatus) { + qWarning().nospace() + << "WebEngineDriver exited unexpectedly (exitCode: " << exitCode + << " exitStatus: " << exitStatus << "):"; + dumpConsoleMessages(); + }); + + m_process.start(); + + bool started = m_process.waitForStarted(); + if (!started) { + qWarning() << "Failed to start WebEngineDriver:" << m_process.errorString(); + return false; + } + + bool ready = QTest::qWaitFor( + [this]() { + if (m_process.state() != QProcess::Running) + return true; + + for (QString line : m_stdout) { + if (line.contains( + QLatin1String("WebEngineDriver was started successfully."))) + return true; + } + + return false; + }, + 10000); + + if (ready && m_process.state() != QProcess::Running) { + // Warning is already reported by handler of QProcess::finished() signal. + return false; + } + + if (!ready) { + if (m_stdout.empty()) + qWarning("Starting WebEngineDriver timed out."); + else + qWarning("Something went wrong while starting WebEngineDriver:"); + + dumpConsoleMessages(); + return false; + } + + return true; + } + + bool shutdown() + { + // Do not warn about unexpected exit. + disconnect(&m_process, &QProcess::finished, nullptr, nullptr); + + bool sent = sendCommand(QLatin1String("/shutdown")); + + bool finished = (m_process.state() == QProcess::NotRunning) || m_process.waitForFinished(); + if (!finished || !sent) + qWarning() << "Failed to properly shutdown WebEngineDriver:" << m_process.errorString(); + + return finished; + } + + bool sendCommand(QString command, const QJsonDocument ¶ms = {}, + QJsonDocument *result = nullptr) + { + if (command.contains(QLatin1String(":sessionId"))) { + if (m_sessionId.isEmpty()) { + qWarning("Unable to execute session command without session."); + return false; + } + + QStringList commandList = command.split(QLatin1Char('/')); + for (int i = 0; i < commandList.size(); ++i) { + if (commandList[i] == QLatin1String(":sessionId")) { + commandList[i] = m_sessionId; + break; + } + } + + command = commandList.join(QLatin1Char('/')); + } + + QNetworkReply::NetworkError replyError = QNetworkReply::NoError; + QString replyString; + + connect( + &m_qnam, &QNetworkAccessManager::finished, this, + [&replyError, &replyString](QNetworkReply *reply) { + replyError = reply->error(); + replyString = QString::fromUtf8(reply->readAll()); + }, + static_cast<Qt::ConnectionType>(Qt::SingleShotConnection)); + + QNetworkRequest request; + QUrl requestURL = m_serverURL; + + requestURL.setPath(command); + request.setUrl(requestURL); + + if (params.isEmpty()) { + m_qnam.get(request); + } else { + request.setHeader(QNetworkRequest::ContentTypeHeader, + QVariant::fromValue(QStringLiteral("application/json"))); + m_qnam.post(request, params.toJson(QJsonDocument::Compact)); + } + + bool ready = QTest::qWaitFor( + [&replyError, &replyString]() { + return replyError != QNetworkReply::NoError || !replyString.isEmpty(); + }, + 10000); + + if (!ready) { + qWarning() << "Command" << command << "timed out."; + dumpConsoleMessages(); + return false; + } + + if (replyError != QNetworkReply::NoError) { + qWarning() << "Network error:" << replyError; + if (!replyString.isEmpty()) { + QJsonDocument errorReply = QJsonDocument::fromJson(replyString.toLatin1()); + if (!errorReply.isNull()) { + QString error = errorReply["value"]["error"].toString(); + QString message = errorReply["value"]["message"].toString(); + if (!error.isEmpty() || message.isEmpty()) { + qWarning() << "error:" << error; + qWarning() << "message:" << message; + return false; + } + } + + qWarning() << replyString; + return false; + } + + dumpConsoleMessages(); + return false; + } + + if (result) { + if (replyString.isEmpty()) { + qWarning("Network reply is empty."); + return false; + } + + QJsonParseError jsonError; + *result = QJsonDocument::fromJson(replyString.toLatin1(), &jsonError); + + if (jsonError.error != QJsonParseError::NoError) { + qWarning() << "Unable to parse reply:" << jsonError.errorString(); + return false; + } + } + + return true; + } + + bool createSession(QJsonObject chromeOptions = {}, QString *sessionId = nullptr) + { + if (!m_sessionId.isEmpty()) { + qWarning("A session already exists."); + return false; + } + + QJsonObject root; + + if (chromeOptions.isEmpty()) { + // Connect to the test by default. + chromeOptions.insert( + QLatin1String("debuggerAddress"), + QLatin1String("localhost:%1").arg(QString::number(REMOTE_DEBUGGING_PORT))); + chromeOptions.insert(QLatin1String("w3c"), true); + } + + QJsonObject alwaysMatch; + alwaysMatch.insert(QLatin1String("goog:chromeOptions"), chromeOptions); + + QJsonObject seOptions; + seOptions.insert(QLatin1String("loggingPrefs"), QJsonObject()); + alwaysMatch.insert(QLatin1String("se:options"), seOptions); + + QJsonObject capabilities; + capabilities.insert(QLatin1String("alwaysMatch"), alwaysMatch); + root.insert(QLatin1String("capabilities"), capabilities); + + QJsonDocument params; + params.setObject(root); + + QJsonDocument sessionReply; + bool sent = sendCommand(QLatin1String("/session"), params, &sessionReply); + if (sent) { + m_sessionId = sessionReply["value"]["sessionId"].toString(); + if (sessionId) + *sessionId = m_sessionId; + } + + return sent; + } + + bool deleteSession() + { + if (m_sessionId.isEmpty()) { + qWarning("There is no active session."); + return false; + } + + QNetworkReply::NetworkError replyError = QNetworkReply::NoError; + QString replyString; + + connect( + &m_qnam, &QNetworkAccessManager::finished, this, + [&replyError, &replyString](QNetworkReply *reply) { + replyError = reply->error(); + replyString = QString::fromUtf8(reply->readAll()); + }, + static_cast<Qt::ConnectionType>(Qt::SingleShotConnection)); + + QNetworkRequest request; + QUrl requestURL = m_serverURL; + requestURL.setPath(QLatin1String("/session/%1").arg(m_sessionId)); + request.setUrl(requestURL); + m_qnam.deleteResource(request); + + bool ready = QTest::qWaitFor( + [&replyError, &replyString]() { + return replyError != QNetworkReply::NoError || !replyString.isEmpty(); + }, + 10000); + + if (!ready) { + qWarning("Deleting session timed out."); + return false; + } + + if (replyError != QNetworkReply::NoError) { + qWarning() << "Network error:" << replyError; + return false; + } + + return true; + } + + QStringList stderrLines() const { return m_stderr; } + + bool waitForMessageOnStderr(const QRegularExpression &re, QString *message = nullptr) const + { + return QTest::qWaitFor( + [this, &re, message]() { + for (QString line : m_stderr) { + if (line.contains(re)) { + if (message) + *message = line; + return true; + } + } + + return false; + }, + 10000); + } + +private: + void dumpConsoleMessages() + { + auto dumpLines = [](QStringList *lines) { + if (lines->empty()) + return; + + for (QString line : *lines) + qWarning() << qPrintable(line); + + lines->clear(); + }; + + dumpLines(&m_stdout); + dumpLines(&m_stderr); + } + + QProcess m_process; + QStringList m_stdout; + QStringList m_stderr; + + QUrl m_serverURL; + QString m_sessionId; + + QNetworkAccessManager m_qnam; +}; + +class tst_WebEngineDriver : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void status(); + void startBrowser(); + void navigate(); + void typeElement(); + void executeScript(); + void screenshot(); +}; + +void tst_WebEngineDriver::status() +{ + DriverServer driverServer; + QVERIFY(driverServer.start()); + + QJsonDocument statusReply; + QVERIFY(driverServer.sendCommand(QLatin1String("/status"), {}, &statusReply)); + + QString versionString = statusReply["value"]["build"]["version"].toString(); + QCOMPARE(qWebEngineChromiumVersion(), versionString.split(QLatin1Char(' '))[0]); + + bool ready = statusReply["value"]["ready"].toBool(); + QVERIFY(ready); +} + +void tst_WebEngineDriver::startBrowser() +{ + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QLatin1String("QTWEBENGINE_REMOTE_DEBUGGING"), + QString::number(REMOTE_DEBUGGING_PORT)); + + QStringList args; + args << QLatin1String("--port=%1").arg(QString::number(WEBENGINEDRIVER_PORT)); + args << QLatin1String("--log-level=ALL"); + + DriverServer driverServer(env, args); + QVERIFY(driverServer.start()); + + QString testBrowserPath = + QCoreApplication::applicationDirPath() + QLatin1String("/testbrowser"); +#if defined(Q_OS_WIN) + testBrowserPath += QLatin1String(".exe"); +#endif + + QJsonArray chromeArgs; + chromeArgs.append(QLatin1String("enable-logging=stderr")); + chromeArgs.append(QLatin1String("v=1")); + // To force graceful shutdown. + chromeArgs.append(QLatin1String("log-net-log")); + + QJsonObject chromeOptions; + chromeOptions.insert(QLatin1String("binary"), testBrowserPath); + chromeOptions.insert(QLatin1String("args"), chromeArgs); + chromeOptions.insert(QLatin1String("w3c"), true); + QString sessionId; + QVERIFY(driverServer.createSession(chromeOptions, &sessionId)); + + // Test if the browser is started. + QVERIFY(driverServer.waitForMessageOnStderr(QRegularExpression( + QLatin1String("^DevTools listening on ws://127.0.0.1:%1/devtools/browser/") + .arg(QString::number(REMOTE_DEBUGGING_PORT))))); + QVERIFY(driverServer.waitForMessageOnStderr(QRegularExpression( + QLatin1String("^Remote debugging server started successfully. " + "Try pointing a Chromium-based browser to http://127.0.0.1:%1") + .arg(QString::number(REMOTE_DEBUGGING_PORT))))); + + // Check custom chromeArgs via logging. + QVERIFY(driverServer.waitForMessageOnStderr(QRegularExpression(QLatin1String("VERBOSE1")))); + + QJsonDocument statusReply; + QVERIFY(driverServer.sendCommand(QLatin1String("/status"), {}, &statusReply)); + bool ready = statusReply["value"]["ready"].toBool(); + QVERIFY(ready); + + QVERIFY(driverServer.deleteSession()); + + // Test if the browser is stopped. + QVERIFY(driverServer.waitForMessageOnStderr( + QRegularExpression(QLatin1String("Test browser is about to quit.")))); + QVERIFY(driverServer.waitForMessageOnStderr( + QRegularExpression(QLatin1String("\\[%1\\] RESPONSE Quit").arg(sessionId)))); +} + +void tst_WebEngineDriver::navigate() +{ + DriverServer driverServer; + QVERIFY(driverServer.start()); + + QWebEnginePage page; + QSignalSpy loadSpy(&page, &QWebEnginePage::loadFinished); + + page.load(QUrl(QLatin1String("about:blank"))); + QTRY_COMPARE(loadSpy.size(), 1); + + QVERIFY(driverServer.createSession()); + + QUrl url = QUrl(QLatin1String("qrc:///resources/input.html")); + QString paramsString = QLatin1String("{\"url\": \"%1\"}").arg(url.toString()); + QJsonDocument params = QJsonDocument::fromJson(paramsString.toUtf8()); + QVERIFY(driverServer.sendCommand(QLatin1String("/session/:sessionId/url"), params)); + QTRY_COMPARE(loadSpy.size(), 2); + QVERIFY(loadSpy.at(1).at(0).toBool()); + QCOMPARE(url, page.url()); + + QVERIFY(driverServer.deleteSession()); +} + +void tst_WebEngineDriver::typeElement() +{ + DriverServer driverServer; + QVERIFY(driverServer.start()); + + QWebEngineView view; + view.resize(300, 100); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + + QUrl url = QUrl(QLatin1String("qrc:///resources/input.html")); + QSignalSpy loadSpy(view.page(), &QWebEnginePage::loadFinished); + view.load(url); + QTRY_COMPARE(loadSpy.size(), 1); + + QVERIFY(driverServer.createSession()); + + // Find <input id="text_input"> element and extract its id from the reply. + QString textInputId; + { + QString paramsString = QLatin1String( + "{\"using\": \"css selector\", \"value\": \"[id=\\\"text_input\\\"]\"}"); + QJsonDocument params = QJsonDocument::fromJson(paramsString.toUtf8()); + QJsonDocument elementReply; + QVERIFY(driverServer.sendCommand(QLatin1String("/session/:sessionId/element"), params, + &elementReply)); + + QVERIFY(!elementReply.isEmpty()); + QJsonObject value = elementReply["value"].toObject(); + QVERIFY(!value.isEmpty()); + QStringList keys = value.keys(); + QCOMPARE(keys.size(), 1); + textInputId = value[keys[0]].toString(); + QVERIFY(!textInputId.isEmpty()); + } + + // Type text into the input field. + QString inputText = QLatin1String("WebEngineDriver"); + { + QString command = QLatin1String("/session/:sessionId/element/%1/value").arg(textInputId); + + QJsonObject root; + root.insert(QLatin1String("text"), inputText); + QJsonArray value; + for (QChar ch : inputText) { + value.append(QJsonValue(ch)); + } + root.insert(QLatin1String("value"), value); + root.insert(QLatin1String("id"), textInputId); + QJsonDocument params; + params.setObject(root); + + QVERIFY(driverServer.sendCommand(command, params)); + + QTRY_COMPARE( + evaluateJavaScriptSync(view.page(), "document.getElementById('text_input').value") + .toString(), + inputText); + } + + QVERIFY(driverServer.deleteSession()); +} + +void tst_WebEngineDriver::executeScript() +{ + DriverServer driverServer; + QVERIFY(driverServer.start()); + + QUrl url = QUrl(QLatin1String("qrc:///resources/input.html")); + QWebEnginePage page; + QSignalSpy loadSpy(&page, &QWebEnginePage::loadFinished); + + page.load(url); + QTRY_COMPARE(loadSpy.size(), 1); + QCOMPARE(page.title(), url.toString()); + QSignalSpy titleSpy(&page, &QWebEnginePage::titleChanged); + + QString newTitle = QLatin1String("WebEngineDriver Test Page"); + QString script = QLatin1String("document.title = '%1';" + "return document.title;") + .arg(newTitle); + + QVERIFY(driverServer.createSession()); + QString paramsString = QLatin1String("{\"script\": \"%1\", \"args\": []}").arg(script); + QJsonDocument params = QJsonDocument::fromJson(paramsString.toUtf8()); + QJsonDocument executeReply; + QVERIFY(driverServer.sendCommand(QLatin1String("/session/:sessionId/execute/sync"), params, + &executeReply)); + + QTRY_COMPARE(titleSpy.size(), 1); + QCOMPARE(executeReply["value"].toString(), newTitle); + QCOMPARE(page.title(), newTitle); + + QVERIFY(driverServer.deleteSession()); +} + +void tst_WebEngineDriver::screenshot() +{ + DriverServer driverServer; + QVERIFY(driverServer.start()); + + QWebEngineView view; + view.resize(300, 100); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + + QSignalSpy loadSpy(view.page(), &QWebEnginePage::loadFinished); + + view.setHtml(QLatin1String("<html><head><style>" + "html {background-color:red;}" + "</style></head><body></body></html>")); + QTRY_COMPARE(loadSpy.size(), 1); + + QVERIFY(driverServer.createSession()); + QJsonDocument screenshotReply; + QVERIFY(driverServer.sendCommand(QLatin1String("/session/:sessionId/screenshot"), {}, + &screenshotReply)); + + QByteArray base64 = screenshotReply["value"].toString().toLocal8Bit(); + QVERIFY(!base64.isEmpty()); + QImage screenshot; + screenshot.loadFromData(QByteArray::fromBase64(base64)); + QVERIFY(!screenshot.isNull()); + QCOMPARE(screenshot.pixel(screenshot.width() / 2, screenshot.height() / 2), 0xFFFF0000); + + QVERIFY(driverServer.deleteSession()); +} + +#define STRINGIFY_LITERAL(x) #x +#define STRINGIFY_EXPANDED(x) STRINGIFY_LITERAL(x) +static QByteArrayList params = QByteArrayList() + << "--remote-debugging-port=" STRINGIFY_EXPANDED(REMOTE_DEBUGGING_PORT); +W_QTEST_MAIN(tst_WebEngineDriver, params) + +#include "tst_webenginedriver.moc" |