Revision control

++ // Copyright (C) 2023 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
++
++ // TESTED_COMPONENT=src/multimedia
++
++ #include "tst_qwindowcapturebackend_fixture.h"
++ #include "tst_qwindowcapturebackend_widget.h"
++
++ #include <QtCore/qcommandlineparser.h>
++ #include <QtCore/qelapsedtimer.h>
++ #include <QtCore/qoperatingsystemversion.h>
++
++ #include <QtGui/qguiapplication.h>
++ #include <QtGui/qwindow.h>
++
++ #include <QtMultimedia/qmediarecorder.h>
++ #include <QtMultimedia/qwindowcapture.h>
++ #if defined(Q_OS_MACOS)
++ #include <QtMultimedia/private/qavfhelpers_p.h>
++ #endif
++ #include <QtMultimedia/private/qmultimedia_ranges_p.h>
++ #include <QtMultimedia/private/qwindowcapture_p.h>
++ #include <QtMultimediaTestLib/private/mediabackendutils_p.h>
++ #include <QtMultimediaTestLib/private/mediainfo_p.h>
++ #include <QtMultimediaTestLib/private/osdetection_p.h>
++ #include <QtMultimediaTestLib/private/qintegrationtestbase_p.h>
++
++ #include <QtTest/qsignalspy.h>
++ #include <QtTest/qtest.h>
++
++ #include <chrono>
++ #include <cstdlib>
++ #include <tuple>
++ #include <vector>
++
++ namespace ranges = QtMultimediaPrivate::ranges;
++
++ using std::chrono::duration_cast;
++ using std::chrono::high_resolution_clock;
++ using std::chrono::microseconds;
++ using std::chrono::milliseconds;
++
++ using QtMultimediaTest::VideoFrameInfo;
++ using QtMultimediaTest::surfaceCaptureTestTimeout;
++
++ using namespace std::chrono_literals;
++
++ using namespace QtMultimediaTest;
++
++ class tst_QWindowCaptureBackend : public QIntegrationTestBase
++ {
++ Q_OBJECT
++
++ private:
++ bool m_skipOddSizedWindows = false;
++ [[nodiscard]] bool skipOddSizedWindows() const { return m_skipOddSizedWindows; }
++
++ // Keeps capturing frames until we receive the size we expect.
++ // If it fails, it emits a QFAIL with a helpful message.
++ void waitForVideoFrameExpectedSize(
++ WindowCaptureFixture &fixture,
++ QSize expectedSize,
++ const QString &context)
++ {
++ bool matched = QTest::qWaitFor(
++ [&] {
++ const std::vector<VideoFrameInfo> &frames = fixture.m_grabber.getFrameInfos();
++ return !frames.empty() && frames.back().size == expectedSize;
++ },
++ surfaceCaptureTestTimeout());
++
++ const std::vector<VideoFrameInfo> &frames = fixture.m_grabber.getFrameInfos();
++ QSize lastFrameSize = frames.empty() ? QSize{} : frames.back().size;
++
++ QVERIFY2(
++ matched,
++ qPrintable(u"%1: expected captured frame %2x%3, but after %4 frame(s) "
++ "the last one was %5x%6"_s
++ .arg(context)
++ .arg(expectedSize.width())
++ .arg(expectedSize.height())
++ .arg(int(frames.size()))
++ .arg(lastFrameSize.width())
++ .arg(lastFrameSize.height())));
++ }
++
++ private slots:
++ void initTestCase()
++ {
++ if (!initIntegrationTestCase())
++ return;
++
++ #ifdef Q_OS_ANDROID
++ QSKIP("Feature does not work on Android");
++ #endif
++
++ // The offscreen platform plugin never shows windows on the native desktop, so the
++ // capture backends have nothing they can capture.
++ if (QGuiApplication::platformName() == u"offscreen"_s)
++ QSKIP("Window capturing does not work with the offscreen platform plugin");
++
++ #if defined(Q_OS_LINUX)
++ if (isCI() && qEnvironmentVariable("XDG_SESSION_TYPE").toLower() != "x11")
++ QSKIP("Skip on wayland; to be fixed");
++ #elif defined(Q_OS_MACOS)
++ if (isCI()) {
++ // Window capturing requires screen capture permissions on macOS. Without them,
++ // none of the tests can succeed, so fail here to abort the entire test run.
++ QVERIFY2(
++ QAVFHelpers::checkMacOsScreenCapturePermissions(),
++ "Missing screen capture permissions. Tests are not expected to succeed.");
++ } else if (!QAVFHelpers::checkMacOsScreenCapturePermissions()) {
++ QAVFHelpers::requestMacOsScreenCapturePermissions();
++ QFAIL("Missing screen capture permissions. Grant permissions and restart test.");
++ }
++
++ // macOS CI machines have some issues with giving hardware frames of
++ // incorrect size on odd-sized windows, so skip those sizes there.
++ m_skipOddSizedWindows = isCI();
++ #endif
++
++ // On some platforms, captured frame size lags slightly behind window geometry
++ // changes, so allow a short settle time after modifying geometry before
++ // starting capture or checking frame sizes.
++ //
++ // If frame size tests are flaky, it might help to increase this value.
++ if (isMacOS || isLinux)
++ WindowCaptureFixture::windowGeometrySettleDelay = 200ms;
++
++ const QWindowCapture capture;
++ if (capture.error() == QWindowCapture::CapturingNotSupported)
++ QSKIP("Screen capturing not supported");
++
++ #if defined(__SANITIZE_ADDRESS__) || __has_feature(address_sanitizer)
++ QSKIP("QTBUG-135614, disabling tst_QWindowCaptureBackend for Address Sanitizer builds, due "
++ "to flakiness");
++ #endif
++ }
++
++ void isActive_returnsFalse_whenNotStarted()
++ {
++ const WindowCaptureFixture fixture;
++ QVERIFY(!fixture.m_capture.isActive());
++ }
++
++ void window_isInvalid_whenNotSet()
++ {
++ WindowCaptureFixture fixture;
++ QVERIFY(!fixture.m_capture.window().isValid());
++ QCOMPARE(fixture.m_capture.window(), QCapturableWindow{});
++ }
++
++ void error_isNoError_whenNotStarted()
++ {
++ WindowCaptureFixture fixture;
++ QCOMPARE(fixture.m_capture.error(), QWindowCapture::Error::NoError);
++ }
++
++ void windowCapture_returnsSession_whenAddedToSession()
++ {
++ WindowCaptureFixture fixture;
++ // The fixture adds the capture to its session on construction.
++ QCOMPARE(fixture.m_capture.captureSession(), &fixture.m_session);
++ }
++
++ void capturableWindows_returnsOnlyValidWindows()
++ {
++ const QList<QCapturableWindow> windows = QWindowCapture::capturableWindows();
++
++ for (const QCapturableWindow &window : windows)
++ QVERIFY(window.isValid());
++ }
++
++ void capturableWindows_containsVisibleWindow()
++ {
++ if (isMacOS) {
++ // Note: Remove this skip if this test is promoted to bundle.
++ QSKIP("On macOS, windows without a bundle identifier are filtered out of "
++ "capturableWindows(), so our test window never appears in the list");
++ }
++
++ TestWidget widget;
++ widget.show();
++ QVERIFY(QTest::qWaitForWindowExposed(&widget, surfaceCaptureTestTimeout()));
++
++ // Our visible window should be discoverable in the list of capturable windows.
++ QList<QCapturableWindow> windows = QWindowCapture::capturableWindows();
++ auto found = ranges::find_if(
++ windows,
++ [&widget](const QCapturableWindow &window) {
++ return window.description() == widget.windowTitle();
++ });
++
++ QVERIFY2(found != windows.end(), "Visible window not found among capturable windows");
++ QVERIFY(found->isValid());
++ }
++
++ void capturableWindow_constructedFromWindow_canBeCaptured()
++ {
++ QVERIFY(!QCapturableWindow{}.isValid());
++ QVERIFY(!QCapturableWindow{ static_cast<QWindow *>(nullptr) }.isValid());
++
++ TestWidget widget;
++ widget.show();
++ QVERIFY(QTest::qWaitForWindowExposed(&widget, surfaceCaptureTestTimeout()));
++
++ QWindow *const window = widget.windowHandle();
++ QVERIFY(window);
++
++ // Constructing a QCapturableWindow directly from a live QWindow yields a
++ // valid handle describing that window.
++ QCapturableWindow capturable{ window };
++ QVERIFY(capturable.isValid());
++ QCOMPARE(capturable.description(), widget.windowTitle());
++
++ // The handle constructed from the QWindow can actually be captured.
++ WindowCaptureFixture fixture;
++ fixture.m_capture.setWindow(capturable);
++ fixture.m_capture.setActive(true);
++
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++ QVERIFY(fixture.m_capture.isActive());
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ void capturableWindows_canAllBeCaptured_data()
++ {
++ QTest::addColumn<QCapturableWindow>("window");
++
++ const QList<QCapturableWindow> windows = QWindowCapture::capturableWindows();
++
++ if (windows.empty()) {
++ // CI machines may run without any windows other than our own, which
++ // do not exist yet at this point.
++ if (isCI())
++ QSKIP("QWindowCapture::capturableWindows() returned no windows");
++
++ // Without any rows, QtTest still runs the test function once, and QFETCH
++ // aborts the whole test run. Add an invalid window instead, so that the
++ // test function reports the empty list as a failure.
++ QTest::newRow("no capturable windows") << QCapturableWindow{};
++ return;
++ }
++
++ for (qsizetype i = 0; i < windows.size(); ++i) {
++ // Window descriptions are not unique, so prefix the tag with the
++ // index to keep the data tags distinct.
++ const QString tag = u"%1: %2"_s.arg(i).arg(windows.at(i).description());
++ QTest::newRow(qPrintable(tag)) << windows.at(i);
++ }
++ }
++
++ /*
++ Every handle that capturableWindows() hands out is advertised as
++ capturable, so starting a stream on any of them must succeed and deliver
++ a valid frame.
++
++ Note that this captures windows belonging to other applications, which
++ makes it sensitive to whatever happens to be on screen.
++ */
++ void capturableWindows_canAllBeCaptured()
++ {
++ QFETCH(const QCapturableWindow, window);
++ QVERIFY2(window.isValid(), "QWindowCapture::capturableWindows() returned no windows");
++
++ WindowCaptureFixture fixture;
++ fixture.m_capture.setWindow(window);
++ fixture.m_capture.setActive(true);
++
++ // Ensure that we have received a frame
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++
++ QVERIFY(fixture.m_capture.isActive());
++ QCOMPARE(fixture.m_activations.size(), 1);
++ QVERIFY(fixture.m_activations.at(0).at(0).toBool());
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ void setActive_failsAndEmitsError_whenWindowIsDefaultConstructed()
++ {
++ WindowCaptureFixture fixture;
++ QWindowCapture &windowCapture = fixture.m_capture;
++
++ QSignalSpy windowChanges{ &windowCapture, &QWindowCapture::windowChanged };
++
++ // A default-constructed QCapturableWindow does not refer to any window,
++ // and can never be captured.
++ const QCapturableWindow emptyWindow;
++ QVERIFY(!emptyWindow.isValid());
++
++ windowCapture.setWindow(emptyWindow);
++
++ // Assigning an invalid window to an inactive capture is a no-op, since
++ // it already holds a default-constructed window.
++ QCOMPARE(windowCapture.window(), QCapturableWindow{});
++ QCOMPARE(windowChanges.size(), 0);
++ QVERIFY(fixture.m_errors.empty());
++
++ // Starting the capture must fail and report an error.
++ windowCapture.setActive(true);
++
++ QVERIFY(!windowCapture.isActive());
++ QVERIFY(fixture.m_activations.empty());
++ QCOMPARE(fixture.m_errors.size(), 1);
++
++ // No frames must ever arrive on the stream.
++ QTest::qWait(500ms);
++ QVERIFY(fixture.m_grabber.getFrameInfos().empty());
++
++ // TODO: Verify a specific error code once it is consistent across
++ // platforms.
++ // QCOMPARE(windowCapture.error(), QWindowCapture::Error::NotFound);
++ }
++
++ void setActive_failsAndEmitsErrors_whenNoWindowSelected()
++ {
++ WindowCaptureFixture fixture;
++
++ QWindowCapture &windowCapture = fixture.m_capture;
++
++ QSignalSpy errorChangedSpy { &windowCapture, &QWindowCapture::errorChanged };
++
++ QCOMPARE(windowCapture.error(), QWindowCapture::Error::NoError);
++
++ windowCapture.setActive(true);
++
++ QVERIFY(!windowCapture.isActive());
++
++ // Make sure we received exactly one signal, and the error code is NotFound.
++ QCOMPARE(fixture.m_errors.count(), 1);
++ QCOMPARE(errorChangedSpy.count(), 1);
++
++ // TODO: Verify last error emitted is a specific error code.
++ /*
++ auto lastErrorEmitted = QWindowCapture::Error(fixture.m_errors[0][0].toInt());
++ QCOMPARE(lastErrorEmitted, QWindowCapture::Error::NotFound);
++
++ QCOMPARE(windowCapture.error(), QWindowCapture::Error::NotFound);
++ */
++ }
++
++ void setActive_isNoOp_whenStoppingCaptureThatNeverStarted()
++ {
++ WindowCaptureFixture fixture;
++
++ QWindowCapture &windowCapture = fixture.m_capture;
++ QVERIFY(!windowCapture.isActive());
++
++ // Stopping a capture that was never started should not change state,
++ // emit activeChanged, or raise an error.
++ windowCapture.setActive(false);
++
++ QVERIFY(!windowCapture.isActive());
++ QVERIFY(fixture.m_activations.empty());
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ void setActive_isNoOp_whenAlreadyActive()
++ {
++ WindowCaptureWithWidgetFixture fixture;
++ QVERIFY(fixture.start());
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++
++ QWindowCapture &windowCapture = fixture.m_capture;
++ QVERIFY(windowCapture.isActive());
++ QCOMPARE(fixture.m_activations.size(), 1);
++
++ // Activating an already-active capture should not emit activeChanged again.
++ windowCapture.setActive(true);
++
++ QVERIFY(windowCapture.isActive());
++ QCOMPARE(fixture.m_activations.size(), 1);
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ void setActive_startsWindowCapture_whenCalledWithTrue()
++ {
++ WindowCaptureWithWidgetFixture fixture;
++ QVERIFY(fixture.start());
++
++ // Ensure that we have received a frame
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++
++ QCOMPARE(fixture.m_activations.size(), 1);
++ QVERIFY(fixture.m_activations.at(0).at(0).toBool());
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ void setActive_stopsWindowCapture_whenCalledWithFalse()
++ {
++ WindowCaptureWithWidgetFixture fixture;
++ QVERIFY(fixture.start());
++
++ QWindowCapture &windowCapture = fixture.m_capture;
++
++ QVERIFY(windowCapture.isActive());
++ QCOMPARE(fixture.m_activations.size(), 1);
++
++ // Ensure capture is actually running before stopping it
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++
++ windowCapture.setActive(false);
++
++ // activeChanged has now fired twice: once for start, once for stop
++ QCOMPARE(fixture.m_activations.size(), 2);
++ QVERIFY(!fixture.m_activations.at(1).at(0).toBool());
++ QVERIFY(!windowCapture.isActive());
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ void setActive_restartsWindowCapture_whenStartedAgainAfterStop()
++ {
++ constexpr int restartCount = 3;
++
++ WindowCaptureWithWidgetFixture fixture;
++ QVERIFY(fixture.start());
++
++ QWindowCapture &windowCapture = fixture.m_capture;
++
++ // Ensure capture is actually running before stopping it
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++
++ for (int i = 0; i < restartCount; ++i) {
++ windowCapture.setActive(false);
++ QVERIFY(!fixture.m_capture.isActive());
++
++ windowCapture.setActive(true);
++
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++ QVERIFY(windowCapture.isActive());
++ }
++
++ // activeChanged fired for the initial start plus a stop/start pair per restart
++ QCOMPARE(fixture.m_activations.size(), 1 + 2 * restartCount);
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ // For some platforms, the hardware-frames we receive are tied to internal frame pools.
++ // If our QVideoFrames point into these pools, we can run into a problem where storing
++ // QVideoFrames over time can stall the stream. We test if we can store a good chunk of
++ // QVideoFrames without stalling.
++ void storingFramesDoesNotStallStream()
++ {
++ // We assume 20 frames is higher than the internal pool of the backend.
++ constexpr int capturedFrameCount = 20;
++
++ WindowCaptureWithWidgetFixture fixture;
++ QVERIFY(fixture.start());
++
++ // Use animated content to make sure backend does not
++ // consider the content idle.
++ fixture.m_widget.setDisplayPattern(TestWidget::Pattern::Animated);
++
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++
++ std::vector<QVideoFrame> capturedFrames = fixture.m_grabber.waitAndTakeVideoFrames(
++ capturedFrameCount);
++
++ // If this fails, it's an indication we stalled the stream.
++ QCOMPARE_GE(capturedFrames.size(), size_t(capturedFrameCount));
++ }
++
++ void setWindow_switchesSource_whileActive()
++ {
++ WindowCaptureWithWidgetFixture fixture;
++ QVERIFY(fixture.start({ 60, 40 }));
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++
++ QWindowCapture &windowCapture = fixture.m_capture;
++
++ QSignalSpy windowChanges{ &windowCapture, &QWindowCapture::windowChanged };
++
++ // Create a second, differently-sized window to switch to
++ TestWidget secondWidget;
++ secondWidget.setSize({ 120, 80 });
++ secondWidget.show();
++ QVERIFY(QTest::qWaitForWindowExposed(&secondWidget, surfaceCaptureTestTimeout()));
++
++ std::optional<QCapturableWindow> secondWindow =
++ WindowCaptureWithWidgetFixture::findCaptureWindow(
++ secondWidget.windowTitle(),
++ secondWidget.windowHandle());
++ QVERIFY(secondWindow && secondWindow->isValid());
++
++ // Switch the captured window while capture is active
++ windowCapture.setWindow(*secondWindow);
++
++ QCOMPARE(windowChanges.size(), 1);
++ QCOMPARE(windowCapture.window(), *secondWindow);
++
++ // Switching source keeps the capture active
++ QVERIFY(windowCapture.isActive());
++ // activeChanged does not fire in between switching source
++ QCOMPARE(fixture.m_activations.size(), 1);
++
++ // Make sure we get frames from the new larger window. The captured frame
++ // is delivered in physical pixels, so scale by the device pixel ratio.
++ const QSize expectedSize =
++ (QSizeF(secondWidget.size()) * secondWidget.devicePixelRatio()).toSize();
++ QTRY_VERIFY_WITH_TIMEOUT(
++ !fixture.m_grabber.getFrameInfos().empty()
++ && fixture.m_grabber.getFrameInfos().back().size == expectedSize,
++ surfaceCaptureTestTimeout());
++
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ // Switching the source of an active capture must keep it active, without
++ // emitting any activation signals in between. Otherwise it may trigger
++ // QMediaRecorder::autoStop.
++ void setWindow_doesNotEmitActiveChanged_whenSwitchedWhileActive()
++ {
++ WindowCaptureWithWidgetFixture fixture;
++ QVERIFY(fixture.start({ 60, 40 }));
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++ QCOMPARE(fixture.m_activations.size(), 1);
++
++ // A differently-sized second window, so we can tell the sources apart.
++ TestWidget secondWidget;
++ secondWidget.setSize({ 120, 80 });
++ secondWidget.show();
++ QVERIFY(QTest::qWaitForWindowExposed(&secondWidget, surfaceCaptureTestTimeout()));
++
++ const std::optional<QCapturableWindow> secondWindow =
++ WindowCaptureWithWidgetFixture::findCaptureWindow(
++ secondWidget.windowTitle(),
++ secondWidget.windowHandle());
++ QVERIFY(secondWindow && secondWindow->isValid());
++
++ // The captured frame is delivered in physical pixels.
++ const auto expectedFrameSize = [](const TestWidget &widget) {
++ return (QSizeF(widget.size()) * widget.devicePixelRatio()).toSize();
++ };
++
++ // Switch back and forth. Wait for frames of each new source, so that we also
++ // catch activation signals that are emitted while the new source is starting up.
++ fixture.m_capture.setWindow(*secondWindow);
++ QVERIFY(fixture.m_capture.isActive());
++ waitForVideoFrameExpectedSize(
++ fixture,
++ expectedFrameSize(secondWidget),
++ u"Switched to second window"_s);
++
++ fixture.m_capture.setWindow(fixture.m_captureWindow);
++ QVERIFY(fixture.m_capture.isActive());
++ waitForVideoFrameExpectedSize(
++ fixture,
++ expectedFrameSize(fixture.m_widget),
++ u"Switched back to first window"_s);
++
++ QVERIFY(fixture.m_capture.isActive());
++ QCOMPARE(fixture.m_activations.size(), 1);
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ // The maximum frame rate must apply to the new source after a switch, both
++ // in not exceeding it and in not stalling.
++ void setWindow_maintainsFrameRate_whenSwitchedWhileActive()
++ {
++ WindowCaptureWithWidgetFixture fixture;
++
++ // Animated content, so the backend does not consider the content idle.
++ fixture.m_widget.setDisplayPattern(TestWidget::Pattern::Animated);
++
++ const float frameRate = 5.f;
++ fixture.m_capture.setMaximumFrameRate(frameRate);
++
++ QVERIFY(fixture.start({ 60, 40 }));
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++
++ TestWidget secondWidget;
++ secondWidget.setDisplayPattern(TestWidget::Pattern::Animated);
++ secondWidget.setSize({ 120, 80 });
++ secondWidget.show();
++ QVERIFY(QTest::qWaitForWindowExposed(&secondWidget, surfaceCaptureTestTimeout()));
++
++ const std::optional<QCapturableWindow> secondWindow =
++ WindowCaptureWithWidgetFixture::findCaptureWindow(
++ secondWidget.windowTitle(),
++ secondWidget.windowHandle());
++ QVERIFY(secondWindow && secondWindow->isValid());
++
++ // The captured frame is delivered in physical pixels.
++ const auto expectedFrameSize = [](const TestWidget &widget) {
++ return (QSizeF(widget.size()) * widget.devicePixelRatio()).toSize();
++ };
++
++ // Range [0, 1]. Lower is better, but may increase flakiness.
++ const float slopFactor = isCI() ? 0.2f : 0.1f;
++
++ // Wait for the switch to settle, then check the rate of the new source.
++ auto verifyFrameRateAfterSwitch = [&](
++ const QCapturableWindow &window,
++ const TestWidget &widget,
++ const QString &description)
++ {
++ fixture.m_capture.setWindow(window);
++ waitForVideoFrameExpectedSize(fixture, expectedFrameSize(widget), description);
++
++ std::optional<std::chrono::milliseconds> durationBetweenFrames =
++ fixture.m_grabber.durationBetweenFrames(3, frameRate);
++ QVERIFY2(
++ durationBetweenFrames.has_value(),
++ qPrintable(description + u": did not receive enough QVideoFrames to measure framerate"_s));
++
++ const qreal actualFps = 1000.0 / durationBetweenFrames->count();
++ QCOMPARE_LT(actualFps, frameRate * (1 + slopFactor));
++ QCOMPARE_GT(actualFps, frameRate * (1 - slopFactor * 2));
++ };
++
++ verifyFrameRateAfterSwitch(
++ *secondWindow, secondWidget, u"Switched to second window"_s);
++ if (QTest::currentTestFailed())
++ return;
++ verifyFrameRateAfterSwitch(
++ fixture.m_captureWindow, fixture.m_widget, u"Switched back to first window"_s);
++ if (QTest::currentTestFailed())
++ return;
++
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ void setWindow_stopsCapture_whenSwitchedToInvalidWindow()
++ {
++ QSKIP_FFMPEG("QTBUG-151355: fails on Windows CI with audio device present");
++
++ WindowCaptureWithWidgetFixture fixture;
++ QVERIFY(fixture.start());
++
++ QWindowCapture &windowCapture = fixture.m_capture;
++
++ // Ensure capture is actually running before switching window
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++ QCOMPARE(fixture.m_activations.size(), 1);
++ QVERIFY(windowCapture.isActive());
++
++ // Switching to a default-constructed (invalid) window cannot be captured,
++ // so the capture stops and becomes inactive.
++ windowCapture.setWindow(QCapturableWindow{});
++
++ QVERIFY(!windowCapture.isActive());
++
++ // activeChanged fired again for the deactivation
++ QCOMPARE(fixture.m_activations.size(), 2);
++ QCOMPARE(windowCapture.window(), QCapturableWindow{});
++ // We should emit an error when invalid window is assigned to
++ // active QWindowCapture.
++ QCOMPARE(fixture.m_errors.size(), 1);
++
++ // TODO: Check that last error state is a specific error code.
++ // QCOMPARE(windowCapture.error(), QWindowCapture::Error::NotFound);
++ }
++
++ void setWindow_updatesPropertyAndEmitsSignal_whenNotActive()
++ {
++ WindowCaptureWithWidgetFixture fixture;
++
++ fixture.m_widget.setSize({ 60, 40 });
++ fixture.m_widget.show();
++ QVERIFY(QTest::qWaitForWindowExposed(
++ &fixture.m_widget,
++ surfaceCaptureTestTimeout()));
++
++ const std::optional<QCapturableWindow> window =
++ WindowCaptureWithWidgetFixture::findCaptureWindow(
++ fixture.m_widget.windowTitle(),
++ fixture.m_widget.windowHandle());
++ QVERIFY(window && window->isValid());
++
++ QWindowCapture &windowCapture = fixture.m_capture;
++ QSignalSpy windowChanges{ &windowCapture, &QWindowCapture::windowChanged };
++
++ windowCapture.setWindow(*window);
++
++ QCOMPARE(windowChanges.size(), 1);
++ QCOMPARE(windowCapture.window(), *window);
++
++ // Selecting a window without activating must not start capturing or raise errors.
++ QVERIFY(!windowCapture.isActive());
++ QVERIFY(fixture.m_activations.empty());
++ QVERIFY(fixture.m_errors.empty());
++
++ // Setting the same window again is a no-op and must not emit windowChanged.
++ windowCapture.setWindow(*window);
++ QCOMPARE(windowChanges.size(), 1);
++ }
++
++ void setFrameRate_updatesPropertyAndEmitsSignal()
++ {
++ WindowCaptureFixture fixture;
++
++ auto frameRateEquals = [](std::optional<qreal> frameRate, float value) {
++ return frameRate && qFuzzyCompare(*frameRate, static_cast<qreal>(value));
++ };
++
++ // No preferred frame rate initially
++ QVERIFY(!fixture.m_capture.maximumFrameRate());
++
++ // Setting a frame rate updates the property and emits maximumFrameRateChanged
++ const float newFrameRate = 1.f;
++ fixture.m_capture.setMaximumFrameRate(newFrameRate);
++
++ QCOMPARE(fixture.m_frameRates.size(), 1);
++ QVERIFY(frameRateEquals(fixture.m_capture.maximumFrameRate(), newFrameRate));
++
++ fixture.m_capture.setMaximumFrameRate(std::nullopt);
++
++ QCOMPARE(fixture.m_frameRates.size(), 2);
++ QVERIFY(!fixture.m_capture.maximumFrameRate());
++
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ void setFrameRate_emitsFramesAtCorrectRate()
++ {
++ WindowCaptureWithWidgetFixture fixture;
++
++ // Use animated content to make sure backend does not
++ // consider the content idle.
++ fixture.m_widget.setDisplayPattern(TestWidget::Pattern::Animated);
++
++ const float newFrameRate = 1.f;
++ fixture.m_capture.setMaximumFrameRate(newFrameRate);
++
++ QVERIFY(fixture.start());
++
++ // Range [0, 1]. Lower is better, but may increase flakiness.
++ float slopFactor = 0.1;
++ if (isCI()) {
++ slopFactor = 0.2;
++ }
++
++ // Check framerate is roughly 1fps
++ using namespace std::chrono;
++ std::optional<std::chrono::milliseconds> durationBetweenFrames =
++ fixture.m_grabber.durationBetweenFrames(3, newFrameRate);
++ QVERIFY2(
++ durationBetweenFrames.has_value(),
++ "Did not receive enough QVideoFrames to measure framerate");
++ const qreal actualFps = 1000.0 / durationBetweenFrames->count();
++ QCOMPARE_LT(actualFps, newFrameRate * (1 + slopFactor));
++ }
++
++ void firstFrame_hasZeroStartTime_whenCaptureIsStartedOrRestarted()
++ {
++ QSKIP_FFMPEG("QTBUG-151355: fails on Windows CI with audio device present");
++
++ constexpr int restartCount = 2;
++
++ WindowCaptureWithWidgetFixture fixture;
++ QVERIFY(fixture.start());
++
++ QWindowCapture &windowCapture = fixture.m_capture;
++
++ // Presentation times are relative to the start of the stream, so the first
++ // frame of every stream, including restarted ones, must start at zero.
++ for (int i = 0; i <= restartCount; ++i) {
++ if (i > 0) {
++ windowCapture.setActive(false);
++ // Deactivation flushes the backend, but frames it already produced may
++ // still be queued towards the sink. Deliver them before restarting, so
++ // they are not mistaken for the first frame of the new stream.
++ QCoreApplication::processEvents();
++ windowCapture.setActive(true);
++ }
++
++ const std::optional<VideoFrameInfo> frame = fixture.tryWaitForFirstFrameInfo();
++ QVERIFY(frame);
++ QCOMPARE(frame->startTime, 0);
++ }
++ }
++
++ void capturedFrames_havePresentationTimesInIncreasingOrder()
++ {
++ QSKIP_FFMPEG("QTBUG-151355: fails on Windows CI with audio device present");
++
++ WindowCaptureWithWidgetFixture fixture;
++
++ // Animated content so the backend keeps producing new frames rather than
++ // considering the content idle.
++ fixture.m_widget.setDisplayPattern(TestWidget::Pattern::Animated);
++ QVERIFY(fixture.start());
++
++ const std::vector<VideoFrameInfo> frames = fixture.m_grabber.waitAndTakeFrameInfos(10);
++ QVERIFY2(
++ frames.size() >= 2,
++ "Did not receive enough QVideoFrames to compare presentation times");
++
++ for (size_t i = 1; i < frames.size(); ++i) {
++ const qint64 prevStart = frames[i - 1].startTime;
++ const qint64 currStart = frames[i].startTime;
++
++ // Presentation (start) times must strictly increase along the stream.
++ QVERIFY2(
++ currStart > prevStart,
++ qPrintable(u"Frame #%1 start time (%2) is not after frame #%3 "
++ "start time (%4)"_s
++ .arg(i).arg(currStart).arg(i - 1).arg(prevStart)));
++
++ // Where the backend also reports an end time, the frame must not end
++ // before it starts, and the next frame must not start before the
++ // previous one ended (frames must not overlap in time).
++ const qint64 prevEnd = frames[i - 1].endTime;
++ if (prevEnd >= 0) {
++ QVERIFY2(
++ prevEnd >= prevStart,
++ qPrintable(u"Frame #%1 end time (%2) precedes its start time (%3)"_s
++ .arg(i - 1).arg(prevEnd).arg(prevStart)));
++ QVERIFY2(
++ currStart >= prevEnd,
++ qPrintable(u"Frame #%1 start time (%2) precedes frame #%3 "
++ "end time (%4)"_s
++ .arg(i).arg(currStart).arg(i - 1).arg(prevEnd)));
++ }
++ }
++ }
++
++ void setFrameRate_emitsFramesWithExpectedPresentationTimeSpacing()
++ {
++ WindowCaptureWithWidgetFixture fixture;
++
++ // Use animated content to make sure the backend does not consider the
++ // content idle and keeps emitting at the requested rate.
++ fixture.m_widget.setDisplayPattern(TestWidget::Pattern::Animated);
++
++ const float frameRate = 5.f;
++ fixture.m_capture.setMaximumFrameRate(frameRate);
++
++ QVERIFY(fixture.start());
++
++ const std::vector<VideoFrameInfo> frames =
++ fixture.m_grabber.waitAndTakeFrameInfos(6, frameRate);
++ QVERIFY2(
++ frames.size() >= 2,
++ "Did not receive enough QVideoFrames to measure presentation time spacing");
++
++ // Expected spacing between consecutive presentation times, in microseconds.
++ const qint64 expectedSpacingUs = qint64(std::micro::den / frameRate);
++
++ // maximumFrameRate is an upper bound, so frames must not be spaced closer
++ // than the requested interval (allowing some slop). An occasional dropped
++ // frame widens the gap, so we do not enforce a tight upper bound per gap;
++ // instead we require the average spacing to stay near the expected value.
++ const double lowerSlop = 0.15;
++ const double upperAverageSlop = 0.5;
++
++ qint64 totalSpacingUs = 0;
++ for (size_t i = 1; i < frames.size(); ++i) {
++ const qint64 spacingUs = frames[i].startTime - frames[i - 1].startTime;
++ totalSpacingUs += spacingUs;
++
++ QVERIFY2(
++ spacingUs >= qint64(expectedSpacingUs * (1 - lowerSlop)),
++ qPrintable(u"Frames #%1 and #%2 are spaced %3us apart, closer than "
++ "the requested minimum of %4us"_s
++ .arg(i - 1).arg(i).arg(spacingUs).arg(expectedSpacingUs)));
++ }
++
++ const qint64 averageSpacingUs = totalSpacingUs / qint64(frames.size() - 1);
++ QVERIFY2(
++ averageSpacingUs <= qint64(expectedSpacingUs * (1 + upperAverageSlop)),
++ qPrintable(u"Average presentation time spacing %1us is far larger than "
++ "the requested %2us"_s
++ .arg(averageSpacingUs).arg(expectedSpacingUs)));
++ }
++
++ void capturedImage_equals_imageFromGrab_data()
++ {
++ QTest::addColumn<QSize>("windowSize");
++ QTest::newRow("small-window") << QSize{60, 40};
++ QTest::newRow("big-window") << QApplication::primaryScreen()->size();
++
++ if (!skipOddSizedWindows()) {
++ QTest::newRow("single-pixel-window") << QSize{1, 1};
++ QTest::newRow("odd-width-window") << QSize{ 61, 40 };
++ QTest::newRow("odd-height-window") << QSize{ 60, 41 };
++ }
++ }
++
++ void capturedImage_equals_imageFromGrab()
++ {
++ QSKIP_FFMPEG("QTBUG-151355: fails on Windows CI with audio device present");
++
++ QFETCH(QSize, windowSize);
++
++ WindowCaptureWithWidgetFixture fixture;
++ fixture.m_grabber.setRetainFirstVideoFrame(true);
++ QVERIFY(fixture.start(windowSize));
++
++ const QImage expected = fixture.m_widget.grabImage();
++
++ const std::optional<QVideoFrame> frame = fixture.consumeFirstVideoFrame();
++ QVERIFY(frame);
++ const QImage actual = frame->toImage();
++
++ QVERIFY(fixture.compareImages(actual, expected));
++ }
++
++ void capturedFrame_hasExpectedSize_data()
++ {
++ QTest::addColumn<QSize>("windowSize");
++ QTest::newRow("small-window") << QSize{ 60, 40 };
++ QTest::newRow("big-window") << QSize{ 800, 600 };
++ if (!skipOddSizedWindows()) {
++ QTest::newRow("odd-width-window") << QSize{ 61, 40 };
++ QTest::newRow("odd-height-window") << QSize{ 60, 41 };
++ }
++ }
++
++ void capturedFrame_hasExpectedSize()
++ {
++ QSKIP_FFMPEG("QTBUG-151355: fails on Windows CI with audio device present");
++
++ QFETCH(QSize, windowSize);
++
++ WindowCaptureWithWidgetFixture fixture;
++
++ QVERIFY(fixture.start(windowSize, FirstFrameSizePolicy::MustMatchWindowSize));
++ QVERIFY(fixture.m_capture.isActive());
++
++ const QSize expectedSize =
++ (QSizeF(fixture.m_widget.size()) * fixture.m_widget.devicePixelRatio()).toSize();
++
++ QVERIFY(QTest::qWaitFor(
++ [&] { return !fixture.m_grabber.getFrameInfos().empty(); }, surfaceCaptureTestTimeout()));
++
++ const std::vector<VideoFrameInfo> frames = fixture.m_grabber.getFrameInfos();
++ for (size_t i = 0; i < frames.size(); ++i) {
++ QVERIFY2(
++ frames[i].size == expectedSize,
++ qPrintable(u"Captured frame #%1 was %2x%3, but expected %4x%5"_s
++ .arg(i)
++ .arg(frames[i].size.width())
++ .arg(frames[i].size.height())
++ .arg(expectedSize.width())
++ .arg(expectedSize.height())));
++ }
++
++ // The video sink should report the same size as the frames it receives.
++ QVERIFY2(
++ fixture.m_grabber.videoSize() == expectedSize,
++ qPrintable(u"Video sink size %1x%2 differs from expected %3x%4"_s
++ .arg(fixture.m_grabber.videoSize().width())
++ .arg(fixture.m_grabber.videoSize().height())
++ .arg(expectedSize.width())
++ .arg(expectedSize.height())));
++
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ // For some backends it's possible to unintentionally store size-snapshots
++ // in QCapturableWindow (i.e ScreenCaptureKit.SCWindow). This test
++ // checks that our stream starts at the current window size, not
++ // when the QCapturableWindow handle was constructed.
++ void capture_startsAtCurrentSize_whenWindowResizedBeforeStart()
++ {
++ QSKIP_FFMPEG("QTBUG-151355: fails on Windows CI with audio device present");
++
++ WindowCaptureWithWidgetFixture fixture;
++
++ const QSize initialSize{ 200, 150 };
++ const QSize resizedSize{ 400, 300 };
++
++ fixture.m_widget.setSize(initialSize);
++ fixture.m_widget.show();
++ QVERIFY(QTest::qWaitForWindowExposed(&fixture.m_widget, surfaceCaptureTestTimeout()));
++
++ // Create the capturable-window handle while the window is still at the
++ // initial size.
++ const QCapturableWindow handle{ fixture.m_widget.windowHandle() };
++ QVERIFY(handle.isValid());
++
++ // Resize, and wait until the new size has actually taken effect before we
++ // start capturing, so the new size is the window's live size.
++ fixture.m_widget.setSize(resizedSize);
++ QVERIFY(QTest::qWaitFor(
++ [&] { return fixture.m_widget.size() == resizedSize; },
++ surfaceCaptureTestTimeout()));
++ // This test inspects the very first captured frame's size, so let the
++ // capture backend observe the new geometry before we start the stream
++ fixture.waitForWindowGeometryToSettle(FirstFrameSizePolicy::MustMatchWindowSize);
++
++ // Start capturing using the handle that was created at the old size.
++ fixture.m_capture.setWindow(handle);
++ fixture.m_capture.setActive(true);
++ QVERIFY(fixture.m_capture.isActive());
++
++ // Every captured frame, including the very first, must reflect the
++ // new size of the window.
++ const QSize expectedSize =
++ (QSizeF(resizedSize) * fixture.m_widget.devicePixelRatio()).toSize();
++
++ QVERIFY(QTest::qWaitFor(
++ [&] { return !fixture.m_grabber.getFrameInfos().empty(); }, surfaceCaptureTestTimeout()));
++
++ const std::vector<VideoFrameInfo> frames = fixture.m_grabber.getFrameInfos();
++ for (size_t i = 0; i < frames.size(); ++i) {
++ QVERIFY2(
++ frames[i].size == expectedSize,
++ qPrintable(u"Captured frame #%1 was %2x%3, but expected %4x%5"_s
++ .arg(i)
++ .arg(frames[i].size.width())
++ .arg(frames[i].size.height())
++ .arg(expectedSize.width())
++ .arg(expectedSize.height())));
++ }
++
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ void capturedFrame_hasNewSize_whenWindowResizedOnce()
++ {
++ QSKIP_FFMPEG("QTBUG-151355: fails on Windows CI with audio device present");
++
++ // The captured frame is delivered in physical pixels, so scale the
++ // window's logical size by the device pixel ratio to get the expected
++ // frame size.
++ const auto expectedFrameSize = [](const TestWidget &widget) {
++ return (QSizeF(widget.size()) * widget.devicePixelRatio()).toSize();
++ };
++
++ WindowCaptureWithWidgetFixture fixture;
++ // Use animated content to make sure backend does not
++ // consider the content idle.
++ fixture.m_widget.setDisplayPattern(TestWidget::Pattern::Animated);
++ QVERIFY(fixture.start({ 200, 150 }));
++ QVERIFY(fixture.m_capture.isActive());
++
++ // Make sure we are receiving frames at the initial window size before
++ // we resize.
++ waitForVideoFrameExpectedSize(
++ fixture,
++ expectedFrameSize(fixture.m_widget), u"Initial capture at 200x150"_s);
++ if (QTest::currentTestFailed())
++ return;
++
++ fixture.resizeWidget({ 400, 300 });
++
++ // At some point we have to receive frames with the new size.
++ waitForVideoFrameExpectedSize(
++ fixture,
++ expectedFrameSize(fixture.m_widget), u"Resized window to 400x300"_s);
++
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ void capturedFrame_hasNewSize_whenWindowResized_data()
++ {
++ using QOSVersion = QOperatingSystemVersion;
++ if (isCI()
++ && QOSVersion::currentType() == QOSVersion::OSType::MacOS
++ && QOSVersion::current() < QOSVersion(QOSVersion::OSType::MacOS, 26))
++ {
++ QSKIP("macOS 15 CI machines and older have issues with reporting content size during window resizing");
++ }
++
++ QTest::addColumn<int>("increment");
++ QTest::newRow("shrink by 1") << -1;
++ QTest::newRow("shrink by 2") << -2;
++ QTest::newRow("grow by 1") << 1;
++ QTest::newRow("grow by 2") << 2;
++ }
++
++ void capturedFrame_hasNewSize_whenWindowResized()
++ {
++ QFETCH(int, increment);
++
++ constexpr int incrementalSteps = 10;
++
++ // The captured frame is delivered in physical pixels, so scale the
++ // window's logical size by the device pixel ratio to get the expected
++ // frame size.
++ const auto expectedFrameSizeFn = [](const TestWidget &widget) {
++ return (QSizeF(widget.size()) * widget.devicePixelRatio()).toSize();
++ };
++
++ WindowCaptureWithWidgetFixture fixture;
++
++ QSize windowSize = { 200, 150 };
++ QVERIFY(fixture.start(windowSize));
++ QVERIFY(fixture.m_capture.isActive());
++
++ // Make sure we are receiving frames at the initial window size before
++ // we start resizing.
++ waitForVideoFrameExpectedSize(
++ fixture,
++ expectedFrameSizeFn(fixture.m_widget),
++ u"Initial capture at 200x150"_s);
++ if (QTest::currentTestFailed())
++ return;
++
++ for (int step = 0; step < incrementalSteps; ++step) {
++ windowSize += QSize{ increment, increment };
++ fixture.resizeWidget(windowSize);
++
++ // At some point, we must receive a frame that has the
++ // expected size.
++ waitForVideoFrameExpectedSize(
++ fixture,
++ expectedFrameSizeFn(fixture.m_widget),
++ u"Resized window to %1x%2 (step %3)"_s
++ .arg(windowSize.width())
++ .arg(windowSize.height())
++ .arg(step));
++ if (QTest::currentTestFailed())
++ return;
++ }
++
++ QVERIFY(fixture.m_errors.empty());
++ }
++
++ void capturedImage_changes_whenWindowContentChanges()
++ {
++ QSKIP_FFMPEG("QTBUG-151355: fails on Windows CI with audio device present");
++
++ WindowCaptureWithWidgetFixture fixture;
++ QVERIFY(fixture.start());
++
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++
++ // Compare every new frame as it arrives, rather than storing the frames.
++ // If any of the new frames match, the test succeeds.
++ QImage expectedGridImage;
++ bool anyNewFrameMatchesNewContent = false;
++ QObject context;
++ connect(
++ &fixture.m_grabber,
++ &QVideoSink::videoFrameChanged,
++ &context,
++ [&](const QVideoFrame &frame) {
++ // Two null images compare equal, so skip frames until we know
++ // the expected image.
++ if (anyNewFrameMatchesNewContent || expectedGridImage.isNull())
++ return;
++ const QImage image = frame.toImage();
++ if (image.convertToFormat(expectedGridImage.format()) == expectedGridImage)
++ anyNewFrameMatchesNewContent = true;
++ });
++
++ fixture.m_widget.setDisplayPattern(TestWidget::Pattern::Grid);
++ expectedGridImage = fixture.m_widget.grabImage();
++
++ QTRY_VERIFY_WITH_TIMEOUT(
++ anyNewFrameMatchesNewContent,
++ surfaceCaptureTestTimeout());
++ }
++
++ void sequenceOfCapturedImages_compareEqual_whenWindowContentIsUnchanged()
++ {
++ if (isWindows)
++ QSKIP(
++ "Windows does not emit frames if content is unchanged. "
++ "Cannot test framerates reliably in CI. QTBUG-147051");
++
++ if (isMacOS)
++ QSKIP(
++ "The macOS ScreenCaptureKit backend will often not emit "
++ "new frames if content is unchanged");
++
++ WindowCaptureWithWidgetFixture fixture;
++ QVERIFY(fixture.start());
++
++ const std::vector<QVideoFrame> frames = fixture.m_grabber.waitAndTakeVideoFrames(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("big-window") << QSize{ 800, 600 };
++
++ if (!skipOddSizedWindows()) {
++ QTest::newRow("odd-width-window") << QSize{ 61, 40 };
++ QTest::newRow("odd-height-window") << QSize{ 60, 41 };
++ }
++ }
++
++ void recorder_encodesFrames_toValidMediaFile()
++ {
++ QSKIP_FFMPEG("QTBUG-151355: fails on Windows CI with audio device present");
++
++ QFETCH(QSize, windowSize);
++
++ WindowCaptureWithWidgetAndRecorderFixture fixture;
++ fixture.start(windowSize);
++
++ // Wait on grabber to ensure that video recorder also get some frames
++ std::ignore = fixture.m_grabber.waitAndTakeFrameInfos(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()
++ {
++ QSKIP_FFMPEG("QTBUG-151355: fails on Windows CI with audio device present");
++
++ 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
++ std::ignore = fixture.m_grabber.waitAndTakeFrameInfos(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 recorder_recordingHasExpectedDuration_whenWindowIsSwitched()
++ {
++ // How long we keep each of the two sources active while recording.
++ constexpr auto sourceDuration = 2s;
++
++ const QSize windowSize{ 200, 150 };
++
++ // Animated content, so that both sources keep producing new frames.
++ TestWidget secondWidget;
++ secondWidget.setSize(windowSize);
++ secondWidget.setDisplayPattern(TestWidget::Pattern::Animated);
++ secondWidget.show();
++ QVERIFY(QTest::qWaitForWindowExposed(&secondWidget, surfaceCaptureTestTimeout()));
++
++ const std::optional<QCapturableWindow> secondWindow =
++ WindowCaptureWithWidgetFixture::findCaptureWindow(
++ secondWidget.windowTitle(),
++ secondWidget.windowHandle());
++ QVERIFY(secondWindow && secondWindow->isValid());
++
++ WindowCaptureWithWidgetAndRecorderFixture fixture;
++ fixture.start(windowSize);
++
++ // Make sure we are both capturing and recording before we start timing.
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++ QTRY_COMPARE_WITH_TIMEOUT(
++ fixture.m_recorderState,
++ QMediaRecorder::RecorderState::RecordingState,
++ surfaceCaptureTestTimeout());
++
++ QElapsedTimer recordingTimer;
++ recordingTimer.start();
++
++ QTest::qWait(sourceDuration);
++
++ fixture.m_capture.setWindow(*secondWindow);
++ QVERIFY(fixture.m_capture.isActive());
++
++ QTest::qWait(sourceDuration);
++
++ const milliseconds expectedDuration{ recordingTimer.elapsed() };
++
++ QVERIFY(fixture.stop());
++
++ QVERIFY(fixture.m_errors.empty());
++ QVERIFY(fixture.m_recorderErrors.empty());
++
++ const std::optional<MediaInfo> info =
++ MediaInfo::create(QUrl::fromLocalFile(fixture.m_mediaFile));
++ QVERIFY(info);
++
++ // Allow some slack for the time it takes to start and switch streams.
++ const milliseconds tolerance = expectedDuration / 4;
++ QVERIFY2(
++ std::chrono::abs(info->m_duration - expectedDuration) <= tolerance,
++ qPrintable(u"Recording lasted %1ms, but sources were active for %2ms"_s
++ .arg(info->m_duration.count())
++ .arg(expectedDuration.count())));
++ }
++
++ // Switching the captured window ends the stream of the old source. This must
++ // not be treated as the end of the input, so the recorder must not auto-stop.
++ void recorder_doesNotAutoStop_whenWindowIsSwitched()
++ {
++ const QSize windowSize{ 200, 150 };
++
++ // Animated content, so that both sources keep producing new frames.
++ TestWidget secondWidget;
++ secondWidget.setSize(windowSize);
++ secondWidget.setDisplayPattern(TestWidget::Pattern::Animated);
++ secondWidget.show();
++ QVERIFY(QTest::qWaitForWindowExposed(&secondWidget, surfaceCaptureTestTimeout()));
++
++ const std::optional<QCapturableWindow> secondWindow =
++ WindowCaptureWithWidgetFixture::findCaptureWindow(
++ secondWidget.windowTitle(),
++ secondWidget.windowHandle());
++ QVERIFY(secondWindow && secondWindow->isValid());
++
++ WindowCaptureWithWidgetAndRecorderFixture fixture;
++ fixture.m_recorder.setAutoStop(true);
++ fixture.start(windowSize);
++
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++ QTRY_COMPARE_WITH_TIMEOUT(
++ fixture.m_recorderState,
++ QMediaRecorder::RecorderState::RecordingState,
++ surfaceCaptureTestTimeout());
++
++ QSignalSpy recorderStateChanges{
++ &fixture.m_recorder,
++ &QMediaRecorder::recorderStateChanged
++ };
++
++ fixture.m_capture.setWindow(*secondWindow);
++ QVERIFY(fixture.m_capture.isActive());
++
++ // Give the recorder time to process frames from the new source.
++ QVERIFY(fixture.m_grabber.waitAndTakeFrameInfos(10).size() >= 10);
++
++ QVERIFY(recorderStateChanges.empty());
++ QCOMPARE(fixture.m_recorder.recorderState(), QMediaRecorder::RecorderState::RecordingState);
++
++ // Deactivating the capture ends the input, which must trigger auto-stop.
++ // This ensures that auto-stop works at all in this setup.
++ fixture.m_capture.setActive(false);
++ QTRY_COMPARE_WITH_TIMEOUT(
++ fixture.m_recorderState,
++ QMediaRecorder::RecorderState::StoppedState,
++ surfaceCaptureTestTimeout());
++
++ QVERIFY(fixture.m_errors.empty());
++ QVERIFY(fixture.m_recorderErrors.empty());
++ }
++
++ void windowCapture_capturesWindowsInOtherProcesses()
++ {
++ QSKIP_FFMPEG("QTBUG-151355: fails on Windows CI with audio device present");
++
++ if (isMacOS) {
++ QSKIP("Separate process tests do not work on macOS "
++ "because they are launched without bundle identifiers");
++ }
++
++ WindowCaptureWithWidgetInOtherProcessFixture fixture;
++ fixture.m_grabber.setRetainFirstVideoFrame(true);
++ 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 std::optional<QVideoFrame> frame = fixture.consumeFirstVideoFrame();
++ QVERIFY(frame);
++ const QImage actual = frame->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()
++ {
++ if (isMacOS) {
++ QSKIP("Separate process tests do not work on macOS "
++ "because they are launched without bundle identifiers");
++ }
++
++ WindowCaptureWithWidgetInOtherProcessFixture fixture;
++ QVERIFY(fixture.start());
++
++ // Get capturing started
++ QVERIFY(fixture.tryWaitForFirstFrameInfo());
++
++ // Closing the process waits for it to exit
++ fixture.m_windowProcess.close();
++
++ QTRY_VERIFY_WITH_TIMEOUT(
++ !fixture.m_errors.empty(),
++ surfaceCaptureTestTimeout());
++
++ // TODO: Verify that the QWindowCapture goes inactive whenever we encounter an error
++ // like this.
++ // QVERIFY(!windowCapture.isActive());
++
++ // TODO: Enforce specific error code when a window is lost and capture stops.
++ // Need to investigate if this is a case we can specifically detect on all platforms.
++ }
++ };
++
++ // QTEST_MAIN defines main, but we want to override it, so ensure that it emits
++ // `testlib_main` instead of `main`
++ #define main testlib_main
++ QTEST_MAIN(tst_QWindowCaptureBackend)
++ #undef main
++
++ 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;
++ }
++
++ return testlib_main(argc, argv);
++ }
++
++ #include "tst_qwindowcapturebackend.moc"