basic qt widgets support

This commit is contained in:
2026-07-08 13:09:00 +08:00
parent 591e786a0d
commit 83df5286c5
16 changed files with 1130 additions and 129 deletions

View File

@@ -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
View 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
View 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")

View 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();
}