diff options
author | Jøger Hansegård <joger.hansegard@qt.io> | 2023-08-03 13:12:03 +0200 |
---|---|---|
committer | Jøger Hansegård <joger.hansegard@qt.io> | 2023-08-19 00:12:04 +0200 |
commit | efb672d13af3a46fa8447335e9e3c31e26b50e13 (patch) | |
tree | 9a1a7ddc626864bce39a15c601d307c05009ec58 | |
parent | 44893dda471c4e7a59fdb6fed7e253ae36197a4d (diff) |
Add integration tests for window capturing
The tests revealed occasional failure to start window capturing on
Windows UWP platform. This was worked around using a retry loop when
the error occurred.
Fixes: QTBUG-115749
Change-Id: Id86c17f1b84fc9ba07e026ed6015cb2cc659a213
Reviewed-by: Artem Dyomin <artem.dyomin@qt.io>
(cherry picked from commit f9e58abcd2f7ac2abf2b6e6fa38ac5c5cc11a0c2 with
manual resolution of build issues)
Reviewed-by: Qt CI Bot <qt_ci_bot@qt-project.org>
11 files changed, 1000 insertions, 4 deletions
diff --git a/src/plugins/multimedia/ffmpeg/qffmpegwindowcapture_uwp.cpp b/src/plugins/multimedia/ffmpeg/qffmpegwindowcapture_uwp.cpp index 959a3a962..b2d62306e 100644 --- a/src/plugins/multimedia/ffmpeg/qffmpegwindowcapture_uwp.cpp +++ b/src/plugins/multimedia/ffmpeg/qffmpegwindowcapture_uwp.cpp @@ -141,7 +141,8 @@ struct WindowGrabber { WindowGrabber() = default; - WindowGrabber(IDXGIAdapter1 *adapter, HWND hwnd) : m_frameSize{ getWindowSize(hwnd) } + WindowGrabber(IDXGIAdapter1 *adapter, HWND hwnd) + : m_frameSize{ getWindowSize(hwnd) }, m_captureWindow{ hwnd } { check_hresult(D3D11CreateDevice(adapter, D3D_DRIVER_TYPE_UNKNOWN, nullptr, 0, nullptr, 0, D3D11_SDK_VERSION, m_device.put(), nullptr, nullptr)); @@ -174,8 +175,19 @@ struct WindowGrabber com_ptr<IDXGISurface> tryGetFrame() { const Direct3D11CaptureFrame frame = m_framePool.TryGetNextFrame(); - if (!frame) + if (!frame) { + + // Stop capture and report failure if window was closed. If we don't stop, + // testing shows that either we don't get any frames, or we get blank frames. + // Emitting an error will prevent this inconsistent behavior, and makes the + // Windows implementation behave like the Linux implementation + if (!IsWindow(m_captureWindow)) + throw std::runtime_error("Window was closed"); + + // Blank frames may come spuriously if no new window texture + // is available yet. return {}; + } if (m_frameSize != frame.ContentSize()) { m_frameSize = frame.ContentSize(); @@ -193,8 +205,33 @@ private: const auto interop = factory.as<IGraphicsCaptureItemInterop>(); GraphicsCaptureItem item = { nullptr }; - check_hresult(interop->CreateForWindow(hwnd, winrt::guid_of<GraphicsCaptureItem>(), - winrt::put_abi(item))); + winrt::hresult status = S_OK; + + // Attempt to create capture item with retry, because this occasionally fails, + // particularly in unit tests. When the failure code is E_INVALIDARG, it + // seems to help to sleep for a bit and retry. See QTBUG-116025. + constexpr int maxRetry = 10; + constexpr std::chrono::milliseconds retryDelay{ 100 }; + for (int retryNum = 0; retryNum < maxRetry; ++retryNum) { + + status = interop->CreateForWindow(hwnd, winrt::guid_of<GraphicsCaptureItem>(), + winrt::put_abi(item)); + + if (status != E_INVALIDARG) + break; + + qCWarning(qLcWindowCaptureUwp) + << "Failed to create capture item:" + << QString::fromStdWString(winrt::hresult_error(status).message().c_str()) + << "Retry number" << retryNum; + + if (retryNum + 1 < maxRetry) + QThread::sleep(retryDelay); + } + + // Throw if we fail to create the capture item + check_hresult(status); + return item; } @@ -243,6 +280,7 @@ private: return texture.as<IDXGISurface>(); } + HWND m_captureWindow{}; winrt::Windows::Graphics::SizeInt32 m_frameSize{}; com_ptr<ID3D11Device> m_device; Direct3D11CaptureFramePool m_framePool{ nullptr }; @@ -320,6 +358,8 @@ protected: + QString::fromWCharArray(err.message().c_str()); updateError(InternalError, message); + } catch (const std::runtime_error& e) { + updateError(CaptureFailed, QString::fromLatin1(e.what())); } return {}; diff --git a/tests/auto/integration/CMakeLists.txt b/tests/auto/integration/CMakeLists.txt index 14d8814ee..b9869c214 100644 --- a/tests/auto/integration/CMakeLists.txt +++ b/tests/auto/integration/CMakeLists.txt @@ -14,6 +14,7 @@ if(TARGET Qt::Widgets) add_subdirectory(qmediacapturesession) add_subdirectory(qcamerabackend) add_subdirectory(qscreencapture_integration) + add_subdirectory(qwindowcapturebackend) endif() if(TARGET Qt::Quick) add_subdirectory(qquickvideooutput) diff --git a/tests/auto/integration/qwindowcapturebackend/BLACKLIST b/tests/auto/integration/qwindowcapturebackend/BLACKLIST new file mode 100644 index 000000000..bc176cf98 --- /dev/null +++ b/tests/auto/integration/qwindowcapturebackend/BLACKLIST @@ -0,0 +1,13 @@ +macos ci + +#QTBUG-112827 on Android +#QTBUG-111190, v4l2m2m issues +[recorder_encodesFrames_toValidMediaFile] +linux ci +android ci + +#QTBUG-112827 on Android +#QTBUG-111190, v4l2m2m issues +[recorder_encodesFrames_toValidMediaFile_whenWindowResizes] +linux ci +android ci diff --git a/tests/auto/integration/qwindowcapturebackend/CMakeLists.txt b/tests/auto/integration/qwindowcapturebackend/CMakeLists.txt new file mode 100644 index 000000000..8f633a1da --- /dev/null +++ b/tests/auto/integration/qwindowcapturebackend/CMakeLists.txt @@ -0,0 +1,20 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +qt_internal_add_test(tst_qwindowcapturebackend + SOURCES + tst_qwindowcapturebackend.cpp + widget.h + widget.cpp + grabber.h + grabber.cpp + fixture.h + fixture.cpp + LIBRARIES + Qt::Multimedia + Qt::Gui + Qt::Widgets + Qt::MultimediaWidgets +) + + diff --git a/tests/auto/integration/qwindowcapturebackend/fixture.cpp b/tests/auto/integration/qwindowcapturebackend/fixture.cpp new file mode 100644 index 000000000..98964eeda --- /dev/null +++ b/tests/auto/integration/qwindowcapturebackend/fixture.cpp @@ -0,0 +1,235 @@ +// 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 "fixture.h" + +#include <qmediaplayer.h> +#include <qvideowidget.h> +#include <qsystemsemaphore.h> +#include <quuid.h> + +DisableCursor::DisableCursor() +{ + QCursor cursor(Qt::BlankCursor); + QApplication::setOverrideCursor(cursor); +} + +DisableCursor::~DisableCursor() +{ + QGuiApplication::restoreOverrideCursor(); +} + +WindowCaptureFixture::WindowCaptureFixture() +{ + m_session.setWindowCapture(&m_capture); + m_session.setVideoSink(&m_grabber); +} + +QString WindowCaptureFixture::getResultsPath(const QString &fileName) +{ + const QString sep = QString::fromLatin1("--"); + + QString stem = QCoreApplication::applicationName(); + if (const char *currentTest = QTest::currentTestFunction()) + stem += sep + QString::fromLatin1(currentTest); + + if (const char *currentTag = QTest::currentDataTag()) + stem += sep + QString::fromLatin1(currentTag); + + stem += sep + fileName; + + const QDir resultsDir = qEnvironmentVariable("COIN_CTEST_RESULTSDIR", QDir::tempPath()); + + return resultsDir.filePath(stem); +} + +bool WindowCaptureFixture::compareImages(QImage actual, const QImage &expected, + const QString &fileSuffix) +{ + // Convert to same format so that we can compare images + actual = actual.convertToFormat(expected.format()); + + if (actual == expected) + return true; + + qWarning() << "Image comparison failed."; + qWarning() << "Actual image:"; + qWarning() << actual; + qWarning() << "Expected image:"; + qWarning() << expected; + + const QString actualName = getResultsPath(QString("actual%1.png").arg(fileSuffix)); + if (!actual.save(actualName)) + qWarning() << "Failed to save actual file to " << actualName; + + const QString expectedName = getResultsPath(QString("expected%1.png").arg(fileSuffix)); + if (!expected.save(expectedName)) + qWarning() << "Failed to save expected file to " << expectedName; + + return false; +} + +bool WindowCaptureWithWidgetFixture::start(QSize size) +{ + // In case of window capture failure, signal the grabber so we can stop + // waiting for frames that will never come. + connect(&m_capture, &QWindowCapture::errorOccurred, &m_grabber, &FrameGrabber::stop); + + m_widget.setSize(size); + + m_widget.show(); + + // Make sure window is in a state that allows it to be found by QWindowCapture. + // Not necessary on Windows, but seems to be necessary on some platforms. + if (!QTest::qWaitForWindowExposed(&m_widget, static_cast<int>(s_testTimeout.count()))) { + qWarning() << "Failed to display widget within timeout"; + return false; + } + + m_captureWindow = findCaptureWindow(m_widget.windowTitle()); + + if (!m_captureWindow.isValid()) + return false; + + m_capture.setWindow(m_captureWindow); + m_capture.setActive(true); + + return true; +} + +QVideoFrame WindowCaptureWithWidgetFixture::waitForFrame(qint64 noOlderThanTime) +{ + const std::vector<QVideoFrame> frames = m_grabber.waitAndTakeFrames(1u, noOlderThanTime); + if (frames.empty()) + return QVideoFrame{}; + + return frames.back(); +} + +QCapturableWindow WindowCaptureWithWidgetFixture::findCaptureWindow(const QString &windowTitle) +{ + QList<QCapturableWindow> allWindows = QWindowCapture::capturableWindows(); + + const auto window = std::find_if(allWindows.begin(), allWindows.end(), + [windowTitle](const QCapturableWindow &win) { + return win.description() == windowTitle; + }); + + // Extra debug output to help understanding if test widget window could not be found + if (window == allWindows.end()) { + qDebug() << "Could not find window" << windowTitle << ". Existing capturable windows:"; + std::for_each(allWindows.begin(), allWindows.end(), [](const QCapturableWindow &win) { + qDebug() << " " << win.description(); + }); + return QCapturableWindow{}; + } + + return *window; +} + +void WindowCaptureWithWidgetAndRecorderFixture::start(QSize size, bool togglePattern) +{ + if (togglePattern) { + // Drive animation + connect(&m_grabber, &FrameGrabber::videoFrameChanged, &m_widget, + &TestWidget::togglePattern); + } + + connect(&m_recorder, &QMediaRecorder::recorderStateChanged, this, + &WindowCaptureWithWidgetAndRecorderFixture::recorderStateChanged); + + m_session.setRecorder(&m_recorder); + m_recorder.setQuality(QMediaRecorder::HighQuality); + m_recorder.setOutputLocation(QUrl::fromLocalFile(m_mediaFile)); + m_recorder.setVideoResolution(size); + + WindowCaptureWithWidgetFixture::start(size); + + m_recorder.record(); +} + +bool WindowCaptureWithWidgetAndRecorderFixture::stop() +{ + m_recorder.stop(); + + const auto recorderStopped = [this] { return m_recorderState == QMediaRecorder::StoppedState; }; + + return QTest::qWaitFor(recorderStopped, static_cast<int>(s_testTimeout.count())); +} + +bool WindowCaptureWithWidgetAndRecorderFixture::testVideoFilePlayback(const QString &fileName) +{ + QVideoWidget widget; + + QMediaPlayer player; + + bool playing = true; + connect(&player, &QMediaPlayer::playbackStateChanged, this, + [&](QMediaPlayer::PlaybackState state) { + if (state == QMediaPlayer::StoppedState) + playing = false; + }); + + QMediaPlayer::Error error = QMediaPlayer::NoError; + connect(&player, &QMediaPlayer::errorOccurred, this, + [&](QMediaPlayer::Error e, const QString &errorString) { + error = e; + qWarning() << errorString; + }); + + player.setSource(QUrl{ fileName }); + player.setVideoOutput(&widget); + widget.show(); + player.play(); + + const bool completed = + QTest::qWaitFor([&] { return !playing || error != QMediaPlayer::NoError; }, + static_cast<int>(s_testTimeout.count())); + + return completed && error == QMediaPlayer::NoError; +} + +void WindowCaptureWithWidgetAndRecorderFixture::recorderStateChanged( + QMediaRecorder::RecorderState state) +{ + m_recorderState = state; +} + +bool WindowCaptureWithWidgetInOtherProcessFixture::start() +{ + // In case of window capture failure, signal the grabber so we can stop + // waiting for frames that will never come. + connect(&m_capture, &QWindowCapture::errorOccurred, &m_grabber, &FrameGrabber::stop); + + // Create a new window title that is also used as a semaphore key with less than 30 characters + const QString windowTitle = QString::number(qHash(QUuid::createUuid().toString())); + + QSystemSemaphore windowVisible{ QNativeIpcKey{ windowTitle } }; + + // Start another instance of the test executable and ask it to show a + // its test widget. + m_windowProcess.setArguments({ "--show", windowTitle }); + m_windowProcess.setProgram(QApplication::applicationFilePath()); + m_windowProcess.start(); + + // Make sure window is in a state that allows it to be found by QWindowCapture. + // We do this by waiting for the process to release the semaphore once its window is visible + windowVisible.acquire(); + + m_captureWindow = findCaptureWindow(windowTitle); + + if (!m_captureWindow.isValid()) + return false; + + // Start capturing the out-of-process window + m_capture.setWindow(m_captureWindow); + m_capture.setActive(true); + + // Show in-process widget used to create a reference image + m_widget.show(); + + return true; +} + + +#include "moc_fixture.cpp" diff --git a/tests/auto/integration/qwindowcapturebackend/fixture.h b/tests/auto/integration/qwindowcapturebackend/fixture.h new file mode 100644 index 000000000..8549d6e9f --- /dev/null +++ b/tests/auto/integration/qwindowcapturebackend/fixture.h @@ -0,0 +1,147 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +#ifndef WINDOW_CAPTURE_FIXTURE_H +#define WINDOW_CAPTURE_FIXTURE_H + +#include "grabber.h" +#include "widget.h" + +#include <chrono> +#include <qmediacapturesession.h> +#include <qmediarecorder.h> +#include <qobject.h> +#include <qsignalspy.h> +#include <qtest.h> +#include <qvideoframe.h> +#include <qwindowcapture.h> +#include <qprocess.h> + +QT_USE_NAMESPACE + +constexpr inline std::chrono::milliseconds s_testTimeout = std::chrono::seconds(60); + +/*! + Utility used to hide application cursor for image comparison tests. + On Windows, the mouse cursor is captured as part of the window capture. + This and can cause differences when comparing captured images with images + from QWindow::grab() which is used as a reference. +*/ +struct DisableCursor final +{ + DisableCursor(); + ~DisableCursor(); + + DisableCursor(const DisableCursor &) = delete; + DisableCursor &operator=(const DisableCursor &) = delete; +}; + +/*! + Fixture class that orchestrates setup/teardown of window capturing +*/ +class WindowCaptureFixture : public QObject +{ + Q_OBJECT + +public: + WindowCaptureFixture(); + + /*! + Compare two images, ignoring format. + If images differ, diagnostic output is logged and images are saved to file. + */ + static bool compareImages(QImage actual, const QImage &expected, + const QString &fileSuffix = ""); + + QMediaCaptureSession m_session; + QWindowCapture m_capture; + FrameGrabber m_grabber; + + QSignalSpy m_errors{ &m_capture, &QWindowCapture::errorOccurred }; + QSignalSpy m_activations{ &m_capture, &QWindowCapture::activeChanged }; + +private: + /*! + Calculate a result path based upon a single filename. + On CI, the file will be located in COIN_CTEST_RESULTSDIR, and on developer + computers, the file will be located in TEMP. + + The file name is on the form "testCase_testFunction_[dataTag_]fileName" + */ + static QString getResultsPath(const QString &fileName); +}; + +/*! + Fixture class that extends window capture fixture with a capturable widget +*/ +class WindowCaptureWithWidgetFixture : public WindowCaptureFixture +{ + Q_OBJECT + +public: + /*! + Starts capturing and returns true if successful. + + Two phase initialization is used to be able to detect + failure to find widget window as a capturable window. + */ + bool start(QSize size = { 60, 40 }); + + /*! + Waits until the a captured frame is received and returns it + */ + QVideoFrame waitForFrame(qint64 noOlderThanTime = 0); + + DisableCursor m_cursorDisabled; // Avoid mouse cursor causing image differences + TestWidget m_widget; + QCapturableWindow m_captureWindow; + +protected: + static QCapturableWindow findCaptureWindow(const QString &windowTitle); +}; + +class WindowCaptureWithWidgetInOtherProcessFixture : public WindowCaptureWithWidgetFixture +{ + Q_OBJECT + +public: + ~WindowCaptureWithWidgetInOtherProcessFixture() { m_windowProcess.close(); } + + /*! + Create widget in separate process and start capturing its content + */ + bool start(); + + QProcess m_windowProcess; +}; + +class WindowCaptureWithWidgetAndRecorderFixture : public WindowCaptureWithWidgetFixture +{ + Q_OBJECT + +public: + void start(QSize size = { 60, 40 }, bool togglePattern = true); + + /*! + Stop recording. + + Since recorder finalizes the file asynchronously, even after destructors are called, + we need to explicitly wait for the stopped state before ending the test. If we don't + do this, the media file can not be deleted by the QTemporaryDir at destruction. + */ + bool stop(); + + bool testVideoFilePlayback(const QString& fileName); + +public slots: + void recorderStateChanged(QMediaRecorder::RecorderState state); + +public: + QTemporaryDir m_tempDir; + const QString m_mediaFile = m_tempDir.filePath("test.mp4"); + QMediaRecorder m_recorder; + QMediaRecorder::RecorderState m_recorderState = QMediaRecorder::StoppedState; + QSignalSpy m_recorderErrors{ &m_recorder, &QMediaRecorder::errorOccurred }; +}; + +#endif diff --git a/tests/auto/integration/qwindowcapturebackend/grabber.cpp b/tests/auto/integration/qwindowcapturebackend/grabber.cpp new file mode 100644 index 000000000..59f3e6577 --- /dev/null +++ b/tests/auto/integration/qwindowcapturebackend/grabber.cpp @@ -0,0 +1,62 @@ +// 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 "grabber.h" +#include "fixture.h" + +#include <qtest.h> +#include <qvideoframe.h> + +FrameGrabber::FrameGrabber() +{ + const auto copyFrame = [this](const QVideoFrame &frame) { m_frames.push_back(frame); }; + + connect(this, &QVideoSink::videoFrameChanged, this, copyFrame, Qt::DirectConnection); +} + +const std::vector<QVideoFrame> &FrameGrabber::getFrames() const +{ + return m_frames; +} + +std::vector<QVideoFrame> FrameGrabber::waitAndTakeFrames(size_t minCount, qint64 noOlderThanTime) +{ + m_frames.clear(); + + const auto enoughFramesOrStopped = [this, minCount, noOlderThanTime]() -> bool { + if (m_stopped) + return true; // Stop waiting + + if (noOlderThanTime > 0) { + // Reject frames older than noOlderThanTime + const auto newEnd = std::remove_if(m_frames.begin(), m_frames.end(), + [noOlderThanTime](const QVideoFrame &frame) { + return frame.startTime() <= noOlderThanTime; + }); + m_frames.erase(newEnd, m_frames.end()); + } + + return m_frames.size() >= minCount; + }; + + if (!QTest::qWaitFor(enoughFramesOrStopped, static_cast<int>(s_testTimeout.count()))) + return {}; + + if (m_stopped) + return {}; + + return std::exchange(m_frames, {}); +} + +bool FrameGrabber::isStopped() const +{ + return m_stopped; +} + +void FrameGrabber::stop() +{ + qWarning() << "Stopping grabber"; + m_stopped = true; +} + +#include "moc_grabber.cpp" diff --git a/tests/auto/integration/qwindowcapturebackend/grabber.h b/tests/auto/integration/qwindowcapturebackend/grabber.h new file mode 100644 index 000000000..aff78f33b --- /dev/null +++ b/tests/auto/integration/qwindowcapturebackend/grabber.h @@ -0,0 +1,43 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +#ifndef WINDOW_CAPTURE_GRABBER_H +#define WINDOW_CAPTURE_GRABBER_H + +#include <qvideosink.h> +#include <vector> + +QT_USE_NAMESPACE + +/*! + The FrameGrabber stores frames that arrive from the window capture, + and is used to inspect captured frames in the tests. +*/ +class FrameGrabber : public QVideoSink +{ + Q_OBJECT + +public: + FrameGrabber(); + + const std::vector<QVideoFrame> &getFrames() const; + + /*! + Wait for at least \a minCount frames that are no older than noOlderThanTime. + + Returns empty if not enough frames arrived, or if grabber was stopped before global timeout + elapsed. + */ + std::vector<QVideoFrame> waitAndTakeFrames(size_t minCount, qint64 noOlderThanTime = 0); + + bool isStopped() const; + +public slots: + void stop(); + +private: + std::vector<QVideoFrame> m_frames; + bool m_stopped = false; +}; + +#endif diff --git a/tests/auto/integration/qwindowcapturebackend/tst_qwindowcapturebackend.cpp b/tests/auto/integration/qwindowcapturebackend/tst_qwindowcapturebackend.cpp new file mode 100644 index 000000000..1beaea069 --- /dev/null +++ b/tests/auto/integration/qwindowcapturebackend/tst_qwindowcapturebackend.cpp @@ -0,0 +1,267 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +// TESTED_COMPONENT=src/multimedia + +#include "fixture.h" +#include "widget.h" + +#include <qmediarecorder.h> +#include <qpainter.h> +#include <qsignalspy.h> +#include <qtest.h> +#include <qwindowcapture.h> +#include <qcommandlineparser.h> + +#include <chrono> +#include <vector> + +using std::chrono::duration_cast; +using std::chrono::high_resolution_clock; +using std::chrono::microseconds; + +QT_USE_NAMESPACE + +class tst_QWindowCaptureBackend : public QObject +{ + Q_OBJECT + +private slots: + static void initTestCase() + { +#if defined(Q_OS_LINUX) + if (qEnvironmentVariable("QTEST_ENVIRONMENT").toLower() == "ci" + && qEnvironmentVariable("XDG_SESSION_TYPE").toLower() != "x11") + QSKIP("Skip on wayland; to be fixed"); +#endif + + const QWindowCapture capture; + if (capture.error() == QWindowCapture::CapturingNotSupported) + QSKIP("Screen capturing not supported"); + } + + void isActive_returnsFalse_whenNotStarted() + { + const WindowCaptureFixture fixture; + QVERIFY(!fixture.m_capture.isActive()); + } + + void setActive_failsAndEmitEerrorOccurred_whenNoWindowSelected() + { + WindowCaptureFixture fixture; + + QTest::ignoreMessage(QtWarningMsg, QRegularExpression{ ".*Screen capture fail.*" }); + fixture.m_capture.setActive(true); + + QVERIFY(!fixture.m_capture.isActive()); + QVERIFY(!fixture.m_errors.empty()); + } + + void setActive_startsWindowCapture_whenCalledWithTrue() + { + WindowCaptureWithWidgetFixture fixture; + QVERIFY(fixture.start()); + + // Ensure that we have received a frame + QVERIFY(fixture.waitForFrame().isValid()); + + QCOMPARE(fixture.m_activations.size(), 1); + QVERIFY(fixture.m_errors.empty()); + } + + void capturedImage_equals_imageFromGrab_data() + { + QTest::addColumn<QSize>("windowSize"); + QTest::newRow("single-pixel-window") << QSize{1, 1}; + QTest::newRow("small-window") << QSize{60, 40}; + QTest::newRow("odd-width-window") << QSize{ 61, 40 }; + QTest::newRow("odd-height-window") << QSize{ 60, 41 }; + QTest::newRow("big-window") << QApplication::primaryScreen()->size(); + } + + void capturedImage_equals_imageFromGrab() + { + QFETCH(QSize, windowSize); + + WindowCaptureWithWidgetFixture fixture; + QVERIFY(fixture.start(windowSize)); + + const QImage expected = fixture.m_widget.grabImage(); + const QImage actual = fixture.waitForFrame().toImage(); + + QVERIFY(fixture.compareImages(actual, expected)); + } + + void capturedImage_changes_whenWindowContentChanges() + { + WindowCaptureWithWidgetFixture fixture; + QVERIFY(fixture.start()); + + const auto startTime = high_resolution_clock::now(); + + const QVideoFrame colorFrame = fixture.waitForFrame(); + QVERIFY(colorFrame.isValid()); + + fixture.m_widget.setDisplayPattern(TestWidget::Grid); + + // Ignore all frames that were grabbed since the colored frame, + // to ensure that we get a frame after we changed display pattern + const high_resolution_clock::duration delay = high_resolution_clock::now() - startTime; + const QVideoFrame gridFrame = fixture.waitForFrame( + colorFrame.endTime() + duration_cast<microseconds>(delay).count()); + + QVERIFY(gridFrame.isValid()); + + // Make sure that the gridFrame has a different content than the colorFrame + QCOMPARE(gridFrame.size(), colorFrame.size()); + QCOMPARE_NE(gridFrame.toImage(), colorFrame.toImage()); + + const QImage actualGridImage = fixture.m_widget.grabImage(); + QVERIFY(fixture.compareImages(gridFrame.toImage(), actualGridImage)); + } + + void sequenceOfCapturedImages_compareEqual_whenWindowContentIsUnchanged() + { + WindowCaptureWithWidgetFixture fixture; + QVERIFY(fixture.start()); + + const std::vector<QVideoFrame> frames = fixture.m_grabber.waitAndTakeFrames(10); + QVERIFY(!frames.empty()); + + QImage firstFrame = frames.front().toImage(); + QVERIFY(!firstFrame.isNull()); + + qsizetype index = 0; + for (const auto &frame : std::as_const(frames)){ + QVERIFY(fixture.compareImages(frame.toImage(), firstFrame, QString::number(index))); + ++index; + } + } + + void recorder_encodesFrames_toValidMediaFile_data() + { + QTest::addColumn<QSize>("windowSize"); + //QTest::newRow("empty-window") << QSize{ 0, 0 }; TODO: Crash + //QTest::newRow("single-pixel-window") << QSize{ 1, 1 }; TODO: Crash + QTest::newRow("small-window") << QSize{ 60, 40 }; + QTest::newRow("odd-width-window") << QSize{ 61, 40 }; + QTest::newRow("odd-height-window") << QSize{ 60, 41 }; + QTest::newRow("big-window") << QSize{ 800, 600 }; + } + + void recorder_encodesFrames_toValidMediaFile() + { + QFETCH(QSize, windowSize); + + WindowCaptureWithWidgetAndRecorderFixture fixture; + fixture.start(windowSize); + + // Wait on grabber to ensure that video recorder also get some frames + fixture.m_grabber.waitAndTakeFrames(60); + + // Wait for recorder finalization + fixture.stop(); + + QVERIFY(fixture.m_recorderErrors.empty()); + QVERIFY(QFile{ fixture.m_mediaFile }.exists()); + QVERIFY(fixture.testVideoFilePlayback(fixture.m_mediaFile)); + } + + void recorder_encodesFrames_toValidMediaFile_whenWindowResizes_data() + { + QTest::addColumn<int>("increment"); + QTest::newRow("shrink") << -1; + QTest::newRow("grow") << 1; + } + + void recorder_encodesFrames_toValidMediaFile_whenWindowResizes() + { + QFETCH(int, increment); + + QSize windowSize = { 200, 150 }; + WindowCaptureWithWidgetAndRecorderFixture fixture; + fixture.start(windowSize, /*toggle pattern*/ false); + + for (qsizetype i = 0; i < 20; ++i) { + windowSize.setWidth(windowSize.width() + increment); + windowSize.setHeight(windowSize.height() + increment); + fixture.m_widget.setSize(windowSize); + + // Wait on grabber to ensure that video recorder also get some frames + fixture.m_grabber.waitAndTakeFrames(1); + } + + // Wait for recorder finalization + fixture.stop(); + + QVERIFY(fixture.m_recorderErrors.empty()); + QVERIFY(QFile{ fixture.m_mediaFile }.exists()); + QVERIFY(fixture.testVideoFilePlayback(fixture.m_mediaFile)); + } + + void windowCapture_capturesWindowsInOtherProcesses() + { + WindowCaptureWithWidgetInOtherProcessFixture fixture; + QVERIFY(fixture.start()); + + // Get reference image from our in-process widget + const QImage expected = fixture.m_widget.grabImage(); + + // Get actual image grabbed from out-of-process widget + const QImage actual = fixture.waitForFrame().toImage(); + + QVERIFY(fixture.compareImages(actual, expected)); + } + + /* + This test is not a requirement per se, but we want all platforms + to behave the same. A reasonable alternative could have been to + treat closed window as a regular 'Stop' capture (not an error). + */ + void windowCapture_stopsWithError_whenProcessCloses() + { + WindowCaptureWithWidgetInOtherProcessFixture fixture; + QVERIFY(fixture.start()); + + // Get capturing started + fixture.m_grabber.waitAndTakeFrames(3); + + QTest::ignoreMessage(QtWarningMsg, QRegularExpression{ ".*Screen capture fail.*" }); + + // Closing the process waits for it to exit + fixture.m_windowProcess.close(); + + const bool captureFailed = QTest::qWaitFor([&] { return !fixture.m_errors.empty(); }, + static_cast<int>(s_testTimeout.count())); + + QVERIFY(captureFailed); + } +}; + +int main(int argc, char *argv[]) +{ + QCommandLineParser cmd; + const QCommandLineOption showTestWidget{ QStringList{ "show" }, + "Creates a test widget with given title", + "windowTitle" }; + cmd.addOption(showTestWidget); + cmd.parse({ argv, argv + argc }); + + if (cmd.isSet(showTestWidget)) { + QApplication app{ argc, argv }; + const QString windowTitle = cmd.value(showTestWidget); + const bool result = showCaptureWindow(windowTitle); + return result ? 0 : 1; + } + + // If no special arguments are set, enter the regular QTest main routine + TESTLIB_SELFCOVERAGE_START("tst_QWindowCaptureatioBackend") + QT_PREPEND_NAMESPACE(QTest::Internal::callInitMain)<tst_QWindowCaptureBackend>(); + QApplication app(argc, argv); + app.setAttribute(Qt::AA_Use96Dpi, true); + tst_QWindowCaptureBackend tc; + QTEST_SET_MAIN_SOURCE_PATH return QTest::qExec(&tc, argc, argv); + +} + +#include "tst_qwindowcapturebackend.moc" diff --git a/tests/auto/integration/qwindowcapturebackend/widget.cpp b/tests/auto/integration/qwindowcapturebackend/widget.cpp new file mode 100644 index 000000000..0c2336810 --- /dev/null +++ b/tests/auto/integration/qwindowcapturebackend/widget.cpp @@ -0,0 +1,125 @@ +// 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 "widget.h" +#include "fixture.h" + +#include <qapplication.h> +#include <qsystemsemaphore.h> +#include <qtest.h> + + +TestWidget::TestWidget(const QString &uuid, QScreen *screen) +{ + // Give each window a unique title so that we can uniquely identify it + setWindowTitle(uuid); + + setScreen(screen ? screen : QApplication::primaryScreen()); + + // Use frameless hint because on Windows UWP platform, the window titlebar is captured, + // but the reference image acquired by 'QWindow::grab()' does not include titlebar. + // This allows us to do pixel-perfect matching of captured content. + setWindowFlags(Qt::Window | Qt::FramelessWindowHint); + setFixedSize(60, 40); +} + +void TestWidget::setDisplayPattern(Pattern p) +{ + m_pattern = p; + repaint(); +} + +void TestWidget::setSize(QSize size) +{ + if (size == QApplication::primaryScreen()->size()) + setWindowState(Qt::WindowMaximized); + else + setFixedSize(size); +} + +QImage TestWidget::grabImage() +{ + return grab().toImage(); +} + +void TestWidget::togglePattern() +{ + Pattern p = m_pattern == ColoredSquares ? Grid : ColoredSquares; + setDisplayPattern(p); +} + +void TestWidget::paintEvent(QPaintEvent *paintEvent) +{ + QPainter p(this); + p.setPen(Qt::NoPen); + p.setBrush(Qt::black); + p.drawRect(rect()); + + if (m_pattern == ColoredSquares) + drawColoredSquares(p); + else + drawGrid(p); + + p.end(); +} + +void TestWidget::drawColoredSquares(QPainter &p) +{ + const std::vector<std::vector<Qt::GlobalColor>> colors = { { Qt::red, Qt::green, Qt::blue }, + { Qt::white, Qt::white, Qt::white }, + { Qt::blue, Qt::green, Qt::red } }; + + const QSize squareSize = size() / 3; + QRect rect{ QPoint{ 0, 0 }, squareSize }; + + for (const auto &row : colors) { + for (const auto &color : row) { + p.setBrush(color); + p.drawRect(rect); + rect.moveLeft(rect.left() + rect.width()); + } + rect.moveTo({ 0, rect.bottom() }); + } +} + +void TestWidget::drawGrid(QPainter &p) const +{ + const QSize winSize = size(); + + p.setPen(Qt::white); + + QLine vertical{ QPoint{ 5, 0 }, QPoint{ 5, winSize.height() } }; + while (vertical.x1() < winSize.width()) { + p.drawLine(vertical); + vertical.translate(10, 0); + } + QLine horizontal{ QPoint{ 0, 5 }, QPoint{ winSize.width(), 5 } }; + while (horizontal.y1() < winSize.height()) { + p.drawLine(horizontal); + horizontal.translate(0, 10); + } +} + +bool showCaptureWindow(const QString &windowTitle) +{ + const QNativeIpcKey key{ windowTitle }; + QSystemSemaphore windowVisible(key); + + TestWidget widget{ windowTitle }; + widget.show(); + + // Wait for window to be visible and suitable for window capturing + const bool result = QTest::qWaitForWindowExposed(&widget, s_testTimeout.count()); + if (!result) + qDebug() << "Failed to show window"; + + // Signal to host process that the window is visible + windowVisible.release(); + + // Keep window visible until a termination signal is received + QApplication::exec(); + + return result; +} + +#include "moc_widget.cpp" diff --git a/tests/auto/integration/qwindowcapturebackend/widget.h b/tests/auto/integration/qwindowcapturebackend/widget.h new file mode 100644 index 000000000..e74c29021 --- /dev/null +++ b/tests/auto/integration/qwindowcapturebackend/widget.h @@ -0,0 +1,43 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +#ifndef WINDOW_CAPTURE_WIDGET_H +#define WINDOW_CAPTURE_WIDGET_H + +#include <qwidget.h> +#include <qscreen.h> +#include <qpainter.h> +#include <quuid.h> + +/*! + Window capable of drawing test patterns used for capture tests + */ +class TestWidget : public QWidget +{ + Q_OBJECT + +public: + enum Pattern { ColoredSquares, Grid }; + + TestWidget(const QString &uuid = QUuid::createUuid().toString(), QScreen *screen = nullptr); + + void setDisplayPattern(Pattern p); + void setSize(QSize size); + QImage grabImage(); + +public slots: + void togglePattern(); + +protected: + void paintEvent(QPaintEvent * /*event*/) override; + +private: + void drawColoredSquares(QPainter &p); + void drawGrid(QPainter &p) const; + + Pattern m_pattern = ColoredSquares; +}; + +bool showCaptureWindow(const QString &windowTitle); + +#endif |