basic qt widgets support
This commit is contained in:
@@ -8,6 +8,7 @@ before calling pytest. If you prefer to run pytest directly, set these:
|
||||
GAMMARAY_LIB_DIR — directory containing GammaRay shared libraries
|
||||
QML_RUNNER — path to the QML runtime executable
|
||||
TEST_QML — path to the test QML application
|
||||
WIDGET_TEST_APP — path to the widget test application (C++ QWidget binary)
|
||||
BRIDGE_EXE — path to the bridge executable
|
||||
BRIDGE_RUN_SCRIPT — path to bridge/run.sh
|
||||
"""
|
||||
@@ -41,6 +42,14 @@ TEST_QML = Path(os.environ.get(
|
||||
"TEST_QML",
|
||||
str(PROJECT_ROOT / "tests" / "testapp" / "main.qml"),
|
||||
))
|
||||
WIDGET_TEST_APP_BIN = Path(os.environ.get(
|
||||
"WIDGET_TEST_APP_BIN",
|
||||
str(PROJECT_ROOT / "tests" / "testapp" / "widget_test_app"),
|
||||
))
|
||||
WIDGET_TEST_APP_SRC = Path(os.environ.get(
|
||||
"WIDGET_TEST_APP_SRC",
|
||||
str(PROJECT_ROOT / "tests" / "testapp" / "widget_test_app.cpp"),
|
||||
))
|
||||
BRIDGE_EXE = Path(os.environ.get(
|
||||
"BRIDGE_EXE",
|
||||
str(PROJECT_ROOT / "bridge" / "build" / "qml-sg-mcp-bridge"),
|
||||
@@ -51,6 +60,43 @@ BRIDGE_RUN_SCRIPT = Path(os.environ.get(
|
||||
))
|
||||
|
||||
|
||||
def _compile_widget_app() -> Path:
|
||||
"""Compile the widget test app if needed. Returns the binary path."""
|
||||
if WIDGET_TEST_APP_BIN.exists():
|
||||
return WIDGET_TEST_APP_BIN
|
||||
if not WIDGET_TEST_APP_SRC.exists():
|
||||
pytest.skip(f"Widget test app source not found at {WIDGET_TEST_APP_SRC}")
|
||||
# Find Qt6 via pkg-config
|
||||
try:
|
||||
import subprocess as sp
|
||||
cflags = sp.check_output(
|
||||
["pkg-config", "--cflags", "Qt6Core", "Qt6Gui", "Qt6Widgets"],
|
||||
text=True
|
||||
).strip()
|
||||
libs = sp.check_output(
|
||||
["pkg-config", "--libs", "Qt6Core", "Qt6Gui", "Qt6Widgets"],
|
||||
text=True
|
||||
).strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
# Fallback: try qmake6
|
||||
try:
|
||||
qmake = sp.check_output(["which", "qmake6"], text=True).strip()
|
||||
prefix = sp.check_output([qmake, "-query", "QT_INSTALL_PREFIX"], text=True).strip()
|
||||
cflags = f"-I{prefix}/include -I{prefix}/include/QtCore -I{prefix}/include/QtGui -I{prefix}/include/QtWidgets"
|
||||
libs = f"-L{prefix}/lib -lQt6Core -lQt6Gui -lQt6Widgets"
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pytest.skip("Cannot find Qt6 build flags (need pkg-config or qmake6)")
|
||||
|
||||
subprocess.run(
|
||||
["g++", "-std=c++17", "-fPIC",
|
||||
str(WIDGET_TEST_APP_SRC),
|
||||
"-o", str(WIDGET_TEST_APP_BIN),
|
||||
*cflags.split(), *libs.split()],
|
||||
check=True, timeout=30,
|
||||
)
|
||||
return WIDGET_TEST_APP_BIN
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--no-probe",
|
||||
@@ -64,6 +110,12 @@ def pytest_addoption(parser):
|
||||
default=15,
|
||||
help="Seconds to wait for probe to be ready",
|
||||
)
|
||||
parser.addoption(
|
||||
"--widget-app",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Use widget test app instead of QML app for probe target",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
@@ -103,12 +155,24 @@ def _probe_accepting_connections(timeout: int = 20) -> bool:
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def probe_process(request) -> subprocess.Popen | None:
|
||||
"""Start a GammaRay probe process (session-scoped, one per entire test run)."""
|
||||
"""Start a GammaRay probe process (session-scoped, one per entire test run).
|
||||
|
||||
Uses either the QML test app or the widget test app depending on --widget-app.
|
||||
"""
|
||||
if request.config.getoption("--no-probe"):
|
||||
return None
|
||||
|
||||
if not GAMMARAY_BIN.exists():
|
||||
pytest.skip(f"GammaRay probe binary not found at {GAMMARAY_BIN}")
|
||||
|
||||
if request.config.getoption("--widget-app"):
|
||||
return _start_widget_probe(request)
|
||||
else:
|
||||
return _start_qml_probe(request)
|
||||
|
||||
|
||||
def _start_qml_probe(request) -> subprocess.Popen:
|
||||
"""Start probe with QML test app."""
|
||||
if not TEST_QML.exists():
|
||||
pytest.skip(f"Test QML file not found at {TEST_QML}")
|
||||
if not QML_RUNNER.exists():
|
||||
@@ -145,14 +209,72 @@ def probe_process(request) -> subprocess.Popen | None:
|
||||
|
||||
timeout = request.config.getoption("--probe-timeout")
|
||||
if not _probe_accepting_connections(timeout):
|
||||
# Fallback: direct spawn
|
||||
proc = subprocess.Popen(
|
||||
[str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732",
|
||||
"--injector", "preload", str(QML_RUNNER), str(TEST_QML)],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if not _probe_accepting_connections(timeout):
|
||||
pytest.fail(f"Probe did not become ready within {timeout}s")
|
||||
pytest.fail(f"QML probe did not become ready within {timeout}s")
|
||||
else:
|
||||
proc = subprocess.Popen(["true"])
|
||||
|
||||
def cleanup():
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "stop", "gammaray-probe-test.service"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
|
||||
request.addfinalizer(cleanup)
|
||||
return proc
|
||||
|
||||
|
||||
def _start_widget_probe(request) -> subprocess.Popen:
|
||||
"""Start probe with widget test app."""
|
||||
widget_bin = _compile_widget_app()
|
||||
|
||||
env = os.environ.copy()
|
||||
env["QT_QPA_PLATFORM"] = "offscreen"
|
||||
env["LD_LIBRARY_PATH"] = GAMMARAY_LIB_DIR
|
||||
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "stop", "gammaray-probe-test.service"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "reset-failed", "gammaray-probe-test.service"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"systemd-run", "--user", "--no-block",
|
||||
"--unit=gammaray-probe-test",
|
||||
"--same-dir",
|
||||
"--working-directory", str(widget_bin.parent),
|
||||
"-E", f"QT_QPA_PLATFORM={env['QT_QPA_PLATFORM']}",
|
||||
"-E", f"LD_LIBRARY_PATH={env['LD_LIBRARY_PATH']}",
|
||||
str(GAMMARAY_BIN),
|
||||
"--inject-only", "--listen", "tcp://127.0.0.1:11732",
|
||||
"--injector", "preload",
|
||||
str(widget_bin),
|
||||
],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
|
||||
timeout = request.config.getoption("--probe-timeout")
|
||||
if not _probe_accepting_connections(timeout):
|
||||
proc = subprocess.Popen(
|
||||
[str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732",
|
||||
"--injector", "preload", str(widget_bin)],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if not _probe_accepting_connections(timeout):
|
||||
pytest.fail(f"Widget probe did not become ready within {timeout}s")
|
||||
else:
|
||||
proc = subprocess.Popen(["true"])
|
||||
|
||||
|
||||
56
tests/run_widget_tests.sh
Executable file
56
tests/run_widget_tests.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run widget-specific tests with a QWidget test app as the probe target.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Usage:
|
||||
# ./run_widget_tests.sh # full widget test suite
|
||||
# ./run_widget_tests.sh -v # verbose
|
||||
# ./run_widget_tests.sh -k "attr" # run only attribute-related tests
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configurable paths
|
||||
# ---------------------------------------------------------------------------
|
||||
: "${GAMMARAY_BIN:=$PROJECT_ROOT/install-prefix/bin/gammaray}"
|
||||
: "${GAMMARAY_LIB_DIR:=$PROJECT_ROOT/install-prefix/lib}"
|
||||
: "${WIDGET_TEST_APP_BIN:=$SCRIPT_DIR/testapp/widget_test_app}"
|
||||
: "${WIDGET_TEST_APP_SRC:=$SCRIPT_DIR/testapp/widget_test_app.cpp}"
|
||||
: "${BRIDGE_EXE:=$PROJECT_ROOT/bridge/build/qml-sg-mcp-bridge}"
|
||||
: "${BRIDGE_RUN_SCRIPT:=$PROJECT_ROOT/bridge/run.sh}"
|
||||
|
||||
export GAMMARAY_BIN
|
||||
export GAMMARAY_LIB_DIR
|
||||
export WIDGET_TEST_APP_BIN
|
||||
export WIDGET_TEST_APP_SRC
|
||||
export BRIDGE_EXE
|
||||
export BRIDGE_RUN_SCRIPT
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure the bridge is built
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ ! -f "$BRIDGE_EXE" ]; then
|
||||
echo "Building bridge first..."
|
||||
cmake -S "$PROJECT_ROOT/bridge" -B "$PROJECT_ROOT/bridge/build" -G Ninja \
|
||||
-DCMAKE_PREFIX_PATH="$PROJECT_ROOT/install-prefix"
|
||||
cmake --build "$PROJECT_ROOT/bridge/build"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime environment
|
||||
# ---------------------------------------------------------------------------
|
||||
export LD_LIBRARY_PATH="$GAMMARAY_LIB_DIR"
|
||||
export QT_QPA_PLATFORM=offscreen
|
||||
export QT_PLUGIN_PATH="$PROJECT_ROOT/bridge/build/lib/x86_64-linux-gnu/qt6/plugins"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "=== Widget Test Run ==="
|
||||
echo "Widget app: $WIDGET_TEST_APP_BIN"
|
||||
echo "Bridge: $BRIDGE_EXE"
|
||||
|
||||
exec uv run --frozen --directory "$PROJECT_ROOT" python "$SCRIPT_DIR/run_with_uv.py" --widget-app "$@"
|
||||
126
tests/test_widgets.py
Normal file
126
tests/test_widgets.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Tests for Qt Widget introspection tools (listWidgets, selectWidget, etc.)."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestWidgetTools:
|
||||
"""Verify listWidgets, selectWidget, getWidgetProperties, getWidgetAttributes."""
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_tools_listed(self, bridge):
|
||||
tools = bridge.list_tools()
|
||||
names = [t["name"] for t in tools]
|
||||
for expected in ["listWidgets", "selectWidget",
|
||||
"getWidgetProperties", "getWidgetAttributes"]:
|
||||
assert expected in names, f"Missing tool: {expected}"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_list_widgets(self, connected_bridge):
|
||||
widgets = connected_bridge.call_tool("listWidgets")
|
||||
assert isinstance(widgets, list), f"Expected list, got: {type(widgets)}"
|
||||
assert len(widgets) >= 1, "No widgets found"
|
||||
|
||||
# Should have a top-level window
|
||||
types = [w.get("type", "") for w in widgets]
|
||||
assert any("QWidget" in t for t in types), \
|
||||
f"No QWidget-type root found: {types}"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_select_widget(self, connected_bridge):
|
||||
widgets = connected_bridge.call_tool("listWidgets")
|
||||
assert isinstance(widgets, list) and len(widgets) > 0
|
||||
|
||||
# Find the first Button-like widget
|
||||
def find_buttons(items_list):
|
||||
buttons = []
|
||||
for item in items_list:
|
||||
if "Button" in (item.get("type", "")) or "PushButton" in (item.get("type", "")):
|
||||
buttons.append(item.get("name", ""))
|
||||
buttons.extend(find_buttons(item.get("children", [])))
|
||||
return buttons
|
||||
|
||||
buttons = find_buttons(widgets)
|
||||
if buttons:
|
||||
addr = buttons[0]
|
||||
else:
|
||||
addr = widgets[0].get("name", "")
|
||||
|
||||
result = connected_bridge.call_tool("selectWidget", {"address": addr})
|
||||
assert isinstance(result, dict), f"selectWidget failed: {result}"
|
||||
assert result.get("propertyCount", 0) > 0, \
|
||||
f"No properties for widget {addr}: {result}"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_get_widget_properties(self, connected_bridge):
|
||||
widgets = connected_bridge.call_tool("listWidgets")
|
||||
assert isinstance(widgets, list) and len(widgets) > 0
|
||||
|
||||
addr = widgets[0].get("name", "")
|
||||
if not addr:
|
||||
pytest.skip("No widget address found")
|
||||
|
||||
props = connected_bridge.call_tool("getWidgetProperties", {"address": addr})
|
||||
assert isinstance(props, dict), f"getWidgetProperties failed: {props}"
|
||||
assert "properties" in props, f"No 'properties' key: {list(props.keys())}"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_widget_has_geometry(self, connected_bridge):
|
||||
"""Verify a widget has geometry properties (x, y, width, height)."""
|
||||
widgets = connected_bridge.call_tool("listWidgets")
|
||||
assert isinstance(widgets, list) and len(widgets) > 0
|
||||
|
||||
addr = widgets[0].get("name", "")
|
||||
if not addr:
|
||||
pytest.skip("No widget address found")
|
||||
|
||||
props = connected_bridge.call_tool("getWidgetProperties", {"address": addr})
|
||||
simple = props.get("properties", {})
|
||||
for key in ("x", "y", "width", "height"):
|
||||
assert key in simple, f"Widget missing '{key}' property"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_widget_attributes(self, connected_bridge):
|
||||
"""Verify getWidgetAttributes returns attribute flags for a widget."""
|
||||
widgets = connected_bridge.call_tool("listWidgets")
|
||||
assert isinstance(widgets, list) and len(widgets) > 0
|
||||
|
||||
# Find the checkBox widget (has various attributes)
|
||||
def find_named(items_list):
|
||||
for item in items_list:
|
||||
name = item.get("name", "")
|
||||
if "check" in name.lower() or "CheckBox" in (item.get("type", "")):
|
||||
return name
|
||||
found = find_named(item.get("children", []))
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
addr = find_named(widgets)
|
||||
if not addr:
|
||||
addr = widgets[0].get("name", "")
|
||||
|
||||
attrs = connected_bridge.call_tool("getWidgetAttributes", {"address": addr})
|
||||
assert isinstance(attrs, dict), f"getWidgetAttributes failed: {attrs}"
|
||||
assert "attributes" in attrs, f"No 'attributes' key: {list(attrs.keys())}"
|
||||
assert attrs.get("count", 0) > 0, "No widget attributes returned"
|
||||
# Should at least have some standard attributes
|
||||
attr_names = list(attrs.get("attributes", {}).keys())
|
||||
assert len(attr_names) > 0, "Empty attributes list"
|
||||
|
||||
@pytest.mark.probe
|
||||
def test_widget_enabled_attribute(self, connected_bridge):
|
||||
"""Verify attributes contain 'enabled' field."""
|
||||
widgets = connected_bridge.call_tool("listWidgets")
|
||||
assert isinstance(widgets, list) and len(widgets) > 0
|
||||
|
||||
addr = widgets[0].get("name", "")
|
||||
if not addr:
|
||||
pytest.skip("No widget address found")
|
||||
|
||||
attrs = connected_bridge.call_tool("getWidgetAttributes", {"address": addr})
|
||||
attributes = attrs.get("attributes", {})
|
||||
# Check at least one attribute has enabled/value keys
|
||||
for attr_name, attr_data in attributes.items():
|
||||
if isinstance(attr_data, dict) and "enabled" in attr_data:
|
||||
return # Found one
|
||||
pytest.skip("No attributes with 'enabled' field found")
|
||||
50
tests/testapp/widget_test_app.cpp
Normal file
50
tests/testapp/widget_test_app.cpp
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
widget_test_app.cpp — Minimal QWidget test application for GammaRay MCP widget tools.
|
||||
|
||||
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
#include <QApplication>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QCheckBox>
|
||||
#include <QGroupBox>
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
app.setApplicationName(QStringLiteral("GammaRay MCP Widget Test App"));
|
||||
|
||||
auto *mainWindow = new QWidget();
|
||||
mainWindow->setWindowTitle(QStringLiteral("GammaRay Widget Test"));
|
||||
mainWindow->resize(400, 300);
|
||||
|
||||
auto *layout = new QVBoxLayout(mainWindow);
|
||||
|
||||
auto *label = new QLabel(QStringLiteral("Hello from GammaRay MCP"));
|
||||
layout->addWidget(label);
|
||||
|
||||
auto *lineEdit = new QLineEdit();
|
||||
lineEdit->setPlaceholderText(QStringLiteral("Type something..."));
|
||||
layout->addWidget(lineEdit);
|
||||
|
||||
auto *checkBox = new QCheckBox(QStringLiteral("Enable feature"));
|
||||
checkBox->setChecked(true);
|
||||
layout->addWidget(checkBox);
|
||||
|
||||
auto *groupBox = new QGroupBox(QStringLiteral("Options"));
|
||||
auto *groupLayout = new QVBoxLayout(groupBox);
|
||||
auto *innerCheck = new QCheckBox(QStringLiteral("Sub-option A"));
|
||||
groupLayout->addWidget(innerCheck);
|
||||
groupLayout->addWidget(new QCheckBox(QStringLiteral("Sub-option B")));
|
||||
layout->addWidget(groupBox);
|
||||
|
||||
auto *button = new QPushButton(QStringLiteral("Click Me"));
|
||||
layout->addWidget(button);
|
||||
|
||||
mainWindow->show();
|
||||
return app.exec();
|
||||
}
|
||||
Reference in New Issue
Block a user