From 83df5286c59c2a35814269317a27c7d2256bd9e6 Mon Sep 17 00:00:00 2001 From: Wang Zichong Date: Wed, 8 Jul 2026 13:09:00 +0800 Subject: [PATCH] basic qt widgets support --- PLAN.md | 63 ++++- bridge/CMakeLists.txt | 6 + bridge/src/gammaray_session.cpp | 4 + bridge/src/main.cpp | 19 +- bridge/src/property_reader.cpp | 142 ++++++++++ bridge/src/property_reader.h | 16 ++ bridge/src/quickinspector_proxy.cpp | 12 + bridge/src/scenegraph_tools.cpp | 149 +++-------- bridge/src/widget_inspector_proxy.cpp | 49 ++++ bridge/src/widget_inspector_proxy.h | 33 +++ bridge/src/widget_tools.cpp | 364 ++++++++++++++++++++++++++ bridge/src/widget_tools.h | 42 +++ tests/conftest.py | 128 ++++++++- tests/run_widget_tests.sh | 56 ++++ tests/test_widgets.py | 126 +++++++++ tests/testapp/widget_test_app.cpp | 50 ++++ 16 files changed, 1130 insertions(+), 129 deletions(-) create mode 100644 bridge/src/property_reader.cpp create mode 100644 bridge/src/property_reader.h create mode 100644 bridge/src/widget_inspector_proxy.cpp create mode 100644 bridge/src/widget_inspector_proxy.h create mode 100644 bridge/src/widget_tools.cpp create mode 100644 bridge/src/widget_tools.h create mode 100755 tests/run_widget_tests.sh create mode 100644 tests/test_widgets.py create mode 100644 tests/testapp/widget_test_app.cpp diff --git a/PLAN.md b/PLAN.md index ec3d1d3..60460ca 100644 --- a/PLAN.md +++ b/PLAN.md @@ -550,11 +550,62 @@ cmake --build build ``` `run.sh` sets `LD_LIBRARY_PATH` (GammaRay + qtmcp libs) and `QT_PLUGIN_PATH` (qtmcp `mcpserverbackend/libqmcpserverstdio.so`) and `QT_QPA_PLATFORM=offscreen`. +## Qt Widget support (planned) + +GammaRay 的 widget inspector 与 quick inspector 共享同一套底层架构(`PropertyController`、`SelectionModelClient`、`RemoteModel`),大部分 QML 工具的模式可直接复用。 + +### 可提供的 widget 工具 + +**导航(与 QML 类似,模型名不同)** +- `listWidgets()` — 遍历 `com.kdab.GammaRay.WidgetTree` 模型,返回 widget 层次树 +- `selectWidget(address)` — 通过 `SelectionModelClient` 选取 widget,触发 `PropertyController` 填充子模型(属性、attribute 等) +- `listTopLevelWindows()`(已有 `listQuickWindows`,可在 widget 应用中继续使用) + +**属性/attribute(与 QML 共享读取实现)** +- `getWidgetProperties(address)` — 读取同一套 `AggregatedPropertyModel`(通过 `PropertyController`),模型对象名 `com.kdab.GammaRay.Widget.
.properties`,与 `getItemProperties` 共享实现代码 +- `getWidgetAttributes(address)` — 读取 `widgetAttributeModel`(`AttributeModel`),列出 Qt::WidgetAttribute 值 + +**视觉/分析(新功能,需 RemoteViewInterface)** +- `grabWidget(address)` — 通过 `WidgetRemoteView`(`com.kdab.GammaRay.WidgetRemoteView`)抓取 widget 截图,异步图像传输 +- `analyzePainting(address)` — 触发 `PaintAnalyzer` 捕获绘制操作 +- `exportWidgetAsSvg(address)` — 通过 `WidgetInspectorInterface` 导出 SVG +- `exportWidgetAsUiFile(address)` — 导出 Qt Designer .ui 文件 + +**控制** +- `setInputRedirection(enabled)` — 允许将鼠标/键盘事件转发到目标 widget + +### 实现策略 + +| 难度 | 工作项 | 说明 | +|---|---|---| +| 低 | `listWidgets()` / `selectWidget()` | 直接复用 `listQuickItems()` / `selectQuickItem()` 的代码模式,模型名改为 `com.kdab.GammaRay.WidgetTree` | +| 低 | `getWidgetProperties()` | 与 `getItemProperties()` 完全一样,都读 `AggregatedPropertyModel`,可提取共享函数 | +| 中 | `getWidgetAttributes()` | 需要读取 `widgetAttributeModel`(`PropertyController` 子模型),模式与 `getMaterialProperties()` 类似 | +| 中 | `WidgetInspectorInterface` 代理 | IID `com.kdab.GammaRay.WidgetInspector`,用于 `export*`/`setInputRedirection` 等方法,类似 `QuickInspectorProxy` | +| 高 | `grabWidget()` / `analyzePainting()` | 需要 `RemoteViewInterface` 的 `TransferImage` 异步路径,模式与 `MaterialExtensionInterface` 的 `gotShader` 信号类似 | + +### 与 QML 工具的关键区别 + +- **选取机制不同**:QML 用 `QuickItemModel.selection`,Widget 用 `WidgetTree.selection` → 不同的 `PropertyController` 实例 +- **模型对象名不同**:property model 对象名为 `com.kdab.GammaRay.QuickItem.
.properties` vs `com.kdab.GammaRay.Widget.
.properties` +- **Attribute 模型是 widget 独有的**:QML 项没有 `Qt::WidgetAttribute` 的概念 +- **Widget 没有 SceneGraph**:不需要 `getNodeVertices`/`getMaterialShaders` 等 SG 工具 +- **Widget 有截图和绘制分析**:QML 的 texture grab 类似但不同 + ## Next steps -1. **Optimize test suite runtime** — share bridge across tests or reduce settle delays to bring ~8min down to ~2-3min. -2. **Test `getShaderSource` async path end-to-end** — needs a QML app with real shaders (current test app uses software renderer where GeometryNodes have `nullptr` geometry). -3. **Test geometry/material tools against a QML app with real QSGGeometry data** — current test app `relayout.qml` uses software renderer so `hasGeometry: false` and empty sub-models are correct behavior. -4. **Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`. Follows the `MaterialExtensionInterface` proxy pattern. -5. **Fix `listScenegraphNodes` first-call fill stability** — single-call instead of caller needing a second call. -6. **Build `--connect` startup flag integration** with systemd service for auto-attach during development. +### QML SceneGraph 工具(当前阶段) + +1. **Test `getShaderSource` async path end-to-end** — needs a QML app with real shaders (current test app uses software renderer where GeometryNodes have `nullptr` geometry). +2. **Test geometry/material tools against a QML app with real QSGGeometry data** — current test app `relayout.qml` uses software renderer so `hasGeometry: false` and empty sub-models are correct behavior. +3. **Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`. Follows the `MaterialExtensionInterface` proxy pattern. +4. **Fix `listScenegraphNodes` first-call fill stability** — single-call instead of caller needing a second call. +5. **Build `--connect` startup flag integration** with systemd service for auto-attach during development. + +### Qt Widget 支持(未来阶段) + +1. 创建 widget_tools.{h,cpp},注册 `listWidgets` / `selectWidget` / `getWidgetProperties` / `getWidgetAttributes` MCP 工具 +2. 从 `scenegraph_tools.cpp` 提取共享的 `AggregatedPropertyModel` 读取函数,供 QML 和 widget 工具共用 +3. 创建 `WidgetInspectorInterface` 代理(类似 `QuickInspectorProxy`),处理 `export*`/`setInputRedirection` +4. 实现 `RemoteViewInterface` 异步图像传输路径(grabWidget/analyzePainting) +5. 添加 widget 测试应用(简单 QWidget 窗口 + 按钮/布局),编写 widget 工具的集成测试 diff --git a/bridge/CMakeLists.txt b/bridge/CMakeLists.txt index 8aeb7de..08cc2e3 100644 --- a/bridge/CMakeLists.txt +++ b/bridge/CMakeLists.txt @@ -48,6 +48,12 @@ add_executable(qml-sg-mcp-bridge src/quickinspector_proxy.cpp src/quickinspector_proxy.h src/quickinspector_types.h + src/property_reader.cpp + src/property_reader.h + src/widget_tools.cpp + src/widget_tools.h + src/widget_inspector_proxy.cpp + src/widget_inspector_proxy.h ) target_link_libraries(qml-sg-mcp-bridge PRIVATE diff --git a/bridge/src/gammaray_session.cpp b/bridge/src/gammaray_session.cpp index 9137094..2336f83 100644 --- a/bridge/src/gammaray_session.cpp +++ b/bridge/src/gammaray_session.cpp @@ -8,6 +8,7 @@ #include "gammaray_session.h" #include "quickinspector_proxy.h" +#include "widget_inspector_proxy.h" #include #include @@ -39,6 +40,9 @@ GammaRaySession::GammaRaySession(QObject *parent) // GammaRay's Endpoint cannot dispatch the signal. GammaRay::registerQuickInspectorProxy(); + // Register a minimal WidgetInspectorInterface proxy similarly. + GammaRay::registerWidgetInspectorProxy(); + // Pre-fetch the remote models we expose as tools. ObjectBroker::model() // lazily creates a RemoteModel which then syncs asynchronously from the // probe (~1-2s for the first rows). Requesting them now at ready() means diff --git a/bridge/src/main.cpp b/bridge/src/main.cpp index 2f4ac8e..7a41c7e 100644 --- a/bridge/src/main.cpp +++ b/bridge/src/main.cpp @@ -16,6 +16,7 @@ #include "gammaray_session.h" #include "scenegraph_tools.h" +#include "widget_tools.h" #include @@ -70,9 +71,9 @@ int main(int argc, char **argv) QMcpServer server(QStringLiteral("stdio")); server.setInstructions(QStringLiteral( - "QML SceneGraph introspection via GammaRay. Connects to a GammaRay probe " - "injected into a Qt/QML application and exposes the scene graph, items, " - "geometry, materials and shaders as tools.")); + "QML SceneGraph & Qt Widget introspection via GammaRay. Connects to a " + "GammaRay probe injected into a Qt/QML application and exposes the scene " + "graph, QML items, widgets, geometry, materials and shaders as tools.")); SceneGraphTools tools(&session, &server); server.registerToolSet(&tools, { @@ -115,8 +116,20 @@ int main(int argc, char **argv) { QStringLiteral("setRenderMode/mode"), QStringLiteral("Render mode: NormalRendering, VisualizeClipping, VisualizeOverdraw, VisualizeBatches, VisualizeChanges, or VisualizeTraces") }, { QStringLiteral("setSlowMode"), QStringLiteral("Toggle slow animations mode (renders continuously instead of on-demand)") }, { QStringLiteral("setSlowMode/enabled"), QStringLiteral("true to enable slow mode, false to disable") }, + // Widget tree navigation + { QStringLiteral("listWidgets"), QStringLiteral("List the QWidget hierarchy (widget types, names, visibility)") }, + // Widget selection + properties + { QStringLiteral("selectWidget"), QStringLiteral("Select a widget by address (from listWidgets) so widget properties populate for it") }, + { QStringLiteral("selectWidget/address"), QStringLiteral("Widget address from listWidgets") }, + { QStringLiteral("getWidgetProperties"), QStringLiteral("Get all Q_PROPERTY values for a selected widget (geometry, font, palette, etc.)") }, + { QStringLiteral("getWidgetProperties/address"), QStringLiteral("Widget address (from listWidgets)") }, + { QStringLiteral("getWidgetAttributes"), QStringLiteral("Get Qt::WidgetAttribute flags (acceptDrops, enabled, etc.) for a selected widget") }, + { QStringLiteral("getWidgetAttributes/address"), QStringLiteral("Widget address (from listWidgets)") }, }); + WidgetTools widgetTools(&session, &server); + server.registerToolSet(&widgetTools, {}); + QObject::connect(&server, &QMcpServer::finished, &app, &QCoreApplication::quit); if (probeUrl.isValid()) { diff --git a/bridge/src/property_reader.cpp b/bridge/src/property_reader.cpp new file mode 100644 index 0000000..ef48753 --- /dev/null +++ b/bridge/src/property_reader.cpp @@ -0,0 +1,142 @@ +/* + property_reader.cpp — shared AggregatedPropertyModel reader. + Extracted from scenegraph_tools.cpp getItemProperties(). + + SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include "property_reader.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// Poll until column-0 data arrives for at least one row (RemoteModel lazy-fetch). +static void waitForPropertyData(QAbstractItemModel *m, int rows, int timeoutMs) +{ + QEventLoop loop; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + bool gotData = false; + while (std::chrono::steady_clock::now() < deadline && !gotData) { + for (int r = 0; r < rows; ++r) { + const auto val = m->data(m->index(r, 0), Qt::DisplayRole).toString(); + if (!val.isEmpty() && val != QStringLiteral("Loading...")) { + gotData = true; + break; + } + } + if (!gotData) { + QTimer::singleShot(200, &loop, &QEventLoop::quit); + loop.exec(); + } + } +} + +QJsonObject readAggregatedPropertyModel(QAbstractItemModel *m) +{ + if (!m) + return {}; + + // Wait for rows to become available (RemoteModel lazy-fetch). + { + QEventLoop loop; + int waited = 0; + const int step = 150; + while (waited < 3000) { + if (m->rowCount() > 0) + break; + QTimer::singleShot(step, &loop, &QEventLoop::quit); + loop.exec(); + waited += step; + } + } + + const int rows = m->rowCount(); + if (rows == 0) + return {}; + + waitForPropertyData(m, rows, 5000); + + const int actualRows = m->rowCount(); + const int cols = m->columnCount(); + + // Pre-fetch children for grouped properties. + for (int r = 0; r < actualRows; ++r) + m->rowCount({ m->index(r, 0) }); + + if (actualRows > 0) { + QEventLoop l2; + QTimer::singleShot(500, &l2, &QEventLoop::quit); + l2.exec(); + } + + for (int r = 0; r < actualRows; ++r) { + const QModelIndex idx0 = m->index(r, 0); + if (m->hasChildren(idx0) && m->canFetchMore(idx0)) + m->fetchMore(idx0); + } + + if (actualRows > 0) { + QEventLoop l2b; + QTimer::singleShot(500, &l2b, &QEventLoop::quit); + l2b.exec(); + } + + QJsonObject simpleProps; + QJsonObject groups; + + for (int r = 0; r < actualRows; ++r) { + const QString name = m->data(m->index(r, 0), Qt::DisplayRole).toString(); + const QString val = m->data(m->index(r, 1), Qt::DisplayRole).toString(); + const QString ptype = cols > 2 ? m->data(m->index(r, 2), Qt::DisplayRole).toString() : QString(); + + QJsonObject prop; + prop.insert(QStringLiteral("value"), val); + prop.insert(QStringLiteral("type"), ptype); + + const QModelIndex idx0 = m->index(r, 0); + if (m->hasChildren(idx0)) { + const int childRows = m->rowCount(idx0); + if (childRows > 0) { + for (int cr = 0; cr < childRows; ++cr) { + m->data(m->index(cr, 0, idx0), Qt::DisplayRole); + m->data(m->index(cr, 1, idx0), Qt::DisplayRole); + } + { + QEventLoop l3; + QTimer::singleShot(500, &l3, &QEventLoop::quit); + l3.exec(); + } + QJsonObject children; + for (int cr = 0; cr < childRows; ++cr) { + const QString cname = m->data(m->index(cr, 0, idx0), Qt::DisplayRole).toString(); + const QString cval = m->data(m->index(cr, 1, idx0), Qt::DisplayRole).toString(); + const QString ctype = cols > 2 ? m->data(m->index(cr, 2, idx0), Qt::DisplayRole).toString() : QString(); + if (cname.isEmpty() || cname == QStringLiteral("Loading...")) + continue; + QJsonObject child; + child.insert(QStringLiteral("value"), cval); + child.insert(QStringLiteral("type"), ctype); + children.insert(cname, child); + } + if (!children.isEmpty()) + prop.insert(QStringLiteral("children"), children); + } + groups.insert(name, prop); + } else { + simpleProps.insert(name, prop); + } + } + + QJsonObject out; + out.insert(QStringLiteral("properties"), simpleProps); + if (!groups.isEmpty()) + out.insert(QStringLiteral("groups"), groups); + return out; +} \ No newline at end of file diff --git a/bridge/src/property_reader.h b/bridge/src/property_reader.h new file mode 100644 index 0000000..7bef1ab --- /dev/null +++ b/bridge/src/property_reader.h @@ -0,0 +1,16 @@ +/* + property_reader.h — shared AggregatedPropertyModel reader for QML items and widgets. + + SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#ifndef PROPERTY_READER_H +#define PROPERTY_READER_H + +class QAbstractItemModel; +class QJsonObject; + +QJsonObject readAggregatedPropertyModel(QAbstractItemModel *m); + +#endif // PROPERTY_READER_H \ No newline at end of file diff --git a/bridge/src/quickinspector_proxy.cpp b/bridge/src/quickinspector_proxy.cpp index 7b66d81..9013d6a 100644 --- a/bridge/src/quickinspector_proxy.cpp +++ b/bridge/src/quickinspector_proxy.cpp @@ -7,7 +7,9 @@ #include "quickinspector_proxy.h" +#include #include +#include #include @@ -32,6 +34,16 @@ void registerQuickInspectorProxy() static bool registered = false; if (registered) return; + + // Only register if the QuickInspector plugin is actually active in the + // target process. Check via QuickWindowModel address. + auto *ep = Endpoint::instance(); + if (!ep) + return; + if (ep->objectAddress(QStringLiteral("com.kdab.GammaRay.QuickWindowModel")) + == Protocol::InvalidObjectAddress) + return; + registered = true; auto *proxy = new QuickInspectorProxy(); diff --git a/bridge/src/scenegraph_tools.cpp b/bridge/src/scenegraph_tools.cpp index fc1afa5..6df2fe2 100644 --- a/bridge/src/scenegraph_tools.cpp +++ b/bridge/src/scenegraph_tools.cpp @@ -8,6 +8,7 @@ #include "scenegraph_tools.h" #include "gammaray_session.h" #include "material_interface.h" +#include "property_reader.h" #include "quickinspector_types.h" #include @@ -40,6 +41,19 @@ static constexpr int kIsCoordinateRole = 257; static constexpr int kDrawingModeRole = 257; static constexpr int kRenderRole = 258; +// Returns a non-empty error string if the QuickInspector is not active. +// Guards QML-specific tools against widget-only target processes. +static QString ensureQuickInspector() +{ + auto *ep = GammaRay::Endpoint::instance(); + if (!ep) + return QStringLiteral("no GammaRay endpoint"); + if (ep->objectAddress(QString::fromUtf8(kQuickInspectorIface)) + == GammaRay::Protocol::InvalidObjectAddress) + return QStringLiteral("QuickInspector not active — target app has no QQuickWindow"); + return {}; +} + // --- async helpers (from earlier scaffold) --- static void waitForData(const QAbstractItemModel *m, int column, int timeoutMs) @@ -324,6 +338,8 @@ QString SceneGraphTools::listQuickWindows() const { if (const QString e = ensureSession(); !e.isEmpty()) return e; + if (const QString e = ensureQuickInspector(); !e.isEmpty()) + return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact)); auto *m = m_session->model(QString::fromUtf8(kQuickWindowModel)); if (!m) @@ -348,6 +364,8 @@ QString SceneGraphTools::selectQuickWindow(int index) const { if (const QString e = ensureSession(); !e.isEmpty()) return e; + if (const QString e = ensureQuickInspector(); !e.isEmpty()) + return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact)); auto *ep = GammaRay::Endpoint::instance(); if (!ep) @@ -367,6 +385,8 @@ QString SceneGraphTools::listQuickItems() const { if (const QString e = ensureSession(); !e.isEmpty()) return e; + if (const QString e = ensureQuickInspector(); !e.isEmpty()) + return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact)); auto *m = m_session->model(QString::fromUtf8(kQuickItemModel)); if (!m) @@ -384,6 +404,8 @@ QString SceneGraphTools::listScenegraphNodes() const { if (const QString e = ensureSession(); !e.isEmpty()) return e; + if (const QString e = ensureQuickInspector(); !e.isEmpty()) + return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact)); auto *m = m_session->model(QString::fromUtf8(kQuickSceneGraphModel)); if (!m) @@ -444,6 +466,8 @@ QString SceneGraphTools::ensureNodeSelected(const QString &address) const { if (const QString e = ensureSession(); !e.isEmpty()) return e; + if (const QString e = ensureQuickInspector(); !e.isEmpty()) + return e; if (address.isEmpty()) return QString::fromUtf8(QJsonDocument(errorJson( @@ -739,6 +763,8 @@ QString SceneGraphTools::ensureItemSelected(const QString &address) const { if (const QString e = ensureSession(); !e.isEmpty()) return e; + if (const QString e = ensureQuickInspector(); !e.isEmpty()) + return e; if (address.isEmpty()) return QString::fromUtf8(QJsonDocument(errorJson( @@ -840,127 +866,12 @@ QString SceneGraphTools::getItemProperties(const QString &address) const return QString::fromUtf8(QJsonDocument(errorJson( QStringLiteral("item property model not available"))).toJson(QJsonDocument::Compact)); - // Item property properties model has 4 columns: - // 0 = property name, 1 = value (display string), 2 = type name, 3 = class name - waitForRows(m, 3000); - - const int rows = m->rowCount(); - if (rows == 0) + QJsonObject out = readAggregatedPropertyModel(m); + if (out.isEmpty()) return QString::fromUtf8(QJsonDocument(errorJson( QStringLiteral("no properties available (did you select a valid QML item?)"))) .toJson(QJsonDocument::Compact)); - // The RemoteModel uses lazy-fetch: data() triggers a fetch and returns - // "Loading..." until the fetch response arrives on the event loop. - // Poll until column 0 data arrives for at least one row. - { - QEventLoop loop; - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); - bool gotData = false; - while (std::chrono::steady_clock::now() < deadline && !gotData) { - for (int r = 0; r < rows; ++r) { - const QString val = m->data(m->index(r, 0), Qt::DisplayRole).toString(); - if (!val.isEmpty() && val != QStringLiteral("Loading...")) { - gotData = true; - break; - } - } - if (!gotData) { - QTimer::singleShot(200, &loop, &QEventLoop::quit); - loop.exec(); - } - } - } - -// Re-read rowCount after polling (more rows may have arrived) - const int actualRows = m->rowCount(); - const int cols = m->columnCount(); - - QJsonObject out; - QStringList headers; - for (int c = 0; c < cols; ++c) - headers.append(m->headerData(c, Qt::Horizontal, Qt::DisplayRole).toString()); - - // Pre-fetch children for grouped properties. RemoteModel uses lazy-fetch: - // rowCount(parent) triggers an async request and returns 0 until the reply - // arrives on the event loop. After polling, hasChildren() works correctly. - for (int r = 0; r < actualRows; ++r) { - m->rowCount(m->index(r, 0)); - } - // Poll to let row count responses arrive - if (actualRows > 0) { - QEventLoop l2; - QTimer::singleShot(500, &l2, &QEventLoop::quit); - l2.exec(); - } - // Second pass: ensure child data is fetched for rows that have children - for (int r = 0; r < actualRows; ++r) { - const QModelIndex idx0 = m->index(r, 0); - if (m->hasChildren(idx0) && m->canFetchMore(idx0)) - m->fetchMore(idx0); - } - if (actualRows > 0) { - QEventLoop l2b; - QTimer::singleShot(500, &l2b, &QEventLoop::quit); - l2b.exec(); - } - - // Top-level properties (simple Q_PROPERTY values like x, y, width, height...) - QJsonObject simpleProps; - // Nested property groups (anchors, transform, etc.) will have children. - QJsonObject groups; - for (int r = 0; r < actualRows; ++r) { - const QString name = m->data(m->index(r, 0), Qt::DisplayRole).toString(); - const QString val = m->data(m->index(r, 1), Qt::DisplayRole).toString(); - const QString ptype = cols > 2 ? m->data(m->index(r, 2), Qt::DisplayRole).toString() : QString(); - - QJsonObject prop; - prop.insert(QStringLiteral("value"), val); - prop.insert(QStringLiteral("type"), ptype); - - const QModelIndex idx0 = m->index(r, 0); - if (m->hasChildren(idx0)) { - // For grouped properties (anchors, transform, etc.), collect children. - // Children also use lazy-fetch, so poll if needed. - const int childRows = m->rowCount(idx0); - if (childRows > 0) { - // Trigger child data fetches first - for (int cr = 0; cr < childRows; ++cr) { - m->data(m->index(cr, 0, idx0), Qt::DisplayRole); - m->data(m->index(cr, 1, idx0), Qt::DisplayRole); - } - // Poll to let child data arrive - { - QEventLoop l3; - QTimer::singleShot(500, &l3, &QEventLoop::quit); - l3.exec(); - } - // Read actual child data - QJsonObject children; - for (int cr = 0; cr < childRows; ++cr) { - const QString cname = m->data(m->index(cr, 0, idx0), Qt::DisplayRole).toString(); - const QString cval = m->data(m->index(cr, 1, idx0), Qt::DisplayRole).toString(); - const QString ctype = cols > 2 ? m->data(m->index(cr, 2, idx0), Qt::DisplayRole).toString() : QString(); - if (cname.isEmpty() || cname == QStringLiteral("Loading...")) - continue; - QJsonObject child; - child.insert(QStringLiteral("value"), cval); - child.insert(QStringLiteral("type"), ctype); - children.insert(cname, child); - } - if (!children.isEmpty()) - prop.insert(QStringLiteral("children"), children); - } - groups.insert(name, prop); - } else { - simpleProps.insert(name, prop); - } - } - - out.insert(QStringLiteral("properties"), simpleProps); - if (!groups.isEmpty()) - out.insert(QStringLiteral("groups"), groups); - return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); } @@ -968,6 +879,8 @@ QString SceneGraphTools::setRenderMode(const QString &mode) const { if (const QString e = ensureSession(); !e.isEmpty()) return e; + if (const QString e = ensureQuickInspector(); !e.isEmpty()) + return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact)); auto *ep = GammaRay::Endpoint::instance(); if (!ep) @@ -1009,6 +922,8 @@ QString SceneGraphTools::setSlowMode(bool enabled) const { if (const QString e = ensureSession(); !e.isEmpty()) return e; + if (const QString e = ensureQuickInspector(); !e.isEmpty()) + return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact)); auto *ep = GammaRay::Endpoint::instance(); if (!ep) diff --git a/bridge/src/widget_inspector_proxy.cpp b/bridge/src/widget_inspector_proxy.cpp new file mode 100644 index 0000000..14b4254 --- /dev/null +++ b/bridge/src/widget_inspector_proxy.cpp @@ -0,0 +1,49 @@ +/* + widget_inspector_proxy.cpp — Minimal WidgetInspectorInterface client proxy. + + SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include "widget_inspector_proxy.h" + +#include +#include +#include + +#include + +namespace GammaRay { + +WidgetInspectorProxy::WidgetInspectorProxy(QObject *parent) + : QObject(parent) +{ +} + +void registerWidgetInspectorProxy() +{ + static bool registered = false; + if (registered) + return; + + auto *ep = Endpoint::instance(); + if (!ep) + return; + + // Only register if the widget inspector plugin is actually active in the + // target process. The QuickInspector plugin activates for any QQuickWindow + // app, but WidgetInspector requires QWidget/QApplication. Check via the + // WidgetTree model address — if it's valid, the plugin is loaded. + if (ep->objectAddress(QStringLiteral("com.kdab.GammaRay.WidgetTree")) + == Protocol::InvalidObjectAddress) + return; // widget inspector not active — skip registration + + registered = true; + + auto *proxy = new WidgetInspectorProxy(); + ObjectBroker::registerObject( + QStringLiteral("com.kdab.GammaRay.WidgetInspector"), + proxy); +} + +} // namespace GammaRay \ No newline at end of file diff --git a/bridge/src/widget_inspector_proxy.h b/bridge/src/widget_inspector_proxy.h new file mode 100644 index 0000000..02ac21d --- /dev/null +++ b/bridge/src/widget_inspector_proxy.h @@ -0,0 +1,33 @@ +/* + widget_inspector_proxy.h — Minimal WidgetInspectorInterface client proxy. + + SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors + SPDX-License-Identifier: GPL-2.0-or-later + + The probe emits WidgetInspectorInterface signals without a matching slot. + This proxy registers with the IID so GammaRay's Endpoint can dispatch signals. +*/ + +#ifndef WIDGET_INSPECTOR_PROXY_H +#define WIDGET_INSPECTOR_PROXY_H + +#include + +namespace GammaRay { + +class WidgetInspectorProxy : public QObject +{ + Q_OBJECT + Q_CLASSINFO("IID", "com.kdab.GammaRay.WidgetInspector") +public: + explicit WidgetInspectorProxy(QObject *parent = nullptr); + +public slots: + void featuresChanged() {} +}; + +void registerWidgetInspectorProxy(); + +} // namespace GammaRay + +#endif // WIDGET_INSPECTOR_PROXY_H \ No newline at end of file diff --git a/bridge/src/widget_tools.cpp b/bridge/src/widget_tools.cpp new file mode 100644 index 0000000..6978215 --- /dev/null +++ b/bridge/src/widget_tools.cpp @@ -0,0 +1,364 @@ +/* + widget_tools.cpp — Qt Widget introspection tools via GammaRay. + + SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include "widget_tools.h" +#include "gammaray_session.h" +#include "property_reader.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +static const char *kWidgetTreeModel = "com.kdab.GammaRay.WidgetTree"; +static const char *kWidgetBaseName = "com.kdab.GammaRay.WidgetInspector"; + +static constexpr int kWidgetFlagsRole = 261; + +static QJsonObject errorJson(const QString &msg) +{ + return { { QStringLiteral("error"), msg } }; +} + +static void settle(QEventLoop &loop, int ms) +{ + QTimer::singleShot(ms, &loop, &QEventLoop::quit); + loop.exec(); +} + +static void primeTree(const QAbstractItemModel *m, const QModelIndex &parent, int depth) +{ + if (depth <= 0) + return; + for (int r = 0; r < m->rowCount(parent); ++r) { + const QModelIndex idx = m->index(r, 0, parent); + m->data(idx, Qt::DisplayRole); + m->data(m->index(r, 1, parent), Qt::DisplayRole); + if (m->hasChildren(idx)) + primeTree(m, idx, depth - 1); + } +} + +static int countLoading(const QAbstractItemModel *m, const QModelIndex &parent, int depth) +{ + if (depth <= 0) + return 0; + int count = 0; + for (int r = 0; r < m->rowCount(parent); ++r) { + const QModelIndex idx = m->index(r, 0, parent); + if (m->data(idx, Qt::DisplayRole).toString() == QLatin1String("Loading...")) + ++count; + if (m->hasChildren(idx)) + count += countLoading(m, idx, depth - 1); + } + return count; +} + +static void primeAndWait(const QAbstractItemModel *m, int maxRounds, int settleMs, int quietMs, + int finalSettleMs = 2000) +{ + QEventLoop loop; + for (int round = 0; round < maxRounds; ++round) { + bool signalActivity = false; + const auto dcConn = QObject::connect(m, &QAbstractItemModel::dataChanged, + [&signalActivity]() { signalActivity = true; }); + const auto riConn = QObject::connect(m, &QAbstractItemModel::rowsInserted, + [&signalActivity]() { signalActivity = true; }); + + primeTree(m, QModelIndex(), 16); + + int quiet = 0; + while (quiet < quietMs) { + signalActivity = false; + QTimer::singleShot(settleMs, &loop, &QEventLoop::quit); + loop.exec(); + quiet += settleMs; + if (signalActivity) + quiet = 0; + } + QObject::disconnect(dcConn); + QObject::disconnect(riConn); + + if (countLoading(m, QModelIndex(), 16) == 0) + return; + } + + if (countLoading(m, QModelIndex(), 16) > 0) { + settle(loop, finalSettleMs); + primeTree(m, QModelIndex(), 16); + settle(loop, finalSettleMs / 2); + } +} + +static QModelIndex findWidgetInTree(const QAbstractItemModel *m, const QModelIndex &parent, + const QString &address, int depth, QEventLoop &loop) +{ + if (depth <= 0) + return {}; + for (int r = 0; r < m->rowCount(parent); ++r) { + const QModelIndex idx = m->index(r, 0, parent); + const QString addr = m->data(idx, Qt::DisplayRole).toString(); + if (addr == address) + return idx; + if (addr == QLatin1String("Loading...")) { + settle(loop, 300); + if (m->data(idx, Qt::DisplayRole).toString() == address) + return idx; + } + if (m->hasChildren(idx)) { + m->rowCount(idx); + settle(loop, 200); + const auto found = findWidgetInTree(m, idx, address, depth - 1, loop); + if (found.isValid()) + return found; + } + } + return {}; +} + +static QJsonArray walkWidgetChildren(const QAbstractItemModel *m, const QModelIndex &parent, int depth) +{ + QJsonArray out; + if (depth <= 0) + return out; + for (int r = 0; r < m->rowCount(parent); ++r) { + const QModelIndex idx = m->index(r, 0, parent); + const QString name = m->data(idx, Qt::DisplayRole).toString(); + const QModelIndex typeIdx = m->index(r, 1, parent); + const QString type = m->data(typeIdx, Qt::DisplayRole).toString(); + const int flags = m->data(idx, kWidgetFlagsRole).toInt(); + QJsonObject node; + node.insert(QStringLiteral("name"), name); + node.insert(QStringLiteral("type"), type); + if (flags & 1) + node.insert(QStringLiteral("invisible"), true); + if (m->hasChildren(idx)) + node.insert(QStringLiteral("children"), walkWidgetChildren(m, idx, depth - 1)); + out.append(node); + } + return out; +} + +// --- WidgetTools --- + +WidgetTools::WidgetTools(GammaRaySession *session, QObject *parent) + : QObject(parent) + , m_session(session) +{ +} + +QString WidgetTools::ensureSession() const +{ + if (!m_session) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("no session"))).toJson(QJsonDocument::Compact)); + if (m_session->lastUrl().isValid()) + m_session->ensureConnected(6000); + if (m_session->isReady()) + return {}; + QJsonObject err = errorJson( + m_session->lastUrl().isValid() + ? QStringLiteral("not connected to probe (last error: %1; call connectProbe to retry)") + .arg(m_session->lastError().isEmpty() ? QStringLiteral("timeout") : m_session->lastError()) + : QStringLiteral("no probe URL configured — call connectProbe(host, port) first")); + err.insert(QStringLiteral("state"), m_session->stateString()); + return QString::fromUtf8(QJsonDocument(err).toJson(QJsonDocument::Compact)); +} + +void WidgetTools::waitForRows(QAbstractItemModel *m, int timeoutMs) const +{ + if (!m) + return; + QEventLoop loop; + int waited = 0; + const int step = 150; + while (waited < timeoutMs) { + if (m->rowCount() > 0) + return; + QTimer::singleShot(step, &loop, &QEventLoop::quit); + loop.exec(); + waited += step; + } +} + +QModelIndex WidgetTools::findWidgetByAddress(QAbstractItemModel *m, const QString &address) const +{ + if (!m || address.isEmpty()) + return {}; + + primeAndWait(m, 6, 300, 500); + + QEventLoop loop; + const auto idx = findWidgetInTree(m, QModelIndex(), address, 16, loop); + if (idx.isValid()) + return idx; + + for (int round = 0; round < 3; ++round) { + primeAndWait(m, 2, 300, 500); + const auto idx2 = findWidgetInTree(m, QModelIndex(), address, 16, loop); + if (idx2.isValid()) + return idx2; + } + return {}; +} + +QString WidgetTools::ensureWidgetSelected(const QString &address) const +{ + if (const QString e = ensureSession(); !e.isEmpty()) + return e; + + if (address.isEmpty()) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("address is required"))).toJson(QJsonDocument::Compact)); + + if (address == m_selectedWidgetAddress) + return {}; + + auto *treeModel = m_session->model(QString::fromUtf8(kWidgetTreeModel)); + if (!treeModel) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("WidgetTree model not available (no QWidget app attached?)"))) + .toJson(QJsonDocument::Compact)); + + const QModelIndex idx = findWidgetByAddress(treeModel, address); + if (!idx.isValid()) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("widget %1 not found in widget tree (still loading?)").arg(address))) + .toJson(QJsonDocument::Compact)); + + auto *selModel = m_session->selectionModel(treeModel); + + auto *propModel = m_session->model(QStringLiteral("%1.properties").arg(QString::fromUtf8(kWidgetBaseName))); + + QEventLoop loop; + settle(loop, 300); + + if (selModel && idx.isValid()) + selModel->select(idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows | QItemSelectionModel::Current); + + settle(loop, 2000); + + m_selectedWidgetAddress = address; + + if (propModel) { + propModel->rowCount(); + settle(loop, 1500); + } + + return {}; +} + +QString WidgetTools::listWidgets() const +{ + if (const QString e = ensureSession(); !e.isEmpty()) + return e; + + auto *m = m_session->model(QString::fromUtf8(kWidgetTreeModel)); + if (!m) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("WidgetTree model not available (no QWidget app attached?)"))) + .toJson(QJsonDocument::Compact)); + + primeAndWait(m, 6, 300, 500); + + const QJsonArray tree = walkWidgetChildren(m, QModelIndex(), 16); + return QString::fromUtf8(QJsonDocument(tree).toJson(QJsonDocument::Compact)); +} + +QString WidgetTools::selectWidget(const QString &address) const +{ + m_selectedWidgetAddress.clear(); + const QString e = ensureWidgetSelected(address); + if (!e.isEmpty()) + return e; + + auto *treeModel = m_session->model(QString::fromUtf8(kWidgetTreeModel)); + QJsonObject out; + if (!treeModel) { + out.insert(QStringLiteral("error"), QStringLiteral("WidgetTree model gone")); + return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); + } + const QModelIndex idx = findWidgetByAddress(treeModel, address); + if (!idx.isValid()) { + out.insert(QStringLiteral("error"), QStringLiteral("address %1 not found after selection").arg(address)); + return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); + } + const QModelIndex typeIdx = treeModel->index(idx.row(), 1, idx.parent()); + const QString type = treeModel->data(typeIdx, Qt::DisplayRole).toString(); + int propertyCount = 0; + auto *propModel = m_session->model(QStringLiteral("%1.properties").arg(QString::fromUtf8(kWidgetBaseName))); + if (propModel) + propertyCount = propModel->rowCount(); + + out.insert(QStringLiteral("selected"), address); + out.insert(QStringLiteral("type"), type); + out.insert(QStringLiteral("propertyCount"), propertyCount); + return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); +} + +QString WidgetTools::getWidgetProperties(const QString &address) const +{ + const QString e = ensureWidgetSelected(address); + if (!e.isEmpty()) + return e; + + auto *m = m_session->model(QStringLiteral("%1.properties").arg(QString::fromUtf8(kWidgetBaseName))); + if (!m) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("widget property model not available"))).toJson(QJsonDocument::Compact)); + + QJsonObject out = readAggregatedPropertyModel(m); + if (out.isEmpty()) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("no properties available (did you select a valid widget?)"))) + .toJson(QJsonDocument::Compact)); + + return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); +} + +QString WidgetTools::getWidgetAttributes(const QString &address) const +{ + const QString e = ensureWidgetSelected(address); + if (!e.isEmpty()) + return e; + + auto *m = m_session->model(QStringLiteral("%1.widgetAttributeModel").arg(QString::fromUtf8(kWidgetBaseName))); + if (!m) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("widget attribute model not available"))).toJson(QJsonDocument::Compact)); + + waitForRows(m, 3000); + + const int rows = m->rowCount(); + if (rows == 0) + return QString::fromUtf8(QJsonDocument(errorJson( + QStringLiteral("no widget attributes (did you select a valid widget?)"))) + .toJson(QJsonDocument::Compact)); + + QJsonObject attrs; + for (int r = 0; r < rows; ++r) { + const QString name = m->data(m->index(r, 0), Qt::DisplayRole).toString(); + const bool enabled = m->data(m->index(r, 1), Qt::DisplayRole).toBool(); + const QString val = m->data(m->index(r, 1), Qt::DisplayRole).toString(); + attrs.insert(name, QJsonObject{ + { QStringLiteral("enabled"), enabled }, + { QStringLiteral("value"), val }, + }); + } + + QJsonObject out; + out.insert(QStringLiteral("attributes"), attrs); + out.insert(QStringLiteral("count"), rows); + return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact)); +} \ No newline at end of file diff --git a/bridge/src/widget_tools.h b/bridge/src/widget_tools.h new file mode 100644 index 0000000..381faa0 --- /dev/null +++ b/bridge/src/widget_tools.h @@ -0,0 +1,42 @@ +/* + widget_tools.h — Q_INVOKABLE methods for Qt Widget introspection via GammaRay. + + SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#ifndef WIDGET_TOOLS_H +#define WIDGET_TOOLS_H + +#include +#include +#include + +class GammaRaySession; + +class WidgetTools : public QObject +{ + Q_OBJECT +public: + explicit WidgetTools(GammaRaySession *session, QObject *parent = nullptr); + + // --- Widget tree navigation --- + Q_INVOKABLE QString listWidgets() const; + // Select a widget by address, triggering property sub-model population. + Q_INVOKABLE QString selectWidget(const QString &address) const; + // Get all Q_PROPERTY values for a selected widget. + Q_INVOKABLE QString getWidgetProperties(const QString &address) const; + // Get Qt::WidgetAttribute flags for a selected widget. + Q_INVOKABLE QString getWidgetAttributes(const QString &address) const; + +private: + GammaRaySession *m_session; + mutable QString m_selectedWidgetAddress; + + QString ensureSession() const; + QString ensureWidgetSelected(const QString &address) const; + QModelIndex findWidgetByAddress(QAbstractItemModel *m, const QString &address) const; + void waitForRows(QAbstractItemModel *m, int timeoutMs) const; +}; + +#endif // WIDGET_TOOLS_H \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 080fd84..f64bfd4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ before calling pytest. If you prefer to run pytest directly, set these: GAMMARAY_LIB_DIR — directory containing GammaRay shared libraries QML_RUNNER — path to the QML runtime executable TEST_QML — path to the test QML application + WIDGET_TEST_APP — path to the widget test application (C++ QWidget binary) BRIDGE_EXE — path to the bridge executable BRIDGE_RUN_SCRIPT — path to bridge/run.sh """ @@ -41,6 +42,14 @@ TEST_QML = Path(os.environ.get( "TEST_QML", str(PROJECT_ROOT / "tests" / "testapp" / "main.qml"), )) +WIDGET_TEST_APP_BIN = Path(os.environ.get( + "WIDGET_TEST_APP_BIN", + str(PROJECT_ROOT / "tests" / "testapp" / "widget_test_app"), +)) +WIDGET_TEST_APP_SRC = Path(os.environ.get( + "WIDGET_TEST_APP_SRC", + str(PROJECT_ROOT / "tests" / "testapp" / "widget_test_app.cpp"), +)) BRIDGE_EXE = Path(os.environ.get( "BRIDGE_EXE", str(PROJECT_ROOT / "bridge" / "build" / "qml-sg-mcp-bridge"), @@ -51,6 +60,43 @@ BRIDGE_RUN_SCRIPT = Path(os.environ.get( )) +def _compile_widget_app() -> Path: + """Compile the widget test app if needed. Returns the binary path.""" + if WIDGET_TEST_APP_BIN.exists(): + return WIDGET_TEST_APP_BIN + if not WIDGET_TEST_APP_SRC.exists(): + pytest.skip(f"Widget test app source not found at {WIDGET_TEST_APP_SRC}") + # Find Qt6 via pkg-config + try: + import subprocess as sp + cflags = sp.check_output( + ["pkg-config", "--cflags", "Qt6Core", "Qt6Gui", "Qt6Widgets"], + text=True + ).strip() + libs = sp.check_output( + ["pkg-config", "--libs", "Qt6Core", "Qt6Gui", "Qt6Widgets"], + text=True + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError): + # Fallback: try qmake6 + try: + qmake = sp.check_output(["which", "qmake6"], text=True).strip() + prefix = sp.check_output([qmake, "-query", "QT_INSTALL_PREFIX"], text=True).strip() + cflags = f"-I{prefix}/include -I{prefix}/include/QtCore -I{prefix}/include/QtGui -I{prefix}/include/QtWidgets" + libs = f"-L{prefix}/lib -lQt6Core -lQt6Gui -lQt6Widgets" + except (subprocess.CalledProcessError, FileNotFoundError): + pytest.skip("Cannot find Qt6 build flags (need pkg-config or qmake6)") + + subprocess.run( + ["g++", "-std=c++17", "-fPIC", + str(WIDGET_TEST_APP_SRC), + "-o", str(WIDGET_TEST_APP_BIN), + *cflags.split(), *libs.split()], + check=True, timeout=30, + ) + return WIDGET_TEST_APP_BIN + + def pytest_addoption(parser): parser.addoption( "--no-probe", @@ -64,6 +110,12 @@ def pytest_addoption(parser): default=15, help="Seconds to wait for probe to be ready", ) + parser.addoption( + "--widget-app", + action="store_true", + default=False, + help="Use widget test app instead of QML app for probe target", + ) def pytest_collection_modifyitems(config, items): @@ -103,12 +155,24 @@ def _probe_accepting_connections(timeout: int = 20) -> bool: @pytest.fixture(scope="session") def probe_process(request) -> subprocess.Popen | None: - """Start a GammaRay probe process (session-scoped, one per entire test run).""" + """Start a GammaRay probe process (session-scoped, one per entire test run). + + Uses either the QML test app or the widget test app depending on --widget-app. + """ if request.config.getoption("--no-probe"): return None if not GAMMARAY_BIN.exists(): pytest.skip(f"GammaRay probe binary not found at {GAMMARAY_BIN}") + + if request.config.getoption("--widget-app"): + return _start_widget_probe(request) + else: + return _start_qml_probe(request) + + +def _start_qml_probe(request) -> subprocess.Popen: + """Start probe with QML test app.""" if not TEST_QML.exists(): pytest.skip(f"Test QML file not found at {TEST_QML}") if not QML_RUNNER.exists(): @@ -145,14 +209,72 @@ def probe_process(request) -> subprocess.Popen | None: timeout = request.config.getoption("--probe-timeout") if not _probe_accepting_connections(timeout): - # Fallback: direct spawn proc = subprocess.Popen( [str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732", "--injector", "preload", str(QML_RUNNER), str(TEST_QML)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if not _probe_accepting_connections(timeout): - pytest.fail(f"Probe did not become ready within {timeout}s") + pytest.fail(f"QML probe did not become ready within {timeout}s") + else: + proc = subprocess.Popen(["true"]) + + def cleanup(): + subprocess.run( + ["systemctl", "--user", "stop", "gammaray-probe-test.service"], + capture_output=True, timeout=5, + ) + subprocess.run( + ["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"], + capture_output=True, timeout=5, + ) + + request.addfinalizer(cleanup) + return proc + + +def _start_widget_probe(request) -> subprocess.Popen: + """Start probe with widget test app.""" + widget_bin = _compile_widget_app() + + env = os.environ.copy() + env["QT_QPA_PLATFORM"] = "offscreen" + env["LD_LIBRARY_PATH"] = GAMMARAY_LIB_DIR + + subprocess.run( + ["systemctl", "--user", "stop", "gammaray-probe-test.service"], + capture_output=True, timeout=5, + ) + subprocess.run( + ["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"], + capture_output=True, timeout=5, + ) + + result = subprocess.run( + [ + "systemd-run", "--user", "--no-block", + "--unit=gammaray-probe-test", + "--same-dir", + "--working-directory", str(widget_bin.parent), + "-E", f"QT_QPA_PLATFORM={env['QT_QPA_PLATFORM']}", + "-E", f"LD_LIBRARY_PATH={env['LD_LIBRARY_PATH']}", + str(GAMMARAY_BIN), + "--inject-only", "--listen", "tcp://127.0.0.1:11732", + "--injector", "preload", + str(widget_bin), + ], + capture_output=True, text=True, timeout=10, + ) + + timeout = request.config.getoption("--probe-timeout") + if not _probe_accepting_connections(timeout): + proc = subprocess.Popen( + [str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732", + "--injector", "preload", str(widget_bin)], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + if not _probe_accepting_connections(timeout): + pytest.fail(f"Widget probe did not become ready within {timeout}s") else: proc = subprocess.Popen(["true"]) diff --git a/tests/run_widget_tests.sh b/tests/run_widget_tests.sh new file mode 100755 index 0000000..5d1d3b5 --- /dev/null +++ b/tests/run_widget_tests.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Run widget-specific tests with a QWidget test app as the probe target. +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Usage: +# ./run_widget_tests.sh # full widget test suite +# ./run_widget_tests.sh -v # verbose +# ./run_widget_tests.sh -k "attr" # run only attribute-related tests + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# --------------------------------------------------------------------------- +# Configurable paths +# --------------------------------------------------------------------------- +: "${GAMMARAY_BIN:=$PROJECT_ROOT/install-prefix/bin/gammaray}" +: "${GAMMARAY_LIB_DIR:=$PROJECT_ROOT/install-prefix/lib}" +: "${WIDGET_TEST_APP_BIN:=$SCRIPT_DIR/testapp/widget_test_app}" +: "${WIDGET_TEST_APP_SRC:=$SCRIPT_DIR/testapp/widget_test_app.cpp}" +: "${BRIDGE_EXE:=$PROJECT_ROOT/bridge/build/qml-sg-mcp-bridge}" +: "${BRIDGE_RUN_SCRIPT:=$PROJECT_ROOT/bridge/run.sh}" + +export GAMMARAY_BIN +export GAMMARAY_LIB_DIR +export WIDGET_TEST_APP_BIN +export WIDGET_TEST_APP_SRC +export BRIDGE_EXE +export BRIDGE_RUN_SCRIPT + +# --------------------------------------------------------------------------- +# Ensure the bridge is built +# --------------------------------------------------------------------------- +if [ ! -f "$BRIDGE_EXE" ]; then + echo "Building bridge first..." + cmake -S "$PROJECT_ROOT/bridge" -B "$PROJECT_ROOT/bridge/build" -G Ninja \ + -DCMAKE_PREFIX_PATH="$PROJECT_ROOT/install-prefix" + cmake --build "$PROJECT_ROOT/bridge/build" +fi + +# --------------------------------------------------------------------------- +# Runtime environment +# --------------------------------------------------------------------------- +export LD_LIBRARY_PATH="$GAMMARAY_LIB_DIR" +export QT_QPA_PLATFORM=offscreen +export QT_PLUGIN_PATH="$PROJECT_ROOT/bridge/build/lib/x86_64-linux-gnu/qt6/plugins" + +cd "$SCRIPT_DIR" + +echo "=== Widget Test Run ===" +echo "Widget app: $WIDGET_TEST_APP_BIN" +echo "Bridge: $BRIDGE_EXE" + +exec uv run --frozen --directory "$PROJECT_ROOT" python "$SCRIPT_DIR/run_with_uv.py" --widget-app "$@" \ No newline at end of file diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 0000000..4857d35 --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,126 @@ +"""Tests for Qt Widget introspection tools (listWidgets, selectWidget, etc.).""" + +import pytest + + +class TestWidgetTools: + """Verify listWidgets, selectWidget, getWidgetProperties, getWidgetAttributes.""" + + @pytest.mark.probe + def test_tools_listed(self, bridge): + tools = bridge.list_tools() + names = [t["name"] for t in tools] + for expected in ["listWidgets", "selectWidget", + "getWidgetProperties", "getWidgetAttributes"]: + assert expected in names, f"Missing tool: {expected}" + + @pytest.mark.probe + def test_list_widgets(self, connected_bridge): + widgets = connected_bridge.call_tool("listWidgets") + assert isinstance(widgets, list), f"Expected list, got: {type(widgets)}" + assert len(widgets) >= 1, "No widgets found" + + # Should have a top-level window + types = [w.get("type", "") for w in widgets] + assert any("QWidget" in t for t in types), \ + f"No QWidget-type root found: {types}" + + @pytest.mark.probe + def test_select_widget(self, connected_bridge): + widgets = connected_bridge.call_tool("listWidgets") + assert isinstance(widgets, list) and len(widgets) > 0 + + # Find the first Button-like widget + def find_buttons(items_list): + buttons = [] + for item in items_list: + if "Button" in (item.get("type", "")) or "PushButton" in (item.get("type", "")): + buttons.append(item.get("name", "")) + buttons.extend(find_buttons(item.get("children", []))) + return buttons + + buttons = find_buttons(widgets) + if buttons: + addr = buttons[0] + else: + addr = widgets[0].get("name", "") + + result = connected_bridge.call_tool("selectWidget", {"address": addr}) + assert isinstance(result, dict), f"selectWidget failed: {result}" + assert result.get("propertyCount", 0) > 0, \ + f"No properties for widget {addr}: {result}" + + @pytest.mark.probe + def test_get_widget_properties(self, connected_bridge): + widgets = connected_bridge.call_tool("listWidgets") + assert isinstance(widgets, list) and len(widgets) > 0 + + addr = widgets[0].get("name", "") + if not addr: + pytest.skip("No widget address found") + + props = connected_bridge.call_tool("getWidgetProperties", {"address": addr}) + assert isinstance(props, dict), f"getWidgetProperties failed: {props}" + assert "properties" in props, f"No 'properties' key: {list(props.keys())}" + + @pytest.mark.probe + def test_widget_has_geometry(self, connected_bridge): + """Verify a widget has geometry properties (x, y, width, height).""" + widgets = connected_bridge.call_tool("listWidgets") + assert isinstance(widgets, list) and len(widgets) > 0 + + addr = widgets[0].get("name", "") + if not addr: + pytest.skip("No widget address found") + + props = connected_bridge.call_tool("getWidgetProperties", {"address": addr}) + simple = props.get("properties", {}) + for key in ("x", "y", "width", "height"): + assert key in simple, f"Widget missing '{key}' property" + + @pytest.mark.probe + def test_widget_attributes(self, connected_bridge): + """Verify getWidgetAttributes returns attribute flags for a widget.""" + widgets = connected_bridge.call_tool("listWidgets") + assert isinstance(widgets, list) and len(widgets) > 0 + + # Find the checkBox widget (has various attributes) + def find_named(items_list): + for item in items_list: + name = item.get("name", "") + if "check" in name.lower() or "CheckBox" in (item.get("type", "")): + return name + found = find_named(item.get("children", [])) + if found: + return found + return None + + addr = find_named(widgets) + if not addr: + addr = widgets[0].get("name", "") + + attrs = connected_bridge.call_tool("getWidgetAttributes", {"address": addr}) + assert isinstance(attrs, dict), f"getWidgetAttributes failed: {attrs}" + assert "attributes" in attrs, f"No 'attributes' key: {list(attrs.keys())}" + assert attrs.get("count", 0) > 0, "No widget attributes returned" + # Should at least have some standard attributes + attr_names = list(attrs.get("attributes", {}).keys()) + assert len(attr_names) > 0, "Empty attributes list" + + @pytest.mark.probe + def test_widget_enabled_attribute(self, connected_bridge): + """Verify attributes contain 'enabled' field.""" + widgets = connected_bridge.call_tool("listWidgets") + assert isinstance(widgets, list) and len(widgets) > 0 + + addr = widgets[0].get("name", "") + if not addr: + pytest.skip("No widget address found") + + attrs = connected_bridge.call_tool("getWidgetAttributes", {"address": addr}) + attributes = attrs.get("attributes", {}) + # Check at least one attribute has enabled/value keys + for attr_name, attr_data in attributes.items(): + if isinstance(attr_data, dict) and "enabled" in attr_data: + return # Found one + pytest.skip("No attributes with 'enabled' field found") \ No newline at end of file diff --git a/tests/testapp/widget_test_app.cpp b/tests/testapp/widget_test_app.cpp new file mode 100644 index 0000000..9042107 --- /dev/null +++ b/tests/testapp/widget_test_app.cpp @@ -0,0 +1,50 @@ +/* + widget_test_app.cpp — Minimal QWidget test application for GammaRay MCP widget tools. + + SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + app.setApplicationName(QStringLiteral("GammaRay MCP Widget Test App")); + + auto *mainWindow = new QWidget(); + mainWindow->setWindowTitle(QStringLiteral("GammaRay Widget Test")); + mainWindow->resize(400, 300); + + auto *layout = new QVBoxLayout(mainWindow); + + auto *label = new QLabel(QStringLiteral("Hello from GammaRay MCP")); + layout->addWidget(label); + + auto *lineEdit = new QLineEdit(); + lineEdit->setPlaceholderText(QStringLiteral("Type something...")); + layout->addWidget(lineEdit); + + auto *checkBox = new QCheckBox(QStringLiteral("Enable feature")); + checkBox->setChecked(true); + layout->addWidget(checkBox); + + auto *groupBox = new QGroupBox(QStringLiteral("Options")); + auto *groupLayout = new QVBoxLayout(groupBox); + auto *innerCheck = new QCheckBox(QStringLiteral("Sub-option A")); + groupLayout->addWidget(innerCheck); + groupLayout->addWidget(new QCheckBox(QStringLiteral("Sub-option B"))); + layout->addWidget(groupBox); + + auto *button = new QPushButton(QStringLiteral("Click Me")); + layout->addWidget(button); + + mainWindow->show(); + return app.exec(); +} \ No newline at end of file