make test run faster

This commit is contained in:
2026-07-08 10:46:14 +08:00
parent 268483577e
commit 591e786a0d
6 changed files with 104 additions and 120 deletions

View File

@@ -413,7 +413,7 @@ The fix was NOT a code change but a sequencing fix in `ensureNodeSelected()`:
All items from the original blocker resolved. Remaining minor issues: All items from the original blocker resolved. Remaining minor issues:
- `listScenegraphNodes` first-call fill stability — first call may show "Loading..." for deep nodes (RemoteModel lazy-fetch behavior); second call returns the full tree. The `primeAndWait()` loop handles this transparently, but single-call would be nicer. - `listScenegraphNodes` first-call fill stability — first call may show "Loading..." for deep nodes (RemoteModel lazy-fetch behavior); second call returns the full tree. The `primeAndWait()` loop handles this transparently, but single-call would be nicer.
- `walkItemChildren` `flagsToJson` uses hardcoded flag bit values (1,2,4,8,16,32,64) — fragile if GammaRay's `ItemFlags` enum changes. Should read from `common/quickitemmodelroles.h` if it were installed. - `walkItemChildren` `flagsToJson` uses hardcoded flag bit values (1,2,4,8,16,32,64) — fragile if GammaRay's `ItemFlags` enum changes. Should read from `common/quickitemmodelroles.h` if it were installed.
- Full test suite takes ~8 minutes due to probe start/stop overhead and 500-1500ms settle delays per tool call. Could be optimized with module-scoped bridge fixtures and shared probe process. - Full test suite ~3.5 min (session-scoped probe + module-scoped bridge + 0.5s settle delays). Connection tests still get fresh bridges per test (function-scoped).
## Step 7: Launch and run ## Step 7: Launch and run

View File

@@ -13,6 +13,7 @@ before calling pytest. If you prefer to run pytest directly, set these:
""" """
import os import os
import socket
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
@@ -24,7 +25,6 @@ from mcp_client import McpClient
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
# All paths read from env vars, with sensible project-local defaults. # All paths read from env vars, with sensible project-local defaults.
# Users only need to override these when GammaRay is installed elsewhere.
GAMMARAY_BIN = Path(os.environ.get( GAMMARAY_BIN = Path(os.environ.get(
"GAMMARAY_BIN", "GAMMARAY_BIN",
str(PROJECT_ROOT / "install-prefix" / "bin" / "gammaray"), str(PROJECT_ROOT / "install-prefix" / "bin" / "gammaray"),
@@ -88,9 +88,22 @@ def run_script_path() -> Path:
return BRIDGE_RUN_SCRIPT return BRIDGE_RUN_SCRIPT
@pytest.fixture(scope="module") def _probe_accepting_connections(timeout: int = 20) -> bool:
"""Check if the probe is accepting connections via raw TCP."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
s = socket.create_connection(("127.0.0.1", 11732), timeout=2)
s.close()
return True
except (OSError, socket.timeout):
time.sleep(1)
return False
@pytest.fixture(scope="session")
def probe_process(request) -> subprocess.Popen | None: def probe_process(request) -> subprocess.Popen | None:
"""Start a GammaRay probe process (session-scoped, one per test run).""" """Start a GammaRay probe process (session-scoped, one per entire test run)."""
if request.config.getoption("--no-probe"): if request.config.getoption("--no-probe"):
return None return None
@@ -105,10 +118,6 @@ def probe_process(request) -> subprocess.Popen | None:
env["QT_QPA_PLATFORM"] = "offscreen" env["QT_QPA_PLATFORM"] = "offscreen"
env["LD_LIBRARY_PATH"] = GAMMARAY_LIB_DIR env["LD_LIBRARY_PATH"] = GAMMARAY_LIB_DIR
proc = None
used_systemd = False
# Try systemd-run first for clean detachment
subprocess.run( subprocess.run(
["systemctl", "--user", "stop", "gammaray-probe-test.service"], ["systemctl", "--user", "stop", "gammaray-probe-test.service"],
capture_output=True, timeout=5, capture_output=True, timeout=5,
@@ -133,25 +142,21 @@ def probe_process(request) -> subprocess.Popen | None:
], ],
capture_output=True, text=True, timeout=10, capture_output=True, text=True, timeout=10,
) )
if result.returncode == 0:
used_systemd = True timeout = request.config.getoption("--probe-timeout")
proc = subprocess.Popen(["true"]) # sentinel: probe is external if not _probe_accepting_connections(timeout):
else:
# Fallback: direct spawn # Fallback: direct spawn
proc = subprocess.Popen( proc = subprocess.Popen(
[str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732", [str(GAMMARAY_BIN), "--inject-only", "--listen", "tcp://127.0.0.1:11732",
"--injector", "preload", str(QML_RUNNER), str(TEST_QML)], "--injector", "preload", str(QML_RUNNER), str(TEST_QML)],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
if not _probe_accepting_connections(timeout):
pytest.fail(f"Probe did not become ready within {timeout}s")
else:
proc = subprocess.Popen(["true"])
def cleanup(): def cleanup():
if proc:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
if used_systemd:
subprocess.run( subprocess.run(
["systemctl", "--user", "stop", "gammaray-probe-test.service"], ["systemctl", "--user", "stop", "gammaray-probe-test.service"],
capture_output=True, timeout=5, capture_output=True, timeout=5,
@@ -162,54 +167,26 @@ def probe_process(request) -> subprocess.Popen | None:
) )
request.addfinalizer(cleanup) request.addfinalizer(cleanup)
# Wait for probe to be ready
timeout = request.config.getoption("--probe-timeout")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
c = McpClient(bridge_path=BRIDGE_EXE)
c.initialize()
result = c.call_tool("connectProbeDefault")
c.close()
if isinstance(result, dict) and result.get("connected"):
break
except Exception:
pass
time.sleep(1)
else:
pytest.fail(f"Probe did not become ready within {timeout}s")
return proc return proc
@pytest.fixture @pytest.fixture(scope="module")
def bridge(run_script_path) -> McpClient: def bridge(run_script_path) -> McpClient:
"""Start a fresh bridge instance per test.""" """Start a bridge instance per module, reused across tests in the module."""
c = McpClient(bridge_path=run_script_path) c = McpClient(bridge_path=run_script_path)
c.initialize() c.initialize()
yield c yield c
# Gracefully disconnect from the probe before killing the bridge.
# GammaRay's QTcpSocket::disconnectFromHost() is asynchronous — the
# Endpoint::disconnected signal (which clears m_socket) fires on a later
# event loop iteration. If we kill the bridge (SIGTERM) while m_socket is
# still set, the probe does not immediately detect the disconnection, and
# the next test's bridge cannot connect. Sending disconnectProbe() first
# triggers a clean GammaRay protocol-level shutdown that waits for the
# async disconnect to complete, releasing the probe for the next test.
try: try:
c.call_tool("disconnectProbe", timeout=5) c.call_tool("disconnectProbe", timeout=5)
except Exception: except Exception:
pass # bridge may have already crashed; best-effort pass
c.close() c.close()
# Allow the probe to detect the disconnection before the next test time.sleep(0.2)
# connects.
time.sleep(1)
@pytest.fixture @pytest.fixture(scope="module")
def connected_bridge(bridge, probe_process) -> McpClient: def connected_bridge(bridge, probe_process) -> McpClient:
"""Bridge connected to a running probe (retries on transient failures).""" """Bridge connected to a running probe (module-scoped, reused across tests)."""
if probe_process is None: if probe_process is None:
pytest.skip("No probe available (try --no-probe to skip probe-dependent tests)") pytest.skip("No probe available (try --no-probe to skip probe-dependent tests)")
deadline = time.monotonic() + 30 deadline = time.monotonic() + 30
@@ -219,5 +196,5 @@ def connected_bridge(bridge, probe_process) -> McpClient:
if isinstance(result, dict) and result.get("connected"): if isinstance(result, dict) and result.get("connected"):
return bridge return bridge
last_error = result last_error = result
time.sleep(3) time.sleep(1)
raise AssertionError(f"Failed to connect to probe after 30s: {last_error}") raise AssertionError(f"Failed to connect to probe after 30s: {last_error}")

View File

@@ -1,67 +1,89 @@
"""Tests for connection management tools.""" """Tests for connection management tools.
These tests need function-scoped bridges because they explicitly test
connect/disconnect/reconnect lifecycle. The conftest.py module-scoped
fixtures would not work here since they share a single connection.
"""
import time
import pytest import pytest
from mcp_client import McpClient
@pytest.fixture(scope="function")
def local_bridge(run_script_path) -> McpClient:
c = McpClient(bridge_path=run_script_path)
c.initialize()
yield c
try:
c.call_tool("disconnectProbe", timeout=5)
except Exception:
pass
c.close()
time.sleep(0.2)
@pytest.fixture(scope="function")
def local_connected_bridge(local_bridge, probe_process) -> McpClient:
if probe_process is None:
pytest.skip("No probe available")
deadline = time.monotonic() + 30
last_error = None
while time.monotonic() < deadline:
result = local_bridge.call_tool("connectProbeDefault")
if isinstance(result, dict) and result.get("connected"):
return local_bridge
last_error = result
time.sleep(1)
raise AssertionError(f"Failed to connect to probe after 30s: {last_error}")
class TestConnection: class TestConnection:
"""Verify connectProbe, probeStatus, disconnectProbe work.""" """Verify connectProbe, probeStatus, disconnectProbe work."""
def test_tools_listed(self, bridge): def test_tools_listed(self, local_bridge):
tools = bridge.list_tools() tools = local_bridge.list_tools()
names = [t["name"] for t in tools] names = [t["name"] for t in tools]
for expected in ["connectProbe", "connectProbeDefault", for expected in ["connectProbe", "connectProbeDefault",
"disconnectProbe", "probeStatus"]: "disconnectProbe", "probeStatus"]:
assert expected in names, f"Missing tool: {expected}" assert expected in names, f"Missing tool: {expected}"
def test_probe_status_disconnected(self, bridge): def test_probe_status_disconnected(self, local_bridge):
status = bridge.call_tool("probeStatus") status = local_bridge.call_tool("probeStatus")
assert isinstance(status, dict) assert isinstance(status, dict)
assert status.get("state") in ("disconnected", "connecting") assert status.get("state") in ("disconnected", "connecting")
assert status.get("ready") is False assert status.get("ready") is False
def test_connect_and_disconnect(self, bridge, probe_process): def test_connect_and_disconnect(self, local_bridge, probe_process):
if probe_process is None: if probe_process is None:
pytest.skip("No probe available") pytest.skip("No probe available")
# Connect result = local_bridge.call_tool("connectProbeDefault")
result = bridge.call_tool("connectProbeDefault")
assert isinstance(result, dict), f"connectProbeDefault failed: {result}" assert isinstance(result, dict), f"connectProbeDefault failed: {result}"
assert result.get("connected"), f"Not connected: {result}" assert result.get("connected"), f"Not connected: {result}"
# Verify status status = local_bridge.call_tool("probeStatus")
status = bridge.call_tool("probeStatus")
assert isinstance(status, dict) assert isinstance(status, dict)
assert status.get("state") == "ready" assert status.get("state") == "ready"
assert status.get("ready") is True assert status.get("ready") is True
assert "tcp://" in status.get("url", "") assert "tcp://" in status.get("url", "")
# Now disconnect result = local_bridge.call_tool("disconnectProbe")
result = bridge.call_tool("disconnectProbe")
assert isinstance(result, dict) assert isinstance(result, dict)
assert result.get("state") == "disconnected" assert result.get("state") == "disconnected"
def test_reconnect(self, bridge, probe_process): def test_reconnect(self, local_bridge, probe_process):
if probe_process is None: if probe_process is None:
pytest.skip("No probe available") pytest.skip("No probe available")
bridge.call_tool("connectProbeDefault") local_bridge.call_tool("connectProbeDefault")
# Disconnect local_bridge.call_tool("disconnectProbe")
bridge.call_tool("disconnectProbe") result = local_bridge.call_tool("connectProbe",
# Reconnect with explicit args
result = bridge.call_tool("connectProbe",
{"host": "127.0.0.1", "port": 11732}) {"host": "127.0.0.1", "port": 11732})
assert isinstance(result, dict) assert isinstance(result, dict)
assert result.get("connected") is True assert result.get("connected") is True
assert result.get("state") == "ready" assert result.get("state") == "ready"
def test_connect_default_after_disconnect(self, bridge, probe_process): def test_reconnect_default_after_disconnect(self, local_connected_bridge):
if probe_process is None: local_connected_bridge.call_tool("disconnectProbe")
pytest.skip("No probe available") result = local_connected_bridge.call_tool("connectProbeDefault")
bridge.call_tool("connectProbeDefault")
bridge.call_tool("disconnectProbe")
result = bridge.call_tool("connectProbeDefault")
assert isinstance(result, dict)
assert result.get("connected") is True
assert result.get("state") == "ready"
def test_connect_default_after_disconnect(self, connected_bridge):
connected_bridge.call_tool("disconnectProbe")
result = connected_bridge.call_tool("connectProbeDefault")
assert isinstance(result, dict) assert isinstance(result, dict)
assert result.get("connected") is True assert result.get("connected") is True

View File

@@ -1,7 +1,6 @@
"""Tests for QML item selection and property inspection.""" """Tests for QML item selection and property inspection."""
import pytest import pytest
import time
class TestItemProperties: class TestItemProperties:
@@ -18,7 +17,7 @@ class TestItemProperties:
def test_select_quick_item(self, connected_bridge): def test_select_quick_item(self, connected_bridge):
# First get items # First get items
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
time.sleep(1)
items = connected_bridge.call_tool("listQuickItems") items = connected_bridge.call_tool("listQuickItems")
assert isinstance(items, list) and len(items) > 0, "No items found" assert isinstance(items, list) and len(items) > 0, "No items found"
@@ -49,7 +48,7 @@ class TestItemProperties:
@pytest.mark.probe @pytest.mark.probe
def test_get_item_properties(self, connected_bridge): def test_get_item_properties(self, connected_bridge):
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
time.sleep(1)
items = connected_bridge.call_tool("listQuickItems") items = connected_bridge.call_tool("listQuickItems")
def find_any_item(items_list): def find_any_item(items_list):
@@ -74,7 +73,7 @@ class TestItemProperties:
def test_item_has_position(self, connected_bridge): def test_item_has_position(self, connected_bridge):
"""Verify that a Button item has x, y, width, height properties.""" """Verify that a Button item has x, y, width, height properties."""
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
time.sleep(1)
items = connected_bridge.call_tool("listQuickItems") items = connected_bridge.call_tool("listQuickItems")
# Find a Button_QMLTYPE item # Find a Button_QMLTYPE item
@@ -102,7 +101,7 @@ class TestItemProperties:
def test_item_has_visual_properties(self, connected_bridge): def test_item_has_visual_properties(self, connected_bridge):
"""Verify items have opacity, visible, z, rotation, scale.""" """Verify items have opacity, visible, z, rotation, scale."""
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
time.sleep(1)
items = connected_bridge.call_tool("listQuickItems") items = connected_bridge.call_tool("listQuickItems")
def find_addr(items_list): def find_addr(items_list):
@@ -128,7 +127,7 @@ class TestItemProperties:
def test_property_model_has_groups(self, connected_bridge): def test_property_model_has_groups(self, connected_bridge):
"""Verify items have nested property groups (background, contentItem, etc.).""" """Verify items have nested property groups (background, contentItem, etc.)."""
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
time.sleep(1)
items = connected_bridge.call_tool("listQuickItems") items = connected_bridge.call_tool("listQuickItems")
def find_button(items_list): def find_button(items_list):

View File

@@ -30,23 +30,16 @@ class TestNavigation:
@pytest.mark.probe @pytest.mark.probe
def test_list_items(self, connected_bridge): def test_list_items(self, connected_bridge):
# Select window first so item model populates
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
import time
time.sleep(1)
items = connected_bridge.call_tool("listQuickItems") items = connected_bridge.call_tool("listQuickItems")
assert isinstance(items, list) assert isinstance(items, list)
# May be empty if model hasn't populated yet; that's OK
if items: if items:
assert isinstance(items[0], dict) assert isinstance(items[0], dict)
assert "type" in items[0] assert "type" in items[0]
@pytest.mark.probe @pytest.mark.probe
def test_list_items_with_window_count(self, connected_bridge): def test_list_items_with_window_count(self, connected_bridge):
"""Collect all items and verify at least some are found."""
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
import time
time.sleep(1)
items = connected_bridge.call_tool("listQuickItems") items = connected_bridge.call_tool("listQuickItems")
def count_all(items_list): def count_all(items_list):
@@ -63,21 +56,15 @@ class TestNavigation:
@pytest.mark.probe @pytest.mark.probe
def test_list_scenegraph_nodes(self, connected_bridge): def test_list_scenegraph_nodes(self, connected_bridge):
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
import time
time.sleep(1)
nodes = connected_bridge.call_tool("listScenegraphNodes") nodes = connected_bridge.call_tool("listScenegraphNodes")
assert isinstance(nodes, list) assert isinstance(nodes, list)
if nodes: if nodes:
assert isinstance(nodes[0], dict) assert isinstance(nodes[0], dict)
# Root should be "Root Node"
assert nodes[0].get("type") == "Root Node" assert nodes[0].get("type") == "Root Node"
@pytest.mark.probe @pytest.mark.probe
def test_scenegraph_has_geometry_nodes(self, connected_bridge): def test_scenegraph_has_geometry_nodes(self, connected_bridge):
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
import time
time.sleep(1)
nodes = connected_bridge.call_tool("listScenegraphNodes") nodes = connected_bridge.call_tool("listScenegraphNodes")
def find_geometry(items_list): def find_geometry(items_list):

View File

@@ -1,7 +1,6 @@
"""Tests for SceneGraph node selection and geometry/material tools.""" """Tests for SceneGraph node selection and geometry/material tools."""
import pytest import pytest
import time
class TestSceneGraph: class TestSceneGraph:
@@ -21,7 +20,7 @@ class TestSceneGraph:
@pytest.mark.probe @pytest.mark.probe
def test_select_scenegraph_node(self, connected_bridge): def test_select_scenegraph_node(self, connected_bridge):
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
time.sleep(1)
nodes = connected_bridge.call_tool("listScenegraphNodes") nodes = connected_bridge.call_tool("listScenegraphNodes")
# Find first Geometry Node # Find first Geometry Node
@@ -47,7 +46,7 @@ class TestSceneGraph:
@pytest.mark.probe @pytest.mark.probe
def test_get_node_vertices(self, connected_bridge): def test_get_node_vertices(self, connected_bridge):
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
time.sleep(1)
nodes = connected_bridge.call_tool("listScenegraphNodes") nodes = connected_bridge.call_tool("listScenegraphNodes")
def find_geo(items_list): def find_geo(items_list):
@@ -71,7 +70,7 @@ class TestSceneGraph:
@pytest.mark.probe @pytest.mark.probe
def test_get_node_adjacency(self, connected_bridge): def test_get_node_adjacency(self, connected_bridge):
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
time.sleep(1)
nodes = connected_bridge.call_tool("listScenegraphNodes") nodes = connected_bridge.call_tool("listScenegraphNodes")
def find_geo(items_list): def find_geo(items_list):
@@ -94,7 +93,7 @@ class TestSceneGraph:
@pytest.mark.probe @pytest.mark.probe
def test_get_material_shaders(self, connected_bridge): def test_get_material_shaders(self, connected_bridge):
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
time.sleep(1)
nodes = connected_bridge.call_tool("listScenegraphNodes") nodes = connected_bridge.call_tool("listScenegraphNodes")
def find_geo(items_list): def find_geo(items_list):
@@ -118,7 +117,7 @@ class TestSceneGraph:
@pytest.mark.probe @pytest.mark.probe
def test_get_material_properties(self, connected_bridge): def test_get_material_properties(self, connected_bridge):
connected_bridge.call_tool("selectQuickWindow", {"index": 0}) connected_bridge.call_tool("selectQuickWindow", {"index": 0})
time.sleep(1)
nodes = connected_bridge.call_tool("listScenegraphNodes") nodes = connected_bridge.call_tool("listScenegraphNodes")
def find_geo(items_list): def find_geo(items_list):