mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-08-03 20:56:01 +00:00
Settle the Playwright-grade reliability contract in agent-desktop-core before the Windows/Linux adapters are built, so they inherit it instead of redesigning it. Every command now observes, waits, verifies, and reports honestly instead of firing blindly. Highlights: capability-supertrait split of PlatformAdapter with not_supported() defaults; canonical role/state vocabulary with live `is --property visible`; display enumeration (`list-displays`) and honest `--screen` with scale factor; truthful Automation permission; `native_id` identity spine; window-id-first resolution; serializable `LocatorQuery` with live `find`; default-on auto-wait before every ref action; three-way `hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name precedence; typed `ActionStep` delivery tier; `ProcessState` and `APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals (`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with independent validation and a green live e2e gate (109/0), plus a head-vs-main performance comparison harness. BREAKING CHANGE: default-on auto-wait changes the timing of every previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0` restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the `APP_UNRESPONSIVE` code and process state in error details). FFI ABI major is `3` (append-only struct evolution; `wait --event` is intentionally not exposed over FFI). The legacy string clipboard API is removed in favor of typed content. `key-down`/`key-up` fail closed until daemon-owned held input exists. `close-app` verifies termination and the osascript fallback path is removed. `--text` matching is subtree containment: `find --text X --first` returns the outermost matching container.
332 lines
13 KiB
Python
332 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import types
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from electron_metrics import (
|
|
MeasurementError,
|
|
assert_stable,
|
|
ensure_distinct_comparison,
|
|
order_for,
|
|
read_trace_events,
|
|
run_sample,
|
|
verify_exact_namespace,
|
|
)
|
|
from electron_metrics_report import build_report, paired_summary, summarize_run
|
|
from electron_metrics_state import target_window_id
|
|
|
|
|
|
def sample(wall_ms, cpu_ms, correct=True, stats=True):
|
|
return {
|
|
"wall_ms": wall_ms,
|
|
"cpu_ms": cpu_ms,
|
|
"command_success": True,
|
|
"addressable": True,
|
|
"exact_reresolution": correct,
|
|
"correct": correct,
|
|
"failure_kind": None if correct else "reresolution",
|
|
"error_code": None,
|
|
"stats": {
|
|
"elapsed_us": wall_ms * 1000,
|
|
"activation": {"attempted": True, "succeeded": True, "ready": True},
|
|
"traversal": {"nodes_visited": 6, "peak_handles_owned": 3},
|
|
"reads": {
|
|
"attribute_batches": 2,
|
|
"attributes_requested": 8,
|
|
"cannot_complete": 0,
|
|
"deadline_exhausted": 0,
|
|
},
|
|
} if stats else None,
|
|
}
|
|
|
|
|
|
def arguments():
|
|
return argparse.Namespace(
|
|
app="Slack",
|
|
warmups=4,
|
|
samples=2,
|
|
timeout_seconds=20,
|
|
capture_limit_bytes=2 * 1024 * 1024,
|
|
)
|
|
|
|
|
|
def command_result(data):
|
|
return types.SimpleNamespace(
|
|
termination_error=None,
|
|
timed_out=False,
|
|
output_limited=False,
|
|
stdout=json.dumps({"ok": True, "data": data}),
|
|
stderr="",
|
|
returncode=0,
|
|
)
|
|
|
|
|
|
def timed_command_result(data, wall_ms=12.5, cpu_ms=3.4):
|
|
result = command_result(data)
|
|
result.wall_ms = wall_ms
|
|
result.cpu_ms = cpu_ms
|
|
return result
|
|
|
|
|
|
def failed_command_result(
|
|
termination_error=None,
|
|
timed_out=False,
|
|
output_limited=False,
|
|
stdout="",
|
|
stderr="",
|
|
returncode=1,
|
|
wall_ms=5.0,
|
|
cpu_ms=1.0,
|
|
):
|
|
return types.SimpleNamespace(
|
|
termination_error=termination_error,
|
|
timed_out=timed_out,
|
|
output_limited=output_limited,
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
returncode=returncode,
|
|
wall_ms=wall_ms,
|
|
cpu_ms=cpu_ms,
|
|
)
|
|
|
|
|
|
def sample_runner(require_stats=False):
|
|
return types.SimpleNamespace(
|
|
binary="agent-desktop",
|
|
label="current",
|
|
environment={},
|
|
trace_path="/tmp/agent-desktop-run-sample-test.jsonl",
|
|
trace_offset=0,
|
|
require_stats=require_stats,
|
|
)
|
|
|
|
|
|
class MetricsSchemaTests(unittest.TestCase):
|
|
def test_reports_valid_per_command_metrics_and_reliability_rates(self):
|
|
failed = sample(30, 5, correct=False)
|
|
run = summarize_run(
|
|
{"path": "/tmp/current", "sha256": "abc", "version": "1.0.0"},
|
|
[sample(10, 2), sample(20, 4), failed],
|
|
)
|
|
|
|
self.assertEqual(run["metrics"]["end_to_end_wall_all_attempts"]["p50"], 20)
|
|
self.assertEqual(run["metrics"]["process_cpu_all_attempts"]["p95"], 5)
|
|
self.assertEqual(run["metrics"]["nodes_visited"]["summary"]["p95"], 6)
|
|
self.assertEqual(run["reliability"]["command_success_rate"], 1)
|
|
self.assertEqual(run["reliability"]["correct_result_rate"], 0.666667)
|
|
self.assertNotIn("rss", json.dumps(run).lower())
|
|
|
|
def test_missing_baseline_locator_stats_are_not_fabricated(self):
|
|
run = summarize_run(
|
|
{"sha256": "baseline"},
|
|
[sample(10, 2, stats=False), sample(20, 3, stats=False)],
|
|
)
|
|
|
|
locator = run["metrics"]["locator_internal"]
|
|
self.assertFalse(locator["available"])
|
|
self.assertEqual(locator["available_samples"], 0)
|
|
self.assertNotIn("summary", locator)
|
|
|
|
def test_paired_deltas_preserve_ab_ba_order_and_signed_direction(self):
|
|
pairs = [
|
|
{"order": "AB", "baseline": sample(20, 5), "current": sample(10, 3)},
|
|
{"order": "BA", "baseline": sample(30, 7), "current": sample(40, 8)},
|
|
]
|
|
summary = paired_summary(pairs)
|
|
|
|
self.assertEqual(summary["orders"], {"AB": 1, "BA": 1})
|
|
self.assertEqual(summary["current_minus_baseline"]["wall"]["p50"], -10)
|
|
self.assertEqual(summary["current_minus_baseline"]["wall"]["p95"], 10)
|
|
self.assertEqual(summary["current_minus_baseline"]["current_faster_wall_rate"], 0.5)
|
|
|
|
def test_baseline_is_never_inferred(self):
|
|
runs = {"current": {"identity": {"sha256": "abc"}, "samples": [sample(10, 2)]}}
|
|
report = build_report(arguments(), runs, [], {"app": {}, "windows": []})
|
|
|
|
self.assertFalse(report["comparison"]["baseline_provided"])
|
|
self.assertNotIn("baseline", report["runs"])
|
|
self.assertNotIn("paired_comparison", report)
|
|
self.assertFalse(report["method"]["rss_reported"])
|
|
|
|
|
|
class MetricsIntegrityTests(unittest.TestCase):
|
|
def test_exact_namespace_accepts_get_property_contract(self):
|
|
responses = [
|
|
command_result({"snapshot_id": "noise"}),
|
|
command_result({"property": "role", "value": "button"}),
|
|
]
|
|
runner = types.SimpleNamespace(binary="agent-desktop", label="current", environment={})
|
|
sample_data = {"snapshot_id": "target", "ref_id": "@e1", "role": "button"}
|
|
|
|
with patch("electron_metrics.run_bounded", side_effect=responses):
|
|
self.assertTrue(
|
|
verify_exact_namespace(runner, arguments(), "w-1", sample_data)
|
|
)
|
|
|
|
def test_exact_namespace_rejects_invalid_get_property_contract(self):
|
|
runner = types.SimpleNamespace(binary="agent-desktop", label="current", environment={})
|
|
sample_data = {"snapshot_id": "target", "ref_id": "@e1", "role": "button"}
|
|
|
|
for data in (
|
|
{"role": "button"},
|
|
{"property": "name", "value": "button"},
|
|
{"property": "role", "value": "checkbox"},
|
|
):
|
|
with self.subTest(data=data):
|
|
responses = [command_result({"snapshot_id": "noise"}), command_result(data)]
|
|
with patch("electron_metrics.run_bounded", side_effect=responses):
|
|
self.assertFalse(
|
|
verify_exact_namespace(runner, arguments(), "w-1", sample_data)
|
|
)
|
|
|
|
def test_target_window_ignores_invisible_framework_windows(self):
|
|
windows = [
|
|
{"id": "w-hidden", "visible": False, "minimized": False, "is_focused": False},
|
|
{"id": "w-visible", "visible": True, "minimized": False, "is_focused": False},
|
|
]
|
|
|
|
self.assertEqual(target_window_id(windows), "w-visible")
|
|
|
|
def test_target_window_rejects_ambiguous_visible_windows(self):
|
|
windows = [
|
|
{"id": "w-1", "visible": True, "minimized": False, "is_focused": False},
|
|
{"id": "w-2", "visible": True, "minimized": False, "is_focused": False},
|
|
]
|
|
|
|
with self.assertRaises(MeasurementError):
|
|
target_window_id(windows)
|
|
|
|
def test_order_is_deterministic_and_balanced(self):
|
|
self.assertEqual(order_for(0, True), ["baseline", "current"])
|
|
self.assertEqual(order_for(1, True), ["current", "baseline"])
|
|
self.assertEqual(order_for(2, False), ["current"])
|
|
|
|
def test_malformed_trace_line_fails_instead_of_being_ignored(self):
|
|
descriptor, path = tempfile.mkstemp()
|
|
try:
|
|
os.write(descriptor, b'{"event":"meta"}\nnot-json\n')
|
|
os.close(descriptor)
|
|
descriptor = -1
|
|
with self.assertRaises(MeasurementError):
|
|
read_trace_events(path, 0)
|
|
finally:
|
|
if descriptor >= 0:
|
|
os.close(descriptor)
|
|
os.unlink(path)
|
|
|
|
def test_trace_offsets_only_return_new_complete_events(self):
|
|
with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") as trace:
|
|
trace.write('{"event":"first"}\n')
|
|
trace.flush()
|
|
first, offset = read_trace_events(trace.name, 0)
|
|
trace.write('{"event":"second"}\n')
|
|
trace.flush()
|
|
second, final_offset = read_trace_events(trace.name, offset)
|
|
|
|
self.assertEqual([event["event"] for event in first], ["first"])
|
|
self.assertEqual([event["event"] for event in second], ["second"])
|
|
self.assertGreater(final_offset, offset)
|
|
|
|
def test_state_drift_is_a_hard_measurement_failure(self):
|
|
reference = {"app": {"pid": 10, "process_instance": "a"}, "windows": []}
|
|
changed = {"app": {"pid": 10, "process_instance": "b"}, "windows": []}
|
|
with self.assertRaises(MeasurementError):
|
|
assert_stable(reference, changed, "after pair")
|
|
|
|
def test_identical_binary_hashes_are_not_reported_as_a_comparison(self):
|
|
runners = {
|
|
"baseline": types.SimpleNamespace(identity={"sha256": "same"}),
|
|
"current": types.SimpleNamespace(identity={"sha256": "same"}),
|
|
}
|
|
with self.assertRaises(MeasurementError):
|
|
ensure_distinct_comparison(runners)
|
|
|
|
|
|
class RunSampleTests(unittest.TestCase):
|
|
def test_command_failure_sets_failure_kind_and_error_code(self):
|
|
result = failed_command_result(termination_error="killed by signal")
|
|
runner = sample_runner(require_stats=True)
|
|
|
|
with patch("electron_metrics.run_bounded", return_value=result), \
|
|
patch("electron_metrics.read_trace_events", return_value=([], 0)):
|
|
sample = run_sample(runner, arguments(), "w-1")
|
|
|
|
self.assertFalse(sample["command_success"])
|
|
self.assertEqual(sample["failure_kind"], "termination_failure")
|
|
self.assertEqual(sample["error_code"], "killed by signal")
|
|
self.assertFalse(sample["addressable"])
|
|
self.assertFalse(sample["correct"])
|
|
|
|
def test_not_addressable_branch_when_match_is_missing(self):
|
|
result = timed_command_result({"snapshot_id": "s-1"})
|
|
runner = sample_runner(require_stats=False)
|
|
|
|
with patch("electron_metrics.run_bounded", return_value=result), \
|
|
patch("electron_metrics.read_trace_events", return_value=([], 0)):
|
|
sample = run_sample(runner, arguments(), "w-1")
|
|
|
|
self.assertTrue(sample["command_success"])
|
|
self.assertFalse(sample["addressable"])
|
|
self.assertEqual(sample["failure_kind"], "not_addressable")
|
|
self.assertFalse(sample["correct"])
|
|
|
|
def test_incomplete_traversal_marks_failure_even_when_reresolution_succeeds(self):
|
|
data = {"snapshot_id": "s-1", "match": {"ref_id": "@e1", "role": "button"}}
|
|
result = timed_command_result(data)
|
|
events = [{"event": "locator.resolve", "complete": False, "query_stats": {"nodes": 4}}]
|
|
runner = sample_runner(require_stats=True)
|
|
|
|
with patch("electron_metrics.run_bounded", return_value=result), \
|
|
patch("electron_metrics.read_trace_events", return_value=(events, 42)), \
|
|
patch("electron_metrics.verify_exact_namespace", return_value=True):
|
|
sample = run_sample(runner, arguments(), "w-1")
|
|
|
|
self.assertTrue(sample["addressable"])
|
|
self.assertTrue(sample["exact_reresolution"])
|
|
self.assertFalse(sample["correct"])
|
|
self.assertEqual(sample["failure_kind"], "incomplete_traversal")
|
|
self.assertEqual(sample["stats"], {"nodes": 4})
|
|
self.assertEqual(runner.trace_offset, 42)
|
|
|
|
def test_reresolution_branch_marks_failure_when_namespace_check_fails(self):
|
|
data = {"snapshot_id": "s-1", "match": {"ref_id": "@e1", "role": "button"}}
|
|
result = timed_command_result(data)
|
|
runner = sample_runner(require_stats=False)
|
|
|
|
with patch("electron_metrics.run_bounded", return_value=result), \
|
|
patch("electron_metrics.read_trace_events", return_value=([], 0)), \
|
|
patch("electron_metrics.verify_exact_namespace", return_value=False):
|
|
sample = run_sample(runner, arguments(), "w-1")
|
|
|
|
self.assertTrue(sample["addressable"])
|
|
self.assertFalse(sample["exact_reresolution"])
|
|
self.assertFalse(sample["correct"])
|
|
self.assertEqual(sample["failure_kind"], "reresolution")
|
|
|
|
def test_success_path_reports_correct_result_and_strips_identity_fields(self):
|
|
data = {"snapshot_id": "s-1", "match": {"ref_id": "@e1", "role": "button"}}
|
|
result = timed_command_result(data)
|
|
events = [{"event": "locator.resolve", "complete": True, "query_stats": {"nodes": 6}}]
|
|
runner = sample_runner(require_stats=True)
|
|
|
|
with patch("electron_metrics.run_bounded", return_value=result), \
|
|
patch("electron_metrics.read_trace_events", return_value=(events, 7)), \
|
|
patch("electron_metrics.verify_exact_namespace", return_value=True):
|
|
sample = run_sample(runner, arguments(), "w-1")
|
|
|
|
self.assertTrue(sample["addressable"])
|
|
self.assertTrue(sample["exact_reresolution"])
|
|
self.assertTrue(sample["correct"])
|
|
self.assertIsNone(sample["failure_kind"])
|
|
self.assertEqual(sample["stats"], {"nodes": 6})
|
|
self.assertNotIn("ref_id", sample)
|
|
self.assertNotIn("snapshot_id", sample)
|
|
self.assertNotIn("role", sample)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|