[Tests] Capture screenshots and dump the widget tree from a playback (#7818)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Fedora 45 release (push) Blocked by required conditions
Build Desktop / Fedora 45 debug (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 sanitize (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 27 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions

pqWidgetTestEventPlayer adds the widget-level commands the UI tests were
missing:

- screenshot grabs the addressed widget and saves it, creating the parent
  directory and expanding $NAME / ${NAME} from the environment so the runner
  decides where captures land
- dumpTree writes the widget paths the tests address, for finding the path of a
  widget while writing a test
- resize sets the top-level window size, since the layout owns the children

The runner sets COCKATRICE_SCREENSHOT_DIR to build/screenshots, which is
ignored, so captures do not show up as untracked files.
Testing/screenshot_test.xml captures the main window and the settings dialog,
and Testing/README.md documents the commands, the pause/process_events split,
path drift and the capture workflow.
This commit is contained in:
BruebachL 2026-11-09 14:19:57 +01:00 • committed by GitHub
parent 3d24597134
commit 3b379618c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 377 additions and 14 deletions

View file

@ -599,9 +599,12 @@ target_link_libraries(
cockatrice_target_enable_crash_reporting(cockatrice)
if(WITH_GUI_TEST)
# These two translate between XML test files and the QtTesting event pipeline;
# they need the QtTesting headers, so they only exist with the harness.
target_sources(cockatrice PRIVATE src/game_graphics/pq_cockatrice_event_player.cpp src/interface/recording.cpp)
# QtTesting integration and the Cockatrice-specific playback commands. They
# need the QtTesting headers, so they only exist with the harness.
target_sources(
cockatrice PRIVATE src/game_graphics/pq_cockatrice_event_player.cpp src/interface/pq_widget_test_event_player.cpp
src/interface/recording.cpp
)
target_link_libraries(cockatrice PRIVATE QtTesting)
target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST)
endif()

View file

@ -0,0 +1,223 @@
#include "pq_widget_test_event_player.h"
#include <QApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QLoggingCategory>
#include <QPixmap>
#include <QTextStream>
#include <QWidget>
#include <pqObjectNaming.h>
Q_LOGGING_CATEGORY(PQ_WIDGET_TEST_EVENT_PLAYER, "cockatrice.guiTest.widgetEventPlayer")
namespace
{
void dumpSubtree(QObject *object, QTextStream &out, int depth)
{
if (auto *widget = qobject_cast<QWidget *>(object)) {
const QString path = pqObjectNaming::GetName(*widget);
if (!path.isEmpty()) {
out << QString(depth * 2, ' ') << path << " [" << widget->metaObject()->className() << "]";
if (!widget->objectName().isEmpty()) {
out << " objectName=" << widget->objectName();
}
out << '\n';
}
}
// children() returns a snapshot, so the recursion below cannot invalidate it.
const QObjectList children = object->children();
for (QObject *child : children) {
dumpSubtree(child, out, depth + 1);
}
}
/**
* Expands $NAME and ${NAME} from the environment.
*
* A test file is static, so this lets the runner send screenshots to a scratch
* directory instead of hardcoding a path that only exists on one machine. An
* unset variable is an error, not an empty string: writing to the working
* directory would look like a pass.
*/
bool expandEnvironment(const QString &command, const QString &path, QString &expanded)
{
expanded.clear();
for (int i = 0; i < path.size(); ++i) {
if (path.at(i) != QLatin1Char('$')) {
expanded += path.at(i);
continue;
}
int nameStart = i + 1;
int nameEnd = nameStart;
if (nameStart < path.size() && path.at(nameStart) == QLatin1Char('{')) {
nameStart++;
nameEnd = path.indexOf(QLatin1Char('}'), nameStart);
if (nameEnd < 0) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << command << ": unterminated ${ in path" << path;
return false;
}
i = nameEnd;
} else {
while (nameEnd < path.size() &&
(path.at(nameEnd).isLetterOrNumber() || path.at(nameEnd) == QLatin1Char('_'))) {
nameEnd++;
}
i = nameEnd - 1;
}
const QString name = path.mid(nameStart, nameEnd - nameStart);
if (name.isEmpty()) {
expanded += QLatin1Char('$');
continue;
}
const QString value = qEnvironmentVariable(name.toUtf8().constData());
if (value.isEmpty()) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << command << ": environment variable" << name << "is not set";
return false;
}
expanded += value;
}
return true;
}
} // namespace
pqWidgetTestEventPlayer::pqWidgetTestEventPlayer(QObject *parent) : pqWidgetEventPlayer(parent)
{
}
bool pqWidgetTestEventPlayer::playEvent(QObject *object, const QString &command, const QString &arguments, bool &error)
{
// A command we know that fails is still a handled event: QtTesting logs
// "Unhandled event" when playEvent returns false, so set `error` and return
// true, as pqCockatriceEventPlayer does.
const auto run = [&error](bool ok) {
if (!ok) {
error = true;
}
return true;
};
if (command == "screenshot") {
return run(playScreenshot(object, arguments));
}
if (command == "dumpTree") {
return run(playDumpTree(object, arguments));
}
if (command == "resize") {
return run(playResize(object, arguments));
}
return false;
}
bool pqWidgetTestEventPlayer::playScreenshot(QObject *object, const QString &arguments)
{
auto *widget = qobject_cast<QWidget *>(object);
if (!widget) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "screenshot: target is not a QWidget";
return false;
}
QString path;
if (!resolveOutputPath(QStringLiteral("screenshot"), arguments.trimmed(), path)) {
return false;
}
// repaint() is synchronous, even on the offscreen platform, so the grab
// contains what the previous events drew.
widget->repaint();
const QPixmap pixmap = widget->grab();
if (pixmap.isNull()) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "screenshot: grab returned a null pixmap for" << widget;
return false;
}
if (!pixmap.save(path)) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER)
<< "screenshot: could not save" << path << "- an image format is chosen from the file extension";
return false;
}
qCInfo(PQ_WIDGET_TEST_EVENT_PLAYER) << "screenshot saved to" << path;
return true;
}
bool pqWidgetTestEventPlayer::playDumpTree(QObject *object, const QString &arguments)
{
if (!qobject_cast<QWidget *>(object)) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "dumpTree: target is not a QWidget";
return false;
}
QString path;
if (!resolveOutputPath(QStringLiteral("dumpTree"), arguments.trimmed(), path)) {
return false;
}
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "dumpTree: could not open" << path << file.errorString();
return false;
}
QTextStream out(&file);
dumpSubtree(object, out, 0);
qCInfo(PQ_WIDGET_TEST_EVENT_PLAYER) << "widget tree dumped to" << path;
return true;
}
bool pqWidgetTestEventPlayer::playResize(QObject *object, const QString &arguments)
{
auto *widget = qobject_cast<QWidget *>(object);
if (!widget) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "resize: target is not a QWidget";
return false;
}
const QStringList parts = arguments.split(QLatin1Char('x'));
if (parts.size() != 2) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "resize: arguments must be 'WIDTHxHEIGHT', got:" << arguments;
return false;
}
bool okWidth = false;
bool okHeight = false;
const int width = parts.at(0).trimmed().toInt(&okWidth);
const int height = parts.at(1).trimmed().toInt(&okHeight);
if (!okWidth || !okHeight || width <= 0 || height <= 0) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << "resize: invalid size" << arguments;
return false;
}
// Resize the top-level window: the layout owns the children.
widget->window()->resize(width, height);
QApplication::processEvents();
return true;
}
bool pqWidgetTestEventPlayer::resolveOutputPath(const QString &command, const QString &path, QString &resolved)
{
if (path.isEmpty()) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << command << ": output path is empty";
return false;
}
QString expanded;
if (!expandEnvironment(command, path, expanded)) {
return false;
}
const QFileInfo info(expanded);
const QString directory = info.absolutePath();
if (!QDir().mkpath(directory)) {
qCCritical(PQ_WIDGET_TEST_EVENT_PLAYER) << command << ": could not create directory" << directory;
return false;
}
resolved = info.absoluteFilePath();
return true;
}

View file

@ -0,0 +1,45 @@
#ifndef PQ_WIDGET_TEST_EVENT_PLAYER_H
#define PQ_WIDGET_TEST_EVENT_PLAYER_H
#include "pqWidgetEventPlayer.h"
/**
* Widget-level commands for the UI tests.
*
* pqCockatriceEventPlayer handles cards and zones inside the game scene. This
* player covers the rest of the client: capturing a widget as an image,
* dumping the paths QtTesting addresses widgets by, and sizing the window.
*
* Commands:
* screenshot "<path>"
* grabs the addressed widget and saves it as an image. Parent directories
* are created, and $NAME / ${NAME} in the path expand from the
* environment, so the runner decides where captures land
* dumpTree "<path>"
* writes the addressed widget's subtree with the paths QtTesting
* addresses it by, for writing a test
* resize "WIDTHxHEIGHT"
* resizes the addressed widget's top-level window
*/
class pqWidgetTestEventPlayer : public pqWidgetEventPlayer
{
Q_OBJECT
typedef pqWidgetEventPlayer Superclass;
public:
explicit pqWidgetTestEventPlayer(QObject *parent = nullptr);
using Superclass::playEvent;
bool playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) override;
private:
bool playScreenshot(QObject *object, const QString &arguments);
bool playDumpTree(QObject *object, const QString &arguments);
bool playResize(QObject *object, const QString &arguments);
/** @brief Expands $NAME / ${NAME} and creates the file's parent directory. */
static bool resolveOutputPath(const QString &command, const QString &path, QString &resolved);
};
#endif // PQ_WIDGET_TEST_EVENT_PLAYER_H

View file

@ -62,6 +62,7 @@
#include "pqEventSource.h"
#include "pqEventTypes.h"
#include "pqTestUtility.h"
#include "pq_widget_test_event_player.h"
#include "recording.h"
#endif
@ -692,6 +693,7 @@ MainWindow::MainWindow(QWidget *parent)
testUtility->addEventObserver("xml", new XMLEventObserver(this));
testUtility->addEventSource("xml", new XMLEventSource(this));
testUtility->eventPlayer()->addWidgetEventPlayer(new pqCockatriceEventPlayer(this));
testUtility->eventPlayer()->addWidgetEventPlayer(new pqWidgetTestEventPlayer(this));
#endif
}