Compare commits
3 Commits
268483577e
...
a6335429e5
| Author | SHA1 | Date | |
|---|---|---|---|
| a6335429e5 | |||
| 83df5286c5 | |||
| 591e786a0d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,9 @@ build/
|
|||||||
bridge/build/
|
bridge/build/
|
||||||
install-prefix/
|
install-prefix/
|
||||||
|
|
||||||
|
# Compiled test binaries (not committed)
|
||||||
|
tests/testapp/widget_test_app
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|||||||
88
PLAN.md
88
PLAN.md
@@ -1,19 +1,19 @@
|
|||||||
# QML SceneGraph MCP Bridge — Plan
|
# GammaRay MCP Bridge — Plan
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Build an MCP server that exposes QML SceneGraph introspection data (from GammaRay's probe) to LLMs for assisted debugging. The bridge is a **GammaRay client**: it connects to a probe injected into the target Qt/QML app, reads the same models the GammaRay GUI uses, and translates them into MCP JSON-RPC tool calls.
|
Build an MCP server that exposes GammaRay probe introspection data (QML SceneGraph, QML items, and Qt Widgets) to LLMs for assisted debugging. The bridge is a **GammaRay client**: it connects to a probe injected into the target Qt app, reads the same models the GammaRay GUI uses, and translates them into MCP JSON-RPC tool calls.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Target Qt/QML App
|
Target Qt/QML App
|
||||||
│ GammaRay probe injected (preload/attach), loads quickinspector plugin
|
│ GammaRay probe injected (preload/attach), loads quickinspector + widgetinspector plugins
|
||||||
│
|
│
|
||||||
│ GammaRay binary protocol over TCP/local socket
|
│ GammaRay binary protocol over TCP/local socket
|
||||||
│ (common/protocol.h — QDataStream-based, NOT JSON-RPC)
|
│ (common/protocol.h — QDataStream-based, NOT JSON-RPC)
|
||||||
▼
|
▼
|
||||||
QML SceneGraph MCP Bridge (new project, GPL-2.0-or-later)
|
GammaRay MCP Bridge (new project, GPL-2.0-or-later)
|
||||||
│ Links gammaray_client + gammaray_common (installed from source)
|
│ Links gammaray_client + gammaray_common (installed from source)
|
||||||
│ Uses ClientConnectionManager to connect, ObjectBroker to get models
|
│ Uses ClientConnectionManager to connect, ObjectBroker to get models
|
||||||
│ Connection is lazy: bridge starts without a probe, MCP client calls
|
│ Connection is lazy: bridge starts without a probe, MCP client calls
|
||||||
@@ -99,7 +99,7 @@ Caveats of the FetchContent approach:
|
|||||||
|
|
||||||
```cmake
|
```cmake
|
||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
project(QmlSceneGraphMcpBridge LANGUAGES CXX)
|
project(GammaRayMcpBridge LANGUAGES CXX)
|
||||||
set(CMAKE_CXX_STANDARD 20) # qtmcp requires C++20
|
set(CMAKE_CXX_STANDARD 20) # qtmcp requires C++20
|
||||||
set(CMAKE_AUTOMOC ON)
|
set(CMAKE_AUTOMOC ON)
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ set(QT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
|||||||
set(QT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
set(QT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||||
FetchContent_MakeAvailable(qtmcp)
|
FetchContent_MakeAvailable(qtmcp)
|
||||||
|
|
||||||
add_executable(qml-sg-mcp-bridge
|
add_executable(gammaray-mcp-bridge
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/gammaray_session.cpp # wraps ClientConnectionManager + ObjectBroker
|
src/gammaray_session.cpp # wraps ClientConnectionManager + ObjectBroker
|
||||||
src/gammaray_session.h
|
src/gammaray_session.h
|
||||||
@@ -127,7 +127,7 @@ add_executable(qml-sg-mcp-bridge
|
|||||||
src/scenegraph_tools.h
|
src/scenegraph_tools.h
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(qml-sg-mcp-bridge PRIVATE
|
target_link_libraries(gammaray-mcp-bridge PRIVATE
|
||||||
gammaray_client # VERIFIED: no GammaRay:: namespace
|
gammaray_client # VERIFIED: no GammaRay:: namespace
|
||||||
gammaray_common
|
gammaray_common
|
||||||
Qt6::Core
|
Qt6::Core
|
||||||
@@ -139,7 +139,7 @@ target_link_libraries(qml-sg-mcp-bridge PRIVATE
|
|||||||
)
|
)
|
||||||
|
|
||||||
# qtmcp shared libs + stdio plugin live in build tree; make runtime find them:
|
# qtmcp shared libs + stdio plugin live in build tree; make runtime find them:
|
||||||
set_target_properties(qml-sg-mcp-bridge PROPERTIES
|
set_target_properties(gammaray-mcp-bridge PROPERTIES
|
||||||
BUILD_RPATH "$<TARGET_FILE_DIR:Qt6::McpServer>;$<TARGET_FILE_DIR:Qt6::McpCommon>"
|
BUILD_RPATH "$<TARGET_FILE_DIR:Qt6::McpServer>;$<TARGET_FILE_DIR:Qt6::McpCommon>"
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -152,7 +152,7 @@ cmake --build build
|
|||||||
# Run with probe libs + qtmcp runtime libs on the path:
|
# Run with probe libs + qtmcp runtime libs on the path:
|
||||||
LD_LIBRARY_PATH=$(pwd)/install-prefix/lib QT_QPA_PLATFORM=offscreen \
|
LD_LIBRARY_PATH=$(pwd)/install-prefix/lib QT_QPA_PLATFORM=offscreen \
|
||||||
QT_PLUGIN_PATH=$PWD/build/lib/x86_64-linux-gnu/qt6/plugins \
|
QT_PLUGIN_PATH=$PWD/build/lib/x86_64-linux-gnu/qt6/plugins \
|
||||||
./build/qml-sg-mcp-bridge --connect tcp://127.0.0.1:11732
|
./build/gammaray-mcp-bridge --connect tcp://127.0.0.1:11732
|
||||||
```
|
```
|
||||||
|
|
||||||
### ❗ MUST use QApplication, not QCoreApplication
|
### ❗ MUST use QApplication, not QCoreApplication
|
||||||
@@ -173,7 +173,7 @@ int main(int argc, char **argv) {
|
|||||||
// ... parse --connect <url> ...
|
// ... parse --connect <url> ...
|
||||||
|
|
||||||
QMcpServer server(QStringLiteral("stdio")); // backend name in ctor, no default
|
QMcpServer server(QStringLiteral("stdio")); // backend name in ctor, no default
|
||||||
server.setInstructions("QML SceneGraph introspection via GammaRay");
|
server.setInstructions("QML SceneGraph & Qt Widget introspection via GammaRay");
|
||||||
|
|
||||||
auto *tools = new SceneGraphTools(&server); // holds GammaRay client connection
|
auto *tools = new SceneGraphTools(&server); // holds GammaRay client connection
|
||||||
server.registerToolSet(tools, {
|
server.registerToolSet(tools, {
|
||||||
@@ -413,7 +413,7 @@ The fix was NOT a code change but a sequencing fix in `ensureNodeSelected()`:
|
|||||||
All items from the original blocker resolved. Remaining minor issues:
|
All items from the original blocker resolved. Remaining minor issues:
|
||||||
- `listScenegraphNodes` first-call fill stability — first call may show "Loading..." for deep nodes (RemoteModel lazy-fetch behavior); second call returns the full tree. The `primeAndWait()` loop handles this transparently, but single-call would be nicer.
|
- `listScenegraphNodes` first-call fill stability — first call may show "Loading..." for deep nodes (RemoteModel lazy-fetch behavior); second call returns the full tree. The `primeAndWait()` loop handles this transparently, but single-call would be nicer.
|
||||||
- `walkItemChildren` `flagsToJson` uses hardcoded flag bit values (1,2,4,8,16,32,64) — fragile if GammaRay's `ItemFlags` enum changes. Should read from `common/quickitemmodelroles.h` if it were installed.
|
- `walkItemChildren` `flagsToJson` uses hardcoded flag bit values (1,2,4,8,16,32,64) — fragile if GammaRay's `ItemFlags` enum changes. Should read from `common/quickitemmodelroles.h` if it were installed.
|
||||||
- Full test suite takes ~8 minutes due to probe start/stop overhead and 500-1500ms settle delays per tool call. Could be optimized with module-scoped bridge fixtures and shared probe process.
|
- Full test suite ~3.5 min (session-scoped probe + module-scoped bridge + 0.5s settle delays). Connection tests still get fresh bridges per test (function-scoped).
|
||||||
|
|
||||||
## Step 7: Launch and run
|
## Step 7: Launch and run
|
||||||
|
|
||||||
@@ -505,7 +505,7 @@ When in doubt, these files have the ground truth:
|
|||||||
- ✅ **Step 1 done**: GammaRay 3.4.0 built + installed to `install-prefix/`. Protocol version 38.
|
- ✅ **Step 1 done**: GammaRay 3.4.0 built + installed to `install-prefix/`. Protocol version 38.
|
||||||
- ✅ **Minimal client built & run**: `minimal-client/` links `gammaray_client` + `gammaray_common`, connects to a live probe, receives `ready()`, fetches `QuickSceneGraphModel` / `QuickWindowModel` via `ObjectBroker` — clean exit. Proves the client ABI + protocol path is viable for the bridge.
|
- ✅ **Minimal client built & run**: `minimal-client/` links `gammaray_client` + `gammaray_common`, connects to a live probe, receives `ready()`, fetches `QuickSceneGraphModel` / `QuickWindowModel` via `ObjectBroker` — clean exit. Proves the client ABI + protocol path is viable for the bridge.
|
||||||
- ✅ **qtmcp FetchContent viable**: `Qt6::McpServer`/`Qt6::McpCommon` build & link from a `FetchContent_MakeAvailable()` call. No separate clone/install.
|
- ✅ **qtmcp FetchContent viable**: `Qt6::McpServer`/`Qt6::McpCommon` build & link from a `FetchContent_MakeAvailable()` call. No separate clone/install.
|
||||||
- ✅ **Bridge scaffold built & working end-to-end** (`bridge/`): `qml-sg-mcp-bridge` links GammaRay client + qtmcp, runs as a stdio MCP server. Smoke test (`/tmp/opencode/smoke_test.py`) against a live probe confirms:
|
- ✅ **Bridge scaffold built & working end-to-end** (`bridge/`): `gammaray-mcp-bridge` links GammaRay client + qtmcp, runs as a stdio MCP server. Smoke test against a live probe confirms:
|
||||||
- `initialize` → serverInfo + capabilities + protocolVersion `2024-11-05` ✅
|
- `initialize` → serverInfo + capabilities + protocolVersion `2024-11-05` ✅
|
||||||
- `tools/list` → 15 tools registered: `connectProbe`, `connectProbeDefault`, `disconnectProbe`, `getMaterialProperties`, `getMaterialShaders`, `getNodeAdjacency`, `getNodeVertices`, `getShaderSource`, `listQuickWindows`, `listScenegraphNodes`, `probeStatus`, `selectQuickWindow`, `selectScenegraphNode`, `setRenderMode`, `setSlowMode` ✅
|
- `tools/list` → 15 tools registered: `connectProbe`, `connectProbeDefault`, `disconnectProbe`, `getMaterialProperties`, `getMaterialShaders`, `getNodeAdjacency`, `getNodeVertices`, `getShaderSource`, `listQuickWindows`, `listScenegraphNodes`, `probeStatus`, `selectQuickWindow`, `selectScenegraphNode`, `setRenderMode`, `setSlowMode` ✅
|
||||||
- `probeStatus` (before connect) → `{"ready":false,"state":"disconnected","url":""}` ✅
|
- `probeStatus` (before connect) → `{"ready":false,"state":"disconnected","url":""}` ✅
|
||||||
@@ -550,11 +550,63 @@ 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`.
|
`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 (implemented)
|
||||||
|
|
||||||
|
GammaRay 的 widget inspector 与 quick inspector 共享同一套底层架构(`PropertyController`、`SelectionModelClient`、`RemoteModel`),大部分 QML 工具的模式已直接复用。
|
||||||
|
|
||||||
|
### 已实现的 widget 工具
|
||||||
|
|
||||||
|
**导航(与 QML 类似,模型名不同)**
|
||||||
|
- ✅ `listWidgets()` — 遍历 `com.kdab.GammaRay.WidgetTree` 模型,返回 widget 层次树
|
||||||
|
- ✅ `selectWidget(address)` — 通过 `SelectionModelClient` 选取 widget,触发 `PropertyController` 填充子模型(属性、attribute 等)
|
||||||
|
|
||||||
|
**属性/attribute(与 QML 共享读取实现)**
|
||||||
|
- ✅ `getWidgetProperties(address)` — 读取同一套 `AggregatedPropertyModel`(通过 `PropertyController`),与 `getItemProperties` 共享实现代码
|
||||||
|
- ✅ `getWidgetAttributes(address)` — 读取 `widgetAttributeModel`(`AttributeModel<QWidget, Qt::WidgetAttribute>`),列出 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`,已提取共享函数 `readAggregatedPropertyModel()` |
|
||||||
|
| ✅ 已完成 | `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.<address>.properties` vs `com.kdab.GammaRay.Widget.<address>.properties`
|
||||||
|
- **Attribute 模型是 widget 独有的**:QML 项没有 `Qt::WidgetAttribute` 的概念
|
||||||
|
- **Widget 没有 SceneGraph**:不需要 `getNodeVertices`/`getMaterialShaders` 等 SG 工具
|
||||||
|
- **Widget 有截图和绘制分析**:QML 的 texture grab 类似但不同
|
||||||
|
|
||||||
## Next steps
|
## 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.
|
- ✅ **QML SceneGraph 工具**:listScenegraphNodes、getNodeVertices、getNodeAdjacency、getMaterialShaders、getShaderSource、getMaterialProperties、setRenderMode、setSlowMode
|
||||||
4. **Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`. Follows the `MaterialExtensionInterface` proxy pattern.
|
- ✅ **QML Item 属性工具**:listQuickItems、selectQuickItem、getItemProperties
|
||||||
5. **Fix `listScenegraphNodes` first-call fill stability** — single-call instead of caller needing a second call.
|
- ✅ **Qt Widget 工具**:listWidgets、selectWidget、getWidgetProperties、getWidgetAttributes
|
||||||
6. **Build `--connect` startup flag integration** with systemd service for auto-attach during development.
|
- ✅ **跨平台保护**:QuickInspector 和 WidgetInspector 的代理注册均已添加条件守卫,避免在未加载对应插件的 target 进程中崩溃
|
||||||
|
- ✅ **测试套件**:27 个 QML 测试 + 7 个 widget 测试,共 34 个集成测试
|
||||||
|
|
||||||
|
### 剩余工作
|
||||||
|
|
||||||
|
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 uses software renderer so `hasGeometry: false` and empty sub-models are correct behavior.
|
||||||
|
3. **Implement `grabWidget(objectId)`** — `RemoteViewInterface` async image grab via `TransferImage`. Follows the `MaterialExtensionInterface` proxy pattern.
|
||||||
|
4. **Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`.
|
||||||
|
5. **Implement `analyzePainting()` / `exportWidgetAsSvg()` / `exportWidgetAsUiFile()`** — via `WidgetInspectorInterface` proxy.
|
||||||
|
6. **Fix `listScenegraphNodes` first-call fill stability** — single-call instead of caller needing a second call.
|
||||||
|
7. **Build `--connect` startup flag integration** with systemd service for auto-attach during development.
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -1,6 +1,6 @@
|
|||||||
# QML SceneGraph MCP Bridge
|
# GammaRay MCP Bridge
|
||||||
|
|
||||||
A [MCP](https://modelcontextprotocol.io/) server that bridges [GammaRay](https://github.com/KDAB/GammaRay) probe introspection data into MCP tools, enabling LLMs to inspect and debug Qt Quick / QML scene graphs, items, geometry, and materials.
|
A [MCP](https://modelcontextprotocol.io/) server that bridges [GammaRay](https://github.com/KDAB/GammaRay) probe introspection data into MCP tools, enabling LLMs to inspect and debug Qt Quick / QML scene graphs, items, geometry, materials, and Qt Widgets.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ Target Qt/QML App ──TCP──► GammaRay MCP Bridge ──stdio──►
|
|||||||
injected) qtmcp MCP server) Claude Desktop, etc.)
|
injected) qtmcp MCP server) Claude Desktop, etc.)
|
||||||
```
|
```
|
||||||
|
|
||||||
The bridge is a GammaRay **client**: it connects to a probe injected into a target Qt/QML app, reads the same models the GammaRay GUI uses, and translates them into MCP JSON-RPC tool calls over stdio.
|
The bridge is a GammaRay **client**: it connects to a probe injected into a target Qt app, reads the same models the GammaRay GUI uses, and translates them into MCP JSON-RPC tool calls over stdio. Supports both QML Quick apps (SceneGraph, QML items) and Qt Widget apps (widget hierarchy, properties, attributes).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -46,6 +46,10 @@ cmake --build build
|
|||||||
install-prefix/bin/gammaray --inject-only --listen tcp://127.0.0.1:11732 \
|
install-prefix/bin/gammaray --inject-only --listen tcp://127.0.0.1:11732 \
|
||||||
--injector preload /usr/lib/qt6/bin/qml /path/to/app.qml -platform offscreen
|
--injector preload /usr/lib/qt6/bin/qml /path/to/app.qml -platform offscreen
|
||||||
|
|
||||||
|
# Or inject into a widget app
|
||||||
|
install-prefix/bin/gammaray --inject-only --listen tcp://127.0.0.1:11732 \
|
||||||
|
--injector preload /path/to/widget-app
|
||||||
|
|
||||||
# Start the bridge (stdio MCP server)
|
# Start the bridge (stdio MCP server)
|
||||||
bridge/run.sh
|
bridge/run.sh
|
||||||
```
|
```
|
||||||
@@ -62,7 +66,7 @@ The bridge starts in lazy-connect mode. Call `connectProbe("127.0.0.1", 11732)`
|
|||||||
| `disconnectProbe()` | Drop connection and forget URL |
|
| `disconnectProbe()` | Drop connection and forget URL |
|
||||||
| `probeStatus()` | Report connection state |
|
| `probeStatus()` | Report connection state |
|
||||||
|
|
||||||
### Navigation
|
### QML Navigation
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `listQuickWindows()` | List QQuickWindows in the target app |
|
| `listQuickWindows()` | List QQuickWindows in the target app |
|
||||||
@@ -92,15 +96,25 @@ The bridge starts in lazy-connect mode. Call `connectProbe("127.0.0.1", 11732)`
|
|||||||
| `setRenderMode(mode)` | Set render mode (NormalRendering, VisualizeOverdraw, etc.) |
|
| `setRenderMode(mode)` | Set render mode (NormalRendering, VisualizeOverdraw, etc.) |
|
||||||
| `setSlowMode(enabled)` | Toggle continuous rendering |
|
| `setSlowMode(enabled)` | Toggle continuous rendering |
|
||||||
|
|
||||||
|
### Widget inspection
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `listWidgets()` | Recursive QWidget hierarchy (types, names, visibility) |
|
||||||
|
| `selectWidget(address)` | Select a widget, populating its property and attribute models |
|
||||||
|
| `getWidgetProperties(address)` | Get all Q_PROPERTY values (geometry, font, palette, window flags, etc.) |
|
||||||
|
| `getWidgetAttributes(address)` | Get Qt::WidgetAttribute flags (acceptDrops, enabled, etc.) |
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Unit tests only (no probe needed):
|
# QML test suite (requires a QML app probe):
|
||||||
./tests/run_tests.sh --no-probe
|
./tests/run_tests.sh
|
||||||
|
|
||||||
# Full integration test suite (probe required):
|
# Widget test suite (requires a widget app probe):
|
||||||
./tests/run_tests.sh --no-probe # skips probe-dependent tests
|
./tests/run_widget_tests.sh
|
||||||
./tests/run_tests.sh -v # verbose
|
|
||||||
|
# Unit tests only:
|
||||||
|
./tests/run_tests.sh --no-probe
|
||||||
```
|
```
|
||||||
|
|
||||||
See `tests/README.md` for details.
|
See `tests/README.md` for details.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# QML SceneGraph MCP Bridge
|
# GammaRay MCP Bridge
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
#
|
#
|
||||||
# Links GammaRay client libs (GPL-2.0-or-later) => this bridge must be
|
# Links GammaRay client libs (GPL-2.0-or-later) => this bridge must be
|
||||||
# GPL-compatible. qtmcp is consumed under its GPL-2.0-only option (compatible).
|
# GPL-compatible. qtmcp is consumed under its GPL-2.0-only option (compatible).
|
||||||
|
|
||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
project(QmlSceneGraphMcpBridge LANGUAGES CXX)
|
project(GammaRayMcpBridge LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20) # qtmcp requires C++20
|
set(CMAKE_CXX_STANDARD 20) # qtmcp requires C++20
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@@ -37,7 +37,7 @@ find_package(GammaRay REQUIRED)
|
|||||||
# --- Qt6 components for the bridge target (Qt6 already found globally by qtmcp) ---
|
# --- Qt6 components for the bridge target (Qt6 already found globally by qtmcp) ---
|
||||||
find_package(Qt6 COMPONENTS Core Gui Widgets Network REQUIRED)
|
find_package(Qt6 COMPONENTS Core Gui Widgets Network REQUIRED)
|
||||||
|
|
||||||
add_executable(qml-sg-mcp-bridge
|
add_executable(gammaray-mcp-bridge
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/gammaray_session.cpp
|
src/gammaray_session.cpp
|
||||||
src/gammaray_session.h
|
src/gammaray_session.h
|
||||||
@@ -48,9 +48,15 @@ add_executable(qml-sg-mcp-bridge
|
|||||||
src/quickinspector_proxy.cpp
|
src/quickinspector_proxy.cpp
|
||||||
src/quickinspector_proxy.h
|
src/quickinspector_proxy.h
|
||||||
src/quickinspector_types.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
|
target_link_libraries(gammaray-mcp-bridge PRIVATE
|
||||||
gammaray_client # VERIFIED: no GammaRay:: namespace
|
gammaray_client # VERIFIED: no GammaRay:: namespace
|
||||||
gammaray_common
|
gammaray_common
|
||||||
Qt6::Core
|
Qt6::Core
|
||||||
@@ -62,7 +68,7 @@ target_link_libraries(qml-sg-mcp-bridge PRIVATE
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Add GammaRay source common/ for message.h (not in installed headers)
|
# Add GammaRay source common/ for message.h (not in installed headers)
|
||||||
target_include_directories(qml-sg-mcp-bridge PRIVATE
|
target_include_directories(gammaray-mcp-bridge PRIVATE
|
||||||
/home/blumia/Sources/GammaRay/common
|
/home/blumia/Sources/GammaRay/common
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,7 +77,7 @@ target_include_directories(qml-sg-mcp-bridge PRIVATE
|
|||||||
# run time (see run.sh) so Qt loads mcpserverbackend/libqmcpserverstdio.so.
|
# run time (see run.sh) so Qt loads mcpserverbackend/libqmcpserverstdio.so.
|
||||||
# Also add GammaRay's install lib dir for libgammaray_*.so.
|
# Also add GammaRay's install lib dir for libgammaray_*.so.
|
||||||
set(_GR_LIBDIR "$<TARGET_PROPERTY:gammaray_client,IMPORTED_LOCATION>")
|
set(_GR_LIBDIR "$<TARGET_PROPERTY:gammaray_client,IMPORTED_LOCATION>")
|
||||||
set_target_properties(qml-sg-mcp-bridge PROPERTIES
|
set_target_properties(gammaray-mcp-bridge PROPERTIES
|
||||||
BUILD_RPATH "$<TARGET_FILE_DIR:Qt6::McpServer>;${CMAKE_SOURCE_DIR}/../install-prefix/lib"
|
BUILD_RPATH "$<TARGET_FILE_DIR:Qt6::McpServer>;${CMAKE_SOURCE_DIR}/../install-prefix/lib"
|
||||||
INSTALL_RPATH "$ORIGIN/../lib"
|
INSTALL_RPATH "$ORIGIN/../lib"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Run the QML SceneGraph MCP bridge.
|
# Run the GammaRay MCP bridge.
|
||||||
# Sets up LD_LIBRARY_PATH (GammaRay + qtmcp libs) and QT_PLUGIN_PATH (qtmcp stdio
|
# Sets up LD_LIBRARY_PATH (GammaRay + qtmcp libs) and QT_PLUGIN_PATH (qtmcp stdio
|
||||||
# backend plugin) so the bridge can find everything at runtime.
|
# backend plugin) so the bridge can find everything at runtime.
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
@@ -10,4 +10,4 @@ BLD="$HERE/build"
|
|||||||
export LD_LIBRARY_PATH="$ROOT/install-prefix/lib:$BLD/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
|
export LD_LIBRARY_PATH="$ROOT/install-prefix/lib:$BLD/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
|
||||||
export QT_PLUGIN_PATH="$BLD/lib/x86_64-linux-gnu/qt6/plugins:${QT_PLUGIN_PATH:-}"
|
export QT_PLUGIN_PATH="$BLD/lib/x86_64-linux-gnu/qt6/plugins:${QT_PLUGIN_PATH:-}"
|
||||||
export QT_QPA_PLATFORM=offscreen
|
export QT_QPA_PLATFORM=offscreen
|
||||||
exec "$BLD/qml-sg-mcp-bridge" "$@"
|
exec "$BLD/gammaray-mcp-bridge" "$@"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include "gammaray_session.h"
|
#include "gammaray_session.h"
|
||||||
|
|
||||||
#include "quickinspector_proxy.h"
|
#include "quickinspector_proxy.h"
|
||||||
|
#include "widget_inspector_proxy.h"
|
||||||
|
|
||||||
#include <client/clientconnectionmanager.h>
|
#include <client/clientconnectionmanager.h>
|
||||||
#include <common/objectbroker.h>
|
#include <common/objectbroker.h>
|
||||||
@@ -39,6 +40,9 @@ GammaRaySession::GammaRaySession(QObject *parent)
|
|||||||
// GammaRay's Endpoint cannot dispatch the signal.
|
// GammaRay's Endpoint cannot dispatch the signal.
|
||||||
GammaRay::registerQuickInspectorProxy();
|
GammaRay::registerQuickInspectorProxy();
|
||||||
|
|
||||||
|
// Register a minimal WidgetInspectorInterface proxy similarly.
|
||||||
|
GammaRay::registerWidgetInspectorProxy();
|
||||||
|
|
||||||
// Pre-fetch the remote models we expose as tools. ObjectBroker::model()
|
// Pre-fetch the remote models we expose as tools. ObjectBroker::model()
|
||||||
// lazily creates a RemoteModel which then syncs asynchronously from the
|
// lazily creates a RemoteModel which then syncs asynchronously from the
|
||||||
// probe (~1-2s for the first rows). Requesting them now at ready() means
|
// probe (~1-2s for the first rows). Requesting them now at ready() means
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
qml-sg-mcp-bridge — MCP server bridging QML SceneGraph introspection.
|
gammaray-mcp-bridge — MCP server bridging GammaRay introspection.
|
||||||
|
|
||||||
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
||||||
SPDX-License-Identifier: GPL-2.0-or-later
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
#include "gammaray_session.h"
|
#include "gammaray_session.h"
|
||||||
#include "scenegraph_tools.h"
|
#include "scenegraph_tools.h"
|
||||||
|
#include "widget_tools.h"
|
||||||
|
|
||||||
#include <common/endpoint.h>
|
#include <common/endpoint.h>
|
||||||
|
|
||||||
@@ -32,12 +33,12 @@ int main(int argc, char **argv)
|
|||||||
qSetMessagePattern(QStringLiteral("[%{type}] %{message}"));
|
qSetMessagePattern(QStringLiteral("[%{type}] %{message}"));
|
||||||
|
|
||||||
QApplication app(argc, argv);
|
QApplication app(argc, argv);
|
||||||
app.setApplicationName(QStringLiteral("qml-sg-mcp-bridge"));
|
app.setApplicationName(QStringLiteral("gammaray-mcp-bridge"));
|
||||||
app.setApplicationVersion(QStringLiteral("0.1.0"));
|
app.setApplicationVersion(QStringLiteral("0.1.0"));
|
||||||
app.setOrganizationName(QStringLiteral("KDAB"));
|
app.setOrganizationName(QStringLiteral("KDAB"));
|
||||||
|
|
||||||
QCommandLineParser parser;
|
QCommandLineParser parser;
|
||||||
parser.setApplicationDescription(QStringLiteral("QML SceneGraph MCP Bridge"));
|
parser.setApplicationDescription(QStringLiteral("GammaRay MCP Bridge"));
|
||||||
parser.addHelpOption();
|
parser.addHelpOption();
|
||||||
parser.addVersionOption();
|
parser.addVersionOption();
|
||||||
|
|
||||||
@@ -70,9 +71,9 @@ int main(int argc, char **argv)
|
|||||||
|
|
||||||
QMcpServer server(QStringLiteral("stdio"));
|
QMcpServer server(QStringLiteral("stdio"));
|
||||||
server.setInstructions(QStringLiteral(
|
server.setInstructions(QStringLiteral(
|
||||||
"QML SceneGraph introspection via GammaRay. Connects to a GammaRay probe "
|
"QML SceneGraph & Qt Widget introspection via GammaRay. Connects to a "
|
||||||
"injected into a Qt/QML application and exposes the scene graph, items, "
|
"GammaRay probe injected into a Qt/QML application and exposes the scene "
|
||||||
"geometry, materials and shaders as tools."));
|
"graph, QML items, widgets, geometry, materials and shaders as tools."));
|
||||||
|
|
||||||
SceneGraphTools tools(&session, &server);
|
SceneGraphTools tools(&session, &server);
|
||||||
server.registerToolSet(&tools, {
|
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("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"), QStringLiteral("Toggle slow animations mode (renders continuously instead of on-demand)") },
|
||||||
{ QStringLiteral("setSlowMode/enabled"), QStringLiteral("true to enable slow mode, false to disable") },
|
{ 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);
|
QObject::connect(&server, &QMcpServer::finished, &app, &QCoreApplication::quit);
|
||||||
|
|
||||||
if (probeUrl.isValid()) {
|
if (probeUrl.isValid()) {
|
||||||
|
|||||||
142
bridge/src/property_reader.cpp
Normal file
142
bridge/src/property_reader.cpp
Normal file
@@ -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 <QAbstractItemModel>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QVariant>
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
16
bridge/src/property_reader.h
Normal file
16
bridge/src/property_reader.h
Normal file
@@ -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
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
|
|
||||||
#include "quickinspector_proxy.h"
|
#include "quickinspector_proxy.h"
|
||||||
|
|
||||||
|
#include <common/endpoint.h>
|
||||||
#include <common/objectbroker.h>
|
#include <common/objectbroker.h>
|
||||||
|
#include <common/protocol.h>
|
||||||
|
|
||||||
#include <QMetaType>
|
#include <QMetaType>
|
||||||
|
|
||||||
@@ -32,6 +34,16 @@ void registerQuickInspectorProxy()
|
|||||||
static bool registered = false;
|
static bool registered = false;
|
||||||
if (registered)
|
if (registered)
|
||||||
return;
|
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;
|
registered = true;
|
||||||
|
|
||||||
auto *proxy = new QuickInspectorProxy();
|
auto *proxy = new QuickInspectorProxy();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include "scenegraph_tools.h"
|
#include "scenegraph_tools.h"
|
||||||
#include "gammaray_session.h"
|
#include "gammaray_session.h"
|
||||||
#include "material_interface.h"
|
#include "material_interface.h"
|
||||||
|
#include "property_reader.h"
|
||||||
#include "quickinspector_types.h"
|
#include "quickinspector_types.h"
|
||||||
|
|
||||||
#include <endpoint.h>
|
#include <endpoint.h>
|
||||||
@@ -40,6 +41,19 @@ static constexpr int kIsCoordinateRole = 257;
|
|||||||
static constexpr int kDrawingModeRole = 257;
|
static constexpr int kDrawingModeRole = 257;
|
||||||
static constexpr int kRenderRole = 258;
|
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) ---
|
// --- async helpers (from earlier scaffold) ---
|
||||||
|
|
||||||
static void waitForData(const QAbstractItemModel *m, int column, int timeoutMs)
|
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())
|
if (const QString e = ensureSession(); !e.isEmpty())
|
||||||
return e;
|
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));
|
auto *m = m_session->model(QString::fromUtf8(kQuickWindowModel));
|
||||||
if (!m)
|
if (!m)
|
||||||
@@ -348,6 +364,8 @@ QString SceneGraphTools::selectQuickWindow(int index) const
|
|||||||
{
|
{
|
||||||
if (const QString e = ensureSession(); !e.isEmpty())
|
if (const QString e = ensureSession(); !e.isEmpty())
|
||||||
return e;
|
return e;
|
||||||
|
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||||
|
return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
|
||||||
|
|
||||||
auto *ep = GammaRay::Endpoint::instance();
|
auto *ep = GammaRay::Endpoint::instance();
|
||||||
if (!ep)
|
if (!ep)
|
||||||
@@ -367,6 +385,8 @@ QString SceneGraphTools::listQuickItems() const
|
|||||||
{
|
{
|
||||||
if (const QString e = ensureSession(); !e.isEmpty())
|
if (const QString e = ensureSession(); !e.isEmpty())
|
||||||
return e;
|
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));
|
auto *m = m_session->model(QString::fromUtf8(kQuickItemModel));
|
||||||
if (!m)
|
if (!m)
|
||||||
@@ -384,6 +404,8 @@ QString SceneGraphTools::listScenegraphNodes() const
|
|||||||
{
|
{
|
||||||
if (const QString e = ensureSession(); !e.isEmpty())
|
if (const QString e = ensureSession(); !e.isEmpty())
|
||||||
return e;
|
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));
|
auto *m = m_session->model(QString::fromUtf8(kQuickSceneGraphModel));
|
||||||
if (!m)
|
if (!m)
|
||||||
@@ -444,6 +466,8 @@ QString SceneGraphTools::ensureNodeSelected(const QString &address) const
|
|||||||
{
|
{
|
||||||
if (const QString e = ensureSession(); !e.isEmpty())
|
if (const QString e = ensureSession(); !e.isEmpty())
|
||||||
return e;
|
return e;
|
||||||
|
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||||
|
return e;
|
||||||
|
|
||||||
if (address.isEmpty())
|
if (address.isEmpty())
|
||||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||||
@@ -739,6 +763,8 @@ QString SceneGraphTools::ensureItemSelected(const QString &address) const
|
|||||||
{
|
{
|
||||||
if (const QString e = ensureSession(); !e.isEmpty())
|
if (const QString e = ensureSession(); !e.isEmpty())
|
||||||
return e;
|
return e;
|
||||||
|
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||||
|
return e;
|
||||||
|
|
||||||
if (address.isEmpty())
|
if (address.isEmpty())
|
||||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||||
@@ -840,127 +866,12 @@ QString SceneGraphTools::getItemProperties(const QString &address) const
|
|||||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||||
QStringLiteral("item property model not available"))).toJson(QJsonDocument::Compact));
|
QStringLiteral("item property model not available"))).toJson(QJsonDocument::Compact));
|
||||||
|
|
||||||
// Item property properties model has 4 columns:
|
QJsonObject out = readAggregatedPropertyModel(m);
|
||||||
// 0 = property name, 1 = value (display string), 2 = type name, 3 = class name
|
if (out.isEmpty())
|
||||||
waitForRows(m, 3000);
|
|
||||||
|
|
||||||
const int rows = m->rowCount();
|
|
||||||
if (rows == 0)
|
|
||||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||||
QStringLiteral("no properties available (did you select a valid QML item?)")))
|
QStringLiteral("no properties available (did you select a valid QML item?)")))
|
||||||
.toJson(QJsonDocument::Compact));
|
.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));
|
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())
|
if (const QString e = ensureSession(); !e.isEmpty())
|
||||||
return e;
|
return e;
|
||||||
|
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||||
|
return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
|
||||||
|
|
||||||
auto *ep = GammaRay::Endpoint::instance();
|
auto *ep = GammaRay::Endpoint::instance();
|
||||||
if (!ep)
|
if (!ep)
|
||||||
@@ -1009,6 +922,8 @@ QString SceneGraphTools::setSlowMode(bool enabled) const
|
|||||||
{
|
{
|
||||||
if (const QString e = ensureSession(); !e.isEmpty())
|
if (const QString e = ensureSession(); !e.isEmpty())
|
||||||
return e;
|
return e;
|
||||||
|
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||||
|
return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
|
||||||
|
|
||||||
auto *ep = GammaRay::Endpoint::instance();
|
auto *ep = GammaRay::Endpoint::instance();
|
||||||
if (!ep)
|
if (!ep)
|
||||||
|
|||||||
49
bridge/src/widget_inspector_proxy.cpp
Normal file
49
bridge/src/widget_inspector_proxy.cpp
Normal file
@@ -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 <common/endpoint.h>
|
||||||
|
#include <common/objectbroker.h>
|
||||||
|
#include <common/protocol.h>
|
||||||
|
|
||||||
|
#include <QAbstractItemModel>
|
||||||
|
|
||||||
|
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
|
||||||
33
bridge/src/widget_inspector_proxy.h
Normal file
33
bridge/src/widget_inspector_proxy.h
Normal file
@@ -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 <QObject>
|
||||||
|
|
||||||
|
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
|
||||||
364
bridge/src/widget_tools.cpp
Normal file
364
bridge/src/widget_tools.cpp
Normal file
@@ -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 <endpoint.h>
|
||||||
|
#include <objectbroker.h>
|
||||||
|
|
||||||
|
#include <QAbstractItemModel>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QItemSelectionModel>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
42
bridge/src/widget_tools.h
Normal file
42
bridge/src/widget_tools.h
Normal file
@@ -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 <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QModelIndex>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -20,13 +20,18 @@ end-to-end against a real GammaRay probe.
|
|||||||
| `test_navigation.py` | `listQuickWindows`, `selectQuickWindow`, `listQuickItems`, `listScenegraphNodes` | Yes |
|
| `test_navigation.py` | `listQuickWindows`, `selectQuickWindow`, `listQuickItems`, `listScenegraphNodes` | Yes |
|
||||||
| `test_items.py` | `selectQuickItem`, `getItemProperties`, position, visual properties, groups | Yes |
|
| `test_items.py` | `selectQuickItem`, `getItemProperties`, position, visual properties, groups | Yes |
|
||||||
| `test_scenegraph.py` | `selectScenegraphNode`, `getNodeVertices`, `getNodeAdjacency`, `getMaterialShaders`, `getMaterialProperties`, `setRenderMode`, `setSlowMode` | Yes |
|
| `test_scenegraph.py` | `selectScenegraphNode`, `getNodeVertices`, `getNodeAdjacency`, `getMaterialShaders`, `getMaterialProperties`, `setRenderMode`, `setSlowMode` | Yes |
|
||||||
|
| `test_widgets.py` | `listWidgets`, `selectWidget`, `getWidgetProperties`, `getWidgetAttributes` | Yes (--widget-app) |
|
||||||
|
|
||||||
## Test QML application
|
## Test applications
|
||||||
|
|
||||||
`testapp/main.qml` is a simple Qt Quick Controls application that exercises all
|
`testapp/main.qml` is a simple Qt Quick Controls application that exercises all
|
||||||
the tools: buttons, switches, sliders, a list view with delegate items, and
|
the QML tools: buttons, switches, sliders, a list view with delegate items, and
|
||||||
visibility/opacity controls.
|
visibility/opacity controls.
|
||||||
|
|
||||||
|
`testapp/widget_test_app.cpp` is a simple QWidget application (QLabel, QLineEdit,
|
||||||
|
QCheckBox, QGroupBox, QPushButton) for testing widget inspection tools. Use
|
||||||
|
`run_widget_tests.sh` to run tests against this app.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
**`run_tests.sh` is the single configuration point.** All paths default to
|
**`run_tests.sh` is the single configuration point.** All paths default to
|
||||||
@@ -40,7 +45,7 @@ system package), override these by editing the variables at the top of
|
|||||||
| `GAMMARAY_LIB_DIR` | `install-prefix/lib` | Runtime library directory for GammaRay |
|
| `GAMMARAY_LIB_DIR` | `install-prefix/lib` | Runtime library directory for GammaRay |
|
||||||
| `QML_RUNNER` | `/usr/lib/qt6/bin/qml` | QML runtime executable |
|
| `QML_RUNNER` | `/usr/lib/qt6/bin/qml` | QML runtime executable |
|
||||||
| `TEST_QML` | `testapp/main.qml` | Test QML application |
|
| `TEST_QML` | `testapp/main.qml` | Test QML application |
|
||||||
| `BRIDGE_EXE` | `bridge/build/qml-sg-mcp-bridge` | Bridge executable |
|
| `BRIDGE_EXE` | `bridge/build/gammaray-mcp-bridge` | Bridge executable |
|
||||||
| `BRIDGE_RUN_SCRIPT` | `bridge/run.sh` | Bridge wrapper script |
|
| `BRIDGE_RUN_SCRIPT` | `bridge/run.sh` | Bridge wrapper script |
|
||||||
|
|
||||||
You can also override any of these via environment variables when calling
|
You can also override any of these via environment variables when calling
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ before calling pytest. If you prefer to run pytest directly, set these:
|
|||||||
GAMMARAY_LIB_DIR — directory containing GammaRay shared libraries
|
GAMMARAY_LIB_DIR — directory containing GammaRay shared libraries
|
||||||
QML_RUNNER — path to the QML runtime executable
|
QML_RUNNER — path to the QML runtime executable
|
||||||
TEST_QML — path to the test QML application
|
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_EXE — path to the bridge executable
|
||||||
BRIDGE_RUN_SCRIPT — path to bridge/run.sh
|
BRIDGE_RUN_SCRIPT — path to bridge/run.sh
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -24,7 +26,6 @@ from mcp_client import McpClient
|
|||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# All paths read from env vars, with sensible project-local defaults.
|
# All paths read from env vars, with sensible project-local defaults.
|
||||||
# Users only need to override these when GammaRay is installed elsewhere.
|
|
||||||
GAMMARAY_BIN = Path(os.environ.get(
|
GAMMARAY_BIN = Path(os.environ.get(
|
||||||
"GAMMARAY_BIN",
|
"GAMMARAY_BIN",
|
||||||
str(PROJECT_ROOT / "install-prefix" / "bin" / "gammaray"),
|
str(PROJECT_ROOT / "install-prefix" / "bin" / "gammaray"),
|
||||||
@@ -41,9 +42,17 @@ TEST_QML = Path(os.environ.get(
|
|||||||
"TEST_QML",
|
"TEST_QML",
|
||||||
str(PROJECT_ROOT / "tests" / "testapp" / "main.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 = Path(os.environ.get(
|
||||||
"BRIDGE_EXE",
|
"BRIDGE_EXE",
|
||||||
str(PROJECT_ROOT / "bridge" / "build" / "qml-sg-mcp-bridge"),
|
str(PROJECT_ROOT / "bridge" / "build" / "gammaray-mcp-bridge"),
|
||||||
))
|
))
|
||||||
BRIDGE_RUN_SCRIPT = Path(os.environ.get(
|
BRIDGE_RUN_SCRIPT = Path(os.environ.get(
|
||||||
"BRIDGE_RUN_SCRIPT",
|
"BRIDGE_RUN_SCRIPT",
|
||||||
@@ -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):
|
def pytest_addoption(parser):
|
||||||
parser.addoption(
|
parser.addoption(
|
||||||
"--no-probe",
|
"--no-probe",
|
||||||
@@ -64,6 +110,12 @@ def pytest_addoption(parser):
|
|||||||
default=15,
|
default=15,
|
||||||
help="Seconds to wait for probe to be ready",
|
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):
|
def pytest_collection_modifyitems(config, items):
|
||||||
@@ -88,14 +140,39 @@ def run_script_path() -> Path:
|
|||||||
return BRIDGE_RUN_SCRIPT
|
return BRIDGE_RUN_SCRIPT
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
def _probe_accepting_connections(timeout: int = 20) -> bool:
|
||||||
|
"""Check if the probe is accepting connections via raw TCP."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
s = socket.create_connection(("127.0.0.1", 11732), timeout=2)
|
||||||
|
s.close()
|
||||||
|
return True
|
||||||
|
except (OSError, socket.timeout):
|
||||||
|
time.sleep(1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
def probe_process(request) -> subprocess.Popen | None:
|
def probe_process(request) -> subprocess.Popen | None:
|
||||||
"""Start a GammaRay probe process (session-scoped, one per 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"):
|
if request.config.getoption("--no-probe"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not GAMMARAY_BIN.exists():
|
if not GAMMARAY_BIN.exists():
|
||||||
pytest.skip(f"GammaRay probe binary not found at {GAMMARAY_BIN}")
|
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():
|
if not TEST_QML.exists():
|
||||||
pytest.skip(f"Test QML file not found at {TEST_QML}")
|
pytest.skip(f"Test QML file not found at {TEST_QML}")
|
||||||
if not QML_RUNNER.exists():
|
if not QML_RUNNER.exists():
|
||||||
@@ -105,10 +182,6 @@ def probe_process(request) -> subprocess.Popen | None:
|
|||||||
env["QT_QPA_PLATFORM"] = "offscreen"
|
env["QT_QPA_PLATFORM"] = "offscreen"
|
||||||
env["LD_LIBRARY_PATH"] = GAMMARAY_LIB_DIR
|
env["LD_LIBRARY_PATH"] = GAMMARAY_LIB_DIR
|
||||||
|
|
||||||
proc = None
|
|
||||||
used_systemd = False
|
|
||||||
|
|
||||||
# Try systemd-run first for clean detachment
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["systemctl", "--user", "stop", "gammaray-probe-test.service"],
|
["systemctl", "--user", "stop", "gammaray-probe-test.service"],
|
||||||
capture_output=True, timeout=5,
|
capture_output=True, timeout=5,
|
||||||
@@ -133,83 +206,109 @@ def probe_process(request) -> subprocess.Popen | None:
|
|||||||
],
|
],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
|
||||||
used_systemd = True
|
timeout = request.config.getoption("--probe-timeout")
|
||||||
proc = subprocess.Popen(["true"]) # sentinel: probe is external
|
if not _probe_accepting_connections(timeout):
|
||||||
else:
|
|
||||||
# Fallback: direct spawn
|
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732",
|
[str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732",
|
||||||
"--injector", "preload", str(QML_RUNNER), str(TEST_QML)],
|
"--injector", "preload", str(QML_RUNNER), str(TEST_QML)],
|
||||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
if not _probe_accepting_connections(timeout):
|
||||||
|
pytest.fail(f"QML probe did not become ready within {timeout}s")
|
||||||
|
else:
|
||||||
|
proc = subprocess.Popen(["true"])
|
||||||
|
|
||||||
def cleanup():
|
def cleanup():
|
||||||
if proc:
|
subprocess.run(
|
||||||
proc.terminate()
|
["systemctl", "--user", "stop", "gammaray-probe-test.service"],
|
||||||
try:
|
capture_output=True, timeout=5,
|
||||||
proc.wait(timeout=5)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
subprocess.run(
|
||||||
proc.kill()
|
["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"],
|
||||||
if used_systemd:
|
capture_output=True, timeout=5,
|
||||||
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)
|
request.addfinalizer(cleanup)
|
||||||
|
|
||||||
# Wait for probe to be ready
|
|
||||||
timeout = request.config.getoption("--probe-timeout")
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
c = McpClient(bridge_path=BRIDGE_EXE)
|
|
||||||
c.initialize()
|
|
||||||
result = c.call_tool("connectProbeDefault")
|
|
||||||
c.close()
|
|
||||||
if isinstance(result, dict) and result.get("connected"):
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
time.sleep(1)
|
|
||||||
else:
|
|
||||||
pytest.fail(f"Probe did not become ready within {timeout}s")
|
|
||||||
|
|
||||||
return proc
|
return proc
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
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"])
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
def bridge(run_script_path) -> McpClient:
|
def bridge(run_script_path) -> McpClient:
|
||||||
"""Start a fresh bridge instance per test."""
|
"""Start a bridge instance per module, reused across tests in the module."""
|
||||||
c = McpClient(bridge_path=run_script_path)
|
c = McpClient(bridge_path=run_script_path)
|
||||||
c.initialize()
|
c.initialize()
|
||||||
yield c
|
yield c
|
||||||
# Gracefully disconnect from the probe before killing the bridge.
|
|
||||||
# GammaRay's QTcpSocket::disconnectFromHost() is asynchronous — the
|
|
||||||
# Endpoint::disconnected signal (which clears m_socket) fires on a later
|
|
||||||
# event loop iteration. If we kill the bridge (SIGTERM) while m_socket is
|
|
||||||
# still set, the probe does not immediately detect the disconnection, and
|
|
||||||
# the next test's bridge cannot connect. Sending disconnectProbe() first
|
|
||||||
# triggers a clean GammaRay protocol-level shutdown that waits for the
|
|
||||||
# async disconnect to complete, releasing the probe for the next test.
|
|
||||||
try:
|
try:
|
||||||
c.call_tool("disconnectProbe", timeout=5)
|
c.call_tool("disconnectProbe", timeout=5)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # bridge may have already crashed; best-effort
|
pass
|
||||||
c.close()
|
c.close()
|
||||||
# Allow the probe to detect the disconnection before the next test
|
time.sleep(0.2)
|
||||||
# connects.
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture(scope="module")
|
||||||
def connected_bridge(bridge, probe_process) -> McpClient:
|
def connected_bridge(bridge, probe_process) -> McpClient:
|
||||||
"""Bridge connected to a running probe (retries on transient failures)."""
|
"""Bridge connected to a running probe (module-scoped, reused across tests)."""
|
||||||
if probe_process is None:
|
if probe_process is None:
|
||||||
pytest.skip("No probe available (try --no-probe to skip probe-dependent tests)")
|
pytest.skip("No probe available (try --no-probe to skip probe-dependent tests)")
|
||||||
deadline = time.monotonic() + 30
|
deadline = time.monotonic() + 30
|
||||||
@@ -219,5 +318,5 @@ def connected_bridge(bridge, probe_process) -> McpClient:
|
|||||||
if isinstance(result, dict) and result.get("connected"):
|
if isinstance(result, dict) and result.get("connected"):
|
||||||
return bridge
|
return bridge
|
||||||
last_error = result
|
last_error = result
|
||||||
time.sleep(3)
|
time.sleep(1)
|
||||||
raise AssertionError(f"Failed to connect to probe after 30s: {last_error}")
|
raise AssertionError(f"Failed to connect to probe after 30s: {last_error}")
|
||||||
@@ -24,7 +24,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|||||||
: "${GAMMARAY_LIB_DIR:=$PROJECT_ROOT/install-prefix/lib}"
|
: "${GAMMARAY_LIB_DIR:=$PROJECT_ROOT/install-prefix/lib}"
|
||||||
: "${QML_RUNNER:=/usr/lib/qt6/bin/qml}"
|
: "${QML_RUNNER:=/usr/lib/qt6/bin/qml}"
|
||||||
: "${TEST_QML:=$SCRIPT_DIR/testapp/main.qml}"
|
: "${TEST_QML:=$SCRIPT_DIR/testapp/main.qml}"
|
||||||
: "${BRIDGE_EXE:=$PROJECT_ROOT/bridge/build/qml-sg-mcp-bridge}"
|
: "${BRIDGE_EXE:=$PROJECT_ROOT/bridge/build/gammaray-mcp-bridge}"
|
||||||
: "${BRIDGE_RUN_SCRIPT:=$PROJECT_ROOT/bridge/run.sh}"
|
: "${BRIDGE_RUN_SCRIPT:=$PROJECT_ROOT/bridge/run.sh}"
|
||||||
|
|
||||||
# Export so conftest.py can read them
|
# Export so conftest.py can read them
|
||||||
|
|||||||
79
tests/run_widget_tests.sh
Executable file
79
tests/run_widget_tests.sh
Executable file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/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/gammaray-mcp-bridge}"
|
||||||
|
: "${BRIDGE_RUN_SCRIPT:=$PROJECT_ROOT/bridge/run.sh}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ensure the widget test app is compiled
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_compile_widget_app() {
|
||||||
|
echo "Compiling widget test app..."
|
||||||
|
local cflags
|
||||||
|
cflags=$(pkg-config --cflags Qt6Core Qt6Gui Qt6Widgets 2>/dev/null) || {
|
||||||
|
echo "Error: pkg-config for Qt6 not found. Install qt6-base-dev or set WIDGET_TEST_APP_BIN to a pre-built binary."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
local libs
|
||||||
|
libs=$(pkg-config --libs Qt6Core Qt6Gui Qt6Widgets 2>/dev/null) || {
|
||||||
|
echo "Error: pkg-config for Qt6 libs not found."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
g++ -std=c++17 -fPIC "$WIDGET_TEST_APP_SRC" -o "$WIDGET_TEST_APP_BIN" \
|
||||||
|
$cflags $libs
|
||||||
|
echo "Widget test app compiled: $WIDGET_TEST_APP_BIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f "$WIDGET_TEST_APP_BIN" ]; then
|
||||||
|
_compile_widget_app
|
||||||
|
fi
|
||||||
|
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 "$@"
|
||||||
@@ -1,67 +1,89 @@
|
|||||||
"""Tests for connection management tools."""
|
"""Tests for connection management tools.
|
||||||
|
|
||||||
|
These tests need function-scoped bridges because they explicitly test
|
||||||
|
connect/disconnect/reconnect lifecycle. The conftest.py module-scoped
|
||||||
|
fixtures would not work here since they share a single connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from mcp_client import McpClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def local_bridge(run_script_path) -> McpClient:
|
||||||
|
c = McpClient(bridge_path=run_script_path)
|
||||||
|
c.initialize()
|
||||||
|
yield c
|
||||||
|
try:
|
||||||
|
c.call_tool("disconnectProbe", timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
c.close()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def local_connected_bridge(local_bridge, probe_process) -> McpClient:
|
||||||
|
if probe_process is None:
|
||||||
|
pytest.skip("No probe available")
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
last_error = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
result = local_bridge.call_tool("connectProbeDefault")
|
||||||
|
if isinstance(result, dict) and result.get("connected"):
|
||||||
|
return local_bridge
|
||||||
|
last_error = result
|
||||||
|
time.sleep(1)
|
||||||
|
raise AssertionError(f"Failed to connect to probe after 30s: {last_error}")
|
||||||
|
|
||||||
|
|
||||||
class TestConnection:
|
class TestConnection:
|
||||||
"""Verify connectProbe, probeStatus, disconnectProbe work."""
|
"""Verify connectProbe, probeStatus, disconnectProbe work."""
|
||||||
|
|
||||||
def test_tools_listed(self, bridge):
|
def test_tools_listed(self, local_bridge):
|
||||||
tools = bridge.list_tools()
|
tools = local_bridge.list_tools()
|
||||||
names = [t["name"] for t in tools]
|
names = [t["name"] for t in tools]
|
||||||
for expected in ["connectProbe", "connectProbeDefault",
|
for expected in ["connectProbe", "connectProbeDefault",
|
||||||
"disconnectProbe", "probeStatus"]:
|
"disconnectProbe", "probeStatus"]:
|
||||||
assert expected in names, f"Missing tool: {expected}"
|
assert expected in names, f"Missing tool: {expected}"
|
||||||
|
|
||||||
def test_probe_status_disconnected(self, bridge):
|
def test_probe_status_disconnected(self, local_bridge):
|
||||||
status = bridge.call_tool("probeStatus")
|
status = local_bridge.call_tool("probeStatus")
|
||||||
assert isinstance(status, dict)
|
assert isinstance(status, dict)
|
||||||
assert status.get("state") in ("disconnected", "connecting")
|
assert status.get("state") in ("disconnected", "connecting")
|
||||||
assert status.get("ready") is False
|
assert status.get("ready") is False
|
||||||
|
|
||||||
def test_connect_and_disconnect(self, bridge, probe_process):
|
def test_connect_and_disconnect(self, local_bridge, probe_process):
|
||||||
if probe_process is None:
|
if probe_process is None:
|
||||||
pytest.skip("No probe available")
|
pytest.skip("No probe available")
|
||||||
# Connect
|
result = local_bridge.call_tool("connectProbeDefault")
|
||||||
result = bridge.call_tool("connectProbeDefault")
|
|
||||||
assert isinstance(result, dict), f"connectProbeDefault failed: {result}"
|
assert isinstance(result, dict), f"connectProbeDefault failed: {result}"
|
||||||
assert result.get("connected"), f"Not connected: {result}"
|
assert result.get("connected"), f"Not connected: {result}"
|
||||||
# Verify status
|
status = local_bridge.call_tool("probeStatus")
|
||||||
status = bridge.call_tool("probeStatus")
|
|
||||||
assert isinstance(status, dict)
|
assert isinstance(status, dict)
|
||||||
assert status.get("state") == "ready"
|
assert status.get("state") == "ready"
|
||||||
assert status.get("ready") is True
|
assert status.get("ready") is True
|
||||||
assert "tcp://" in status.get("url", "")
|
assert "tcp://" in status.get("url", "")
|
||||||
# Now disconnect
|
result = local_bridge.call_tool("disconnectProbe")
|
||||||
result = bridge.call_tool("disconnectProbe")
|
|
||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
assert result.get("state") == "disconnected"
|
assert result.get("state") == "disconnected"
|
||||||
|
|
||||||
def test_reconnect(self, bridge, probe_process):
|
def test_reconnect(self, local_bridge, probe_process):
|
||||||
if probe_process is None:
|
if probe_process is None:
|
||||||
pytest.skip("No probe available")
|
pytest.skip("No probe available")
|
||||||
bridge.call_tool("connectProbeDefault")
|
local_bridge.call_tool("connectProbeDefault")
|
||||||
# Disconnect
|
local_bridge.call_tool("disconnectProbe")
|
||||||
bridge.call_tool("disconnectProbe")
|
result = local_bridge.call_tool("connectProbe",
|
||||||
# Reconnect with explicit args
|
{"host": "127.0.0.1", "port": 11732})
|
||||||
result = bridge.call_tool("connectProbe",
|
|
||||||
{"host": "127.0.0.1", "port": 11732})
|
|
||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
assert result.get("connected") is True
|
assert result.get("connected") is True
|
||||||
assert result.get("state") == "ready"
|
assert result.get("state") == "ready"
|
||||||
|
|
||||||
def test_connect_default_after_disconnect(self, bridge, probe_process):
|
def test_reconnect_default_after_disconnect(self, local_connected_bridge):
|
||||||
if probe_process is None:
|
local_connected_bridge.call_tool("disconnectProbe")
|
||||||
pytest.skip("No probe available")
|
result = local_connected_bridge.call_tool("connectProbeDefault")
|
||||||
bridge.call_tool("connectProbeDefault")
|
|
||||||
bridge.call_tool("disconnectProbe")
|
|
||||||
result = bridge.call_tool("connectProbeDefault")
|
|
||||||
assert isinstance(result, dict)
|
|
||||||
assert result.get("connected") is True
|
|
||||||
assert result.get("state") == "ready"
|
|
||||||
|
|
||||||
def test_connect_default_after_disconnect(self, connected_bridge):
|
|
||||||
connected_bridge.call_tool("disconnectProbe")
|
|
||||||
result = connected_bridge.call_tool("connectProbeDefault")
|
|
||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
assert result.get("connected") is True
|
assert result.get("connected") is True
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Tests for QML item selection and property inspection."""
|
"""Tests for QML item selection and property inspection."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class TestItemProperties:
|
class TestItemProperties:
|
||||||
@@ -18,7 +17,7 @@ class TestItemProperties:
|
|||||||
def test_select_quick_item(self, connected_bridge):
|
def test_select_quick_item(self, connected_bridge):
|
||||||
# First get items
|
# First get items
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
time.sleep(1)
|
|
||||||
items = connected_bridge.call_tool("listQuickItems")
|
items = connected_bridge.call_tool("listQuickItems")
|
||||||
assert isinstance(items, list) and len(items) > 0, "No items found"
|
assert isinstance(items, list) and len(items) > 0, "No items found"
|
||||||
|
|
||||||
@@ -49,7 +48,7 @@ class TestItemProperties:
|
|||||||
@pytest.mark.probe
|
@pytest.mark.probe
|
||||||
def test_get_item_properties(self, connected_bridge):
|
def test_get_item_properties(self, connected_bridge):
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
time.sleep(1)
|
|
||||||
items = connected_bridge.call_tool("listQuickItems")
|
items = connected_bridge.call_tool("listQuickItems")
|
||||||
|
|
||||||
def find_any_item(items_list):
|
def find_any_item(items_list):
|
||||||
@@ -74,7 +73,7 @@ class TestItemProperties:
|
|||||||
def test_item_has_position(self, connected_bridge):
|
def test_item_has_position(self, connected_bridge):
|
||||||
"""Verify that a Button item has x, y, width, height properties."""
|
"""Verify that a Button item has x, y, width, height properties."""
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
time.sleep(1)
|
|
||||||
items = connected_bridge.call_tool("listQuickItems")
|
items = connected_bridge.call_tool("listQuickItems")
|
||||||
|
|
||||||
# Find a Button_QMLTYPE item
|
# Find a Button_QMLTYPE item
|
||||||
@@ -102,7 +101,7 @@ class TestItemProperties:
|
|||||||
def test_item_has_visual_properties(self, connected_bridge):
|
def test_item_has_visual_properties(self, connected_bridge):
|
||||||
"""Verify items have opacity, visible, z, rotation, scale."""
|
"""Verify items have opacity, visible, z, rotation, scale."""
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
time.sleep(1)
|
|
||||||
items = connected_bridge.call_tool("listQuickItems")
|
items = connected_bridge.call_tool("listQuickItems")
|
||||||
|
|
||||||
def find_addr(items_list):
|
def find_addr(items_list):
|
||||||
@@ -128,7 +127,7 @@ class TestItemProperties:
|
|||||||
def test_property_model_has_groups(self, connected_bridge):
|
def test_property_model_has_groups(self, connected_bridge):
|
||||||
"""Verify items have nested property groups (background, contentItem, etc.)."""
|
"""Verify items have nested property groups (background, contentItem, etc.)."""
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
time.sleep(1)
|
|
||||||
items = connected_bridge.call_tool("listQuickItems")
|
items = connected_bridge.call_tool("listQuickItems")
|
||||||
|
|
||||||
def find_button(items_list):
|
def find_button(items_list):
|
||||||
|
|||||||
@@ -30,23 +30,16 @@ class TestNavigation:
|
|||||||
|
|
||||||
@pytest.mark.probe
|
@pytest.mark.probe
|
||||||
def test_list_items(self, connected_bridge):
|
def test_list_items(self, connected_bridge):
|
||||||
# Select window first so item model populates
|
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
import time
|
|
||||||
time.sleep(1)
|
|
||||||
items = connected_bridge.call_tool("listQuickItems")
|
items = connected_bridge.call_tool("listQuickItems")
|
||||||
assert isinstance(items, list)
|
assert isinstance(items, list)
|
||||||
# May be empty if model hasn't populated yet; that's OK
|
|
||||||
if items:
|
if items:
|
||||||
assert isinstance(items[0], dict)
|
assert isinstance(items[0], dict)
|
||||||
assert "type" in items[0]
|
assert "type" in items[0]
|
||||||
|
|
||||||
@pytest.mark.probe
|
@pytest.mark.probe
|
||||||
def test_list_items_with_window_count(self, connected_bridge):
|
def test_list_items_with_window_count(self, connected_bridge):
|
||||||
"""Collect all items and verify at least some are found."""
|
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
import time
|
|
||||||
time.sleep(1)
|
|
||||||
items = connected_bridge.call_tool("listQuickItems")
|
items = connected_bridge.call_tool("listQuickItems")
|
||||||
|
|
||||||
def count_all(items_list):
|
def count_all(items_list):
|
||||||
@@ -63,21 +56,15 @@ class TestNavigation:
|
|||||||
@pytest.mark.probe
|
@pytest.mark.probe
|
||||||
def test_list_scenegraph_nodes(self, connected_bridge):
|
def test_list_scenegraph_nodes(self, connected_bridge):
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
import time
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||||
assert isinstance(nodes, list)
|
assert isinstance(nodes, list)
|
||||||
if nodes:
|
if nodes:
|
||||||
assert isinstance(nodes[0], dict)
|
assert isinstance(nodes[0], dict)
|
||||||
# Root should be "Root Node"
|
|
||||||
assert nodes[0].get("type") == "Root Node"
|
assert nodes[0].get("type") == "Root Node"
|
||||||
|
|
||||||
@pytest.mark.probe
|
@pytest.mark.probe
|
||||||
def test_scenegraph_has_geometry_nodes(self, connected_bridge):
|
def test_scenegraph_has_geometry_nodes(self, connected_bridge):
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
import time
|
|
||||||
time.sleep(1)
|
|
||||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||||
|
|
||||||
def find_geometry(items_list):
|
def find_geometry(items_list):
|
||||||
@@ -90,4 +77,4 @@ class TestNavigation:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
geo = find_geometry(nodes)
|
geo = find_geometry(nodes)
|
||||||
assert geo is not None, "No Geometry Node found in SG tree"
|
assert geo is not None, "No Geometry Node found in SG tree"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Tests for SceneGraph node selection and geometry/material tools."""
|
"""Tests for SceneGraph node selection and geometry/material tools."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class TestSceneGraph:
|
class TestSceneGraph:
|
||||||
@@ -21,7 +20,7 @@ class TestSceneGraph:
|
|||||||
@pytest.mark.probe
|
@pytest.mark.probe
|
||||||
def test_select_scenegraph_node(self, connected_bridge):
|
def test_select_scenegraph_node(self, connected_bridge):
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
time.sleep(1)
|
|
||||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||||
|
|
||||||
# Find first Geometry Node
|
# Find first Geometry Node
|
||||||
@@ -47,7 +46,7 @@ class TestSceneGraph:
|
|||||||
@pytest.mark.probe
|
@pytest.mark.probe
|
||||||
def test_get_node_vertices(self, connected_bridge):
|
def test_get_node_vertices(self, connected_bridge):
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
time.sleep(1)
|
|
||||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||||
|
|
||||||
def find_geo(items_list):
|
def find_geo(items_list):
|
||||||
@@ -71,7 +70,7 @@ class TestSceneGraph:
|
|||||||
@pytest.mark.probe
|
@pytest.mark.probe
|
||||||
def test_get_node_adjacency(self, connected_bridge):
|
def test_get_node_adjacency(self, connected_bridge):
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
time.sleep(1)
|
|
||||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||||
|
|
||||||
def find_geo(items_list):
|
def find_geo(items_list):
|
||||||
@@ -94,7 +93,7 @@ class TestSceneGraph:
|
|||||||
@pytest.mark.probe
|
@pytest.mark.probe
|
||||||
def test_get_material_shaders(self, connected_bridge):
|
def test_get_material_shaders(self, connected_bridge):
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
time.sleep(1)
|
|
||||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||||
|
|
||||||
def find_geo(items_list):
|
def find_geo(items_list):
|
||||||
@@ -118,7 +117,7 @@ class TestSceneGraph:
|
|||||||
@pytest.mark.probe
|
@pytest.mark.probe
|
||||||
def test_get_material_properties(self, connected_bridge):
|
def test_get_material_properties(self, connected_bridge):
|
||||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||||
time.sleep(1)
|
|
||||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||||
|
|
||||||
def find_geo(items_list):
|
def find_geo(items_list):
|
||||||
|
|||||||
126
tests/test_widgets.py
Normal file
126
tests/test_widgets.py
Normal file
@@ -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")
|
||||||
50
tests/testapp/widget_test_app.cpp
Normal file
50
tests/testapp/widget_test_app.cpp
Normal file
@@ -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 <QApplication>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QGroupBox>
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user