Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
blockingtestdialogs.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
3
5
6#include "shellhelpers.h"
7
8#include <QtCore/qfile.h>
9#include <QtCore/qjsonarray.h>
10#include <QtCore/qjsondocument.h>
11#include <QtCore/qpoint.h>
12#include <QtCore/qstringlist.h>
13#include <QtCore/qthread.h>
14
15#include <algorithm>
16#include <chrono>
17#include <cstdio>
18
19QT_BEGIN_NAMESPACE
20
21using namespace Qt::StringLiterals;
22using namespace std::chrono_literals;
23
24namespace {
25
26QRegularExpression caseInsensitivePattern(const QString &pattern)
27{
28 return QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption);
29}
30
31QRect parseUiTestBounds(const QString &bounds)
32{
33 static const QRegularExpression pattern(u"\\‍[(-?\\d+),(-?\\d+)\\‍]\\‍[(-?\\d+),(-?\\d+)\\‍]"_s);
34 const QRegularExpressionMatch match = pattern.match(bounds);
35 if (!match.hasMatch())
36 return {};
37 return QRect(
38 QPoint(match.captured(1).toInt(), match.captured(2).toInt()),
39 QPoint(match.captured(3).toInt(), match.captured(4).toInt()));
40}
41
42QJsonObject dumpScreenLayout(const Hdc &hdc)
43{
44 constexpr auto layoutPathOnDevice = "/data/local/tmp/harmonyostestrunner-layout.json"_L1;
45
46 hdc.shell({ u"uitest"_s, u"dumpLayout"_s, u"-p"_s, layoutPathOnDevice });
47 return QJsonDocument::fromJson(readDeviceFile(hdc, layoutPathOnDevice).toUtf8()).object();
48}
49
50void clickAt(const Hdc &hdc, QPoint point)
51{
52 hdc.shell({ u"uitest"_s, u"uiInput"_s, u"click"_s,
53 QString::number(point.x()), QString::number(point.y()) });
54}
55
56} // namespace
57
58BlockingTestDialogs::BlockingTestDialogs(const QString &testConfigPath, Hdc hdc)
59 : m_hdc(std::move(hdc))
60{
61 loadDeclarations(testConfigPath);
62}
63
64bool BlockingTestDialogs::isEmpty() const
65{
66 return m_dialogs.isEmpty();
67}
68
69void BlockingTestDialogs::loadDeclarations(const QString &testConfigPath)
70{
71 if (testConfigPath.isEmpty())
72 return;
73
74 QFile configFile(testConfigPath);
75 if (!configFile.open(QIODevice::ReadOnly)) {
76 fprintf(stderr, "harmonyostestrunner: cannot read the test config %s\n",
77 qPrintable(testConfigPath));
78 return;
79 }
80
81 QJsonParseError parseError;
82 const QJsonDocument document = QJsonDocument::fromJson(configFile.readAll(), &parseError);
83 if (parseError.error != QJsonParseError::NoError) {
84 fprintf(stderr, "harmonyostestrunner: test config %s is not valid JSON: %s\n",
85 qPrintable(testConfigPath), qPrintable(parseError.errorString()));
86 return;
87 }
88
89 const QJsonArray declaredDialogs =
90 document.object().value(u"blockingTestDialogs"_s).toArray();
91 for (const QJsonValue &value : declaredDialogs) {
92 const std::optional<Dialog> dialog = parseDialog(value.toObject());
93 if (dialog.has_value())
94 m_dialogs.append(*dialog);
95 }
96}
97
98std::optional<BlockingTestDialogs::Dialog>
99BlockingTestDialogs::parseDialog(const QJsonObject &declaration)
100{
101 Dialog dialog;
102 dialog.name = declaration.value(u"name"_s).toString();
103 dialog.prompt = caseInsensitivePattern(declaration.value(u"prompt"_s).toString());
104 const QJsonArray buttons = declaration.value(u"buttons"_s).toArray();
105 for (const QJsonValue &button : buttons)
106 dialog.buttons.append(caseInsensitivePattern(button.toString()));
107 dialog.position = declaration.value(u"position"_s).toString();
108
109 if (dialog.prompt.pattern().isEmpty() || dialog.buttons.isEmpty()) {
110 fprintf(stderr, "harmonyostestrunner: ignoring blocking test dialog '%s': "
111 "prompt and button patterns are required\n", qPrintable(dialog.name));
112 return std::nullopt;
113 }
114 return dialog;
115}
116
117void BlockingTestDialogs::collectTexts(const QJsonObject &node, QList<ScreenText> &texts)
118{
119 const QJsonObject attributes = node.value(u"attributes"_s).toObject();
120 const QString text = attributes.value(u"text"_s).toString().trimmed();
121 if (!text.isEmpty())
122 texts.append({ text, parseUiTestBounds(attributes.value(u"bounds"_s).toString()) });
123
124 const QJsonArray children = node.value(u"children"_s).toArray();
125 for (const QJsonValue &child : children)
126 collectTexts(child.toObject(), texts);
127}
128
129QList<BlockingTestDialogs::ScreenText> BlockingTestDialogs::dumpScreenTexts() const
130{
131 QList<ScreenText> texts;
132 collectTexts(dumpScreenLayout(m_hdc), texts);
133 return texts;
134}
135
136bool BlockingTestDialogs::anyTextMatches(
137 const QList<ScreenText> &texts, const QRegularExpression &pattern)
138{
139 return std::any_of(texts.cbegin(), texts.cend(), [&pattern](const ScreenText &screenText) {
140 return pattern.match(screenText.text).hasMatch();
141 });
142}
143
144std::optional<BlockingTestDialogs::ScreenText>
145BlockingTestDialogs::findButton(
146 const QList<ScreenText> &texts, const QRegularExpression &pattern,
147 const QString &position)
148{
149 QList<ScreenText> candidates;
150 for (const ScreenText &screenText : texts) {
151 const QRegularExpressionMatch match = pattern.match(screenText.text);
152 if (match.hasMatch() && match.capturedLength() == screenText.text.size()
153 && !screenText.bounds.isNull())
154 candidates.append(screenText);
155 }
156 if (candidates.isEmpty())
157 return std::nullopt;
158
159 auto byHorizontalCentre = [](const ScreenText &left, const ScreenText &right) {
160 return left.bounds.center().x() < right.bounds.center().x();
161 };
162 if (position == u"leftmost"_s)
163 return *std::min_element(candidates.cbegin(), candidates.cend(), byHorizontalCentre);
164 return *std::max_element(candidates.cbegin(), candidates.cend(), byHorizontalCentre);
165}
166
167bool BlockingTestDialogs::pressButtons(const Dialog &dialog, const QList<ScreenText> &texts) const
168{
169 constexpr auto delayBetweenButtons = 700ms;
170
171 bool pressedAnything = false;
172 for (const QRegularExpression &buttonPattern : dialog.buttons) {
173 const std::optional<ScreenText> button = findButton(texts, buttonPattern, dialog.position);
174 if (!button.has_value())
175 continue;
176
177 const QPoint centre = button->bounds.center();
178 fprintf(stderr, "harmonyostestrunner: answering blocking test dialog '%s': pressing "
179 "\"%s\" at %d,%d\n", qPrintable(dialog.name), qPrintable(button->text),
180 centre.x(), centre.y());
181 clickAt(m_hdc, centre);
182 pressedAnything = true;
183 QThread::sleep(delayBetweenButtons);
184 }
185 return pressedAnything;
186}
187
188void BlockingTestDialogs::reportUnmatchedDialog(const QList<ScreenText> &texts)
189{
190 constexpr int maximumTextsInReportedDialog = 8;
191
192 if (texts.size() > maximumTextsInReportedDialog)
193 return;
194
195 static const QRegularExpression declineButton =
196 caseInsensitivePattern(u"^(cancel|deny|not now|later)$"_s);
197 if (!anyTextMatches(texts, declineButton))
198 return;
199
200 QStringList onScreen;
201 for (const ScreenText &screenText : texts)
202 onScreen << screenText.text;
203
204 const QString joined = onScreen.join(u" | "_s);
205 if (joined == m_lastReportedUnknownDialog)
206 return;
207
208 m_lastReportedUnknownDialog = joined;
209 fprintf(stderr, "harmonyostestrunner: dialog on screen matches no declaration, leaving it "
210 "alone: %s\n", qPrintable(joined));
211}
212
213bool BlockingTestDialogs::answerVisibleDialog()
214{
215 if (m_dialogs.isEmpty())
216 return false;
217
218 const QList<ScreenText> texts = dumpScreenTexts();
219 if (texts.isEmpty())
220 return false;
221
222 for (const Dialog &dialog : m_dialogs) {
223 if (!anyTextMatches(texts, dialog.prompt))
224 continue;
225
226 const bool pressedAnything = pressButtons(dialog, texts);
227 if (!pressedAnything) {
228 fprintf(stderr, "harmonyostestrunner: blocking test dialog '%s' is on screen but none "
229 "of its buttons matched the declaration\n", qPrintable(dialog.name));
230 }
231 return pressedAnything;
232 }
233
234 reportUnmatchedDialog(texts);
235 return false;
236}
237
238QT_END_NAMESPACE