diff options
Diffstat (limited to 'tests/manual/wasm/qtwasmtestlib')
-rw-r--r-- | tests/manual/wasm/qtwasmtestlib/README.md | 75 | ||||
-rw-r--r-- | tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.cpp | 175 | ||||
-rw-r--r-- | tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.h | 73 | ||||
-rw-r--r-- | tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.js | 137 |
4 files changed, 460 insertions, 0 deletions
diff --git a/tests/manual/wasm/qtwasmtestlib/README.md b/tests/manual/wasm/qtwasmtestlib/README.md new file mode 100644 index 0000000000..6de81fe8b4 --- /dev/null +++ b/tests/manual/wasm/qtwasmtestlib/README.md @@ -0,0 +1,75 @@ +QtWasmTestLib - async auto tests for WebAssembly +================================================ + +QtWasmTestLib supports auto-test cases in the web browser. Like QTestLib, each +test case is defined by a QObject subclass with one or more test functions. The +test functions may be asynchronous, where they return early and then complete +at some later point. + +The test lib is implemented as a C++ and JavaScript library, where the test is written +using C++ and a hosting html page calls JavaScript API to run the test. + +Implementing a basic test case +------------------------------ + +In the test cpp file, define the test functions as private slots. All test +functions must call completeTestFunction() exactly once, or will time out +otherwise. Subsequent calls to completeTestFunction will be disregarded. +It is advised to use QWASMSUCCESS/QWASMFAIL for reporting the test execution +status and QWASMCOMPARE/QWASMVERIFY to assert on test conditions. The call can +be made after the test function itself has returned. + + class TestTest: public QObject + { + Q_OBJECT + private slots: + void timerTest() { + QTimer::singleShot(timeout, [](){ + completeTestFunction(); + }); + } + }; + +Then define a main() function which calls initTestCase(). The main() +function is async too, as per Emscripten default. Build the .cpp file +as a normal Qt for WebAssembly app. + + int main(int argc, char **argv) + { + auto testObject = std::make_shared<TestTest>(); + initTestCase<QCoreApplication>(argc, argv, testObject); + return 0; + } + +Finally provide an html file which hosts the test runner and calls runTestCase() + + <!doctype html> + <script type="text/javascript" src="qtwasmtestlib.js"></script> + <script type="text/javascript" src="test_case.js"></script> + <script> + window.onload = async () => { + runTestCase(entryFunction, document.getElementById("log")); + }; + </script> + <p>Running Foo auto test.</p> + <div id="log"></div> + +Implementing a GUI test case +---------------------------- + +This is similar to implementing a basic test case, with the difference that the hosting +html file provides container elements which becomes QScreens for the test code. + + <!doctype html> + <script type="text/javascript" src="qtwasmtestlib.js"></script> + <script type="text/javascript" src="test_case.js"></script> + <script> + window.onload = async () => { + let log = document.getElementById("log") + let containers = [document.getElementById("container")]; + runTestCase(entryFunction, log, containers); + }; + </script> + <p>Running Foo auto test.</p> + <div id="container"></div> + <div id="log"></div> diff --git a/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.cpp b/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.cpp new file mode 100644 index 0000000000..ec03c7209a --- /dev/null +++ b/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.cpp @@ -0,0 +1,175 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include "qtwasmtestlib.h" + +#include <QtCore/qmetaobject.h> + +#include <emscripten/bind.h> +#include <emscripten.h> +#include <emscripten/threading.h> + +namespace QtWasmTest { +namespace { +QObject *g_testObject = nullptr; +std::string g_currentTestName; +std::function<void ()> g_cleanup; +} + +void runOnMainThread(std::function<void(void)> fn); +static bool isValidSlot(const QMetaMethod &sl); + + +// +// Public API +// + +// Initializes the test case with a test object and cleanup function. The +// cleanup function is called when all test functions have completed. +void initTestCase(QObject *testObject, std::function<void ()> cleanup) +{ + g_testObject = testObject; + g_cleanup = cleanup; +} + +void verify(bool condition, std::string_view conditionString, std::string_view file, int line) +{ + if (!condition) { + completeTestFunction( + TestResult::Fail, + formatMessage(file, line, "Condition failed: " + std::string(conditionString))); + } +} + +// Completes the currently running test function with a result. This function is +// thread-safe and call be called from any thread. +void completeTestFunction(TestResult result, std::string message) +{ + auto resultString = [](TestResult result) { + switch (result) { + case TestResult::Pass: + return "PASS"; + break; + case TestResult::Fail: + return "FAIL"; + break; + case TestResult::Skip: + return "SKIP"; + break; + } + }; + + // Report test result to JavaScript test runner, on the main thread + runOnMainThread([resultString = resultString(result), message](){ + EM_ASM({ + completeTestFunction(UTF8ToString($0), UTF8ToString($1), UTF8ToString($2)); + }, g_currentTestName.c_str(), resultString, message.c_str()); + }); +} + +// Completes the currently running test function with a Pass result. +void completeTestFunction() +{ + completeTestFunction(TestResult::Pass, std::string()); +} + +// +// Private API for the Javascript test runnner +// + +std::string formatMessage(std::string_view file, int line, std::string_view message) +{ + return "[" + std::string(file) + ":" + QString::number(line).toStdString() + "] " + std::string(message); +} + +void cleanupTestCase() +{ + g_testObject = nullptr; + g_cleanup(); +} + +std::string getTestFunctions() +{ + std::string testFunctions; + + // Duplicate qPrintTestSlots (private QTestLib function) logic. + for (int i = 0; i < g_testObject->metaObject()->methodCount(); ++i) { + QMetaMethod sl = g_testObject->metaObject()->method(i); + if (!isValidSlot(sl)) + continue; + QByteArray signature = sl.methodSignature(); + Q_ASSERT(signature.endsWith("()")); + signature.chop(2); + if (!testFunctions.empty()) + testFunctions += " "; + testFunctions += std::string(signature.constData()); + } + + return testFunctions; +} + +void runTestFunction(std::string name) +{ + g_currentTestName = name; + QMetaObject::invokeMethod(g_testObject, name.c_str()); +} + +void failTest(std::string message) +{ + completeTestFunction(QtWasmTest::Fail, std::move(message)); +} + +void passTest() +{ + completeTestFunction(QtWasmTest::Pass, ""); +} + +EMSCRIPTEN_BINDINGS(qtwebtestrunner) { + emscripten::function("cleanupTestCase", &cleanupTestCase); + emscripten::function("getTestFunctions", &getTestFunctions); + emscripten::function("runTestFunction", &runTestFunction); + emscripten::function("qtWasmFail", &failTest); + emscripten::function("qtWasmPass", &passTest); +} + +// +// Test lib implementation +// + +static bool isValidSlot(const QMetaMethod &sl) +{ + if (sl.access() != QMetaMethod::Private || sl.parameterCount() != 0 + || sl.returnType() != QMetaType::Void || sl.methodType() != QMetaMethod::Slot) + return false; + const QByteArray name = sl.name(); + return !(name.isEmpty() || name.endsWith("_data") + || name == "initTestCase" || name == "cleanupTestCase" + || name == "init" || name == "cleanup"); +} + +void trampoline(void *context) +{ + Q_ASSERT(emscripten_is_main_runtime_thread()); + + emscripten_async_call([](void *context) { + std::function<void(void)> *fn = reinterpret_cast<std::function<void(void)> *>(context); + (*fn)(); + delete fn; + }, context, 0); +} + +// Runs the given function on the main thread, asynchronously +void runOnMainThread(std::function<void(void)> fn) +{ + void *context = new std::function<void(void)>(fn); + if (emscripten_is_main_runtime_thread()) { + trampoline(context); + } else { +#if QT_CONFIG(thread) + emscripten_async_run_in_main_runtime_thread_(EM_FUNC_SIG_VI, reinterpret_cast<void *>(trampoline), context); +#endif + } +} + +} // namespace QtWasmTest + diff --git a/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.h b/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.h new file mode 100644 index 0000000000..2307ed1ccd --- /dev/null +++ b/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.h @@ -0,0 +1,73 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#ifndef QT_WASM_TESTRUNNER_H +#define QT_WASM_TESTRUNNER_H + +#include <QtCore/qobject.h> + +#include <functional> + +namespace QtWasmTest { + +enum TestResult { + Pass, + Fail, + Skip, +}; + +std::string formatMessage(std::string_view file, + int line, + std::string_view message); + +void completeTestFunction(TestResult result, std::string message); +void completeTestFunction(); +void initTestCase(QObject *testObject, std::function<void ()> cleanup); +template <typename App> +void initTestCase(int argc, + char **argv, + std::shared_ptr<QObject> testObject) +{ + auto app = std::make_shared<App>(argc, argv); + auto cleanup = [testObject, app]() mutable { + // C++ lambda capture destruction order is unspecified; + // delete test before app by calling reset(). + testObject.reset(); + app.reset(); + }; + initTestCase(testObject.get(), cleanup); +} +void verify(bool condition, + std::string_view conditionString, + std::string_view file, + int line); + +template<class L, class R> +void compare(const L& lhs, + const R& rhs, + std::string_view lhsString, + std::string_view rhsString, + std::string_view file, + int line) { + if (lhs != rhs) { + completeTestFunction( + TestResult::Fail, + formatMessage(file, line, "Comparison failed: " + std::string(lhsString) + " == " + std::string(rhsString))); + } +} + +} // namespace QtWasmTest + +#define QWASMVERIFY(condition) \ + QtWasmTest::verify((condition), #condition, __FILE__, __LINE__); + +#define QWASMCOMPARE(left, right) \ + QtWasmTest::compare((left), (right), #left, #right, __FILE__, __LINE__); + +#define QWASMSUCCESS() \ + QtWasmTest::completeTestFunction(QtWasmTest::Pass, "") + +#define QWASMFAIL(message) \ + QtWasmTest::completeTestFunction(QtWasmTest::Fail, QtWasmTest::formatMessage(__FILE__, __LINE__, message)) + +#endif diff --git a/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.js b/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.js new file mode 100644 index 0000000000..d4f815b887 --- /dev/null +++ b/tests/manual/wasm/qtwasmtestlib/qtwasmtestlib.js @@ -0,0 +1,137 @@ +// Copyright (C) 2022 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +// A minimal async test runner for Qt async auto tests. +// +// Usage: Call runTest(name, testFunctionCompleted), where "name" is the name of the app +// (the .wasm file name), and testFunctionCompleted is a test-function-complete +// callback. The test runner will then instantiate the app and run tests. +// +// The test runner expects that the app instance defines the following +// functions: +// +// void cleanupTestCase() +// string getTestFunctions() +// runTestFunction(string) +// +// Further, the test runner expects that the app instance calls +// completeTestFunction() (below - note that both the instance and this +// file have a function with that name) when a test function finishes. This +// can be done during runTestFunction(), or after it has returned (this +// is the part which enables async testing). Test functions which fail +// to call completeTestFunction() will time out after 2000ms. +// +const g_maxTime = 2000; + +class TestFunction { + constructor(instance, name) { + this.instance = instance; + this.name = name; + this.resolve = undefined; + this.reject = undefined; + this.timeoutId = undefined; + } + + complete(result, details) { + // Reset timeout + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + + const callback = result.startsWith('FAIL') ? this.reject : this.resolve; + callback(`${result}${details ? ': ' + details : ''}`); + } + + run() { + // Set timer which will catch test functions + // which fail to call completeTestFunction() + this.timeoutId = setTimeout(() => { + completeTestFunction(this.name, 'FAIL', `Timeout after ${g_maxTime} ms`) + }, g_maxTime); + + return new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + + this.instance.runTestFunction(this.name); + }); + } +}; + +function completeTestFunction(testFunctionName, result, details) { + if (!window.currentTestFunction || testFunctionName !== window.currentTestFunction.name) + return; + + window.currentTestFunction.complete(result, details); +} + +async function runTestFunction(instance, name) { + if (window.currentTestFunction) { + throw new Error(`While trying to run ${name}: Last function hasn't yet finished`); + } + window.currentTestFunction = new TestFunction(instance, name); + try { + const result = await window.currentTestFunction.run(); + return result; + } finally { + delete window.currentTestFunction; + } +} + +async function runTestCaseImpl(entryFunction, testFunctionStarted, testFunctionCompleted, qtContainers) +{ + // Create test case instance + const config = { + qtContainerElements: qtContainers || [] + } + const instance = await entryFunction(config); + + // Run all test functions + const functionsString = instance.getTestFunctions(); + const functions = functionsString.split(" ").filter(Boolean); + for (const name of functions) { + testFunctionStarted(name); + try { + const result = await runTestFunction(instance, name); + testFunctionCompleted(result); + } catch (err) { + testFunctionCompleted(err.message ?? err); + } + } + + // Cleanup + instance.cleanupTestCase(); +} + +var g_htmlLogElement = undefined; + +function testFunctionStarted(name) { + let line = name + ": "; + g_htmlLogElement.innerHTML += line; +} + +function testFunctionCompleted(status) { + + const color = (status) => { + if (status.startsWith("PASS")) + return "green"; + if (status.startsWith("FAIL")) + return "red"; + if (status.startsWith("SKIP")) + return "tan"; + return "black"; + }; + + const line = `<span style='color: ${color(status)};'>${status}</text><br>`; + g_htmlLogElement.innerHTML += line; +} + +async function runTestCase(entryFunction, htmlLogElement, qtContainers) +{ + g_htmlLogElement = htmlLogElement; + try { + await runTestCaseImpl(entryFunction, testFunctionStarted, testFunctionCompleted, qtContainers); + g_htmlLogElement.innerHTML += "<br> DONE" + } catch (err) { + g_htmlLogElement.innerHTML += err + } +} |