Compare commits
7 Commits
316d52bfe5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e60b19acaf | |||
| a3380dbab5 | |||
| a6335429e5 | |||
| 83df5286c5 | |||
| 591e786a0d | |||
| 268483577e | |||
| f7d6a51220 |
25
.gitignore
vendored
25
.gitignore
vendored
@@ -1,2 +1,25 @@
|
||||
install-prefix/
|
||||
# Build artifacts
|
||||
build/
|
||||
bridge/build/
|
||||
install-prefix/
|
||||
_CPack_Packages/
|
||||
*.deb
|
||||
|
||||
# Compiled test binaries (not committed)
|
||||
tests/testapp/widget_test_app
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
105
PLAN.md
105
PLAN.md
@@ -1,19 +1,19 @@
|
||||
# QML SceneGraph MCP Bridge — Plan
|
||||
# GammaRay MCP Bridge — Plan
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
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
|
||||
│ (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)
|
||||
│ Uses ClientConnectionManager to connect, ObjectBroker to get models
|
||||
│ Connection is lazy: bridge starts without a probe, MCP client calls
|
||||
@@ -99,7 +99,7 @@ Caveats of the FetchContent approach:
|
||||
|
||||
```cmake
|
||||
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_AUTOMOC ON)
|
||||
|
||||
@@ -119,7 +119,7 @@ set(QT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(QT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(qtmcp)
|
||||
|
||||
add_executable(qml-sg-mcp-bridge
|
||||
add_executable(gammaray-mcp-bridge
|
||||
src/main.cpp
|
||||
src/gammaray_session.cpp # wraps ClientConnectionManager + ObjectBroker
|
||||
src/gammaray_session.h
|
||||
@@ -127,7 +127,7 @@ add_executable(qml-sg-mcp-bridge
|
||||
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_common
|
||||
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:
|
||||
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>"
|
||||
)
|
||||
```
|
||||
@@ -152,7 +152,7 @@ cmake --build build
|
||||
# Run with probe libs + qtmcp runtime libs on the path:
|
||||
LD_LIBRARY_PATH=$(pwd)/install-prefix/lib QT_QPA_PLATFORM=offscreen \
|
||||
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
|
||||
@@ -173,7 +173,7 @@ int main(int argc, char **argv) {
|
||||
// ... parse --connect <url> ...
|
||||
|
||||
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
|
||||
server.registerToolSet(tools, {
|
||||
@@ -357,7 +357,7 @@ All introspection tools below call `session->ensureConnected(timeoutMs)` first;
|
||||
|
||||
### Implementation status (updated — see "Known issues / Blocked" section for details on the sub-model population blocker)
|
||||
|
||||
**Working end-to-end (verified via stdio MCP smoke test against a live probe):**
|
||||
**Working end-to-end (verified via full test suite — 26/26 tests passing, 2026-07-07):**
|
||||
- ✅ `connectProbe(host?, port?)` / `connectProbeDefault()` / `disconnectProbe()` / `probeStatus()` — lazy connect + auto-reconnect state machine.
|
||||
- ✅ `listQuickWindows()` / `selectQuickWindow(index)` — window navigation.
|
||||
- ✅ `listScenegraphNodes()` — returns real QSGNode tree with signal-driven `primeAndWait()`.
|
||||
@@ -369,13 +369,25 @@ All introspection tools below call `session->ensureConnected(timeoutMs)` first;
|
||||
- ✅ `getMaterialShaders(address)` — reads `shaderModel`. Returns "no shaders" for nodes without materials.
|
||||
- ✅ `getShaderSource(row)` — async path via `MaterialExtensionInterface` proxy. Works end-to-end when shaderModel has data.
|
||||
- ✅ `getMaterialProperties(address)` — reads `materialPropertyModel`. Returns "no material properties" for nodes without materials.
|
||||
- ✅ `selectQuickItem(address)` — selects a QML item via `SelectionModelClient` for `QuickItemModel.selection`. Returns item property data.
|
||||
- ✅ `getItemProperties(address)` — reads `AggregatedPropertyModel` for a selected QML item. Returns 4-column model (name/value/type/class) with nested property groups.
|
||||
- ✅ **Connection tests** (5/5): tools listed, disconnected status, connect+disconnect, reconnect, reconnect after disconnect.
|
||||
- ✅ **Navigation tests** (7/7): list windows, select window, list items, list scenegraph nodes, scenegraph has geometry nodes.
|
||||
- ✅ **Item property tests** (6/6): tools listed, select item, get properties, has position, has visual properties, has property groups.
|
||||
- ✅ **SceneGraph tests** (8/8): select node, get vertices, get adjacency, get shaders, get material properties, set render mode, set slow mode.
|
||||
|
||||
**Not yet implemented:**
|
||||
- ❌ `grabTexture(objectId)` — `TextureExtension` async image grab. Not started.
|
||||
|
||||
### Known issues / Blocked
|
||||
|
||||
**RESOLVED — Sub-models now populate correctly after SG node selection (2026-07-07).**
|
||||
**All known blockers resolved as of 2026-07-07.**
|
||||
|
||||
**RESOLVED — Item property model lazy-fetch causes `Loading...` data (2026-07-07).**
|
||||
|
||||
`RemoteModel::data()` returns `"Loading..."` on the first call and queues an async fetch. The data only arrives after the event loop processes the server reply. The fix adds a polling loop in `getItemProperties()` that repeatedly calls `data()` and runs the event loop until non-`"Loading..."` data arrives (5s timeout). Similarly, grouped properties (background, contentItem, etc.) require explicit `rowCount()` triggering + event loop processing + `hasChildren()` check because `RemoteModel::rowCount(parent)` returns 0 until the child count response arrives asynchronously.
|
||||
|
||||
The `primeAndWait()` approach was abandoned for the property model because it triggers a GammaRay `RemoteModel` use-after-free crash (`typeinfo name for QSharedMemory` RTTI corruption). The polling approach avoids this by using only single-cell `data()` calls and short event loop iterations instead of deep recursive tree traversal.
|
||||
|
||||
The fix was NOT a code change but a sequencing fix in `ensureNodeSelected()`:
|
||||
|
||||
@@ -401,6 +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:
|
||||
- `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.
|
||||
- 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
|
||||
|
||||
@@ -445,12 +458,12 @@ For headless/CI launching of the probe, `systemd-run --user --no-block` (with `S
|
||||
|
||||
Bridge accepts an optional default probe URL via `--connect <url>` (or `GAMMARAY_PROBE_URL` env). At runtime the connection is driven by `connectProbe`/`disconnectProbe` MCP tools; the URL is remembered so tools can auto-reconnect after a probe restart. The bridge's `GammaRaySession` state machine (`Disconnected → Connecting → Ready`, with `→ Failed` on persistent error) is the single source of truth; `probeStatus()` reports it to the client.
|
||||
|
||||
The bridge acts as an MCP server on stdio. The LLM host (e.g. Claude Desktop, opencode) launches the bridge as a subprocess and communicates via JSON-RPC over stdio.
|
||||
The bridge acts as an MCP server on stdio. The LLM host (e.g. ZCode, opencode) launches the bridge as a subprocess and communicates via JSON-RPC over stdio.
|
||||
|
||||
## Key gotchas to remember
|
||||
|
||||
1. **Protocol version lock**: probe and client must be same GammaRay build. System package too old → build from 3.4.0 source. (`common/protocol.h:141`)
|
||||
2. **GPL-2.0-or-later**: bridge linking gammaray_client must be GPL-compatible. Accepted. qtmcp offers GPL-2.0-only option — compatible.
|
||||
2. **GPL-2.0-or-later**: bridge linking gammaray_client must be GPL-compatible. Accepted. qtmcp offers LGPL-3.0-only / GPL-2.0-only / GPL-3.0-only — we comply under GPL-2.0-only.
|
||||
3. **Qt private headers**: GammaRay build needs `qt6-base-private-dev`, `qt6-declarative-private-dev`. The bridge itself only needs public Qt + installed GammaRay headers. **qtmcp requires Qt 6.8+** (newer than GammaRay's 6.5+ floor); ensure build environment Qt >= 6.8.
|
||||
4. **C++20**: qtmcp requires C++20. Bridge project set to C++20; GammaRay C++17 libs link fine.
|
||||
5. **Models only available after `ClientConnectionManager::ready()`** — do not call `ObjectBroker::model()` before handshake completes. MCP tool calls arriving before probe connection should return an error or wait.
|
||||
@@ -492,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.
|
||||
- ✅ **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.
|
||||
- ✅ **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` ✅
|
||||
- `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":""}` ✅
|
||||
@@ -537,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`.
|
||||
|
||||
## 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
|
||||
|
||||
### 已完成的阶段
|
||||
|
||||
- ✅ **QML SceneGraph 工具**:listScenegraphNodes、getNodeVertices、getNodeAdjacency、getMaterialShaders、getShaderSource、getMaterialProperties、setRenderMode、setSlowMode
|
||||
- ✅ **QML Item 属性工具**:listQuickItems、selectQuickItem、getItemProperties
|
||||
- ✅ **Qt Widget 工具**:listWidgets、selectWidget、getWidgetProperties、getWidgetAttributes
|
||||
- ✅ **跨平台保护**: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 `relayout.qml` uses software renderer so `hasGeometry: false` and empty sub-models are correct behavior.
|
||||
3. **Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`. Follows the `MaterialExtensionInterface` proxy pattern.
|
||||
4. **Fix `listScenegraphNodes` first-call fill stability** — single-call instead of caller needing a second call.
|
||||
5. **Pin qtmcp `GIT_TAG`** to a specific commit/tag for reproducibility (currently tracks `main`).
|
||||
6. **Build `--connect` startup flag integration** with systemd service for auto-attach during development.
|
||||
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.
|
||||
|
||||
146
README.md
Normal file
146
README.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 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, materials, and Qt Widgets.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Target Qt/QML App ──TCP──► GammaRay MCP Bridge ──stdio──► LLM / AI Agent
|
||||
(GammaRay probe (GammaRay client + (opencode,
|
||||
injected) qtmcp MCP server) ZCode, etc.)
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- Qt 6.8+ (system)
|
||||
- C++20 compiler (GCC 12+, Clang 16+)
|
||||
- GammaRay 3.4.0+ (system package: `gammaray-dev` on Debian/Deepin)
|
||||
- [qtmcp](https://github.com/signal-slot/qtmcp) (consumed via FetchContent — no manual install)
|
||||
- Python 3.10+ with `pytest` (for running the test suite)
|
||||
|
||||
## Building
|
||||
|
||||
### 1. Install system dependencies
|
||||
|
||||
```bash
|
||||
# Debian / Deepin
|
||||
sudo apt install gammaray-dev gammaray-plugin-quickinspector
|
||||
```
|
||||
|
||||
### 2. Build the bridge
|
||||
|
||||
```bash
|
||||
cd bridge
|
||||
cmake -S . -B build -G Ninja
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
#### Offline build (using a local qtmcp clone)
|
||||
|
||||
If you have a local clone of qtmcp (e.g. at `/home/user/Sources/qtmcp`),
|
||||
pass `FETCHCONTENT_SOURCE_DIR_QTMcp` to skip the network fetch:
|
||||
|
||||
```bash
|
||||
cd bridge
|
||||
cmake -S . -B build -G Ninja \
|
||||
-DFETCHCONTENT_SOURCE_DIR_QTMcp=/home/user/Sources/qtmcp \
|
||||
-DFETCHCONTENT_FULLY_DISCONNECTED=ON
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
### 3. Run
|
||||
|
||||
```bash
|
||||
# Start a probe (inject into a QML app)
|
||||
gammaray --inject-only --listen tcp://127.0.0.1:11732 \
|
||||
--injector preload /usr/lib/qt6/bin/qml /path/to/app.qml -platform offscreen
|
||||
|
||||
# Or inject into a widget app
|
||||
gammaray --inject-only --listen tcp://127.0.0.1:11732 \
|
||||
--injector preload /path/to/widget-app
|
||||
|
||||
# Start the bridge (stdio MCP server)
|
||||
bridge/run.sh
|
||||
```
|
||||
|
||||
The bridge starts in lazy-connect mode. Call `connectProbe("127.0.0.1", 11732)` from your MCP client once the probe is up.
|
||||
|
||||
### 4. Build a .deb package (optional)
|
||||
|
||||
```bash
|
||||
cd bridge/build
|
||||
cpack -G DEB
|
||||
```
|
||||
|
||||
The package installs the bridge binary (`/usr/bin/gammaray-mcp-bridge`),
|
||||
qtmcp shared libs and plugins, and the `run.sh` helper script.
|
||||
System dependencies (`gammaray >= 3.4.0`, Qt6 libs) are auto-detected.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
### Connection management
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `connectProbe(host, port)` | Connect to a GammaRay probe (defaults: 127.0.0.1:11732) |
|
||||
| `connectProbeDefault()` | Convenience: connect to 127.0.0.1:11732 |
|
||||
| `disconnectProbe()` | Drop connection and forget URL |
|
||||
| `probeStatus()` | Report connection state |
|
||||
|
||||
### QML Navigation
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `listQuickWindows()` | List QQuickWindows in the target app |
|
||||
| `selectQuickWindow(index)` | Select a window for scene graph introspection |
|
||||
| `listQuickItems()` | Recursive QQuickItem tree with types and flags |
|
||||
| `listScenegraphNodes()` | Recursive QSGNode tree (all node types) |
|
||||
|
||||
### QML Item inspection
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `selectQuickItem(address)` | Select a QML item by address, populating its properties model |
|
||||
| `getItemProperties(address)` | Get all Q_PROPERTY values (x, y, width, height, opacity, visible, z, anchors, text, font, etc.) |
|
||||
|
||||
### SG Node inspection
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `selectScenegraphNode(address)` | Select a SG node, populating geometry/material sub-models |
|
||||
| `getNodeVertices(address)` | Read vertex data of a GeometryNode |
|
||||
| `getNodeAdjacency(address)` | Read adjacency/drawing mode of a GeometryNode |
|
||||
| `getMaterialShaders(address)` | List shader stages for a node's material |
|
||||
| `getShaderSource(row)` | Get shader source code (async via MaterialExtensionInterface) |
|
||||
| `getMaterialProperties(address)` | Get material property name/value pairs |
|
||||
|
||||
### Rendering visualization
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `setRenderMode(mode)` | Set render mode (NormalRendering, VisualizeOverdraw, etc.) |
|
||||
| `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
|
||||
|
||||
```bash
|
||||
# QML test suite (requires a QML app probe):
|
||||
./tests/run_tests.sh
|
||||
|
||||
# Widget test suite (requires a widget app probe):
|
||||
./tests/run_widget_tests.sh
|
||||
|
||||
# Unit tests only:
|
||||
./tests/run_tests.sh --no-probe
|
||||
```
|
||||
|
||||
See `tests/README.md` for details.
|
||||
|
||||
## License
|
||||
|
||||
GPL-2.0-or-later. The bridge links GammaRay libraries (GPL-2.0-or-later) and uses qtmcp (available under LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only — we comply under GPL-2.0-only).
|
||||
@@ -1,11 +1,12 @@
|
||||
# QML SceneGraph MCP Bridge
|
||||
# GammaRay MCP Bridge
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# 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 triple-licensed (LGPL-3.0-only / GPL-2.0-only /
|
||||
# GPL-3.0-only); we comply under GPL-2.0-only.
|
||||
|
||||
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_REQUIRED ON)
|
||||
@@ -21,22 +22,26 @@ include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
qtmcp
|
||||
GIT_REPOSITORY https://github.com/signal-slot/qtmcp.git
|
||||
GIT_TAG main # TODO: pin to a released tag/commit for reproducibility
|
||||
GIT_TAG 2706345c44e282aba15cf020608af2283e9fb5f9
|
||||
GIT_SHALLOW TRUE
|
||||
GIT_REMOTE_UPDATE_STRATEGY CHECKOUT
|
||||
)
|
||||
# Qt-prefixed vars (NOT BUILD_EXAMPLES) — verified to suppress example/test build.
|
||||
set(QT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(QT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
# qtmcp is built as shared libs + plugins (Qt plugin system requires this).
|
||||
# For a distribution deb, the package includes the qtmcp shared libs and
|
||||
# stdio plugin alongside the bridge binary. CPACK_DEBIAN_PACKAGE_SHLIBDEPS
|
||||
# auto-detects the required system dependencies (gammaray, qt6, etc.).
|
||||
FetchContent_MakeAvailable(qtmcp)
|
||||
|
||||
# --- GammaRay (built & installed in ../install-prefix by Step 1) ---
|
||||
# Pass -DCMAKE_PREFIX_PATH=$(pwd)/../install-prefix at configure.
|
||||
# --- GammaRay (system package: gammaray-dev >= 3.4.0) ---
|
||||
find_package(GammaRay REQUIRED)
|
||||
|
||||
# --- Qt6 components for the bridge target (Qt6 already found globally by qtmcp) ---
|
||||
find_package(Qt6 COMPONENTS Core Gui Widgets Network REQUIRED)
|
||||
|
||||
add_executable(qml-sg-mcp-bridge
|
||||
add_executable(gammaray-mcp-bridge
|
||||
src/main.cpp
|
||||
src/gammaray_session.cpp
|
||||
src/gammaray_session.h
|
||||
@@ -44,10 +49,18 @@ add_executable(qml-sg-mcp-bridge
|
||||
src/scenegraph_tools.h
|
||||
src/material_interface.cpp
|
||||
src/material_interface.h
|
||||
src/quickinspector_proxy.cpp
|
||||
src/quickinspector_proxy.h
|
||||
src/quickinspector_types.h
|
||||
src/property_reader.cpp
|
||||
src/property_reader.h
|
||||
src/widget_tools.cpp
|
||||
src/widget_tools.h
|
||||
src/widget_inspector_proxy.cpp
|
||||
src/widget_inspector_proxy.h
|
||||
)
|
||||
|
||||
target_link_libraries(qml-sg-mcp-bridge PRIVATE
|
||||
target_link_libraries(gammaray-mcp-bridge PRIVATE
|
||||
gammaray_client # VERIFIED: no GammaRay:: namespace
|
||||
gammaray_common
|
||||
Qt6::Core
|
||||
@@ -58,17 +71,30 @@ target_link_libraries(qml-sg-mcp-bridge PRIVATE
|
||||
Qt6::McpCommon
|
||||
)
|
||||
|
||||
# Add GammaRay source common/ for message.h (not in installed headers)
|
||||
target_include_directories(qml-sg-mcp-bridge PRIVATE
|
||||
/home/blumia/Sources/GammaRay/common
|
||||
install(TARGETS gammaray-mcp-bridge RUNTIME DESTINATION bin)
|
||||
install(PROGRAMS run.sh DESTINATION bin)
|
||||
|
||||
# Runtime: qtmcp shared libs and stdio plugin are built in the build tree.
|
||||
# For development, run.sh sets LD_LIBRARY_PATH and QT_PLUGIN_PATH.
|
||||
# For distribution, CPack bundles qtmcp's install rules automatically.
|
||||
set_target_properties(gammaray-mcp-bridge PROPERTIES
|
||||
BUILD_RPATH "$<TARGET_FILE_DIR:Qt6::McpServer>"
|
||||
INSTALL_RPATH "$ORIGIN/../lib/x86_64-linux-gnu"
|
||||
)
|
||||
|
||||
# Runtime: qtmcp builds SHARED libs + the stdio backend plugin into the build
|
||||
# tree. Set RPATH so the bridge finds libQt6Mcp*.so, and set QT_PLUGIN_PATH at
|
||||
# run time (see run.sh) so Qt loads mcpserverbackend/libqmcpserverstdio.so.
|
||||
# Also add GammaRay's install lib dir for libgammaray_*.so.
|
||||
set(_GR_LIBDIR "$<TARGET_PROPERTY:gammaray_client,IMPORTED_LOCATION>")
|
||||
set_target_properties(qml-sg-mcp-bridge PROPERTIES
|
||||
BUILD_RPATH "$<TARGET_FILE_DIR:Qt6::McpServer>;${CMAKE_SOURCE_DIR}/../install-prefix/lib"
|
||||
INSTALL_RPATH "$ORIGIN/../lib"
|
||||
)
|
||||
# --- CPack packaging ---
|
||||
set(CPACK_PACKAGE_NAME "gammaray-mcp-bridge")
|
||||
set(CPACK_PACKAGE_VERSION "0.1.0")
|
||||
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "GammaRay MCP bridge")
|
||||
set(CPACK_PACKAGE_DESCRIPTION "MCP server that exposes GammaRay probe introspection data (QML SceneGraph, QML items, and Qt Widgets) to LLMs for assisted debugging.")
|
||||
set(CPACK_PACKAGE_CONTACT "Blumia <blumia@example.com>")
|
||||
set(CPACK_PACKAGE_VENDOR "gammaray-mcp")
|
||||
set(CPACK_PACKAGE_SECTION "devel")
|
||||
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "Blumia <blumia@example.com>")
|
||||
set(CPACK_DEBIAN_PACKAGE_SECTION "devel")
|
||||
set(CPACK_DEBIAN_PACKAGE_HOMEPAGE "https://github.com/blumia/gammaray-mcp")
|
||||
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
|
||||
set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "gammaray-plugin-quickinspector")
|
||||
set(CPACK_DEBIAN_FILE_NAME "DEB-DEFAULT")
|
||||
set(CPACK_GENERATOR "DEB")
|
||||
include(CPack)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Run the QML SceneGraph MCP bridge.
|
||||
# Sets up LD_LIBRARY_PATH (GammaRay + qtmcp libs) and QT_PLUGIN_PATH (qtmcp stdio
|
||||
# backend plugin) so the bridge can find everything at runtime.
|
||||
# Run the GammaRay MCP bridge.
|
||||
# Sets up LD_LIBRARY_PATH for qtmcp shared libs (in build tree) and QT_PLUGIN_PATH
|
||||
# (qtmcp stdio backend plugin) so the bridge can find everything at runtime.
|
||||
# GammaRay libraries come from the system package (gammaray-dev).
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
set -e
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$HERE/.."
|
||||
BLD="$HERE/build"
|
||||
export LD_LIBRARY_PATH="$ROOT/install-prefix/lib:$BLD/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
|
||||
export LD_LIBRARY_PATH="$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_QPA_PLATFORM=offscreen
|
||||
exec "$BLD/qml-sg-mcp-bridge" "$@"
|
||||
exec "$BLD/gammaray-mcp-bridge" "$@"
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
|
||||
#include "gammaray_session.h"
|
||||
|
||||
#include "quickinspector_proxy.h"
|
||||
#include "widget_inspector_proxy.h"
|
||||
|
||||
#include <client/clientconnectionmanager.h>
|
||||
#include <common/objectbroker.h>
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QEventLoop>
|
||||
#include <QItemSelectionModel>
|
||||
#include <QMargins>
|
||||
#include <QTimer>
|
||||
|
||||
// Defined in material_interface.cpp — registers the MaterialExtensionInterface
|
||||
@@ -29,6 +33,16 @@ GammaRaySession::GammaRaySession(QObject *parent)
|
||||
QObject::connect(m_conMan, &GammaRay::ClientConnectionManager::ready, [this]() {
|
||||
m_lastError.clear();
|
||||
setState(State::Ready);
|
||||
|
||||
// Register a minimal QuickInspectorInterface proxy now that
|
||||
// Endpoint::instance() is available. The probe emits features() during
|
||||
// selectWindow — without a client-side object with the right IID,
|
||||
// GammaRay's Endpoint cannot dispatch the signal.
|
||||
GammaRay::registerQuickInspectorProxy();
|
||||
|
||||
// Register a minimal WidgetInspectorInterface proxy similarly.
|
||||
GammaRay::registerWidgetInspectorProxy();
|
||||
|
||||
// Pre-fetch the remote models we expose as tools. ObjectBroker::model()
|
||||
// lazily creates a RemoteModel which then syncs asynchronously from the
|
||||
// probe (~1-2s for the first rows). Requesting them now at ready() means
|
||||
@@ -66,6 +80,13 @@ GammaRaySession::~GammaRaySession() = default;
|
||||
void GammaRaySession::initOnce()
|
||||
{
|
||||
GammaRay::ClientConnectionManager::init();
|
||||
|
||||
// Register Qt types that GammaRay's QDataStream may serialize/deserialize.
|
||||
// Without these, QVariant::load() fails with "unknown user type" and the
|
||||
// GammaRay::Message stream enters an error state, crashing the bridge.
|
||||
qRegisterMetaType<QMargins>("QMargins");
|
||||
qRegisterMetaType<QMarginsF>("QMarginsF");
|
||||
|
||||
// Register our MaterialExtensionInterface proxy factory. The real factory
|
||||
// is in QuickInspectorUiFactory::initUi() (a GUI plugin we don't load).
|
||||
// Without this, ObjectBroker::object<MaterialExtensionInterface *>() would
|
||||
@@ -85,11 +106,23 @@ void GammaRaySession::connectToHost(const QUrl &url)
|
||||
void GammaRaySession::disconnectFromHost()
|
||||
{
|
||||
m_reconnectScheduled = false;
|
||||
// Clear the URL so auto-reconnect won't fire after an explicit disconnect.
|
||||
// (lastUrl() returns empty afterwards — connectProbe() is required to
|
||||
// re-establish, which matches user intent for "I want this bridge off".)
|
||||
m_serverUrl = QUrl();
|
||||
m_conMan->disconnectFromHost();
|
||||
|
||||
// Wait for the async disconnect to complete. QTcpSocket::disconnectFromHost()
|
||||
// is asynchronous — Endpoint::connectionClosed() (which clears m_socket)
|
||||
// fires on a later event loop iteration. Without this wait, the next
|
||||
// connectToHost() in the same process would trigger
|
||||
// socketConnected() → Endpoint::setDevice()
|
||||
// which asserts Q_ASSERT(!m_socket) because the old socket is still set.
|
||||
{
|
||||
QEventLoop loop;
|
||||
QObject::connect(m_conMan, &GammaRay::ClientConnectionManager::disconnected,
|
||||
&loop, &QEventLoop::quit);
|
||||
QTimer::singleShot(5000, &loop, &QEventLoop::quit);
|
||||
loop.exec();
|
||||
}
|
||||
|
||||
setState(State::Disconnected);
|
||||
}
|
||||
|
||||
@@ -98,7 +131,7 @@ bool GammaRaySession::ensureConnected(int timeoutMs)
|
||||
if (m_state == State::Ready)
|
||||
return true;
|
||||
if (!m_serverUrl.isValid())
|
||||
return false; // caller must invoke connectProbe() first
|
||||
return false;
|
||||
if (m_state == State::Disconnected || m_state == State::Failed)
|
||||
connectToHost(m_serverUrl); // kick a fresh attempt
|
||||
|
||||
|
||||
@@ -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-License-Identifier: GPL-2.0-or-later
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
#include "gammaray_session.h"
|
||||
#include "scenegraph_tools.h"
|
||||
#include "widget_tools.h"
|
||||
|
||||
#include <common/endpoint.h>
|
||||
|
||||
@@ -32,12 +33,12 @@ int main(int argc, char **argv)
|
||||
qSetMessagePattern(QStringLiteral("[%{type}] %{message}"));
|
||||
|
||||
QApplication app(argc, argv);
|
||||
app.setApplicationName(QStringLiteral("qml-sg-mcp-bridge"));
|
||||
app.setApplicationName(QStringLiteral("gammaray-mcp-bridge"));
|
||||
app.setApplicationVersion(QStringLiteral("0.1.0"));
|
||||
app.setOrganizationName(QStringLiteral("KDAB"));
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(QStringLiteral("QML SceneGraph MCP Bridge"));
|
||||
parser.setApplicationDescription(QStringLiteral("GammaRay MCP Bridge"));
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
|
||||
@@ -70,9 +71,9 @@ int main(int argc, char **argv)
|
||||
|
||||
QMcpServer server(QStringLiteral("stdio"));
|
||||
server.setInstructions(QStringLiteral(
|
||||
"QML SceneGraph introspection via GammaRay. Connects to a GammaRay probe "
|
||||
"injected into a Qt/QML application and exposes the scene graph, items, "
|
||||
"geometry, materials and shaders as tools."));
|
||||
"QML SceneGraph & Qt Widget introspection via GammaRay. Connects to a "
|
||||
"GammaRay probe injected into a Qt/QML application and exposes the scene "
|
||||
"graph, QML items, widgets, geometry, materials and shaders as tools."));
|
||||
|
||||
SceneGraphTools tools(&session, &server);
|
||||
server.registerToolSet(&tools, {
|
||||
@@ -115,8 +116,20 @@ int main(int argc, char **argv)
|
||||
{ QStringLiteral("setRenderMode/mode"), QStringLiteral("Render mode: NormalRendering, VisualizeClipping, VisualizeOverdraw, VisualizeBatches, VisualizeChanges, or VisualizeTraces") },
|
||||
{ QStringLiteral("setSlowMode"), QStringLiteral("Toggle slow animations mode (renders continuously instead of on-demand)") },
|
||||
{ QStringLiteral("setSlowMode/enabled"), QStringLiteral("true to enable slow mode, false to disable") },
|
||||
// Widget tree navigation
|
||||
{ QStringLiteral("listWidgets"), QStringLiteral("List the QWidget hierarchy (widget types, names, visibility)") },
|
||||
// Widget selection + properties
|
||||
{ QStringLiteral("selectWidget"), QStringLiteral("Select a widget by address (from listWidgets) so widget properties populate for it") },
|
||||
{ QStringLiteral("selectWidget/address"), QStringLiteral("Widget address from listWidgets") },
|
||||
{ QStringLiteral("getWidgetProperties"), QStringLiteral("Get all Q_PROPERTY values for a selected widget (geometry, font, palette, etc.)") },
|
||||
{ QStringLiteral("getWidgetProperties/address"), QStringLiteral("Widget address (from listWidgets)") },
|
||||
{ QStringLiteral("getWidgetAttributes"), QStringLiteral("Get Qt::WidgetAttribute flags (acceptDrops, enabled, etc.) for a selected widget") },
|
||||
{ QStringLiteral("getWidgetAttributes/address"), QStringLiteral("Widget address (from listWidgets)") },
|
||||
});
|
||||
|
||||
WidgetTools widgetTools(&session, &server);
|
||||
server.registerToolSet(&widgetTools, {});
|
||||
|
||||
QObject::connect(&server, &QMcpServer::finished, &app, &QCoreApplication::quit);
|
||||
|
||||
if (probeUrl.isValid()) {
|
||||
|
||||
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
|
||||
55
bridge/src/quickinspector_proxy.cpp
Normal file
55
bridge/src/quickinspector_proxy.cpp
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
quickinspector_proxy.cpp — Minimal QuickInspectorInterface client proxy.
|
||||
|
||||
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#include "quickinspector_proxy.h"
|
||||
|
||||
#include <common/endpoint.h>
|
||||
#include <common/objectbroker.h>
|
||||
#include <common/protocol.h>
|
||||
|
||||
#include <QMetaType>
|
||||
|
||||
namespace GammaRay {
|
||||
|
||||
QuickInspectorProxy::QuickInspectorProxy(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
// Register the metatype string for QFlags<QuickInspectorInterface::Feature>
|
||||
// as a typedef for int. The features() signal packs this type into QVariant,
|
||||
// and QVariant::load() fails with "unknown user type" without registration.
|
||||
if (QMetaType::fromName("QFlags<GammaRay::QuickInspectorInterface::Feature>").id() == QMetaType::UnknownType) {
|
||||
QMetaType::registerNormalizedTypedef(
|
||||
"QFlags<GammaRay::QuickInspectorInterface::Feature>",
|
||||
QMetaType::fromType<int>());
|
||||
}
|
||||
}
|
||||
|
||||
void registerQuickInspectorProxy()
|
||||
{
|
||||
// Guard: only register once (Endpoint::registerObject asserts on duplicate).
|
||||
static bool registered = false;
|
||||
if (registered)
|
||||
return;
|
||||
|
||||
// Only register if the QuickInspector plugin is actually active in the
|
||||
// target process. Check via QuickWindowModel address.
|
||||
auto *ep = Endpoint::instance();
|
||||
if (!ep)
|
||||
return;
|
||||
if (ep->objectAddress(QStringLiteral("com.kdab.GammaRay.QuickWindowModel"))
|
||||
== Protocol::InvalidObjectAddress)
|
||||
return;
|
||||
|
||||
registered = true;
|
||||
|
||||
auto *proxy = new QuickInspectorProxy();
|
||||
ObjectBroker::registerObject(
|
||||
QStringLiteral("com.kdab.GammaRay.QuickInspectorInterface/1.0"),
|
||||
proxy);
|
||||
}
|
||||
|
||||
} // namespace GammaRay
|
||||
38
bridge/src/quickinspector_proxy.h
Normal file
38
bridge/src/quickinspector_proxy.h
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
quickinspector_proxy.h — Minimal QuickInspectorInterface client proxy.
|
||||
|
||||
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
The probe emits QuickInspectorInterface signals (features, slowModeChanged,
|
||||
etc.) without a matching slot registered via ObjectBroker. This proxy
|
||||
registers with the IID as object name so GammaRay's Endpoint can dispatch
|
||||
signals. No-op stub slots prevent "cannot call method features on unknown
|
||||
object" errors.
|
||||
*/
|
||||
|
||||
#ifndef QUICKINSPECTOR_PROXY_H
|
||||
#define QUICKINSPECTOR_PROXY_H
|
||||
|
||||
#include <QObject>
|
||||
|
||||
namespace GammaRay {
|
||||
|
||||
class QuickInspectorProxy : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_CLASSINFO("IID", "com.kdab.GammaRay.QuickInspectorInterface/1.0")
|
||||
public:
|
||||
explicit QuickInspectorProxy(QObject *parent = nullptr);
|
||||
|
||||
public slots:
|
||||
void features(int) {}
|
||||
void serverSideDecorationChanged(bool) {}
|
||||
void slowModeChanged(bool) {}
|
||||
};
|
||||
|
||||
void registerQuickInspectorProxy();
|
||||
|
||||
} // namespace GammaRay
|
||||
|
||||
#endif // QUICKINSPECTOR_PROXY_H
|
||||
@@ -8,10 +8,10 @@
|
||||
#include "scenegraph_tools.h"
|
||||
#include "gammaray_session.h"
|
||||
#include "material_interface.h"
|
||||
#include "property_reader.h"
|
||||
#include "quickinspector_types.h"
|
||||
|
||||
#include <endpoint.h>
|
||||
#include <message.h>
|
||||
#include <protocol.h>
|
||||
#include <objectbroker.h>
|
||||
|
||||
@@ -40,6 +40,19 @@ static constexpr int kIsCoordinateRole = 257;
|
||||
static constexpr int kDrawingModeRole = 257;
|
||||
static constexpr int kRenderRole = 258;
|
||||
|
||||
// Returns a non-empty error string if the QuickInspector is not active.
|
||||
// Guards QML-specific tools against widget-only target processes.
|
||||
static QString ensureQuickInspector()
|
||||
{
|
||||
auto *ep = GammaRay::Endpoint::instance();
|
||||
if (!ep)
|
||||
return QStringLiteral("no GammaRay endpoint");
|
||||
if (ep->objectAddress(QString::fromUtf8(kQuickInspectorIface))
|
||||
== GammaRay::Protocol::InvalidObjectAddress)
|
||||
return QStringLiteral("QuickInspector not active — target app has no QQuickWindow");
|
||||
return {};
|
||||
}
|
||||
|
||||
// --- async helpers (from earlier scaffold) ---
|
||||
|
||||
static void waitForData(const QAbstractItemModel *m, int column, int timeoutMs)
|
||||
@@ -324,6 +337,8 @@ QString SceneGraphTools::listQuickWindows() const
|
||||
{
|
||||
if (const QString e = ensureSession(); !e.isEmpty())
|
||||
return e;
|
||||
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
|
||||
|
||||
auto *m = m_session->model(QString::fromUtf8(kQuickWindowModel));
|
||||
if (!m)
|
||||
@@ -348,6 +363,8 @@ QString SceneGraphTools::selectQuickWindow(int index) const
|
||||
{
|
||||
if (const QString e = ensureSession(); !e.isEmpty())
|
||||
return e;
|
||||
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
|
||||
|
||||
auto *ep = GammaRay::Endpoint::instance();
|
||||
if (!ep)
|
||||
@@ -367,6 +384,8 @@ QString SceneGraphTools::listQuickItems() const
|
||||
{
|
||||
if (const QString e = ensureSession(); !e.isEmpty())
|
||||
return e;
|
||||
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
|
||||
|
||||
auto *m = m_session->model(QString::fromUtf8(kQuickItemModel));
|
||||
if (!m)
|
||||
@@ -384,6 +403,8 @@ QString SceneGraphTools::listScenegraphNodes() const
|
||||
{
|
||||
if (const QString e = ensureSession(); !e.isEmpty())
|
||||
return e;
|
||||
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
|
||||
|
||||
auto *m = m_session->model(QString::fromUtf8(kQuickSceneGraphModel));
|
||||
if (!m)
|
||||
@@ -444,6 +465,8 @@ QString SceneGraphTools::ensureNodeSelected(const QString &address) const
|
||||
{
|
||||
if (const QString e = ensureSession(); !e.isEmpty())
|
||||
return e;
|
||||
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||
return e;
|
||||
|
||||
if (address.isEmpty())
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||
@@ -739,6 +762,8 @@ QString SceneGraphTools::ensureItemSelected(const QString &address) const
|
||||
{
|
||||
if (const QString e = ensureSession(); !e.isEmpty())
|
||||
return e;
|
||||
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||
return e;
|
||||
|
||||
if (address.isEmpty())
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||
@@ -780,8 +805,17 @@ QString SceneGraphTools::ensureItemSelected(const QString &address) const
|
||||
|
||||
m_selectedItemAddress = address;
|
||||
|
||||
// Prime the property model to fetch its row count and cell data.
|
||||
if (propModel) { propModel->rowCount(); primeAndWait(propModel, 4, 300, 500, 1500); }
|
||||
// Wait for the property model to populate. The RemoteModel's lazy-fetch
|
||||
// mechanism requests data from the probe on the first rowCount() call.
|
||||
// After the wait, the data should have arrived.
|
||||
if (propModel) {
|
||||
propModel->rowCount();
|
||||
// Give the model time to settle. Use a longer wait since the
|
||||
// AggregatedPropertyModel has nested groups that need multiple
|
||||
// fetch rounds. primeAndWait() is avoided here because it triggers
|
||||
// a GammaRay RemoteModel use-after-free in primeTree().
|
||||
settle(loop, 1500);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -795,26 +829,28 @@ QString SceneGraphTools::selectQuickItem(const QString &address) const
|
||||
|
||||
// Report what we know about the selected item.
|
||||
auto *itemModel = m_session->model(QString::fromUtf8(kQuickItemModel));
|
||||
const QModelIndex idx = findNodeByAddress(itemModel, address);
|
||||
QString type;
|
||||
int flags = 0;
|
||||
int propertyCount = 0;
|
||||
if (idx.isValid()) {
|
||||
const QModelIndex typeIdx = itemModel->index(idx.row(), 1, idx.parent());
|
||||
type = itemModel->data(typeIdx, Qt::DisplayRole).toString();
|
||||
flags = itemModel->data(idx, kItemFlagsRole).toInt();
|
||||
QJsonObject out;
|
||||
if (!itemModel) {
|
||||
out.insert(QStringLiteral("error"), QStringLiteral("QuickItemModel gone"));
|
||||
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
const QModelIndex idx = findNodeByAddress(itemModel, 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 = itemModel->index(idx.row(), 1, idx.parent());
|
||||
const QString type = itemModel->data(typeIdx, Qt::DisplayRole).toString();
|
||||
const int flags = itemModel->data(idx, kItemFlagsRole).toInt();
|
||||
int propertyCount = 0;
|
||||
auto *propModel = m_session->model(QStringLiteral("%1.properties").arg(QString::fromUtf8(kItemBaseName)));
|
||||
if (propModel)
|
||||
propertyCount = propModel->rowCount();
|
||||
|
||||
QJsonObject out = {
|
||||
{ QStringLiteral("address"), address },
|
||||
{ QStringLiteral("type"), type },
|
||||
{ QStringLiteral("flags"), flagsToJson(flags) },
|
||||
{ QStringLiteral("propertyCount"), propertyCount },
|
||||
};
|
||||
out.insert(QStringLiteral("selected"), address);
|
||||
out.insert(QStringLiteral("type"), type);
|
||||
out.insert(QStringLiteral("flags"), flags);
|
||||
out.insert(QStringLiteral("propertyCount"), propertyCount);
|
||||
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
@@ -829,61 +865,12 @@ QString SceneGraphTools::getItemProperties(const QString &address) const
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||
QStringLiteral("item property model not available"))).toJson(QJsonDocument::Compact));
|
||||
|
||||
// Item property properties model has 4 columns:
|
||||
// 0 = property name, 1 = value (display string), 2 = type name, 3 = class name
|
||||
waitForRows(m, 3000);
|
||||
|
||||
const int rows = m->rowCount();
|
||||
if (rows == 0)
|
||||
QJsonObject out = readAggregatedPropertyModel(m);
|
||||
if (out.isEmpty())
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(
|
||||
QStringLiteral("no properties available (did you select a valid QML item?)")))
|
||||
.toJson(QJsonDocument::Compact));
|
||||
|
||||
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());
|
||||
|
||||
// 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 < rows; ++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();
|
||||
|
||||
// Check if this property has children (nested sub-properties).
|
||||
QJsonObject prop;
|
||||
prop.insert(QStringLiteral("value"), val);
|
||||
prop.insert(QStringLiteral("type"), ptype);
|
||||
|
||||
if (m->hasChildren(m->index(r, 0))) {
|
||||
// For grouped properties (anchors, transform, etc.), collect children.
|
||||
QJsonObject children;
|
||||
for (int cr = 0; cr < m->rowCount(m->index(r, 0)); ++cr) {
|
||||
const QString cname = m->data(m->index(cr, 0, m->index(r, 0)), Qt::DisplayRole).toString();
|
||||
const QString cval = m->data(m->index(cr, 1, m->index(r, 0)), Qt::DisplayRole).toString();
|
||||
const QString ctype = cols > 2 ? m->data(m->index(cr, 2, m->index(r, 0)), Qt::DisplayRole).toString() : QString();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -891,6 +878,8 @@ QString SceneGraphTools::setRenderMode(const QString &mode) const
|
||||
{
|
||||
if (const QString e = ensureSession(); !e.isEmpty())
|
||||
return e;
|
||||
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
|
||||
|
||||
auto *ep = GammaRay::Endpoint::instance();
|
||||
if (!ep)
|
||||
@@ -932,6 +921,8 @@ QString SceneGraphTools::setSlowMode(bool enabled) const
|
||||
{
|
||||
if (const QString e = ensureSession(); !e.isEmpty())
|
||||
return e;
|
||||
if (const QString e = ensureQuickInspector(); !e.isEmpty())
|
||||
return QString::fromUtf8(QJsonDocument(errorJson(e)).toJson(QJsonDocument::Compact));
|
||||
|
||||
auto *ep = GammaRay::Endpoint::instance();
|
||||
if (!ep)
|
||||
|
||||
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
|
||||
13
pyproject.toml
Normal file
13
pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "gammaray-mcp-tests"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["pytest>=8.0"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
markers = [
|
||||
"probe: tests that require a running GammaRay probe",
|
||||
]
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
76
tests/README.md
Normal file
76
tests/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Test Suite
|
||||
|
||||
Integration tests for the gammaray-mcp bridge, written in Python using `pytest`.
|
||||
Tests communicate with the bridge over stdio JSON-RPC, exercising every MCP tool
|
||||
end-to-end against a real GammaRay probe.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
./run_tests.sh --no-probe # unit tests only (no probe needed)
|
||||
./run_tests.sh -v # full suite, verbose
|
||||
./run_tests.sh -k "item" # item-related tests only
|
||||
```
|
||||
|
||||
## Test files
|
||||
|
||||
| File | Tests | Probe required |
|
||||
|---|---|---|
|
||||
| `test_connection.py` | `connectProbe`, `probeStatus`, `disconnectProbe`, reconnect | Yes (except `test_tools_listed`, `test_probe_status_disconnected`) |
|
||||
| `test_navigation.py` | `listQuickWindows`, `selectQuickWindow`, `listQuickItems`, `listScenegraphNodes` | Yes |
|
||||
| `test_items.py` | `selectQuickItem`, `getItemProperties`, position, visual properties, groups | Yes |
|
||||
| `test_scenegraph.py` | `selectScenegraphNode`, `getNodeVertices`, `getNodeAdjacency`, `getMaterialShaders`, `getMaterialProperties`, `setRenderMode`, `setSlowMode` | Yes |
|
||||
| `test_widgets.py` | `listWidgets`, `selectWidget`, `getWidgetProperties`, `getWidgetAttributes` | Yes (--widget-app) |
|
||||
|
||||
## Test applications
|
||||
|
||||
`testapp/main.qml` is a simple Qt Quick Controls application that exercises all
|
||||
the QML tools: buttons, switches, sliders, a list view with delegate items, and
|
||||
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
|
||||
|
||||
**`run_tests.sh` is the single configuration point.** All paths default to
|
||||
project-local locations. When GammaRay is installed elsewhere (e.g. from a
|
||||
system package), override these by editing the variables at the top of
|
||||
`run_tests.sh`:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `GAMMARAY_BIN` | `install-prefix/bin/gammaray` | Path to the probe binary |
|
||||
| `GAMMARAY_LIB_DIR` | `install-prefix/lib` | Runtime library directory for GammaRay |
|
||||
| `QML_RUNNER` | `/usr/lib/qt6/bin/qml` | QML runtime executable |
|
||||
| `TEST_QML` | `testapp/main.qml` | Test QML application |
|
||||
| `BRIDGE_EXE` | `bridge/build/gammaray-mcp-bridge` | Bridge executable |
|
||||
| `BRIDGE_RUN_SCRIPT` | `bridge/run.sh` | Bridge wrapper script |
|
||||
|
||||
You can also override any of these via environment variables when calling
|
||||
`run_tests.sh` or `pytest` directly:
|
||||
|
||||
```bash
|
||||
GAMMARAY_BIN=/usr/bin/gammaray GAMMARAY_LIB_DIR=/usr/lib/x86_64-linux-gnu \
|
||||
./run_tests.sh --no-probe
|
||||
```
|
||||
|
||||
## pytest options
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `--no-probe` | `false` | Skip all probe-dependent tests |
|
||||
| `--probe-timeout` | 15s | Seconds to wait for probe to become ready |
|
||||
|
||||
## Markers
|
||||
|
||||
- `@pytest.mark.probe` — test requires a running GammaRay probe.
|
||||
Automatically skipped with `--no-probe`.
|
||||
|
||||
## Fixtures
|
||||
|
||||
- `bridge` — fresh `McpClient` instance per test (initialized, not connected)
|
||||
- `connected_bridge` — bridge connected to a probe (via `connectProbeDefault`)
|
||||
- `probe_process` — session-scoped, starts a probe via `systemd-run` (fallback:
|
||||
direct spawn) and waits for `ready()`
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# gammaray-mcp test suite
|
||||
322
tests/conftest.py
Normal file
322
tests/conftest.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""pytest fixtures for gammaray-mcp bridge tests.
|
||||
|
||||
All paths are configurable via environment variables. run_tests.sh is the
|
||||
canonical configuration point — it sets sensible defaults and exports them
|
||||
before calling pytest. If you prefer to run pytest directly, set these:
|
||||
|
||||
GAMMARAY_BIN — path to the gammaray probe binary
|
||||
GAMMARAY_LIB_DIR — directory containing GammaRay shared libraries
|
||||
QML_RUNNER — path to the QML runtime executable
|
||||
TEST_QML — path to the test QML application
|
||||
WIDGET_TEST_APP — path to the widget test application (C++ QWidget binary)
|
||||
BRIDGE_EXE — path to the bridge executable
|
||||
BRIDGE_RUN_SCRIPT — path to bridge/run.sh
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_client import McpClient
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# All paths read from env vars, with sensible project-local defaults.
|
||||
GAMMARAY_BIN = Path(os.environ.get(
|
||||
"GAMMARAY_BIN",
|
||||
str(PROJECT_ROOT / "install-prefix" / "bin" / "gammaray"),
|
||||
))
|
||||
GAMMARAY_LIB_DIR = os.environ.get(
|
||||
"GAMMARAY_LIB_DIR",
|
||||
str(PROJECT_ROOT / "install-prefix" / "lib"),
|
||||
)
|
||||
QML_RUNNER = Path(os.environ.get(
|
||||
"QML_RUNNER",
|
||||
"/usr/lib/qt6/bin/qml",
|
||||
))
|
||||
TEST_QML = Path(os.environ.get(
|
||||
"TEST_QML",
|
||||
str(PROJECT_ROOT / "tests" / "testapp" / "main.qml"),
|
||||
))
|
||||
WIDGET_TEST_APP_BIN = Path(os.environ.get(
|
||||
"WIDGET_TEST_APP_BIN",
|
||||
str(PROJECT_ROOT / "tests" / "testapp" / "widget_test_app"),
|
||||
))
|
||||
WIDGET_TEST_APP_SRC = Path(os.environ.get(
|
||||
"WIDGET_TEST_APP_SRC",
|
||||
str(PROJECT_ROOT / "tests" / "testapp" / "widget_test_app.cpp"),
|
||||
))
|
||||
BRIDGE_EXE = Path(os.environ.get(
|
||||
"BRIDGE_EXE",
|
||||
str(PROJECT_ROOT / "bridge" / "build" / "gammaray-mcp-bridge"),
|
||||
))
|
||||
BRIDGE_RUN_SCRIPT = Path(os.environ.get(
|
||||
"BRIDGE_RUN_SCRIPT",
|
||||
str(PROJECT_ROOT / "bridge" / "run.sh"),
|
||||
))
|
||||
|
||||
|
||||
def _compile_widget_app() -> Path:
|
||||
"""Compile the widget test app if needed. Returns the binary path."""
|
||||
if WIDGET_TEST_APP_BIN.exists():
|
||||
return WIDGET_TEST_APP_BIN
|
||||
if not WIDGET_TEST_APP_SRC.exists():
|
||||
pytest.skip(f"Widget test app source not found at {WIDGET_TEST_APP_SRC}")
|
||||
# Find Qt6 via pkg-config
|
||||
try:
|
||||
import subprocess as sp
|
||||
cflags = sp.check_output(
|
||||
["pkg-config", "--cflags", "Qt6Core", "Qt6Gui", "Qt6Widgets"],
|
||||
text=True
|
||||
).strip()
|
||||
libs = sp.check_output(
|
||||
["pkg-config", "--libs", "Qt6Core", "Qt6Gui", "Qt6Widgets"],
|
||||
text=True
|
||||
).strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
# Fallback: try qmake6
|
||||
try:
|
||||
qmake = sp.check_output(["which", "qmake6"], text=True).strip()
|
||||
prefix = sp.check_output([qmake, "-query", "QT_INSTALL_PREFIX"], text=True).strip()
|
||||
cflags = f"-I{prefix}/include -I{prefix}/include/QtCore -I{prefix}/include/QtGui -I{prefix}/include/QtWidgets"
|
||||
libs = f"-L{prefix}/lib -lQt6Core -lQt6Gui -lQt6Widgets"
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pytest.skip("Cannot find Qt6 build flags (need pkg-config or qmake6)")
|
||||
|
||||
subprocess.run(
|
||||
["g++", "-std=c++17", "-fPIC",
|
||||
str(WIDGET_TEST_APP_SRC),
|
||||
"-o", str(WIDGET_TEST_APP_BIN),
|
||||
*cflags.split(), *libs.split()],
|
||||
check=True, timeout=30,
|
||||
)
|
||||
return WIDGET_TEST_APP_BIN
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--no-probe",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip tests that need a running probe",
|
||||
)
|
||||
parser.addoption(
|
||||
"--probe-timeout",
|
||||
type=int,
|
||||
default=15,
|
||||
help="Seconds to wait for probe to be ready",
|
||||
)
|
||||
parser.addoption(
|
||||
"--widget-app",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Use widget test app instead of QML app for probe target",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
if config.getoption("--no-probe"):
|
||||
skip_probe = pytest.mark.skip(reason="Skipping probe-dependent tests (--no-probe)")
|
||||
for item in items:
|
||||
if "probe" in item.keywords:
|
||||
item.add_marker(skip_probe)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def bridge_path() -> Path:
|
||||
if not BRIDGE_EXE.exists():
|
||||
pytest.skip(f"Bridge not built at {BRIDGE_EXE}")
|
||||
return BRIDGE_EXE
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def run_script_path() -> Path:
|
||||
if not BRIDGE_RUN_SCRIPT.exists():
|
||||
pytest.skip(f"run.sh not found at {BRIDGE_RUN_SCRIPT}")
|
||||
return BRIDGE_RUN_SCRIPT
|
||||
|
||||
|
||||
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:
|
||||
"""Start a GammaRay probe process (session-scoped, one per entire test run).
|
||||
|
||||
Uses either the QML test app or the widget test app depending on --widget-app.
|
||||
"""
|
||||
if request.config.getoption("--no-probe"):
|
||||
return None
|
||||
|
||||
if not GAMMARAY_BIN.exists():
|
||||
pytest.skip(f"GammaRay probe binary not found at {GAMMARAY_BIN}")
|
||||
|
||||
if request.config.getoption("--widget-app"):
|
||||
return _start_widget_probe(request)
|
||||
else:
|
||||
return _start_qml_probe(request)
|
||||
|
||||
|
||||
def _start_qml_probe(request) -> subprocess.Popen:
|
||||
"""Start probe with QML test app."""
|
||||
if not TEST_QML.exists():
|
||||
pytest.skip(f"Test QML file not found at {TEST_QML}")
|
||||
if not QML_RUNNER.exists():
|
||||
pytest.skip(f"QML runner not found at {QML_RUNNER}")
|
||||
|
||||
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(TEST_QML.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(QML_RUNNER), str(TEST_QML),
|
||||
],
|
||||
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(QML_RUNNER), str(TEST_QML)],
|
||||
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():
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "stop", "gammaray-probe-test.service"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
|
||||
request.addfinalizer(cleanup)
|
||||
return proc
|
||||
|
||||
|
||||
def _start_widget_probe(request) -> subprocess.Popen:
|
||||
"""Start probe with widget test app."""
|
||||
widget_bin = _compile_widget_app()
|
||||
|
||||
env = os.environ.copy()
|
||||
env["QT_QPA_PLATFORM"] = "offscreen"
|
||||
env["LD_LIBRARY_PATH"] = GAMMARAY_LIB_DIR
|
||||
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "stop", "gammaray-probe-test.service"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"systemd-run", "--user", "--no-block",
|
||||
"--unit=gammaray-probe-test",
|
||||
"--same-dir",
|
||||
"--working-directory", str(widget_bin.parent),
|
||||
"-E", f"QT_QPA_PLATFORM={env['QT_QPA_PLATFORM']}",
|
||||
"-E", f"LD_LIBRARY_PATH={env['LD_LIBRARY_PATH']}",
|
||||
str(GAMMARAY_BIN),
|
||||
"--inject-only", "--listen", "tcp://127.0.0.1:11732",
|
||||
"--injector", "preload",
|
||||
str(widget_bin),
|
||||
],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
|
||||
timeout = request.config.getoption("--probe-timeout")
|
||||
if not _probe_accepting_connections(timeout):
|
||||
proc = subprocess.Popen(
|
||||
[str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732",
|
||||
"--injector", "preload", str(widget_bin)],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if not _probe_accepting_connections(timeout):
|
||||
pytest.fail(f"Widget probe did not become ready within {timeout}s")
|
||||
else:
|
||||
proc = subprocess.Popen(["true"])
|
||||
|
||||
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:
|
||||
"""Start a bridge instance per module, reused across tests in the module."""
|
||||
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="module")
|
||||
def connected_bridge(bridge, probe_process) -> McpClient:
|
||||
"""Bridge connected to a running probe (module-scoped, reused across tests)."""
|
||||
if probe_process is None:
|
||||
pytest.skip("No probe available (try --no-probe to skip probe-dependent tests)")
|
||||
deadline = time.monotonic() + 30
|
||||
last_error = None
|
||||
while time.monotonic() < deadline:
|
||||
result = bridge.call_tool("connectProbeDefault")
|
||||
if isinstance(result, dict) and result.get("connected"):
|
||||
return bridge
|
||||
last_error = result
|
||||
time.sleep(1)
|
||||
raise AssertionError(f"Failed to connect to probe after 30s: {last_error}")
|
||||
97
tests/mcp_client.py
Normal file
97
tests/mcp_client.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""MCP client helper for testing the gammaray-mcp bridge."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class McpClient:
|
||||
"""A minimal JSON-RPC MCP client communicating over stdio."""
|
||||
|
||||
def __init__(self, bridge_path: Path):
|
||||
self._proc = subprocess.Popen(
|
||||
[str(bridge_path)],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
self._next_id = 0
|
||||
self._initialized = False
|
||||
|
||||
def close(self):
|
||||
if self._proc and self._proc.poll() is None:
|
||||
self._proc.terminate()
|
||||
try:
|
||||
self._proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._proc.kill()
|
||||
|
||||
@property
|
||||
def returncode(self):
|
||||
return self._proc.poll()
|
||||
|
||||
@property
|
||||
def stderr(self):
|
||||
return self._proc.stderr
|
||||
|
||||
def send_request(self, method: str, params: dict | None = None, timeout: float = 60.0) -> dict:
|
||||
self._next_id += 1
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": self._next_id,
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
}
|
||||
line = json.dumps(req)
|
||||
self._proc.stdin.write(line + "\n")
|
||||
self._proc.stdin.flush()
|
||||
return self._read_response(self._next_id, timeout=timeout)
|
||||
|
||||
def _read_response(self, expected_id: int, timeout: float = 60.0) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
line = self._proc.stdout.readline()
|
||||
if not line:
|
||||
stderr = self._proc.stderr.read()
|
||||
rc = self._proc.poll()
|
||||
raise RuntimeError(
|
||||
f"Bridge process terminated (rc={rc}, stderr: {stderr[-500:] if stderr else 'none'})")
|
||||
resp = json.loads(line)
|
||||
rid = resp.get("id")
|
||||
if rid == expected_id:
|
||||
return resp
|
||||
# Could be a notification; ignore and keep reading
|
||||
raise TimeoutError(f"No response for id={expected_id} within {timeout}s")
|
||||
|
||||
def initialize(self, protocol_version: str = "2025-03-26") -> dict:
|
||||
resp = self.send_request("initialize", {
|
||||
"protocolVersion": protocol_version,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "test", "version": "1.0"},
|
||||
})
|
||||
# Send initialized notification
|
||||
self._proc.stdin.write(
|
||||
json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) + "\n"
|
||||
)
|
||||
self._proc.stdin.flush()
|
||||
self._initialized = True
|
||||
return resp
|
||||
|
||||
def call_tool(self, name: str, arguments: dict | None = None, timeout: float = 60.0) -> dict:
|
||||
resp = self.send_request("tools/call", {"name": name, "arguments": arguments or {}}, timeout=timeout)
|
||||
content = resp.get("result", {}).get("content", [])
|
||||
if content and content[0].get("type") == "text":
|
||||
text = content[0]["text"]
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {"_raw": text}
|
||||
return resp.get("result", {})
|
||||
|
||||
def list_tools(self) -> list[dict]:
|
||||
resp = self.send_request("tools/list")
|
||||
return resp.get("result", {}).get("tools", [])
|
||||
57
tests/run_tests.sh
Executable file
57
tests/run_tests.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Convenience script to run the gammaray-mcp test suite.
|
||||
#
|
||||
# This is the SINGLE configuration point for paths. If you install GammaRay
|
||||
# from a system package instead of the local install-prefix, change the
|
||||
# variables below. Everything else (conftest.py, the QML test app, etc.)
|
||||
# reads from these environment variables.
|
||||
#
|
||||
# Usage:
|
||||
# ./run_tests.sh # probe-required tests (default)
|
||||
# ./run_tests.sh --no-probe # only unit tests (no probe needed)
|
||||
# ./run_tests.sh -v # verbose
|
||||
# ./run_tests.sh -k "item" # run only item-related tests
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configurable paths — adjust these if GammaRay is installed elsewhere
|
||||
# ---------------------------------------------------------------------------
|
||||
: "${GAMMARAY_BIN:=$PROJECT_ROOT/install-prefix/bin/gammaray}"
|
||||
: "${GAMMARAY_LIB_DIR:=$PROJECT_ROOT/install-prefix/lib}"
|
||||
: "${QML_RUNNER:=/usr/lib/qt6/bin/qml}"
|
||||
: "${TEST_QML:=$SCRIPT_DIR/testapp/main.qml}"
|
||||
: "${BRIDGE_EXE:=$PROJECT_ROOT/bridge/build/gammaray-mcp-bridge}"
|
||||
: "${BRIDGE_RUN_SCRIPT:=$PROJECT_ROOT/bridge/run.sh}"
|
||||
|
||||
# Export so conftest.py can read them
|
||||
export GAMMARAY_BIN
|
||||
export GAMMARAY_LIB_DIR
|
||||
export QML_RUNNER
|
||||
export TEST_QML
|
||||
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"
|
||||
|
||||
exec uv run --frozen --directory "$PROJECT_ROOT" python "$SCRIPT_DIR/run_with_uv.py" "$@"
|
||||
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 "$@"
|
||||
8
tests/run_with_uv.py
Normal file
8
tests/run_with_uv.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Entry point for uv run: adds tests dir to path and runs pytest."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.resolve()))
|
||||
import pytest
|
||||
|
||||
sys.exit(pytest.main(sys.argv[1:]))
|
||||
89
tests/test_connection.py
Normal file
89
tests/test_connection.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""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
|
||||
|
||||
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:
|
||||
"""Verify connectProbe, probeStatus, disconnectProbe work."""
|
||||
|
||||
def test_tools_listed(self, local_bridge):
|
||||
tools = local_bridge.list_tools()
|
||||
names = [t["name"] for t in tools]
|
||||
for expected in ["connectProbe", "connectProbeDefault",
|
||||
"disconnectProbe", "probeStatus"]:
|
||||
assert expected in names, f"Missing tool: {expected}"
|
||||
|
||||
def test_probe_status_disconnected(self, local_bridge):
|
||||
status = local_bridge.call_tool("probeStatus")
|
||||
assert isinstance(status, dict)
|
||||
assert status.get("state") in ("disconnected", "connecting")
|
||||
assert status.get("ready") is False
|
||||
|
||||
def test_connect_and_disconnect(self, local_bridge, probe_process):
|
||||
if probe_process is None:
|
||||
pytest.skip("No probe available")
|
||||
result = local_bridge.call_tool("connectProbeDefault")
|
||||
assert isinstance(result, dict), f"connectProbeDefault failed: {result}"
|
||||
assert result.get("connected"), f"Not connected: {result}"
|
||||
status = local_bridge.call_tool("probeStatus")
|
||||
assert isinstance(status, dict)
|
||||
assert status.get("state") == "ready"
|
||||
assert status.get("ready") is True
|
||||
assert "tcp://" in status.get("url", "")
|
||||
result = local_bridge.call_tool("disconnectProbe")
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("state") == "disconnected"
|
||||
|
||||
def test_reconnect(self, local_bridge, probe_process):
|
||||
if probe_process is None:
|
||||
pytest.skip("No probe available")
|
||||
local_bridge.call_tool("connectProbeDefault")
|
||||
local_bridge.call_tool("disconnectProbe")
|
||||
result = local_bridge.call_tool("connectProbe",
|
||||
{"host": "127.0.0.1", "port": 11732})
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("connected") is True
|
||||
assert result.get("state") == "ready"
|
||||
|
||||
def test_reconnect_default_after_disconnect(self, local_connected_bridge):
|
||||
local_connected_bridge.call_tool("disconnectProbe")
|
||||
result = local_connected_bridge.call_tool("connectProbeDefault")
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("connected") is True
|
||||
149
tests/test_items.py
Normal file
149
tests/test_items.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Tests for QML item selection and property inspection."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestItemProperties:
|
||||
"""Verify selectQuickItem and getItemProperties."""
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_tools_listed(self, bridge):
|
||||
tools = bridge.list_tools()
|
||||
names = [t["name"] for t in tools]
|
||||
for expected in ["selectQuickItem", "getItemProperties"]:
|
||||
assert expected in names
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_select_quick_item(self, connected_bridge):
|
||||
# First get items
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
|
||||
items = connected_bridge.call_tool("listQuickItems")
|
||||
assert isinstance(items, list) and len(items) > 0, "No items found"
|
||||
|
||||
# Find the first Button-like item
|
||||
def find_buttons(items_list):
|
||||
buttons = []
|
||||
for item in items_list:
|
||||
if "Button" in (item.get("type", "")):
|
||||
buttons.append(item.get("name", ""))
|
||||
buttons.extend(find_buttons(item.get("children", [])))
|
||||
return buttons
|
||||
|
||||
buttons = find_buttons(items)
|
||||
if not buttons:
|
||||
# Try any item
|
||||
def first_addr(items_list):
|
||||
for item in items_list:
|
||||
return item.get("name", "")
|
||||
addr = first_addr(items)
|
||||
else:
|
||||
addr = buttons[0]
|
||||
|
||||
result = connected_bridge.call_tool("selectQuickItem", {"address": addr})
|
||||
assert isinstance(result, dict), f"selectQuickItem failed: {result}"
|
||||
assert result.get("propertyCount", 0) > 0, \
|
||||
f"No properties for item {addr}: {result}"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_get_item_properties(self, connected_bridge):
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
|
||||
items = connected_bridge.call_tool("listQuickItems")
|
||||
|
||||
def find_any_item(items_list):
|
||||
for item in items_list:
|
||||
addr = item.get("name", "")
|
||||
if addr and not addr.startswith("Loading"):
|
||||
return addr
|
||||
found = find_any_item(item.get("children", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
addr = find_any_item(items)
|
||||
if not addr:
|
||||
pytest.skip("No usable item address found")
|
||||
|
||||
props = connected_bridge.call_tool("getItemProperties", {"address": addr})
|
||||
assert isinstance(props, dict), f"getItemProperties failed: {props}"
|
||||
assert "properties" in props, f"No 'properties' key: {list(props.keys())}"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_item_has_position(self, connected_bridge):
|
||||
"""Verify that a Button item has x, y, width, height properties."""
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
|
||||
items = connected_bridge.call_tool("listQuickItems")
|
||||
|
||||
# Find a Button_QMLTYPE item
|
||||
def find_button(items_list):
|
||||
for item in items_list:
|
||||
if "Button" in (item.get("type", "")):
|
||||
return item.get("name", "")
|
||||
found = find_button(item.get("children", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
addr = find_button(items)
|
||||
if not addr:
|
||||
pytest.skip("No Button item found")
|
||||
|
||||
props = connected_bridge.call_tool("getItemProperties", {"address": addr})
|
||||
simple = props.get("properties", {})
|
||||
for key in ("x", "y", "width", "height"):
|
||||
assert key in simple, f"Button missing '{key}' property"
|
||||
val = simple[key].get("value", "")
|
||||
assert val != "", f"Button.{key} is empty"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_item_has_visual_properties(self, connected_bridge):
|
||||
"""Verify items have opacity, visible, z, rotation, scale."""
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
|
||||
items = connected_bridge.call_tool("listQuickItems")
|
||||
|
||||
def find_addr(items_list):
|
||||
for item in items_list:
|
||||
addr = item.get("name", "")
|
||||
if addr and not addr.startswith("Loading") and addr != "ApplicationWindow":
|
||||
return addr
|
||||
found = find_addr(item.get("children", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
addr = find_addr(items)
|
||||
if not addr:
|
||||
pytest.skip("No suitable item found")
|
||||
|
||||
props = connected_bridge.call_tool("getItemProperties", {"address": addr})
|
||||
simple = props.get("properties", {})
|
||||
for key in ("opacity", "visible", "z"):
|
||||
assert key in simple, f"Item missing '{key}'"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_property_model_has_groups(self, connected_bridge):
|
||||
"""Verify items have nested property groups (background, contentItem, etc.)."""
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
|
||||
items = connected_bridge.call_tool("listQuickItems")
|
||||
|
||||
def find_button(items_list):
|
||||
for item in items_list:
|
||||
if "Button" in (item.get("type", "")):
|
||||
return item.get("name", "")
|
||||
found = find_button(item.get("children", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
addr = find_button(items)
|
||||
if not addr:
|
||||
pytest.skip("No Button item found")
|
||||
|
||||
props = connected_bridge.call_tool("getItemProperties", {"address": addr})
|
||||
groups = props.get("groups", {})
|
||||
# Buttons typically have [background], [contentItem], [parent], [window]
|
||||
assert len(groups) > 0, f"No property groups found: {list(props.keys())}"
|
||||
80
tests/test_navigation.py
Normal file
80
tests/test_navigation.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Tests for navigation tools (windows, items, scene graph tree)."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestNavigation:
|
||||
"""Verify listQuickWindows, selectQuickWindow, listQuickItems, listScenegraphNodes."""
|
||||
|
||||
def test_tools_listed(self, bridge):
|
||||
tools = bridge.list_tools()
|
||||
names = [t["name"] for t in tools]
|
||||
for expected in ["listQuickWindows", "selectQuickWindow",
|
||||
"listQuickItems", "listScenegraphNodes"]:
|
||||
assert expected in names
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_list_windows(self, connected_bridge):
|
||||
windows = connected_bridge.call_tool("listQuickWindows")
|
||||
assert isinstance(windows, list)
|
||||
assert len(windows) >= 1
|
||||
for w in windows:
|
||||
assert "address" in w
|
||||
assert "type" in w
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_select_window(self, connected_bridge):
|
||||
result = connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("status") == "selected"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_list_items(self, connected_bridge):
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
items = connected_bridge.call_tool("listQuickItems")
|
||||
assert isinstance(items, list)
|
||||
if items:
|
||||
assert isinstance(items[0], dict)
|
||||
assert "type" in items[0]
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_list_items_with_window_count(self, connected_bridge):
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
items = connected_bridge.call_tool("listQuickItems")
|
||||
|
||||
def count_all(items_list):
|
||||
count = 0
|
||||
for item in items_list:
|
||||
count += 1
|
||||
count += count_all(item.get("children", []))
|
||||
return count
|
||||
|
||||
if items:
|
||||
total = count_all(items)
|
||||
assert total > 0
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_list_scenegraph_nodes(self, connected_bridge):
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||
assert isinstance(nodes, list)
|
||||
if nodes:
|
||||
assert isinstance(nodes[0], dict)
|
||||
assert nodes[0].get("type") == "Root Node"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_scenegraph_has_geometry_nodes(self, connected_bridge):
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||
|
||||
def find_geometry(items_list):
|
||||
for item in items_list:
|
||||
if item.get("type") == "Geometry Node":
|
||||
return item
|
||||
found = find_geometry(item.get("children", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
geo = find_geometry(nodes)
|
||||
assert geo is not None, "No Geometry Node found in SG tree"
|
||||
156
tests/test_scenegraph.py
Normal file
156
tests/test_scenegraph.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Tests for SceneGraph node selection and geometry/material tools."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSceneGraph:
|
||||
"""Verify selectScenegraphNode, getNodeVertices, getNodeAdjacency, etc."""
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_tools_listed(self, bridge):
|
||||
tools = bridge.list_tools()
|
||||
names = [t["name"] for t in tools]
|
||||
for expected in [
|
||||
"selectScenegraphNode", "getNodeVertices", "getNodeAdjacency",
|
||||
"getMaterialShaders", "getShaderSource", "getMaterialProperties",
|
||||
"setRenderMode", "setSlowMode",
|
||||
]:
|
||||
assert expected in names, f"Missing tool: {expected}"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_select_scenegraph_node(self, connected_bridge):
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
|
||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||
|
||||
# Find first Geometry Node
|
||||
def find_geo(items_list):
|
||||
for item in items_list:
|
||||
if item.get("type") == "Geometry Node":
|
||||
return item.get("address", "")
|
||||
found = find_geo(item.get("children", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
addr = find_geo(nodes)
|
||||
if not addr:
|
||||
pytest.skip("No Geometry Node found")
|
||||
|
||||
result = connected_bridge.call_tool("selectScenegraphNode", {"address": addr})
|
||||
assert isinstance(result, dict), f"selectScenegraphNode failed: {result}"
|
||||
assert result.get("type") == "Geometry Node"
|
||||
assert "hasGeometry" in result
|
||||
assert "hasMaterial" in result
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_get_node_vertices(self, connected_bridge):
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
|
||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||
|
||||
def find_geo(items_list):
|
||||
for item in items_list:
|
||||
if item.get("type") == "Geometry Node":
|
||||
return item.get("address", "")
|
||||
found = find_geo(item.get("children", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
addr = find_geo(nodes)
|
||||
if not addr:
|
||||
pytest.skip("No Geometry Node found")
|
||||
|
||||
result = connected_bridge.call_tool("getNodeVertices", {"address": addr})
|
||||
assert isinstance(result, dict)
|
||||
# May return "no vertex data" for software renderer -- that's OK
|
||||
assert "error" in result or "vertexCount" in result
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_get_node_adjacency(self, connected_bridge):
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
|
||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||
|
||||
def find_geo(items_list):
|
||||
for item in items_list:
|
||||
if item.get("type") == "Geometry Node":
|
||||
return item.get("address", "")
|
||||
found = find_geo(item.get("children", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
addr = find_geo(nodes)
|
||||
if not addr:
|
||||
pytest.skip("No Geometry Node found")
|
||||
|
||||
result = connected_bridge.call_tool("getNodeAdjacency", {"address": addr})
|
||||
assert isinstance(result, dict)
|
||||
assert "error" in result or "drawingMode" in result
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_get_material_shaders(self, connected_bridge):
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
|
||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||
|
||||
def find_geo(items_list):
|
||||
for item in items_list:
|
||||
if item.get("type") == "Geometry Node":
|
||||
return item.get("address", "")
|
||||
found = find_geo(item.get("children", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
addr = find_geo(nodes)
|
||||
if not addr:
|
||||
pytest.skip("No Geometry Node found")
|
||||
|
||||
result = connected_bridge.call_tool("getMaterialShaders", {"address": addr})
|
||||
assert isinstance(result, dict)
|
||||
# Software renderer -> no shaders; that's expected
|
||||
assert "error" in result or isinstance(result, list)
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_get_material_properties(self, connected_bridge):
|
||||
connected_bridge.call_tool("selectQuickWindow", {"index": 0})
|
||||
|
||||
nodes = connected_bridge.call_tool("listScenegraphNodes")
|
||||
|
||||
def find_geo(items_list):
|
||||
for item in items_list:
|
||||
if item.get("type") == "Geometry Node":
|
||||
return item.get("address", "")
|
||||
found = find_geo(item.get("children", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
addr = find_geo(nodes)
|
||||
if not addr:
|
||||
pytest.skip("No Geometry Node found")
|
||||
|
||||
result = connected_bridge.call_tool("getMaterialProperties", {"address": addr})
|
||||
assert isinstance(result, dict)
|
||||
assert "error" in result or "properties" in result
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_set_render_mode(self, connected_bridge):
|
||||
result = connected_bridge.call_tool("setRenderMode",
|
||||
{"mode": "VisualizeOverdraw"})
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("renderMode") == "VisualizeOverdraw"
|
||||
|
||||
# Reset
|
||||
connected_bridge.call_tool("setRenderMode", {"mode": "NormalRendering"})
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_set_slow_mode(self, connected_bridge):
|
||||
result = connected_bridge.call_tool("setSlowMode", {"enabled": True})
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("slowMode") is True
|
||||
|
||||
connected_bridge.call_tool("setSlowMode", {"enabled": False})
|
||||
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")
|
||||
93
tests/testapp/main.qml
Normal file
93
tests/testapp/main.qml
Normal file
@@ -0,0 +1,93 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
ApplicationWindow {
|
||||
visible: true
|
||||
width: 600
|
||||
height: 500
|
||||
title: "GammaRay MCP Test App"
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 5
|
||||
|
||||
// Row of buttons at the top
|
||||
RowLayout {
|
||||
spacing: 6
|
||||
Button { text: "Add Item"; onClicked: listModel.append({ name: "Item " + (listModel.count + 1) }) }
|
||||
Button { text: "Remove Item"; onClicked: { if (listModel.count > 0) listModel.remove(listModel.count - 1) } }
|
||||
Button { text: "Force Layout"; onClicked: listView.forceLayout() }
|
||||
}
|
||||
|
||||
// Switch to toggle visibility
|
||||
RowLayout {
|
||||
spacing: 6
|
||||
Label { text: "Show ListView:" }
|
||||
Switch {
|
||||
id: visibilitySwitch
|
||||
checked: true
|
||||
onCheckedChanged: listView.visible = checked
|
||||
}
|
||||
Label { text: "Opacity:" }
|
||||
Slider {
|
||||
id: opacitySlider
|
||||
from: 0.0; to: 1.0; value: 1.0
|
||||
onValueChanged: listView.opacity = value
|
||||
}
|
||||
}
|
||||
|
||||
// ListView with delegate items
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: "#f0f0f0"
|
||||
radius: 4
|
||||
border.color: "#ccc"
|
||||
border.width: 1
|
||||
|
||||
ListView {
|
||||
id: listView
|
||||
anchors.fill: parent
|
||||
anchors.margins: 4
|
||||
model: ListModel {
|
||||
id: listModel
|
||||
ListElement { name: "Alpha" }
|
||||
ListElement { name: "Beta" }
|
||||
ListElement { name: "Gamma" }
|
||||
ListElement { name: "Delta" }
|
||||
}
|
||||
delegate: Rectangle {
|
||||
width: parent.width
|
||||
height: 32
|
||||
color: index % 2 === 0 ? "#ffffff" : "#f5f5f5"
|
||||
border.color: "#ddd"
|
||||
border.width: 1
|
||||
radius: 2
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: model.name
|
||||
font.pixelSize: 14
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom status bar
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 24
|
||||
color: "#e0e0e0"
|
||||
radius: 2
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "Items: " + listModel.count + " | Visible: " + listView.visible
|
||||
font.pixelSize: 11
|
||||
color: "#666"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
152
uv.lock
generated
Normal file
152
uv.lock
generated
Normal file
@@ -0,0 +1,152 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gammaray-mcp-tests"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "pytest", specifier = ">=8.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user