diff --git a/PLAN.md b/PLAN.md index da6f7bf..d4f7f6c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 takes ~8 minutes due to probe start/stop overhead and 500-1500ms settle delays per tool call. Could be optimized with module-scoped bridge fixtures and shared probe process. ## Step 7: Launch and run @@ -539,9 +552,9 @@ cmake --build build ## Next steps -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`). +1. **Optimize test suite runtime** — share bridge across tests or reduce settle delays to bring ~8min down to ~2-3min. +2. **Test `getShaderSource` async path end-to-end** — needs a QML app with real shaders (current test app uses software renderer where GeometryNodes have `nullptr` geometry). +3. **Test geometry/material tools against a QML app with real QSGGeometry data** — current test app `relayout.qml` uses software renderer so `hasGeometry: false` and empty sub-models are correct behavior. +4. **Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface` `TransferImage`. Follows the `MaterialExtensionInterface` proxy pattern. +5. **Fix `listScenegraphNodes` first-call fill stability** — single-call instead of caller needing a second call. 6. **Build `--connect` startup flag integration** with systemd service for auto-attach during development. diff --git a/README.md b/README.md index f992a5f..f13fab6 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ install-prefix/bin/gammaray --inject-only --listen tcp://127.0.0.1:11732 \ --injector preload /usr/lib/qt6/bin/qml /path/to/app.qml -platform offscreen # Start the bridge (stdio MCP server) -./run.sh +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. diff --git a/bridge/CMakeLists.txt b/bridge/CMakeLists.txt index cdec134..8aeb7de 100644 --- a/bridge/CMakeLists.txt +++ b/bridge/CMakeLists.txt @@ -21,8 +21,9 @@ 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) @@ -44,6 +45,8 @@ 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 ) diff --git a/bridge/src/gammaray_session.cpp b/bridge/src/gammaray_session.cpp index 531dcb1..9137094 100644 --- a/bridge/src/gammaray_session.cpp +++ b/bridge/src/gammaray_session.cpp @@ -7,12 +7,15 @@ #include "gammaray_session.h" +#include "quickinspector_proxy.h" + #include #include #include #include #include +#include #include // Defined in material_interface.cpp — registers the MaterialExtensionInterface @@ -29,6 +32,13 @@ 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(); + // 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 +76,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"); + qRegisterMetaType("QMarginsF"); + // Register our MaterialExtensionInterface proxy factory. The real factory // is in QuickInspectorUiFactory::initUi() (a GUI plugin we don't load). // Without this, ObjectBroker::object() would @@ -85,11 +102,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 +127,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 diff --git a/bridge/src/quickinspector_proxy.cpp b/bridge/src/quickinspector_proxy.cpp new file mode 100644 index 0000000..7b66d81 --- /dev/null +++ b/bridge/src/quickinspector_proxy.cpp @@ -0,0 +1,43 @@ +/* + 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 + +#include + +namespace GammaRay { + +QuickInspectorProxy::QuickInspectorProxy(QObject *parent) + : QObject(parent) +{ + // Register the metatype string for QFlags + // 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").id() == QMetaType::UnknownType) { + QMetaType::registerNormalizedTypedef( + "QFlags", + QMetaType::fromType()); + } +} + +void registerQuickInspectorProxy() +{ + // Guard: only register once (Endpoint::registerObject asserts on duplicate). + static bool registered = false; + if (registered) + return; + registered = true; + + auto *proxy = new QuickInspectorProxy(); + ObjectBroker::registerObject( + QStringLiteral("com.kdab.GammaRay.QuickInspectorInterface/1.0"), + proxy); +} + +} // namespace GammaRay \ No newline at end of file diff --git a/bridge/src/quickinspector_proxy.h b/bridge/src/quickinspector_proxy.h new file mode 100644 index 0000000..374ae2f --- /dev/null +++ b/bridge/src/quickinspector_proxy.h @@ -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 + +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 \ No newline at end of file diff --git a/bridge/src/scenegraph_tools.cpp b/bridge/src/scenegraph_tools.cpp index efaa620..fc1afa5 100644 --- a/bridge/src/scenegraph_tools.cpp +++ b/bridge/src/scenegraph_tools.cpp @@ -780,8 +780,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 +804,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)); } @@ -839,6 +850,30 @@ QString SceneGraphTools::getItemProperties(const QString &address) const QStringLiteral("no properties available (did you select a valid QML item?)"))) .toJson(QJsonDocument::Compact)); + // The RemoteModel uses lazy-fetch: data() triggers a fetch and returns + // "Loading..." until the fetch response arrives on the event loop. + // Poll until column 0 data arrives for at least one row. + { + QEventLoop loop; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + bool gotData = false; + while (std::chrono::steady_clock::now() < deadline && !gotData) { + for (int r = 0; r < rows; ++r) { + const QString val = m->data(m->index(r, 0), Qt::DisplayRole).toString(); + if (!val.isEmpty() && val != QStringLiteral("Loading...")) { + gotData = true; + break; + } + } + if (!gotData) { + QTimer::singleShot(200, &loop, &QEventLoop::quit); + loop.exec(); + } + } + } + +// Re-read rowCount after polling (more rows may have arrived) + const int actualRows = m->rowCount(); const int cols = m->columnCount(); QJsonObject out; @@ -846,34 +881,76 @@ QString SceneGraphTools::getItemProperties(const QString &address) const for (int c = 0; c < cols; ++c) headers.append(m->headerData(c, Qt::Horizontal, Qt::DisplayRole).toString()); + // Pre-fetch children for grouped properties. RemoteModel uses lazy-fetch: + // rowCount(parent) triggers an async request and returns 0 until the reply + // arrives on the event loop. After polling, hasChildren() works correctly. + for (int r = 0; r < actualRows; ++r) { + m->rowCount(m->index(r, 0)); + } + // Poll to let row count responses arrive + if (actualRows > 0) { + QEventLoop l2; + QTimer::singleShot(500, &l2, &QEventLoop::quit); + l2.exec(); + } + // Second pass: ensure child data is fetched for rows that have children + for (int r = 0; r < actualRows; ++r) { + const QModelIndex idx0 = m->index(r, 0); + if (m->hasChildren(idx0) && m->canFetchMore(idx0)) + m->fetchMore(idx0); + } + if (actualRows > 0) { + QEventLoop l2b; + QTimer::singleShot(500, &l2b, &QEventLoop::quit); + l2b.exec(); + } + // Top-level properties (simple Q_PROPERTY values like x, y, width, height...) QJsonObject simpleProps; // Nested property groups (anchors, transform, etc.) will have children. QJsonObject groups; - for (int r = 0; r < rows; ++r) { + 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(); - // 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))) { + const QModelIndex idx0 = m->index(r, 0); + if (m->hasChildren(idx0)) { // 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); + // Children also use lazy-fetch, so poll if needed. + const int childRows = m->rowCount(idx0); + if (childRows > 0) { + // Trigger child data fetches first + for (int cr = 0; cr < childRows; ++cr) { + m->data(m->index(cr, 0, idx0), Qt::DisplayRole); + m->data(m->index(cr, 1, idx0), Qt::DisplayRole); + } + // Poll to let child data arrive + { + QEventLoop l3; + QTimer::singleShot(500, &l3, &QEventLoop::quit); + l3.exec(); + } + // Read actual child data + QJsonObject children; + for (int cr = 0; cr < childRows; ++cr) { + const QString cname = m->data(m->index(cr, 0, idx0), Qt::DisplayRole).toString(); + const QString cval = m->data(m->index(cr, 1, idx0), Qt::DisplayRole).toString(); + const QString ctype = cols > 2 ? m->data(m->index(cr, 2, idx0), Qt::DisplayRole).toString() : QString(); + if (cname.isEmpty() || cname == QStringLiteral("Loading...")) + continue; + QJsonObject child; + child.insert(QStringLiteral("value"), cval); + child.insert(QStringLiteral("type"), ctype); + children.insert(cname, child); + } + if (!children.isEmpty()) + prop.insert(QStringLiteral("children"), children); } - if (!children.isEmpty()) - prop.insert(QStringLiteral("children"), children); groups.insert(name, prop); } else { simpleProps.insert(name, prop); diff --git a/pyproject.toml b/pyproject.toml index 9ee3545..5c36413 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,9 @@ +[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"] diff --git a/tests/README.md b/tests/README.md index 55e182f..17d38bc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,31 +1,15 @@ # Test Suite -Integration tests for the gammaray-mcp bridge, written in Python using `pytest`. Tests communicate with the bridge over stdio JSON-RPC (the same protocol MCP clients use), exercising every tool end-to-end against a real GammaRay probe. +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. -## Prerequisites - -- The bridge must be built: `cmake --build bridge/build` -- `pytest` (install via `pip3 install pytest` or `pip install pytest` in a venv) -- For probe-dependent tests: a QML test app at `~/Sources/testqml/relayout.qml` (configurable via `TEST_QML` env) - -## Running +## Quick start ```bash -# From the project root: -./tests/run_tests.sh --no-probe # unit tests only (no probe needed) -./tests/run_tests.sh -v # full suite, verbose -./tests/run_tests.sh -k "item" # run only item-related tests -./tests/run_tests.sh --no-probe -x # stop on first failure, no probe -``` - -Or directly with pytest: - -```bash -cd tests -LD_LIBRARY_PATH=../install-prefix/lib \ -QT_QPA_PLATFORM=offscreen \ -QT_PLUGIN_PATH=../bridge/build/lib/x86_64-linux-gnu/qt6/plugins \ -pytest -v +./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 @@ -37,19 +21,51 @@ pytest -v | `test_items.py` | `selectQuickItem`, `getItemProperties`, position, visual properties, groups | Yes | | `test_scenegraph.py` | `selectScenegraphNode`, `getNodeVertices`, `getNodeAdjacency`, `getMaterialShaders`, `getMaterialProperties`, `setRenderMode`, `setSlowMode` | Yes | +## Test QML application + +`testapp/main.qml` is a simple Qt Quick Controls application that exercises all +the tools: buttons, switches, sliders, a list view with delegate items, and +visibility/opacity controls. + +## 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/qml-sg-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`. +- `@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()` - -## Options - -| Flag | Default | Description | -|---|---|---| -| `--no-probe` | `false` | Skip all probe-dependent tests | -| `--probe-timeout` | 15s | Seconds to wait for probe to become ready | \ No newline at end of file +- `probe_process` — session-scoped, starts a probe via `systemd-run` (fallback: + direct spawn) and waits for `ready()` \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index bf835e0..2265a10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,16 @@ -"""pytest fixtures for gammaray-mcp bridge tests.""" +"""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 + BRIDGE_EXE — path to the bridge executable + BRIDGE_RUN_SCRIPT — path to bridge/run.sh +""" import os import subprocess @@ -10,11 +22,33 @@ import pytest from mcp_client import McpClient PROJECT_ROOT = Path(__file__).resolve().parent.parent -BRIDGE_EXE = PROJECT_ROOT / "bridge" / "build" / "qml-sg-mcp-bridge" -RUN_SCRIPT = PROJECT_ROOT / "bridge" / "run.sh" -PROBE_BIN = PROJECT_ROOT / "install-prefix" / "bin" / "gammaray" -TEST_QML = Path(os.environ.get("TEST_QML", str(Path.home() / "Sources" / "testqml" / "relayout.qml"))) -QML_RUNNER = "/usr/lib/qt6/bin/qml" + +# All paths read from env vars, with sensible project-local defaults. +# Users only need to override these when GammaRay is installed elsewhere. +GAMMARAY_BIN = Path(os.environ.get( + "GAMMARAY_BIN", + 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"), +)) +BRIDGE_EXE = Path(os.environ.get( + "BRIDGE_EXE", + str(PROJECT_ROOT / "bridge" / "build" / "qml-sg-mcp-bridge"), +)) +BRIDGE_RUN_SCRIPT = Path(os.environ.get( + "BRIDGE_RUN_SCRIPT", + str(PROJECT_ROOT / "bridge" / "run.sh"), +)) def pytest_addoption(parser): @@ -42,7 +76,6 @@ def pytest_collection_modifyitems(config, items): @pytest.fixture(scope="session") def bridge_path() -> Path: - """Path to the bridge executable.""" if not BRIDGE_EXE.exists(): pytest.skip(f"Bridge not built at {BRIDGE_EXE}") return BRIDGE_EXE @@ -50,20 +83,19 @@ def bridge_path() -> Path: @pytest.fixture(scope="session") def run_script_path() -> Path: - """Path to the run.sh wrapper.""" - if not RUN_SCRIPT.exists(): - pytest.skip(f"run.sh not found at {RUN_SCRIPT}") - return RUN_SCRIPT + if not BRIDGE_RUN_SCRIPT.exists(): + pytest.skip(f"run.sh not found at {BRIDGE_RUN_SCRIPT}") + return BRIDGE_RUN_SCRIPT -@pytest.fixture(scope="session") +@pytest.fixture(scope="module") def probe_process(request) -> subprocess.Popen | None: """Start a GammaRay probe process (session-scoped, one per test run).""" if request.config.getoption("--no-probe"): return None - if not PROBE_BIN.exists(): - pytest.skip(f"GammaRay probe binary not found at {PROBE_BIN}") + if not GAMMARAY_BIN.exists(): + pytest.skip(f"GammaRay probe binary not found at {GAMMARAY_BIN}") if not TEST_QML.exists(): pytest.skip(f"Test QML file not found at {TEST_QML}") if not QML_RUNNER.exists(): @@ -71,7 +103,7 @@ def probe_process(request) -> subprocess.Popen | None: env = os.environ.copy() env["QT_QPA_PLATFORM"] = "offscreen" - env["LD_LIBRARY_PATH"] = str(PROJECT_ROOT / "install-prefix" / "lib") + env["LD_LIBRARY_PATH"] = GAMMARAY_LIB_DIR proc = None used_systemd = False @@ -94,20 +126,21 @@ def probe_process(request) -> subprocess.Popen | None: "--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(PROBE_BIN), + str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732", "--injector", "preload", - QML_RUNNER, str(TEST_QML), + str(QML_RUNNER), str(TEST_QML), ], capture_output=True, text=True, timeout=10, ) if result.returncode == 0: used_systemd = True + proc = subprocess.Popen(["true"]) # sentinel: probe is external else: # Fallback: direct spawn proc = subprocess.Popen( - [str(PROBE_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732", - "--injector", "preload", QML_RUNNER, str(TEST_QML)], + [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, ) @@ -156,15 +189,35 @@ def bridge(run_script_path) -> McpClient: c = McpClient(bridge_path=run_script_path) c.initialize() yield c + # Gracefully disconnect from the probe before killing the bridge. + # GammaRay's QTcpSocket::disconnectFromHost() is asynchronous — the + # Endpoint::disconnected signal (which clears m_socket) fires on a later + # event loop iteration. If we kill the bridge (SIGTERM) while m_socket is + # still set, the probe does not immediately detect the disconnection, and + # the next test's bridge cannot connect. Sending disconnectProbe() first + # triggers a clean GammaRay protocol-level shutdown that waits for the + # async disconnect to complete, releasing the probe for the next test. + try: + c.call_tool("disconnectProbe", timeout=5) + except Exception: + pass # bridge may have already crashed; best-effort c.close() + # Allow the probe to detect the disconnection before the next test + # connects. + time.sleep(1) @pytest.fixture def connected_bridge(bridge, probe_process) -> McpClient: - """Bridge connected to a running probe.""" + """Bridge connected to a running probe (retries on transient failures).""" if probe_process is None: - pytest.skip("No probe available") - result = bridge.call_tool("connectProbeDefault") - assert isinstance(result, dict), f"connectProbeDefault failed: {result}" - assert result.get("connected"), f"Not connected: {result}" - return bridge \ No newline at end of file + 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(3) + raise AssertionError(f"Failed to connect to probe after 30s: {last_error}") \ No newline at end of file diff --git a/tests/mcp_client.py b/tests/mcp_client.py index 9e7f8ba..71d712e 100644 --- a/tests/mcp_client.py +++ b/tests/mcp_client.py @@ -30,11 +30,15 @@ class McpClient: 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) -> dict: + def send_request(self, method: str, params: dict | None = None, timeout: float = 60.0) -> dict: self._next_id += 1 req = { "jsonrpc": "2.0", @@ -45,14 +49,17 @@ class McpClient: line = json.dumps(req) self._proc.stdin.write(line + "\n") self._proc.stdin.flush() - return self._read_response(self._next_id) + 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: - raise RuntimeError("Bridge process terminated") + 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: @@ -74,8 +81,8 @@ class McpClient: self._initialized = True return resp - def call_tool(self, name: str, arguments: dict | None = None) -> dict: - resp = self.send_request("tools/call", {"name": name, "arguments": arguments or {}}) + 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"] diff --git a/tests/run_tests.sh b/tests/run_tests.sh index 48768f7..1363166 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -1,34 +1,57 @@ #!/usr/bin/env bash -# Run the gammaray-mcp test suite. +# 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/qml-sg-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 "$PROJECT_ROOT/bridge/build/qml-sg-mcp-bridge" ]; then +# --------------------------------------------------------------------------- +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 -# Use the same env as run.sh -export LD_LIBRARY_PATH="$PROJECT_ROOT/install-prefix/lib" +# --------------------------------------------------------------------------- +# 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" -# Install test dependencies if needed -python3 -c "import pytest" 2>/dev/null || { - echo "pytest not found. Installing..." - pip3 install pytest --user -} - -exec python3 -m pytest "$@" . \ No newline at end of file +exec uv run --frozen --directory "$PROJECT_ROOT" python "$SCRIPT_DIR/run_with_uv.py" "$@" \ No newline at end of file diff --git a/tests/run_with_uv.py b/tests/run_with_uv.py new file mode 100644 index 0000000..56013c0 --- /dev/null +++ b/tests/run_with_uv.py @@ -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:])) \ No newline at end of file diff --git a/tests/test_connection.py b/tests/test_connection.py index 7020627..ea06824 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -19,26 +19,43 @@ class TestConnection: assert status.get("state") in ("disconnected", "connecting") assert status.get("ready") is False - def test_connect_and_disconnect(self, connected_bridge): - # connectProbeDefault is already called by connected_bridge fixture - status = connected_bridge.call_tool("probeStatus") + def test_connect_and_disconnect(self, bridge, probe_process): + if probe_process is None: + pytest.skip("No probe available") + # Connect + result = bridge.call_tool("connectProbeDefault") + assert isinstance(result, dict), f"connectProbeDefault failed: {result}" + assert result.get("connected"), f"Not connected: {result}" + # Verify status + status = 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", "") - # Now disconnect - result = connected_bridge.call_tool("disconnectProbe") + result = bridge.call_tool("disconnectProbe") assert isinstance(result, dict) assert result.get("state") == "disconnected" - def test_reconnect(self, connected_bridge): + def test_reconnect(self, bridge, probe_process): + if probe_process is None: + pytest.skip("No probe available") + bridge.call_tool("connectProbeDefault") # Disconnect - connected_bridge.call_tool("disconnectProbe") - + bridge.call_tool("disconnectProbe") # Reconnect with explicit args - result = connected_bridge.call_tool("connectProbe", - {"host": "127.0.0.1", "port": 11732}) + result = 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_connect_default_after_disconnect(self, bridge, probe_process): + if probe_process is None: + pytest.skip("No probe available") + bridge.call_tool("connectProbeDefault") + bridge.call_tool("disconnectProbe") + result = bridge.call_tool("connectProbeDefault") assert isinstance(result, dict) assert result.get("connected") is True assert result.get("state") == "ready" diff --git a/tests/testapp/main.qml b/tests/testapp/main.qml new file mode 100644 index 0000000..0dff6c5 --- /dev/null +++ b/tests/testapp/main.qml @@ -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" + } + } + } +} \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b596871 --- /dev/null +++ b/uv.lock @@ -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" }, +]