From 1204f289b57df15aa7e3261442774c4eab98d26d Mon Sep 17 00:00:00 2001 From: xxxyxun Date: Wed, 4 Nov 2026 23:52:17 +0800 Subject: [PATCH 1/5] fix(gui): repair foreground target discovery and action execution --- src/tandem/gui/candidates.py | 27 +++-- src/tandem/gui/field_binding.py | 90 ++++++++++++++++ src/tandem/gui/goal_candidates.py | 89 ++-------------- src/tandem/gui/grounding.py | 3 +- src/tandem/gui/interaction.py | 5 +- src/tandem/gui/semantics.py | 84 +++++++++++++-- src/tandem/gui/targets.py | 4 + src/tandem/models/slow.py | 23 ++++- src/tandem/planning/goal_planner.py | 4 + src/tandem_eval/browser.py | 53 ++++++++-- src/tandem_eval/osworld.py | 5 +- tests/eval/test_browser.py | 71 +++++++++++-- tests/eval/test_browser_controls.py | 65 ++++++++++++ tests/eval/test_osworld_env.py | 8 ++ tests/harness/test_observation_closure.py | 120 ++++++++++++++++++++++ tests/harness/test_slow_transport.py | 97 +++++++++++++++++ tests/harness/test_target_binding.py | 108 +++++++++++++++++++ 17 files changed, 733 insertions(+), 123 deletions(-) create mode 100644 src/tandem/gui/field_binding.py create mode 100644 tests/eval/test_browser_controls.py create mode 100644 tests/harness/test_observation_closure.py create mode 100644 tests/harness/test_target_binding.py diff --git a/src/tandem/gui/candidates.py b/src/tandem/gui/candidates.py index 46cbdae..817a385 100644 --- a/src/tandem/gui/candidates.py +++ b/src/tandem/gui/candidates.py @@ -91,6 +91,8 @@ def build( context: str = "", ) -> ActionSpace: context_words = set(re.findall(r"[a-z0-9]+", context.lower())) + navigation_requested = bool(re.search( + r"\b(sidebar|side bar|navigation (menu|panel|bar))\b|侧栏|导航栏", context, re.I)) hint_words = { word for item in action_inputs or [] @@ -100,17 +102,26 @@ def build( ) } - def relevance(target: UITarget) -> tuple[int, int]: + def relevance(target: UITarget) -> tuple[int, float, int]: words = set(re.findall(r"[a-z0-9]+", target.name.lower())) contextual_words = set( re.findall(r"[a-z0-9]+", " ".join( (target.name, *target.parent_context, target.nearby_text)).lower()) ) return ( - len(words & hint_words) * 4 + len(contextual_words & context_words), + int(navigation_requested and any( + "navigation" in parent.lower() for parent in target.parent_context)), + len(words & hint_words) * 4 + + len(words & context_words) / max(1, len(words)) * 8 + + min(2, len(contextual_words & context_words)), -targets.index(target), ) + def passive_navigation_text(target: UITarget) -> bool: + return (navigation_requested and target.scope == "web_content" + and target.role in {"text", "label", "static", "table_cell", "grid_cell"} + and "click" not in target.operations) + close_browser_requested = any( phrase in context.lower() for phrase in ( @@ -127,21 +138,23 @@ def relevance(target: UITarget) -> tuple[int, int]: ( target for target in targets - if close_browser_requested - or not ( + if not passive_navigation_text(target) + and (close_browser_requested or not ( target.name.lower() == "close" and any("google chrome" in item.lower() for item in target.parent_context) and ( len(target.parent_context) <= 2 or "memory usage" in target.parent_context[-1].lower() ) - ) + )) ), key=relevance, reverse=True, - )[: min(max_candidates, 24)] + )[:max_candidates] editable_targets = [target for target in targets if target.role in EDITABLE_ROLES] double_targets = sorted( - [target for target in targets if target.role in DOUBLE_CLICK_ROLES], key=relevance, reverse=True + [target for target in targets if target.role in DOUBLE_CLICK_ROLES + and not passive_navigation_text(target)], + key=relevance, reverse=True )[:4] def candidates(operation: str, values: list[UITarget], description: str) -> list[ActionCandidate]: diff --git a/src/tandem/gui/field_binding.py b/src/tandem/gui/field_binding.py new file mode 100644 index 0000000..86a9b8a --- /dev/null +++ b/src/tandem/gui/field_binding.py @@ -0,0 +1,90 @@ +"""Observed field identity and supplied-value binding; no model or execution calls.""" + +import re + + +def matches_field(hint, label, owner=""): + """Match an intended field to its observed label, not an arbitrary focused control.""" + def words(value): + return set(re.findall(r"\w+", value.casefold())) - {"the", "a", "field", "required"} + + observed = words(label) + intended = words(hint) + intended -= words(owner) - observed + weak_labels = {"name", "search", "value", "title", "date", "number"} + if observed <= weak_labels and len(observed) == 1 and intended != observed: + return False # A qualifier distinguishes this field from a different form's generic label. + return (bool(observed) and (observed <= intended or len(intended) >= 2 and intended <= observed) + and not label.startswith(("Unnamed ", "Focused "))) + + +def launcher_input(value): + """A launcher search is not a website's identically named Search field.""" + return (value.get("scope") == "desktop_launcher" + or "desktop search" in value.get("target_hint", value["label"]).casefold()) + + +def _input_target(value, targets): + """Bind to one best observed field; spatial hints must not override its own label.""" + scores = [] + for target in targets: + choice = value.get("kind") == "choice" and target.role in { + "button", "push_button", "radio_button", "option", "list_item"} + editable = (target.role in {"textbox", "entry", "searchbox", "spin_button", "password_text"} + or launcher_input(value) and target.role == "text" and target.focused + or "type_text" in target.operations or target.input_type == "select-one") + if not (editable or choice): + continue + if launcher_input(value) and target.application != "gnome-shell": + continue + if (target.role == "spin_button" and target.input_type != "date" + and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value["text"])): + continue # A complete date is not a Day/Month/Year segment or an ordinary numeric spinner. + named = bool(target.name) and not target.name.startswith(("Unnamed ", "Focused ")) + labels = [target.name] if named else [target.description] + if not named and len(target.nearby_text) <= 80 and "\n" not in target.nearby_text: + labels.append(target.nearby_text) + score = 0 + if choice: + wanted = set(re.findall(r"\w+", value["text"].casefold())) + option_name = re.sub(r"\([^)]*\)", "", target.name.casefold()) + observed = set(re.findall(r"\w+", option_name)) + hint = set(re.findall(r"\w+", value.get("target_hint", value["label"]).casefold())) + if wanted and wanted <= observed <= wanted | hint: + score = 6 # Match a specific option, never a container holding the entire form. + for label in labels: + if not label or not editable: + continue + hint = value.get("target_hint", value["label"]) + owner = " ".join((target.application, *target.parent_context)) + if launcher_input(value): + owner += " desktop" + if not matches_field(hint, label, owner): + continue # Descriptive context words cannot override the explicit intended field. + ignored = {"brief", "required", "enter", "input", "field", "the", "a"} + if (set(re.findall(r"\w+", hint.casefold())) - ignored + == set(re.findall(r"\w+", label.casefold())) - ignored): + score = max(score, 8) + elif matches_field(value["label"], label): + score = max(score, 4) + elif matches_field(hint, label, owner): + score = max(score, 3) + if score: + scores.append((score, target)) + best = max((score for score, _ in scores), default=0) + matching = [target for score, target in scores if score == best] + return matching[0] if len(matching) == 1 else None + + +def input_satisfied(value, target): + """Chrome may omit a URL scheme in the displayed address, unlike ordinary text fields.""" + text = value["text"] + if value.get("kind") == "choice" and target.role in { + "button", "push_button", "radio_button", "option", "list_item"}: + return target.checked is True or target.selected is True + if target.input_type == "select-one": + return target.value.strip().casefold() == text.strip().casefold() + if ("address" in target.name.casefold() and "://" not in target.value + and re.match(r"^(?:https?|file)://", text)): + text = text.split("://", 1)[1] + return target.value == text or value["input_mode"] == "insert" and text in target.value diff --git a/src/tandem/gui/goal_candidates.py b/src/tandem/gui/goal_candidates.py index bbce26e..363a37c 100644 --- a/src/tandem/gui/goal_candidates.py +++ b/src/tandem/gui/goal_candidates.py @@ -8,90 +8,11 @@ from urllib.parse import unquote, urlsplit from .candidates import ActionCandidate +from .field_binding import _input_target, input_satisfied, launcher_input, matches_field from .focus import KEYS, input_and_menu_context from .targets import _attribute, _local, _pair, _state -def matches_field(hint, label): - """Match an intended field to its observed label, not an arbitrary focused control.""" - def words(value): - return set(re.findall(r"\w+", value.casefold())) - {"the", "a", "field", "required"} - - observed = words(label) - intended = words(hint) - return (bool(observed) and (observed <= intended or len(intended) >= 2 and intended <= observed) - and not label.startswith(("Unnamed ", "Focused "))) - - -def launcher_input(value): - """A launcher search is not a website's identically named Search field.""" - return (value.get("scope") == "desktop_launcher" - or "desktop search" in value.get("target_hint", value["label"]).casefold()) - - -def _input_target(value, targets): - """Bind to one best observed field; spatial hints must not override its own label.""" - scores = [] - for target in targets: - choice = value.get("kind") == "choice" and target.role in { - "button", "push_button", "radio_button", "option", "list_item"} - editable = (target.role in {"textbox", "entry", "searchbox", "spin_button"} - or launcher_input(value) and target.role == "text" and target.focused - or "type_text" in target.operations or target.input_type == "select-one") - if not (editable or choice): - continue - if launcher_input(value) and target.application != "gnome-shell": - continue - if (target.role == "spin_button" and target.input_type != "date" - and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value["text"])): - continue # A complete date is not a Day/Month/Year segment or an ordinary numeric spinner. - named = bool(target.name) and not target.name.startswith(("Unnamed ", "Focused ")) - labels = [target.name] if named else [target.description] - if not named and len(target.nearby_text) <= 80 and "\n" not in target.nearby_text: - labels.append(target.nearby_text) - score = 0 - if choice: - wanted = set(re.findall(r"\w+", value["text"].casefold())) - option_name = re.sub(r"\([^)]*\)", "", target.name.casefold()) - observed = set(re.findall(r"\w+", option_name)) - hint = set(re.findall(r"\w+", value.get("target_hint", value["label"]).casefold())) - if wanted and wanted <= observed <= wanted | hint: - score = 6 # Match a specific option, never a container holding the entire form. - for label in labels: - if not label or not editable: - continue - hint = value.get("target_hint", value["label"]) - if not matches_field(hint, label): - continue # Descriptive context words cannot override the explicit intended field. - ignored = {"brief", "required", "enter", "input", "field", "the", "a"} - if (set(re.findall(r"\w+", hint.casefold())) - ignored - == set(re.findall(r"\w+", label.casefold())) - ignored): - score = max(score, 8) - elif matches_field(value["label"], label): - score = max(score, 4) - elif matches_field(hint, label): - score = max(score, 3) - if score: - scores.append((score, target)) - best = max((score for score, _ in scores), default=0) - matching = [target for score, target in scores if score == best] - return matching[0] if len(matching) == 1 else None - - -def input_satisfied(value, target): - """Chrome may omit a URL scheme in the displayed address, unlike ordinary text fields.""" - text = value["text"] - if value.get("kind") == "choice" and target.role in { - "button", "push_button", "radio_button", "option", "list_item"}: - return target.checked is True or target.selected is True - if target.input_type == "select-one": - return target.value.strip().casefold() == text.strip().casefold() - if ("address" in target.name.casefold() and "://" not in target.value - and re.match(r"^(?:https?|file)://", text)): - text = text.split("://", 1)[1] - return target.value == text or value["input_mode"] == "insert" and text in target.value - - def input_frontier(space, targets, inputs): """Expose actionable supplied inputs and missing required values on the current page.""" fields = list(targets) @@ -226,6 +147,7 @@ def active(element, value): elif app is not shell: windows = [e for e in app.iter() if _local(e.tag) in {"frame", "dialog"}] named = [e for e in windows if title and e.get("name") == title] + named = [e for e in named if _state(e, "active") is True] or named if named: active(app, False) for window in windows: @@ -391,10 +313,11 @@ def modal_frontier(space, targets, tree): root = ET.fromstring(tree or "") dialogs = [n for n in root.iter() if _local(n.tag) in {"dialog", "alert"} and _live_dialog(n, root)] visual = next((c.metadata for c in space.candidates() if c.metadata.get("blocking_overlay")), {}) - if not dialogs and not visual: + portal = portal_needs_grounding(tree) + if not dialogs and not visual and not portal: return {} dialog = dialogs[-1] if dialogs else None - name = dialog.get("name", "Dialog") if dialog is not None else visual["overlay_name"] + name = dialog.get("name", "Dialog") if dialog is not None else visual.get("overlay_name", portal) position = _pair(_attribute(dialog, "screencoord")) if dialog is not None else None size = _pair(_attribute(dialog, "size")) if dialog is not None else None bounds = [*position, *size] if position and size else visual.get("overlay_bounds") @@ -426,7 +349,7 @@ def inside(candidate): space.controls = {k: c for k, c in space.controls.items() if k in {"return_to_planner", "visual_fallback"}} return {"blocking_overlay": name, "overlay_bounds": bounds, - "overlay_kind": visual.get("overlay_kind", "unknown"), + "overlay_kind": visual.get("overlay_kind", "task_dialog" if portal else "unknown"), "next_intent": f"The active dialog {name!r} owns input; work inside its visible controls."} diff --git a/src/tandem/gui/grounding.py b/src/tandem/gui/grounding.py index 1e99714..3414626 100644 --- a/src/tandem/gui/grounding.py +++ b/src/tandem/gui/grounding.py @@ -70,7 +70,8 @@ def ground_visual(client, instruction, plan, screenshot): and re.search(r"date|time|calendar|日期|时间", str(item.get("target_hint", "")), re.I)): continue # Native date typing requires observed CDP input_type/date_order. if ((item.get("text"), item.get("input_mode"), item.get("target_hint")) not in allowed_text - or not matches_field(item.get("target_hint", ""), item.get("target", ""))): + or not matches_field(item.get("target_hint", ""), item.get("target", ""), + metadata.get("overlay_name", ""))): continue metadata.update(text=item["text"], input_mode=item["input_mode"], target_hint=item["target_hint"], field_label=item["target"]) diff --git a/src/tandem/gui/interaction.py b/src/tandem/gui/interaction.py index 187cbbd..c1148d9 100644 --- a/src/tandem/gui/interaction.py +++ b/src/tandem/gui/interaction.py @@ -63,9 +63,12 @@ def target_effect(old, targets): and old["target_semantic"] == t.semantic_key] if not intended and old.get("field_label"): intended = [t for t in targets if matches_field(old["field_label"], t.name) - and t.role in {"entry", "text", "textbox", "spin_button", "searchbox"}] + and t.role in {"entry", "text", "textbox", "spin_button", "searchbox", + "password_text"}] if intended: focus = intended + if len(focus) == 1 and focus[0].role == "password_text": + return "unknown" # Login, not a dispatch or masked value, verifies this input. if len(focus) == 1 and (focus[0].value or old.get("field_label")): if old.get("field_label") and not matches_field(old["field_label"], focus[0].name): return "not_met" diff --git a/src/tandem/gui/semantics.py b/src/tandem/gui/semantics.py index 37d67ac..1cae04f 100644 --- a/src/tandem/gui/semantics.py +++ b/src/tandem/gui/semantics.py @@ -5,8 +5,9 @@ import re import xml.etree.ElementTree as ET from dataclasses import replace +from statistics import median -from .targets import UITarget +from .targets import UITarget, _is_overlay, _local, _state _LABEL_ROLES = {"label", "static", "text"} _MEMORY_USAGE = re.compile(r"\s+-\s+Memory usage\s+-\s+[\d.]+\s+[KMG]B\b", re.I) @@ -110,8 +111,9 @@ def _scope(target: UITarget, content_y: float) -> str: def _overlap(a: UITarget, b: UITarget) -> float: wide = min(a.x + a.width, b.x + b.width) - max(a.x, b.x) tall = min(a.y + a.height, b.y + b.height) - max(a.y, b.y) - smaller = min(a.width * a.height, b.width * b.height) - return wide * tall / smaller if wide > 0 and tall > 0 and smaller > 0 else 0.0 + intersection = wide * tall if wide > 0 and tall > 0 else 0 + union = a.width * a.height + b.width * b.height - intersection + return intersection / union if union > 0 else 0.0 def _roles_compatible(a: str, b: str) -> bool: @@ -125,6 +127,28 @@ def _roles_compatible(a: str, b: str) -> bool: return a == b or any(a in group and b in group for group in groups) +def _aligned_snapshot(targets, snapshot): + """Correct a common frame offset only when three independent controls agree.""" + if not snapshot: + return snapshot + offsets = [] + for item in snapshot.get("elements", []): + matches = [t for t in targets if _norm(t.name) == _norm(item.get("name", "")) + and _roles_compatible(t.role, item.get("role", "")) + and "chrome" in t.application.lower() + and 0.8 <= t.width / max(1, item.get("width", 0)) <= 1.25 + and 0.8 <= t.height / max(1, item.get("height", 0)) <= 1.25] + if len(matches) == 1: + offsets.append((matches[0].x - item["x"], matches[0].y - item["y"])) + if len(offsets) < 3 or any(max(v) - min(v) > 4 for v in zip(*offsets, strict=True)): + return snapshot + dx, dy = (median(v) for v in zip(*offsets, strict=True)) + return {**snapshot, "elements": [{**i, "x": i["x"] + dx, "y": i["y"] + dy} + for i in snapshot["elements"]], + "viewport": {**snapshot.get("viewport", {}), + "screen_y": snapshot.get("viewport", {}).get("screen_y", 0) + dy}} + + def _dom_target(item: dict, snapshot_id: str) -> UITarget | None: try: x, y = round(float(item["x"])), round(float(item["y"])) @@ -151,7 +175,7 @@ def _dom_target(item: dict, snapshot_id: str) -> UITarget | None: width=width, height=height, semantic_key="", - parent_context=(), + parent_context=tuple(item.get("parent_context") or ()), source="dom", application="Google Chrome", description=str(item.get("description") or ""), @@ -167,9 +191,8 @@ def _dom_target(item: dict, snapshot_id: str) -> UITarget | None: def _merge(base: UITarget, dom: UITarget) -> UITarget: use_dom_name = ( - dom.label_source != "dom" - or base.label_source == "unresolved" - or len(dom.name) >= len(base.name) + base.label_source in {"unresolved", "synthetic"} + or not base.name ) merged = replace( base, @@ -183,6 +206,7 @@ def _merge(base: UITarget, dom: UITarget) -> UITarget: scope="web_content", label_source=dom.label_source, operations=dom.operations, + parent_context=dom.parent_context or base.parent_context, input_type=dom.input_type or base.input_type, date_order=dom.date_order or base.date_order, ) @@ -227,6 +251,29 @@ def unrepresented_window(root: ET.Element | None) -> str: return title if windows and title not in windows else "" +def _native_input_owner(root): + if root is None: + return None + owners = [] + + def visit(node, web=False): + web = web or _local(node.tag) in {"document", "document_web", "web_document"} + if (not web and (_is_overlay(node) or _local(node.tag) == "frame" and _state(node, "modal") is True) + and _state(node, "visible") is not False + and _state(node, "showing") is not False and ( + _state(node, "active") is True + or bool(root.get("foreground_title")) and node.get("name") == root.get("foreground_title") + or not root.get("foreground_title") and _state(node, "modal") is True + and _state(node, "showing") is True + or any(_state(child, "focused") is True for child in node.iter()))): + owners.append(node) + for child in node: + visit(child, web) + + visit(root) + return owners[-1] if owners else None + + def enrich_targets( targets: list[UITarget], accessibility_tree: str | None, @@ -240,7 +287,15 @@ def enrich_targets( root = None if unrepresented_window(root): return [] # The existing visual fallback must locate this real input owner first. + owner = _native_input_owner(root) + if owner is not None: + bounds = {_bounds(node) for node in owner.iter()} + targets = [t for t in targets if (t.x, t.y, t.width, t.height) in bounds] + owner_scope = "" + if owner is not None: + owner_scope = "menu" if _local(owner.tag) in {"menu", "popup_menu"} else "dialog" labels, chrome_active = _a11y_context(root) + browser_snapshot = _aligned_snapshot(targets, browser_snapshot) content_y = _content_y(targets, browser_snapshot) enriched = [] for target in targets: @@ -260,7 +315,7 @@ def enrich_targets( target, name=name, nearby_text=nearby, - scope=_scope(target, content_y), + scope=owner_scope or _scope(target, content_y), label_source=source, ) enriched.append(replace(item, semantic_key=_key(item))) @@ -269,7 +324,7 @@ def enrich_targets( "chrome" in target.application.lower() or "chromium" in target.application.lower() for target in targets ) - if browser_snapshot and (chrome_active or chrome_targets): + if owner is None and browser_snapshot and (chrome_active or chrome_targets): snapshot_id = targets[0].snapshot_id if targets else "dom" for raw in browser_snapshot.get("elements", []): dom = _dom_target(raw, snapshot_id) @@ -279,7 +334,18 @@ def enrich_targets( (index, _overlap(base, dom)) for index, base in enumerate(enriched) if _roles_compatible(base.role, dom.role) + and (_norm(base.name) == _norm(dom.name) + or base.label_source in {"unresolved", "synthetic"}) ] + same_label = [index for index, base in enumerate(enriched) + if _roles_compatible(base.role, dom.role) and _norm(base.name) == _norm(dom.name) + and abs(base.x - dom.x) <= max(base.width, dom.width) + and abs(base.y - dom.y) <= max(base.height, dom.height) + and 0.5 <= base.width / dom.width <= 2 + and 0.5 <= base.height / dom.height <= 2] + if len(same_label) == 1: + enriched[same_label[0]] = _merge(enriched[same_label[0]], dom) + continue best = max(compatible, key=lambda pair: pair[1], default=(None, 0.0)) if best[0] is not None and best[1] >= 0.5: enriched[best[0]] = _merge(enriched[best[0]], dom) diff --git a/src/tandem/gui/targets.py b/src/tandem/gui/targets.py index 5e9ba24..1eb078b 100644 --- a/src/tandem/gui/targets.py +++ b/src/tandem/gui/targets.py @@ -189,6 +189,8 @@ def visit(element, active, parents, app_name, modal=False, roles=(), file_manage enabled = _state(element, "enabled") visible = _state(element, "visible") showing = _state(element, "showing") + native_click = any(_local(key) in {"clickancestor_desc", "click_desc", "press_desc"} + for key in element.attrib) context = parents + ((name,) if name else ()) interactive_text = role != "text" or ( _state(element, "editable") is True or _state(element, "focusable") is True @@ -212,6 +214,7 @@ def visit(element, active, parents, app_name, modal=False, roles=(), file_manage if ( (active or system_target) and (role in SUPPORTED_ROLES or safe_label or (include_sliders and role == "slider") + or native_click or filter_occluded and (role == "image" or file_manager and role == "canvas")) and (name or role in SUPPORTED_ROLES) and interactive_text @@ -252,6 +255,7 @@ def visit(element, active, parents, app_name, modal=False, roles=(), file_manage ), label_source=label_source, source="a11y_dialog" if filter_occluded and modal else "a11y", + operations=("click",) if native_click else (), range_min=_number(element, "min") if role == "slider" else None, range_max=_number(element, "max") if role == "slider" else None, ) diff --git a/src/tandem/models/slow.py b/src/tandem/models/slow.py index 504bdf8..0d569a3 100644 --- a/src/tandem/models/slow.py +++ b/src/tandem/models/slow.py @@ -6,6 +6,7 @@ from __future__ import annotations import base64 +import http.client import io import json import time @@ -250,6 +251,17 @@ def public_headers(headers): return {name.lower(): value for name, value in headers.items() if name.lower() in allowed} excluded: set[str] = set() + + def alternate_key(category, seconds, started, attempt): + if len(self.key_pool.keys) == 1: + return key # A manually configured endpoint keeps its bounded retry path. + self.key_pool.cooldown(key["id"], seconds, category) + excluded.add(key["id"]) + audit("key_cooldown", started, attempt, category=category, cooldown_seconds=seconds) + replacement = self.key_pool.select(excluded) + audit("key_changed", started, attempt, category=category, next_key_id=replacement["id"]) + return replacement + key = self.key_pool.select() attempt = 0 failures = 0 @@ -311,6 +323,7 @@ def public_headers(headers): if failures >= 3: raise error delay = retry_after_seconds(headers.get("retry-after"), 30) + key = alternate_key("transient", max(60, delay), started, attempt) audit("retry_wait", started, attempt, retry_wait_seconds=delay) self._retry_wait(delay) continue @@ -342,7 +355,8 @@ def public_headers(headers): and correction.startswith("Invalid") else "") raise ModelResponseError("slow model response failed validation: " + detail[:600]) return value - except (urllib.error.URLError, ConnectionError, TimeoutError, ValueError) as error: + except (urllib.error.URLError, ConnectionError, TimeoutError, + http.client.IncompleteRead, ValueError) as error: audit( "transport_or_validation_error", started, @@ -355,7 +369,12 @@ def public_headers(headers): raise # 内容级失败(空正文/截断/非法 JSON)时升档思考强度后重试,不原样重发 current_effort = payload.get("reasoning_effort") - if (self.config.execution_mode == "goal" and role == "plan_stage" + if isinstance(error, (urllib.error.URLError, ConnectionError, TimeoutError, + http.client.IncompleteRead)): + if isinstance(error, TaskBudgetExceeded): + raise + key = alternate_key("transport_error", 60, started, attempt) + elif (self.config.execution_mode == "goal" and role == "plan_stage" and "finish_reason=length" in str(error)): payload["reasoning_effort"] = "low" messages.append({"role": "user", "content": diff --git a/src/tandem/planning/goal_planner.py b/src/tandem/planning/goal_planner.py index 473f37f..8283457 100644 --- a/src/tandem/planning/goal_planner.py +++ b/src/tandem/planning/goal_planner.py @@ -78,6 +78,10 @@ For every text input, target_hint must identify its intended observed field (copy the visible label when available). For browser navigation use the observed address-bar label; for a document body or desktop launcher identify that specific editor/search field. Text is never interchangeable among fields. +If navigation changes the form/channel/document, first delegate navigation with no future-page inputs. +Plan those inputs after observing the destination. Never bind a future recipient's text to the current +recipient's composer merely because it is visible. A completed download is not an opened document: +open the downloaded artifact rather than clicking the same download link again. The choice model cannot generate free text. It can click, type provided inputs, use keyboard, scroll, wait, request stage verification, or return control. Missing data/new reasoning requires another stage. For CLI provide one bounded Bash command, current inputs and expected real results. Prefer actually diff --git a/src/tandem_eval/browser.py b/src/tandem_eval/browser.py index 4d8a41b..a450d3c 100644 --- a/src/tandem_eval/browser.py +++ b/src/tandem_eval/browser.py @@ -11,6 +11,7 @@ import http.client import json import os +import re import socket import urllib.request @@ -32,7 +33,8 @@ [...(element.labels || [])].map(label => name(label, seen)).filter(Boolean).join(' ') || (['button','submit','reset'].includes(element.type) ? element.value : '') || element.getAttribute('alt') || - (element.tagName === 'INPUT' ? '' : [...element.childNodes].map(node => + (['INPUT','TEXTAREA'].includes(element.tagName) || element.isContentEditable ? '' : + [...element.childNodes].map(node => node.nodeType === 3 ? node.textContent : node.nodeType === 1 ? name(node, seen) : '').join(' ')) || element.getAttribute('title') || element.getAttribute('placeholder') || element.getAttribute('name')); }; @@ -40,8 +42,9 @@ 'gridcell','combobox','textbox','searchbox','spinbutton','slider']); const selector = 'a[href],button,input,textarea,select,summary,[contenteditable="true"],' + [...roles].map(role => '[role="' + role + '"]').join(','); - const clickable = element => element.tabIndex >= 0 || typeof element.onclick === 'function' || - getComputedStyle(element).cursor === 'pointer'; + const clickable = element => typeof element.onclick === 'function' || + element.hasAttribute('onclick') || (typeof getEventListeners === 'function' && + (getEventListeners(element).click || []).length > 0); const secretAutocomplete = new RegExp( '(^|\\s)(current-password|new-password|one-time-code|cc-number|' + 'cc-csc|cc-exp|cc-exp-month|cc-exp-year)(\\s|$)'); @@ -133,6 +136,7 @@ const editable = !element.readOnly && element.getAttribute('aria-readonly') !== 'true' && (['textbox','searchbox','spinbutton'].includes(domRole) || (domRole === 'combobox' && ['INPUT','TEXTAREA'].includes(element.tagName))); + const navigation = element.closest('nav,[role="navigation"]'); const item = { index: elements.length + 1, role: roleNames[domRole] || domRole, @@ -148,6 +152,7 @@ .map(p => p.type) : [], x: originX + rect.left, y: originY + rect.top, width: rect.width, height: rect.height, scope: 'web_content', label_source: name(element) ? 'dom' : 'dom_nearby', + parent_context: navigation ? ['navigation', name(navigation) || 'Navigation'] : [], nearby_text: itemContext(element), operations: editable ? ['click','type_text'] : ['click'], source: 'dom' @@ -160,7 +165,8 @@ elements.push(item); if (elements.length >= 250) break; } - return {url:location.href, title:document.title, visibility:document.visibilityState, elements, + return {url:location.href, title:document.title, visibility:document.visibilityState, + focused:document.hasFocus(), elements, omitted:Math.max(0, rawCount - elements.length), viewport:{width:innerWidth,height:innerHeight,screen_x:originX,screen_y:originY,dpr:devicePixelRatio}}; })() @@ -225,7 +231,8 @@ def evaluate(port, page): if b' 101 ' not in response.split(b'\r\n', 1)[0]: raise OSError('CDP websocket handshake failed') send_frame(sock, json.dumps({'id':1, 'method':'Runtime.evaluate', - 'params':{'expression':EXPRESSION, 'returnByValue':True, 'awaitPromise':True}})) + 'params':{'expression':EXPRESSION, 'returnByValue':True, 'awaitPromise':True, + 'includeCommandLineAPI':True}})) chunks = None for _ in range(100): finished, opcode, payload = recv_frame(sock) @@ -252,9 +259,37 @@ def evaluate(port, page): sock.close() +def foreground_debug_port(): + from Xlib.display import Display + display = Display() + try: + active = display.screen().root.get_full_property(display.intern_atom('_NET_ACTIVE_WINDOW'), 0) + if active is None or not active.value[0]: + raise OSError('No foreground X11 window') + window = display.create_resource_object('window', int(active.value[0])) + process = window.get_full_property(display.intern_atom('_NET_WM_PID'), 0) + if process is None: + raise OSError('Foreground window has no process identity') + with open(f'/proc/{int(process.value[0])}/cmdline', 'rb') as stream: + command = stream.read().decode('utf-8') + # Chromium can rewrite argv into one space-separated process title. + match = re.search(r'(?:^|[\s\x00])--remote-debugging-port(?:=|[\s\x00]+)([0-9]+)(?=$|[\s\x00])', + command) + if match: + return int(match.group(1)) + raise OSError('Foreground browser does not expose a debugging port') + finally: + display.close() + + def main(): errors = [] - for port in (1337, 9222): + try: + port = foreground_debug_port() + except Exception as error: + print(json.dumps({'error':f'foreground_cdp_unavailable:{type(error).__name__}:{str(error)[:160]}'})) + return + for port in (port,): try: opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) with opener.open(f'http://127.0.0.1:{port}/json/list', timeout=3) as response: @@ -263,13 +298,11 @@ def main(): snapshot = None for page in candidates: candidate = evaluate(port, page) - if snapshot is None: - snapshot = candidate - if candidate.get('visibility') == 'visible': + if candidate.get('visibility') == 'visible' and candidate.get('focused'): snapshot = candidate break if snapshot is None: - raise OSError('no CDP page target') + raise OSError('no focused CDP page target') print(json.dumps({'snapshot':snapshot, 'port':port}, ensure_ascii=False)) break except Exception as error: diff --git a/src/tandem_eval/osworld.py b/src/tandem_eval/osworld.py index 064889d..6a88be5 100644 --- a/src/tandem_eval/osworld.py +++ b/src/tandem_eval/osworld.py @@ -82,11 +82,12 @@ def gui_code(action: GuiAction) -> str: steps.append("pyautogui.press('f2')") if params.get("input_mode", "replace") == "replace": steps.append("pyautogui.hotkey('ctrl', 'a')") - if params["text"].isascii(): + if params["text"].isascii() and not any(c in params["text"] for c in "\r\n\t"): steps.append(f"pyautogui.write({params['text']!r}, interval=0.03)") else: steps.append("__import__('subprocess').run(['xclip','-selection','clipboard','-t','UTF8_STRING']," - f"input={params['text']!r}.encode('utf-8'),check=True)") + f"input={params['text']!r}.encode('utf-8'),check=True,timeout=10," + "stdout=__import__('subprocess').DEVNULL,stderr=__import__('subprocess').DEVNULL)") steps.append("pyautogui.hotkey('ctrl','v')") return "; ".join(steps) raise ValueError(f"不支持的 GUI 动作类型: {action.action_type}") diff --git a/tests/eval/test_browser.py b/tests/eval/test_browser.py index 2a4961e..d35f7c6 100644 --- a/tests/eval/test_browser.py +++ b/tests/eval/test_browser.py @@ -45,13 +45,14 @@ def test_readonly_javascript_collects_clickable_cards_but_not_their_children_or_ namespace = {"__name__": "cdp_test"} exec(VM_SCRIPT, namespace) setup = r''' - const body = {tabIndex:-1, onclick:null, cursor:'auto', closest:()=>null, + const body = {tabIndex:-1, onclick:null, cursor:'auto', closest:()=>null, hasAttribute:()=>false, matches:s=>s.includes('body')}; const make = (text, x, parent=body, native=false) => ({ tagName:native?'BUTTON':'DIV', type:'', tabIndex:-1, onclick:native?null:()=>{}, cursor:native?'auto':'pointer', parentElement:parent, childNodes:[{nodeType:3,textContent:text}], labels:[], innerText:text, disabled:false, checkVisibility:()=>true, - getAttribute:()=>null, matches:s=>native&&s.includes('button'), closest:()=>null, + getAttribute:()=>null, hasAttribute:()=>false, + matches:s=>native&&s.includes('button'), closest:()=>null, querySelector:()=>null, querySelectorAll:()=>[], getBoundingClientRect:()=>({left:x,top:20,right:x+100,bottom:60,width:100,height:40}), contains(other){return other===this||other.parentElement===this;}, @@ -61,19 +62,23 @@ def test_readonly_javascript_collects_clickable_cards_but_not_their_children_or_ const child=make('Child label',20,card); const hidden=make('Covered button',200,body,true); const cover=make('Overlay',200); + const registered=make('Registered click handler',350);registered.onclick=null; + const plain=make('/analytics',500);plain.onclick=null;plain.tagName='TD'; + global.getEventListeners=e=>({click:e===registered?[{listener:()=>{}}]:[]}); global.getComputedStyle=e=>({cursor:e.cursor,display:'block',visibility:'visible',opacity:'1'}); global.innerWidth=800;global.innerHeight=600; global.window={screenX:0,screenY:0,outerWidth:800,innerWidth:800,outerHeight:600,innerHeight:600}; global.location={href:'https://example.test/'};global.devicePixelRatio=1; - global.document={title:'Synthetic cards',visibilityState:'visible',activeElement:null, + global.document={title:'Synthetic cards',visibilityState:'visible',hasFocus:()=>true,activeElement:null, getElementById:()=>null, - querySelectorAll:s=>s.startsWith('a[href]')?[card,child,hidden]:[], - elementFromPoint:x=>x<150?child:cover}; + querySelectorAll:s=>s.startsWith('a[href]')?[card,child,hidden,registered,plain]:[], + elementFromPoint:x=>x<150?child:x<300?cover:x<480?registered:plain}; ''' run = subprocess.run([node, "-e", setup + "\nconsole.log(JSON.stringify(" + namespace["EXPRESSION"] + "));"], capture_output=True, text=True, check=False) assert run.returncode == 0, run.stderr - assert [e["name"] for e in json.loads(run.stdout)["elements"]] == ["Afternoon class"] + assert [e["name"] for e in json.loads(run.stdout)["elements"]] == [ + "Afternoon class", "Registered click handler"] def test_vm_websocket_frame_parser_executes_with_stdlib_only(): @@ -91,7 +96,57 @@ def recv(self, size): assert namespace["recv_frame"](Socket()) == (True, 1, b"{}") -def test_vm_evaluate_reads_fragmented_cdp_response(): +def test_cdp_main_does_not_collect_from_an_unrelated_background_browser(capsys): + namespace = {"__name__": "cdp_test"} + exec(VM_SCRIPT, namespace) + + def unavailable(): + raise OSError("Active application does not expose CDP") + + namespace["foreground_debug_port"] = unavailable + namespace["evaluate"] = lambda *args: (_ for _ in ()).throw(AssertionError("Must not collect")) + namespace["main"]() + assert "foreground_cdp_unavailable" in capsys.readouterr().out + + +def test_foreground_port_supports_both_argv_and_chromium_process_title(monkeypatch): + import io + import sys + from types import SimpleNamespace + + namespace = {"__name__": "cdp_test"} + exec(VM_SCRIPT, namespace) + window = SimpleNamespace(get_full_property=lambda *args: SimpleNamespace(value=[123])) + display = SimpleNamespace( + screen=lambda: SimpleNamespace(root=window), intern_atom=lambda value: value, + create_resource_object=lambda *args: window, close=lambda: None) + monkeypatch.setitem(sys.modules, "Xlib.display", SimpleNamespace(Display=lambda: display)) + for command in [b"chrome\0--remote-debugging-port=9333\0", + b"chrome --remote-debugging-port=9333 --new-window about:blank\0", + b"chrome\0--remote-debugging-port\09333\0"]: + monkeypatch.setattr("builtins.open", lambda *args, command=command: io.BytesIO(command)) + assert namespace["foreground_debug_port"]() == 9333 + + +def test_cdp_selects_focused_page_not_first_visible_background_window(monkeypatch, capsys): + import io + from types import SimpleNamespace + + namespace = {"__name__": "cdp_test"} + exec(VM_SCRIPT, namespace) + namespace["foreground_debug_port"] = lambda: 9333 + pages = [{"type": "page", "focused": focused} for focused in (False, True)] + opener = SimpleNamespace(open=lambda *args, **kwargs: io.StringIO(json.dumps(pages))) + monkeypatch.setattr(namespace["urllib"].request, "build_opener", lambda *args: opener) + namespace["evaluate"] = lambda port, page: {"visibility": "visible", "focused": page["focused"]} + namespace["main"]() + assert json.loads(capsys.readouterr().out)["snapshot"]["focused"] is True + pages.pop() + namespace["main"]() + assert "no focused CDP page" in capsys.readouterr().out + + +def test_vm_evaluate_reads_fragmented_cdp_response(monkeypatch): namespace = {"__name__": "cdp_test"} exec(VM_SCRIPT, namespace) @@ -109,7 +164,7 @@ def close(self): pass socket = Socket() - namespace["socket"].create_connection = lambda address, timeout: socket + monkeypatch.setattr(namespace["socket"], "create_connection", lambda address, timeout: socket) namespace["send_frame"] = lambda *args: None frames = iter([ (False, 1, b'{"id":1,"result":'), diff --git a/tests/eval/test_browser_controls.py b/tests/eval/test_browser_controls.py new file mode 100644 index 0000000..fc5467d --- /dev/null +++ b/tests/eval/test_browser_controls.py @@ -0,0 +1,65 @@ +"""Optional real-CDP smoke on a synthetic page; not a VM or benchmark acceptance test.""" + +import json +import shutil +import subprocess +import time +import urllib.parse +import urllib.request + +import pytest + +from tandem_eval.browser import VM_SCRIPT + + +def test_real_cdp_discovers_click_handlers_without_promoting_plain_text(tmp_path): + chrome = shutil.which("chromium") or shutil.which("chrome") + if chrome is None: + pytest.skip("Optional Chromium executable is unavailable") + html = '''Synthetic target audit + + +

/analytics

/analytics/inventory
+
Focusable ordinary text
+ + ''' + profile = tmp_path / "chrome-profile" + process = subprocess.Popen([ + chrome, "--headless", "--no-sandbox", "--disable-dev-shm-usage", "--no-first-run", + "--disable-background-networking", "--disable-component-update", "--remote-debugging-port=0", + f"--user-data-dir={profile}", "data:text/html," + urllib.parse.quote(html), + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + deadline = time.monotonic() + 15 + pages = [] + while time.monotonic() < deadline: + active_port = profile / "DevToolsActivePort" + if active_port.exists(): + port = int(active_port.read_text().splitlines()[0]) + with opener.open(f"http://127.0.0.1:{port}/json/list", timeout=2) as response: + pages = json.load(response) + if any(p.get("title") == "Synthetic target audit" for p in pages): + break + assert process.poll() is None, "Synthetic Chromium exited before CDP was ready" + time.sleep(0.05) + page = next(p for p in pages if p.get("title") == "Synthetic target audit") + namespace = {"__name__": "cdp_smoke"} + exec(VM_SCRIPT, namespace) + snapshot = namespace["evaluate"](port, page) + controls = {e["name"]: e for e in snapshot["elements"]} + assert set(controls) == {"Metrics", "Inventory", "Save"} + assert controls["Inventory"]["parent_context"] == ["navigation", "Workspace"] + assert controls["Metrics"]["role"] == "push_button" + assert snapshot["title"] == "Synthetic target audit" # Discovery never invokes handlers. + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) diff --git a/tests/eval/test_osworld_env.py b/tests/eval/test_osworld_env.py index ed7bdec..bfd15b1 100644 --- a/tests/eval/test_osworld_env.py +++ b/tests/eval/test_osworld_env.py @@ -59,6 +59,14 @@ def test_action_code_cli_wait_and_terminals(): gui_code(GuiAction("drag", {"x": 1})) +@pytest.mark.parametrize("text", ["First line\nSecond line", "Unicode: 合成测试", "Column\tValue"]) +def test_multiline_and_unicode_use_bounded_clipboard_without_typing_enter(text): + code = gui_code(GuiAction("type", {"text": text, "input_mode": "replace"})) + assert "pyautogui.write" not in code + assert "xclip" in code and "timeout=10" in code + assert "DEVNULL" in code and "hotkey('ctrl','v')" in code + + def test_png_size_parses_ihdr(): assert png_size(_png(1920, 1080)) == (1920, 1080) diff --git a/tests/harness/test_observation_closure.py b/tests/harness/test_observation_closure.py new file mode 100644 index 0000000..274e807 --- /dev/null +++ b/tests/harness/test_observation_closure.py @@ -0,0 +1,120 @@ +"""Synthetic observation-to-action regressions; no benchmark IDs or answers.""" + +from dataclasses import replace + +from tandem.gui.candidates import ActionCandidateBuilder +from tandem.gui.field_binding import _input_target, matches_field +from tandem.gui.goal_candidates import foreground_tree, goal_candidates, modal_frontier +from tandem.gui.interaction import target_effect +from tandem.gui.semantics import enrich_targets +from tandem.gui.targets import A11yTargetBuilder + +from .test_semantics import _named, _targets + + +def test_native_click_capability_preserves_static_navigation_not_plain_text(): + tree = ''' + + + + ''' + targets = A11yTargetBuilder().build(tree, "test", filter_occluded=True) + assert [t.name for t in targets] == ["Inventory"] + assert targets[0].operations == ("click",) + space = ActionCandidateBuilder().build(targets, context="Use sidebar Inventory") + assert [c.target_id for c in space.target_heads["click"]] == [targets[0].id] + + +def test_candidate_limit_is_explicit_not_a_hidden_twenty_four(): + base = _named(_targets(), "Save") + targets = [replace(base, id=str(i), name=f"Control {i}", x=i * 10) for i in range(40)] + space = ActionCandidateBuilder().build(targets, max_candidates=40) + assert len(space.target_heads["click"]) == 40 + assert len(ActionCandidateBuilder().build(targets, max_candidates=7).target_heads["click"]) == 7 + + +def test_exact_control_label_beats_repeated_sidebar_context(): + base = _named(_targets(), "Save") + targets = [replace(base, id=str(i), name=f"Section {i}", + nearby_text="Permissions project user policy roles settings") for i in range(30)] + targets.append(replace(base, id="wanted", name="Permissions")) + space = ActionCandidateBuilder().build(targets, max_candidates=4, context="Open Permissions") + assert space.target_heads["click"][0].target_id == "wanted" + + +def test_page_container_cannot_rename_a_nested_button(): + base = replace(_named(_targets(), "Save"), name="Publish", x=500, y=400) + tree = '' + snapshot = {"elements": [{"index": 1, "role": "push_button", + "name": "Workspace all documents Publish and other actions", "x": 0, "y": 0, + "width": 1600, "height": 900, "operations": ["click"]}]} + targets = enrich_targets([base], tree, (1920, 1080), snapshot) + assert next(t for t in targets if t.id == base.id).name == "Publish" + assert next(t for t in targets if t.id == base.id).source == "a11y" + + +def test_same_named_button_retains_native_coordinates_despite_frame_offset(): + base = replace(_named(_targets(), "Save"), name="Publish", x=500, y=400) + snapshot = {"elements": [{"index": 1, "role": "push_button", "name": "Publish", + "x": 500, "y": 425, "width": base.width, "height": base.height, "operations": ["click"]}]} + targets = enrich_targets([base], None, (1920, 1080), snapshot) + assert len(targets) == 1 + assert targets[0].y == 400 and targets[0].source == "a11y+dom" + + +def test_three_unique_anchors_correct_dom_only_control_coordinates(): + base = _named(_targets(), "Save") + anchors = [replace(base, id=str(i), name=f"Anchor {i}", x=200, y=150 + 60 * i) + for i in range(3)] + elements = [{"index": i, "role": t.role, "name": t.name, "x": t.x, "y": t.y + 25, + "width": t.width, "height": t.height} for i, t in enumerate(anchors)] + elements.append({"index": 4, "role": "push_button", "name": "New action", "x": 600, + "y": 425, "width": 100, "height": 30}) + targets = enrich_targets(anchors, None, (1920, 1080), {"elements": elements}) + assert _named(targets, "New action").y == 400 + + +def test_ambiguous_same_title_windows_do_not_activate_background_frame(): + tree = ''' + + + + ''' + targets = A11yTargetBuilder().build(foreground_tree(tree), "test") + assert [t.name for t in targets] == ["Current"] + + +def test_generic_short_label_does_not_erase_field_qualifier_or_owner(): + assert not matches_field("File name", "Name") + assert not matches_field("Messaging Search mail", "Search", "Cloud console") + assert matches_field("Messaging Search mail", "Search mail", "Messaging") + assert matches_field("Name", "Name") + + +def test_password_has_an_input_candidate_and_is_not_claimed_verified(): + tree = ''' + + ''' + targets = A11yTargetBuilder().build(tree, "test") + value = {"label": "Login password", "target_hint": "Password", "text": "synthetic-only", + "input_mode": "replace"} + assert _input_target(value, targets) is targets[0] + space = goal_candidates(ActionCandidateBuilder().build(targets), targets, tree, [value]) + assert space.target_heads["type_text"][0].metadata["text"] == "synthetic-only" + assert target_effect({"operation": "type_text", "target_semantic": targets[0].semantic_key, + "field_label": "Password"}, targets) == "unknown" + + +def test_coordinate_free_native_chooser_still_owns_input(): + tree = ''' + + ''' + space = goal_candidates(ActionCandidateBuilder().build([]), [], tree, []) + frontier = modal_frontier(space, [], tree) + assert frontier["blocking_overlay"] == "Choose Files" + assert frontier["overlay_kind"] == "task_dialog" + assert "active dialog" in frontier["next_intent"] diff --git a/tests/harness/test_slow_transport.py b/tests/harness/test_slow_transport.py index 394f7ce..753dc26 100644 --- a/tests/harness/test_slow_transport.py +++ b/tests/harness/test_slow_transport.py @@ -5,6 +5,7 @@ import io import json import random +import urllib.error import urllib.request import pytest @@ -196,3 +197,99 @@ def opener(request, **kwargs): assert len(requests) == 1 assert len(requests[0].data) <= config.slow_max_request_bytes assert "NOTHING" in messages[-1]["content"] + + +@pytest.fixture +def pooled_client(tmp_path, monkeypatch): + credentials = tmp_path / "synthetic-credentials.json" + credentials.write_text(json.dumps({"kimi": [ + {"id": f"synthetic-{i}", "priority": 1, "api_key": f"test-only-{i}"} + for i in range(3) + ]})) + monkeypatch.setattr("tandem.models.slow.time.sleep", lambda seconds: None) + return SlowModelClient({ + "base_url": "https://api.kimi.com/coding/v1", "model": "synthetic", + "key_pool": {"provider": "kimi", "key_ids": [1, 2, 3], + "credentials_file": str(credentials), "state_file": str(tmp_path / "state.json")}, + }, SessionConfig(slow_reasoning_effort="low"), tmp_path / "audit.jsonl") + + +@pytest.mark.parametrize("role", ["plan_stage", "verify_stage", "ground_visual"]) +@pytest.mark.parametrize("failure", [TimeoutError, http.client.RemoteDisconnected, + urllib.error.URLError, http.client.IncompleteRead]) +def test_transport_failure_switches_key_without_changing_reasoning_or_replaying_actions( + pooled_client, monkeypatch, role, failure): + requests = [] + + def opener(request, **kwargs): + requests.append(request) + if len(requests) == 1: + if failure is http.client.IncompleteRead: + raise failure(b"synthetic partial response") + raise failure("synthetic transport failure") + return _Response() + + monkeypatch.setattr(urllib.request, "urlopen", opener) + assert pooled_client.json_request([{"role": "user", "content": "plan"}], lambda v: True, + role=role) == {"done": True} + assert [r.get_header("Authorization") for r in requests] == ["Bearer test-only-0", "Bearer test-only-1"] + assert requests[0].data == requests[1].data + events = [json.loads(line) for line in pooled_client.audit_path.read_text().splitlines()] + changed = next(e for e in events if e["event"] == "key_changed") + assert (changed["key_id"], changed["next_key_id"]) == ("synthetic-0", "synthetic-1") + assert "test-only-" not in pooled_client.audit_path.read_text() + assert pooled_client.key_pool.select()["id"] != "synthetic-0" + + +def test_temporary_server_error_switches_key(pooled_client, monkeypatch): + requests = [] + + class Unavailable(_Response): + status = 503 + + def read(self): + return b'{"error":{"message":"synthetic temporary outage"}}' + + def opener(request, **kwargs): + requests.append(request) + return Unavailable() if len(requests) == 1 else _Response() + + monkeypatch.setattr(urllib.request, "urlopen", opener) + assert pooled_client.json_request([{"role": "user", "content": "plan"}], lambda v: True, + role="plan_stage") == {"done": True} + assert requests[0].get_header("Authorization") != requests[1].get_header("Authorization") + + +@pytest.mark.parametrize("failure", [TimeoutError, http.client.IncompleteRead]) +def test_transport_key_rotation_is_bounded(pooled_client, monkeypatch, failure): + requests = [] + + def opener(request, **kwargs): + requests.append(request) + if failure is http.client.IncompleteRead: + raise failure(b"synthetic partial response") + raise TimeoutError("synthetic read timeout") + + monkeypatch.setattr(urllib.request, "urlopen", opener) + with pytest.raises(failure): + pooled_client.json_request([{"role": "user", "content": "plan"}], lambda v: True, + role="plan_stage") + assert len(requests) == len({r.get_header("Authorization") for r in requests}) == 3 + + +def test_invalid_json_does_not_cool_down_or_switch_key(pooled_client, monkeypatch): + requests = [] + + class Invalid(_Response): + def read(self): + return b'{"choices":[{"message":{"content":"not JSON"}}]}' + + def opener(request, **kwargs): + requests.append(request) + return Invalid() if len(requests) == 1 else _Response() + + monkeypatch.setattr(urllib.request, "urlopen", opener) + assert pooled_client.json_request([{"role": "user", "content": "plan"}], lambda v: True, + role="plan_stage") == {"done": True} + assert requests[0].get_header("Authorization") == requests[1].get_header("Authorization") + assert "key_cooldown" not in pooled_client.audit_path.read_text() diff --git a/tests/harness/test_target_binding.py b/tests/harness/test_target_binding.py new file mode 100644 index 0000000..e69a709 --- /dev/null +++ b/tests/harness/test_target_binding.py @@ -0,0 +1,108 @@ +"""Generic synthetic target-binding regressions; not benchmark or real-desktop acceptance.""" + +from dataclasses import replace + +import pytest + +from tandem.gui.candidates import ActionCandidateBuilder +from tandem.gui.semantics import enrich_targets +from tandem.gui.targets import A11yTargetBuilder + +from .test_semantics import CHROME_XML, _named, _targets + + +@pytest.mark.parametrize("text", ["/analytics", "/inventory", "42,000"]) +def test_sidebar_goal_does_not_promote_body_text_or_table_cells_to_navigation(text): + base = _named(_targets(), "Save") + targets = [replace(base, id=role, role=role, name=text, operations=()) + for role in ("text", "label", "table_cell")] + targets.append(replace(base, id="nav", name="Inventory", source="dom", operations=("click",), + parent_context=("navigation", "Workspace"))) + space = ActionCandidateBuilder().build(targets, context="Open Inventory using the sidebar") + assert [c.target_id for c in space.target_heads["click"]] == ["nav"] + assert not space.target_heads["double_click"] + + +def test_explicitly_actionable_grid_cell_and_native_table_editing_are_preserved(): + base = _named(_targets(), "Save") + targets = [replace(base, id="interactive", role="grid_cell", operations=("click",)), + replace(base, id="native", role="table_cell", scope="native", operations=())] + space = ActionCandidateBuilder().build(targets, context="Use the sidebar and edit the selected cell") + assert {c.target_id for c in space.target_heads["click"]} == {"interactive", "native"} + + +def test_structural_navigation_binding_survives_candidate_budget_and_dom_merge(): + base = _named(_targets(), "Save") + body = [replace(base, id=str(i), name="Open Inventory using sidebar", x=300 + i) + for i in range(30)] + snapshot = {"elements": [{"index": 1, "role": "push_button", "name": "Inventory", "x": 50, + "y": 220, "width": 100, "height": 30, "parent_context": ["navigation", "Workspace"], + "operations": ["click"]}]} + targets = enrich_targets(body, CHROME_XML, (1920, 1080), snapshot) + space = ActionCandidateBuilder().build(targets, max_candidates=4, + context="Open Inventory using the sidebar") + assert space.target_heads["click"][0].target_id == "dom:t8:1" + + +@pytest.mark.parametrize("tag", ["dialog", "frame"]) +def test_native_modal_owns_input_and_browser_dom_cannot_reintroduce_background_controls(tag): + xml = f''' + + + + + <{tag} name="Select a document" modal="true" active="true" visible="true" showing="true" + screencoord="(200,200)" size="(600,400)"> + + + + + ''' + snapshot = {"elements": [{"index": 1, "role": "push_button", "name": "Background delete", + "x": 900, "y": 400, "width": 100, "height": 30}]} + raw = A11yTargetBuilder().build(xml, "t0", filter_occluded=True) + targets = enrich_targets(raw, xml, (1920, 1080), snapshot) + assert {t.name for t in targets} == {"Location", "Open"} + assert all(t.scope == "dialog" for t in targets) + + +def test_native_popup_menu_suspends_even_uncovered_web_dom_until_menu_closes(): + def tree(showing): + return f''' + + + + + + ''' + snapshot = {"elements": [{"index": 1, "role": "push_button", "name": "Delete row", + "x": 700, "y": 500, "width": 100, "height": 30}]} + for showing, expected in [("true", {"Context", "Back"}), ("false", {"Delete row"})]: + xml = tree(showing) + raw = A11yTargetBuilder().build(xml, "t0", filter_occluded=True) + targets = enrich_targets(raw, xml, (1920, 1080), snapshot) + assert {t.name for t in targets} == expected + if showing == "true": + assert all(t.scope == "menu" for t in targets) + + +def test_retained_inactive_modal_does_not_suspend_the_current_page(): + xml = ''' + + + ''' + snapshot = {"elements": [{"index": 1, "role": "push_button", "name": "Save", + "x": 700, "y": 500, "width": 100, "height": 30}]} + assert [t.name for t in enrich_targets([], xml, (1920, 1080), snapshot)] == ["Save"] + + +def test_web_dialog_does_not_disable_its_browser_dom_controls(): + xml = ''' + + + ''' + snapshot = {"elements": [{"index": 1, "role": "push_button", "name": "Replace", + "x": 700, "y": 500, "width": 100, "height": 30}]} + targets = enrich_targets([], xml, (1920, 1080), snapshot) + assert [t.name for t in targets] == ["Replace"] From 185f43dfa7cbae23092e1ae3b005667da4af9ba8 Mon Sep 17 00:00:00 2001 From: xxxyxun Date: Thu, 5 Nov 2026 11:12:20 +0800 Subject: [PATCH 2/5] fix(gui): preserve clickable text and retry CDP readiness probes --- src/tandem/gui/targets.py | 2 +- tests/eval/test_browser_controls.py | 64 ++++++++++++++++++----- tests/harness/test_observation_closure.py | 14 +++-- 3 files changed, 61 insertions(+), 19 deletions(-) diff --git a/src/tandem/gui/targets.py b/src/tandem/gui/targets.py index 1eb078b..e871823 100644 --- a/src/tandem/gui/targets.py +++ b/src/tandem/gui/targets.py @@ -192,7 +192,7 @@ def visit(element, active, parents, app_name, modal=False, roles=(), file_manage native_click = any(_local(key) in {"clickancestor_desc", "click_desc", "press_desc"} for key in element.attrib) context = parents + ((name,) if name else ()) - interactive_text = role != "text" or ( + interactive_text = role != "text" or native_click or ( _state(element, "editable") is True or _state(element, "focusable") is True ) system_target = "gnome-shell" in {item.lower() for item in parents} diff --git a/tests/eval/test_browser_controls.py b/tests/eval/test_browser_controls.py index fc5467d..0d3566f 100644 --- a/tests/eval/test_browser_controls.py +++ b/tests/eval/test_browser_controls.py @@ -1,17 +1,67 @@ """Optional real-CDP smoke on a synthetic page; not a VM or benchmark acceptance test.""" +import io import json import shutil import subprocess import time +import urllib.error import urllib.parse import urllib.request +from unittest.mock import Mock import pytest from tandem_eval.browser import VM_SCRIPT +def _wait_for_cdp_page(opener, process, active_port): + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if active_port.exists(): + port = int(active_port.read_text().splitlines()[0]) + try: + with opener.open(f"http://127.0.0.1:{port}/json/list", timeout=2) as response: + pages = json.load(response) + except (TimeoutError, urllib.error.URLError): + pages = [] + page = next((p for p in pages if p.get("title") == "Synthetic target audit"), None) + if page is not None: + return port, page + assert process.poll() is None, "Synthetic Chromium exited before CDP was ready" + time.sleep(0.05) + pytest.fail("Synthetic Chromium CDP page was not ready before the deadline") + + +@pytest.mark.parametrize("error", [TimeoutError("startup timeout"), urllib.error.URLError("starting")]) +def test_cdp_readiness_retries_temporary_connection_failure(tmp_path, monkeypatch, error): + active_port = tmp_path / "DevToolsActivePort" + active_port.write_text("9222\n") + page = {"id": "synthetic", "title": "Synthetic target audit"} + opener = Mock() + opener.open.side_effect = [error, io.BytesIO(json.dumps([page]).encode())] + process = Mock() + process.poll.return_value = None + monkeypatch.setattr(time, "sleep", lambda seconds: None) + assert _wait_for_cdp_page(opener, process, active_port) == (9222, page) + assert opener.open.call_count == 2 + + +def test_cdp_readiness_stops_at_deadline(tmp_path, monkeypatch): + active_port = tmp_path / "DevToolsActivePort" + active_port.write_text("9222\n") + opener = Mock() + opener.open.side_effect = TimeoutError("still starting") + process = Mock() + process.poll.return_value = None + clock = [0.0] + monkeypatch.setattr(time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(time, "sleep", lambda seconds: clock.__setitem__(0, 15.0)) + with pytest.raises(pytest.fail.Exception, match="before the deadline"): + _wait_for_cdp_page(opener, process, active_port) + assert opener.open.call_count == 1 + + def test_real_cdp_discovers_click_handlers_without_promoting_plain_text(tmp_path): chrome = shutil.which("chromium") or shutil.which("chrome") if chrome is None: @@ -35,19 +85,7 @@ def test_real_cdp_discovers_click_handlers_without_promoting_plain_text(tmp_path ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - deadline = time.monotonic() + 15 - pages = [] - while time.monotonic() < deadline: - active_port = profile / "DevToolsActivePort" - if active_port.exists(): - port = int(active_port.read_text().splitlines()[0]) - with opener.open(f"http://127.0.0.1:{port}/json/list", timeout=2) as response: - pages = json.load(response) - if any(p.get("title") == "Synthetic target audit" for p in pages): - break - assert process.poll() is None, "Synthetic Chromium exited before CDP was ready" - time.sleep(0.05) - page = next(p for p in pages if p.get("title") == "Synthetic target audit") + port, page = _wait_for_cdp_page(opener, process, profile / "DevToolsActivePort") namespace = {"__name__": "cdp_smoke"} exec(VM_SCRIPT, namespace) snapshot = namespace["evaluate"](port, page) diff --git a/tests/harness/test_observation_closure.py b/tests/harness/test_observation_closure.py index 274e807..73f3d10 100644 --- a/tests/harness/test_observation_closure.py +++ b/tests/harness/test_observation_closure.py @@ -2,6 +2,8 @@ from dataclasses import replace +import pytest + from tandem.gui.candidates import ActionCandidateBuilder from tandem.gui.field_binding import _input_target, matches_field from tandem.gui.goal_candidates import foreground_tree, goal_candidates, modal_frontier @@ -12,16 +14,18 @@ from .test_semantics import _named, _targets -def test_native_click_capability_preserves_static_navigation_not_plain_text(): - tree = ''' +@pytest.mark.parametrize("role", ["static", "text"]) +@pytest.mark.parametrize("action", ["clickAncestor_desc", "click_desc", "press_desc"]) +def test_native_click_capability_preserves_navigation_not_plain_text(role, action): + tree = f''' - - + <{role} name="Inventory" a:{action}="" screencoord="(20,100)" size="(80,20)"/> + <{role} name="/inventory" screencoord="(400,200)" size="(80,20)"/> ''' targets = A11yTargetBuilder().build(tree, "test", filter_occluded=True) assert [t.name for t in targets] == ["Inventory"] assert targets[0].operations == ("click",) - space = ActionCandidateBuilder().build(targets, context="Use sidebar Inventory") + space = ActionCandidateBuilder().build(targets, action_inputs=[], context="Use sidebar Inventory") assert [c.target_id for c in space.target_heads["click"]] == [targets[0].id] From e3a3435ee2ae807260a4191be50e192795ad3dad Mon Sep 17 00:00:00 2001 From: xxxyxun Date: Thu, 5 Nov 2026 11:27:24 +0800 Subject: [PATCH 3/5] fix(gui): require distinct anchors for coordinate fusion --- src/tandem/gui/semantics.py | 21 +++++---- tests/harness/test_observation_closure.py | 55 ++++++++++++++++++++--- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/tandem/gui/semantics.py b/src/tandem/gui/semantics.py index 1cae04f..61be9a5 100644 --- a/src/tandem/gui/semantics.py +++ b/src/tandem/gui/semantics.py @@ -4,6 +4,7 @@ import re import xml.etree.ElementTree as ET +from collections import Counter from dataclasses import replace from statistics import median @@ -131,7 +132,7 @@ def _aligned_snapshot(targets, snapshot): """Correct a common frame offset only when three independent controls agree.""" if not snapshot: return snapshot - offsets = [] + pairs = set() for item in snapshot.get("elements", []): matches = [t for t in targets if _norm(t.name) == _norm(item.get("name", "")) and _roles_compatible(t.role, item.get("role", "")) @@ -139,7 +140,14 @@ def _aligned_snapshot(targets, snapshot): and 0.8 <= t.width / max(1, item.get("width", 0)) <= 1.25 and 0.8 <= t.height / max(1, item.get("height", 0)) <= 1.25] if len(matches) == 1: - offsets.append((matches[0].x - item["x"], matches[0].y - item["y"])) + target = matches[0] + pairs.add(((target.x, target.y, target.width, target.height), + (item["x"], item["y"], item["width"], item["height"]))) + # DOM aliases are one physical anchor; ambiguous correspondences cannot calibrate a frame. + native_counts = Counter(native for native, dom in pairs) + dom_counts = Counter(dom for native, dom in pairs) + offsets = [(native[0] - dom[0], native[1] - dom[1]) for native, dom in pairs + if native_counts[native] == dom_counts[dom] == 1] if len(offsets) < 3 or any(max(v) - min(v) > 4 for v in zip(*offsets, strict=True)): return snapshot dx, dy = (median(v) for v in zip(*offsets, strict=True)) @@ -337,15 +345,6 @@ def enrich_targets( and (_norm(base.name) == _norm(dom.name) or base.label_source in {"unresolved", "synthetic"}) ] - same_label = [index for index, base in enumerate(enriched) - if _roles_compatible(base.role, dom.role) and _norm(base.name) == _norm(dom.name) - and abs(base.x - dom.x) <= max(base.width, dom.width) - and abs(base.y - dom.y) <= max(base.height, dom.height) - and 0.5 <= base.width / dom.width <= 2 - and 0.5 <= base.height / dom.height <= 2] - if len(same_label) == 1: - enriched[same_label[0]] = _merge(enriched[same_label[0]], dom) - continue best = max(compatible, key=lambda pair: pair[1], default=(None, 0.0)) if best[0] is not None and best[1] >= 0.5: enriched[best[0]] = _merge(enriched[best[0]], dom) diff --git a/tests/harness/test_observation_closure.py b/tests/harness/test_observation_closure.py index 73f3d10..2aca601 100644 --- a/tests/harness/test_observation_closure.py +++ b/tests/harness/test_observation_closure.py @@ -57,25 +57,66 @@ def test_page_container_cannot_rename_a_nested_button(): assert next(t for t in targets if t.id == base.id).source == "a11y" -def test_same_named_button_retains_native_coordinates_despite_frame_offset(): +@pytest.mark.parametrize("dx,dy", [(80, 0), (0, 25), (40, 0)]) +def test_same_named_controls_without_verified_alignment_remain_distinct(dx, dy): base = replace(_named(_targets(), "Save"), name="Publish", x=500, y=400) snapshot = {"elements": [{"index": 1, "role": "push_button", "name": "Publish", - "x": 500, "y": 425, "width": base.width, "height": base.height, "operations": ["click"]}]} + "x": base.x + dx, "y": base.y + dy, "width": base.width, "height": base.height, + "operations": ["click"]}]} targets = enrich_targets([base], None, (1920, 1080), snapshot) - assert len(targets) == 1 - assert targets[0].y == 400 and targets[0].source == "a11y+dom" + assert [(t.x, t.y, t.source) for t in targets] == [ + (500, 400, "a11y"), (500 + dx, 400 + dy, "dom"), + ] + space = ActionCandidateBuilder().build(targets, action_inputs=[]) + assert {c.target_id for c in space.target_heads["click"]} == {t.id for t in targets} -def test_three_unique_anchors_correct_dom_only_control_coordinates(): +@pytest.mark.parametrize("anchor_count,expected_y", [(2, 425), (3, 400)]) +def test_only_three_unique_anchors_correct_dom_only_control_coordinates(anchor_count, expected_y): base = _named(_targets(), "Save") anchors = [replace(base, id=str(i), name=f"Anchor {i}", x=200, y=150 + 60 * i) - for i in range(3)] + for i in range(anchor_count)] elements = [{"index": i, "role": t.role, "name": t.name, "x": t.x, "y": t.y + 25, "width": t.width, "height": t.height} for i, t in enumerate(anchors)] elements.append({"index": 4, "role": "push_button", "name": "New action", "x": 600, "y": 425, "width": 100, "height": 30}) targets = enrich_targets(anchors, None, (1920, 1080), {"elements": elements}) - assert _named(targets, "New action").y == 400 + assert _named(targets, "New action").y == expected_y + if anchor_count == 3: + assert len(targets) == 4 + assert [(t.id, t.y, t.source) for t in targets[:3]] == [ + (t.id, t.y, "a11y+dom") for t in anchors + ] + + +@pytest.mark.parametrize("alias_kind", ["dom_duplicate", "dom_nested", "native_duplicate"]) +def test_aliases_of_one_physical_control_cannot_calibrate_the_page(alias_kind): + base = _named(_targets(), "Save") + anchors = ([replace(base, id=str(i), name=f"Alias {i}") for i in range(3)] + if alias_kind == "native_duplicate" else [base]) + elements = [{"index": i, "role": base.role, "name": anchors[i].name if len(anchors) == 3 else base.name, + "x": base.x, "y": base.y + 25, + "width": base.width + (i if alias_kind == "dom_nested" else 0), "height": base.height} + for i in range(3)] + elements.append({"index": 4, "role": "push_button", "name": "New action", "x": 600, + "y": 425, "width": 100, "height": 30}) + targets = enrich_targets(anchors, None, (1920, 1080), {"elements": elements}) + assert _named(targets, "New action").y == 425 + + +def test_ambiguous_anchor_does_not_contribute_to_frame_alignment(): + base = _named(_targets(), "Save") + anchors = [replace(base, id=str(i), name=f"Anchor {i}", x=200, y=150 + 60 * i) + for i in range(3)] + elements = [{"index": i, "role": t.role, "name": t.name, "x": t.x, "y": t.y + 25, + "width": t.width, "height": t.height} for i, t in enumerate(anchors)] + elements.extend([ + {**elements[0], "index": 3, "y": anchors[0].y + 27}, + {"index": 4, "role": "push_button", "name": "New action", "x": 600, + "y": 425, "width": 100, "height": 30}, + ]) + targets = enrich_targets(anchors, None, (1920, 1080), {"elements": elements}) + assert _named(targets, "New action").y == 425 def test_ambiguous_same_title_windows_do_not_activate_background_frame(): From 8645ca1fd8a981223b763a222827b5f0a23e4041 Mon Sep 17 00:00:00 2001 From: xxxyxun Date: Thu, 5 Nov 2026 11:59:43 +0800 Subject: [PATCH 4/5] fix(gui): gate DOM-only actions on verified screen mapping --- src/tandem/gui/semantics.py | 35 +++++--- tests/harness/test_goal_integration.py | 3 +- tests/harness/test_goal_mechanisms.py | 9 +- tests/harness/test_observation_closure.py | 104 +++++++++++++++++++--- tests/harness/test_semantics.py | 4 +- tests/harness/test_target_binding.py | 21 +++-- 6 files changed, 140 insertions(+), 36 deletions(-) diff --git a/src/tandem/gui/semantics.py b/src/tandem/gui/semantics.py index 61be9a5..ccedf73 100644 --- a/src/tandem/gui/semantics.py +++ b/src/tandem/gui/semantics.py @@ -129,13 +129,14 @@ def _roles_compatible(a: str, b: str) -> bool: def _aligned_snapshot(targets, snapshot): - """Correct a common frame offset only when three independent controls agree.""" + """Return the snapshot and whether three independent controls verified its screen mapping.""" if not snapshot: - return snapshot + return snapshot, False pairs = set() for item in snapshot.get("elements", []): matches = [t for t in targets if _norm(t.name) == _norm(item.get("name", "")) and _roles_compatible(t.role, item.get("role", "")) + and t.source.startswith("a11y") and "chrome" in t.application.lower() and 0.8 <= t.width / max(1, item.get("width", 0)) <= 1.25 and 0.8 <= t.height / max(1, item.get("height", 0)) <= 1.25] @@ -149,12 +150,13 @@ def _aligned_snapshot(targets, snapshot): offsets = [(native[0] - dom[0], native[1] - dom[1]) for native, dom in pairs if native_counts[native] == dom_counts[dom] == 1] if len(offsets) < 3 or any(max(v) - min(v) > 4 for v in zip(*offsets, strict=True)): - return snapshot + return snapshot, False dx, dy = (median(v) for v in zip(*offsets, strict=True)) - return {**snapshot, "elements": [{**i, "x": i["x"] + dx, "y": i["y"] + dy} - for i in snapshot["elements"]], - "viewport": {**snapshot.get("viewport", {}), - "screen_y": snapshot.get("viewport", {}).get("screen_y", 0) + dy}} + aligned = {**snapshot, "elements": [{**i, "x": i["x"] + dx, "y": i["y"] + dy} + for i in snapshot["elements"]]} + if "screen_y" in snapshot.get("viewport", {}): + aligned["viewport"] = {**snapshot["viewport"], "screen_y": snapshot["viewport"]["screen_y"] + dy} + return aligned, True def _dom_target(item: dict, snapshot_id: str) -> UITarget | None: @@ -303,8 +305,8 @@ def enrich_targets( if owner is not None: owner_scope = "menu" if _local(owner.tag) in {"menu", "popup_menu"} else "dialog" labels, chrome_active = _a11y_context(root) - browser_snapshot = _aligned_snapshot(targets, browser_snapshot) - content_y = _content_y(targets, browser_snapshot) + browser_snapshot, dom_coordinates_verified = _aligned_snapshot(targets, browser_snapshot) + content_y = _content_y(targets, browser_snapshot if dom_coordinates_verified else None) enriched = [] for target in targets: name, source = target.name, target.label_source @@ -317,7 +319,8 @@ def enrich_targets( name, source = nearby, "a11y_nearby" if not name and target.focused: name, source = f"Focused {target.role.replace('_', ' ')}", "synthetic" - if not name: + # Keep unnamed native boxes for DOM labeling; discard any still unnamed after fusion. + if not name and not browser_snapshot: continue item = replace( target, @@ -343,15 +346,19 @@ def enrich_targets( for index, base in enumerate(enriched) if _roles_compatible(base.role, dom.role) and (_norm(base.name) == _norm(dom.name) + or not base.name or base.label_source in {"unresolved", "synthetic"}) ] - best = max(compatible, key=lambda pair: pair[1], default=(None, 0.0)) - if best[0] is not None and best[1] >= 0.5: - enriched[best[0]] = _merge(enriched[best[0]], dom) - else: + matches = [index for index, overlap in compatible if overlap >= 0.5] + if len(matches) == 1: + enriched[matches[0]] = _merge(enriched[matches[0]], dom) + elif dom_coordinates_verified: + # Unmatched DOM nodes stay in the raw snapshot until their screen mapping is verified. enriched.append(dom) unique = {} for target in enriched: + if not target.name: + continue center_x = target.x + target.width / 2 center_y = target.y + target.height / 2 if not (0 <= center_x < screen_size[0] and 0 <= center_y < screen_size[1]): diff --git a/tests/harness/test_goal_integration.py b/tests/harness/test_goal_integration.py index 582eed7..38e6991 100644 --- a/tests/harness/test_goal_integration.py +++ b/tests/harness/test_goal_integration.py @@ -47,7 +47,8 @@ def test_goal_passes_cdp_enriched_semantics_to_planner_and_selector(tmp_path): def planner(*args, **kwargs): target = next(t for t in args[2] if t.name == "Receipt Date") - assert target.source == "dom" and target.scope == "web_content" + assert target.source == "a11y+dom" and target.scope == "web_content" + assert target.id.startswith("a11y:") and (target.x, target.y) == (250, 293) return {**plan(), "inputs": [{"label": "Receipt Date", "target_hint": "Receipt Date", "text": "11/12/2025", "input_mode": "replace"}]} diff --git a/tests/harness/test_goal_mechanisms.py b/tests/harness/test_goal_mechanisms.py index 1268c37..de72b1f 100644 --- a/tests/harness/test_goal_mechanisms.py +++ b/tests/harness/test_goal_mechanisms.py @@ -49,7 +49,8 @@ def test_native_date_uses_observed_segments_and_checks_iso_value(order, suffix): "input_type": "date", "date_order": order, "value": "", "label_source": "dom", "x": original.x, "y": original.y, "width": original.width, "height": original.height, "operations": ["click", "type_text"]}]} - targets = [replace(t, application="Google Chrome") for t in targets] + targets = [replace(t, application="Google Chrome", + name="Departure date" if t.id == original.id else t.name) for t in targets] target = next(t for t in enrich_targets(targets, FORM, (1920, 1080), snapshot) if t.name == "Departure date") from tandem.gui.goal_candidates import goal_candidates @@ -330,7 +331,11 @@ def test_duplicate_buttons_keep_row_context_and_identity_after_layout_shift(): snapshot = {"elements": [{"index": index, "role": "push_button", "name": "Select", "nearby_text": row, "x": 100, "y": 100 * index, "width": 80, "height": 30, "operations": ["click"]} for index, row in enumerate(["G8 09:00 North", "G14 11:00 South"], 1)]} - targets = enrich_targets([], None, (1920, 1080), snapshot) + base = _space(FORM)[0][0] + native = [replace(base, id=f"row-{e['index']}", application="Google Chrome", role=e["role"], + name=e["name"], x=e["x"], y=e["y"], width=e["width"], height=e["height"]) + for e in snapshot["elements"]] + targets = enrich_targets(native, None, (1920, 1080), snapshot) space = ActionCandidateBuilder().build(targets, context="G14") choices = space.target_heads["click"] assert "G14" in choices[0].description and "G8" in choices[1].description diff --git a/tests/harness/test_observation_closure.py b/tests/harness/test_observation_closure.py index 2aca601..e8ec3af 100644 --- a/tests/harness/test_observation_closure.py +++ b/tests/harness/test_observation_closure.py @@ -1,10 +1,12 @@ """Synthetic observation-to-action regressions; no benchmark IDs or answers.""" +from copy import deepcopy from dataclasses import replace import pytest from tandem.gui.candidates import ActionCandidateBuilder +from tandem.gui.compile import gui_action from tandem.gui.field_binding import _input_target, matches_field from tandem.gui.goal_candidates import foreground_tree, goal_candidates, modal_frontier from tandem.gui.interaction import target_effect @@ -58,35 +60,113 @@ def test_page_container_cannot_rename_a_nested_button(): @pytest.mark.parametrize("dx,dy", [(80, 0), (0, 25), (40, 0)]) -def test_same_named_controls_without_verified_alignment_remain_distinct(dx, dy): +def test_unverified_dom_coordinates_do_not_create_duplicate_click_targets(dx, dy): base = replace(_named(_targets(), "Save"), name="Publish", x=500, y=400) snapshot = {"elements": [{"index": 1, "role": "push_button", "name": "Publish", "x": base.x + dx, "y": base.y + dy, "width": base.width, "height": base.height, "operations": ["click"]}]} targets = enrich_targets([base], None, (1920, 1080), snapshot) - assert [(t.x, t.y, t.source) for t in targets] == [ - (500, 400, "a11y"), (500 + dx, 400 + dy, "dom"), - ] + assert [(t.x, t.y, t.source) for t in targets] == [(500, 400, "a11y")] space = ActionCandidateBuilder().build(targets, action_inputs=[]) - assert {c.target_id for c in space.target_heads["click"]} == {t.id for t in targets} + assert {c.target_id for c in space.target_heads["click"]} == {base.id} + assert "visual_fallback" in space.controls -@pytest.mark.parametrize("anchor_count,expected_y", [(2, 425), (3, 400)]) -def test_only_three_unique_anchors_correct_dom_only_control_coordinates(anchor_count, expected_y): +@pytest.mark.parametrize("anchor_count,offset", [(2, 25), (3, 0), (3, 25)]) +def test_only_three_unique_anchors_admit_dom_only_control_coordinates(anchor_count, offset): base = _named(_targets(), "Save") anchors = [replace(base, id=str(i), name=f"Anchor {i}", x=200, y=150 + 60 * i) for i in range(anchor_count)] - elements = [{"index": i, "role": t.role, "name": t.name, "x": t.x, "y": t.y + 25, + elements = [{"index": i, "role": t.role, "name": t.name, "x": t.x, "y": t.y + offset, "width": t.width, "height": t.height} for i, t in enumerate(anchors)] elements.append({"index": 4, "role": "push_button", "name": "New action", "x": 600, "y": 425, "width": 100, "height": 30}) - targets = enrich_targets(anchors, None, (1920, 1080), {"elements": elements}) - assert _named(targets, "New action").y == expected_y + snapshot = {"elements": elements} + original = deepcopy(snapshot) + targets = enrich_targets(anchors, None, (1920, 1080), snapshot) + assert snapshot == original # Raw DOM evidence is retained, not overwritten by calibration. if anchor_count == 3: + assert _named(targets, "New action").y == 425 - offset assert len(targets) == 4 assert [(t.id, t.y, t.source) for t in targets[:3]] == [ (t.id, t.y, "a11y+dom") for t in anchors ] + candidate = next(c for c in ActionCandidateBuilder().build(targets).target_heads["click"] + if c.target_id == _named(targets, "New action").id) + action = gui_action(candidate, _named(targets, "New action")) + assert action.parameters == {"x": 650, "y": 440 - offset} + else: + assert [t.id for t in targets] == [t.id for t in anchors] + + +def test_dom_nodes_cannot_validate_their_own_coordinate_mapping(): + base = _named(_targets(), "Save") + dom = [replace(base, id=str(i), name=f"Control {i}", source="dom", y=150 + 60 * i) + for i in range(3)] + elements = [{"index": i, "role": t.role, "name": t.name, "x": t.x, "y": t.y, + "width": t.width, "height": t.height} for i, t in enumerate(dom)] + elements.append({"index": 4, "role": "push_button", "name": "New action", "x": 600, + "y": 425, "width": 100, "height": 30}) + targets = enrich_targets(dom, None, (1920, 1080), {"elements": elements}) + assert not any(t.name == "New action" for t in targets) + + +def test_alignment_without_viewport_origin_does_not_reclassify_browser_chrome(): + base = _named(_targets(), "Save") + anchors = [replace(base, id=str(i), name=f"Anchor {i}", y=150 + 60 * i) for i in range(3)] + shell = replace(base, id="shell", name="Window menu", y=20) + elements = [{"index": i, "role": t.role, "name": t.name, "x": t.x, "y": t.y + 25, + "width": t.width, "height": t.height} for i, t in enumerate(anchors)] + targets = enrich_targets([*anchors, shell], None, (1920, 1080), {"elements": elements}) + assert _named(targets, "Window menu").scope == "browser_chrome" + + +def test_a_single_local_dom_match_keeps_a11y_coordinates_and_dom_semantics(): + base = replace(_named(_targets(), "Save"), name="Publish", x=500, y=400) + snapshot = {"elements": [{"index": 1, "role": base.role, "name": base.name, + "x": 501, "y": 401, "width": base.width, "height": base.height, + "parent_context": ["navigation", "Workspace"], "operations": ["click"]}]} + targets = enrich_targets([base], None, (1920, 1080), snapshot) + assert len(targets) == 1 and targets[0].source == "a11y+dom" + assert (targets[0].x, targets[0].y) == (500, 400) + assert targets[0].parent_context == ("navigation", "Workspace") + + +def test_ambiguous_local_dom_match_does_not_overwrite_an_arbitrary_a11y_control(): + base = replace(_named(_targets(), "Save"), name="Publish", x=500, y=400) + other = replace(base, id="a11y:other") + snapshot = {"elements": [{"index": 1, "role": base.role, "name": base.name, + "x": 500, "y": 400, "width": base.width, "height": base.height, + "parent_context": ["navigation", "Workspace"]}]} + targets = enrich_targets([base, other], None, (1920, 1080), snapshot) + assert all(t.source == "a11y" and t.parent_context == base.parent_context for t in targets) + + +@pytest.mark.parametrize("role", ["push_button", "entry", "table_cell"]) +def test_no_a11y_anchors_means_no_dom_coordinate_actions(role): + snapshot = {"elements": [{"index": 1, "role": role, "name": "Control", "x": 500, + "y": 425, "width": 80, "height": 30, "operations": ["click", "type_text"]}]} + targets = enrich_targets([], None, (1920, 1080), snapshot) + space = ActionCandidateBuilder().build(targets) + assert not targets + assert not any(c.target_id for c in space.candidates()) + assert "visual_fallback" in space.controls + + +def test_distinct_same_named_a11y_controls_are_never_removed_by_dom_coordinate_gate(): + base = replace(_named(_targets(), "Save"), name="Publish", x=500, y=400) + adjacent = replace(base, id="a11y:adjacent", x=580) + snapshot = {"elements": [{"index": 1, "role": base.role, "name": base.name, + "x": 500, "y": 425, "width": base.width, "height": base.height}]} + targets = enrich_targets([base, adjacent], None, (1920, 1080), snapshot) + assert [(t.id, t.x, t.y) for t in targets] == [(base.id, 500, 400), (adjacent.id, 580, 400)] + + +def test_unnamed_native_control_without_local_dom_match_remains_non_actionable(): + base = replace(_named(_targets(), "Save"), name="", label_source="unresolved", x=500, y=400) + snapshot = {"elements": [{"index": 1, "role": base.role, "name": "Publish", + "x": 500, "y": 425, "width": base.width, "height": base.height}]} + assert not enrich_targets([base], None, (1920, 1080), snapshot) @pytest.mark.parametrize("alias_kind", ["dom_duplicate", "dom_nested", "native_duplicate"]) @@ -101,7 +181,7 @@ def test_aliases_of_one_physical_control_cannot_calibrate_the_page(alias_kind): elements.append({"index": 4, "role": "push_button", "name": "New action", "x": 600, "y": 425, "width": 100, "height": 30}) targets = enrich_targets(anchors, None, (1920, 1080), {"elements": elements}) - assert _named(targets, "New action").y == 425 + assert not any(t.source == "dom" for t in targets) def test_ambiguous_anchor_does_not_contribute_to_frame_alignment(): @@ -116,7 +196,7 @@ def test_ambiguous_anchor_does_not_contribute_to_frame_alignment(): "y": 425, "width": 100, "height": 30}, ]) targets = enrich_targets(anchors, None, (1920, 1080), {"elements": elements}) - assert _named(targets, "New action").y == 425 + assert not any(t.source == "dom" for t in targets) def test_ambiguous_same_title_windows_do_not_activate_background_frame(): diff --git a/tests/harness/test_semantics.py b/tests/harness/test_semantics.py index ada56a0..e625ef3 100644 --- a/tests/harness/test_semantics.py +++ b/tests/harness/test_semantics.py @@ -50,7 +50,7 @@ def test_a11y_recovers_nearby_labels_and_separates_browser_chrome(): assert _named(targets, "Minimise").scope == "browser_chrome" -def test_dom_enriches_matching_a11y_target_and_adds_dom_only_controls(): +def test_dom_enriches_a11y_semantics_without_admitting_uncalibrated_dom_only_controls(): date = _named(_targets(), "Receipt Date") snapshot = { "viewport": {"screen_y": 112}, @@ -72,7 +72,7 @@ def test_dom_enriches_matching_a11y_target_and_adds_dom_only_controls(): merged = _named(targets, "Receipt Date") assert merged.id == date.id and merged.source == "a11y+dom" assert merged.value == "11/12/2025" and merged.operations == ("click", "type_text") - assert _named(targets, "Upload receipt").source == "dom" + assert not any(t.name == "Upload receipt" for t in targets) click = next(c for c in _space(targets).target_heads["click"] if c.target_id == merged.id) assert click.source == "a11y+dom" diff --git a/tests/harness/test_target_binding.py b/tests/harness/test_target_binding.py index e69a709..b3220b1 100644 --- a/tests/harness/test_target_binding.py +++ b/tests/harness/test_target_binding.py @@ -35,13 +35,15 @@ def test_structural_navigation_binding_survives_candidate_budget_and_dom_merge() base = _named(_targets(), "Save") body = [replace(base, id=str(i), name="Open Inventory using sidebar", x=300 + i) for i in range(30)] + body.append(replace(base, id="nav", name="Inventory", x=50, y=220, width=100, height=30)) snapshot = {"elements": [{"index": 1, "role": "push_button", "name": "Inventory", "x": 50, "y": 220, "width": 100, "height": 30, "parent_context": ["navigation", "Workspace"], "operations": ["click"]}]} targets = enrich_targets(body, CHROME_XML, (1920, 1080), snapshot) space = ActionCandidateBuilder().build(targets, max_candidates=4, context="Open Inventory using the sidebar") - assert space.target_heads["click"][0].target_id == "dom:t8:1" + assert space.target_heads["click"][0].target_id == "nav" + assert _named(targets, "Inventory").source == "a11y+dom" @pytest.mark.parametrize("tag", ["dialog", "frame"]) @@ -70,6 +72,7 @@ def test_native_popup_menu_suspends_even_uncovered_web_dom_until_menu_closes(): def tree(showing): return f''' + @@ -89,20 +92,28 @@ def tree(showing): def test_retained_inactive_modal_does_not_suspend_the_current_page(): xml = ''' - + + ''' snapshot = {"elements": [{"index": 1, "role": "push_button", "name": "Save", "x": 700, "y": 500, "width": 100, "height": 30}]} - assert [t.name for t in enrich_targets([], xml, (1920, 1080), snapshot)] == ["Save"] + raw = A11yTargetBuilder().build(xml, "t0", filter_occluded=True) + targets = enrich_targets(raw, xml, (1920, 1080), snapshot) + assert [t.name for t in targets] == ["Save"] + assert targets[0].source == "a11y+dom" def test_web_dialog_does_not_disable_its_browser_dom_controls(): xml = ''' - + + + ''' snapshot = {"elements": [{"index": 1, "role": "push_button", "name": "Replace", "x": 700, "y": 500, "width": 100, "height": 30}]} - targets = enrich_targets([], xml, (1920, 1080), snapshot) + raw = A11yTargetBuilder().build(xml, "t0", filter_occluded=True) + targets = enrich_targets(raw, xml, (1920, 1080), snapshot) assert [t.name for t in targets] == ["Replace"] + assert targets[0].source == "a11y+dom" From 35681b3f644e98762a612e0eb2c90257e834c2ba Mon Sep 17 00:00:00 2001 From: xxxyxun Date: Thu, 5 Nov 2026 12:25:16 +0800 Subject: [PATCH 5/5] test(eval): wait for the actual CDP document to load --- tests/eval/test_browser_controls.py | 130 ++++++++++++++++++++-------- 1 file changed, 93 insertions(+), 37 deletions(-) diff --git a/tests/eval/test_browser_controls.py b/tests/eval/test_browser_controls.py index 0d3566f..5fd38f6 100644 --- a/tests/eval/test_browser_controls.py +++ b/tests/eval/test_browser_controls.py @@ -1,13 +1,15 @@ """Optional real-CDP smoke on a synthetic page; not a VM or benchmark acceptance test.""" +import http.server import io import json import shutil import subprocess +import threading import time import urllib.error -import urllib.parse import urllib.request +from functools import partial from unittest.mock import Mock import pytest @@ -15,38 +17,76 @@ from tandem_eval.browser import VM_SCRIPT -def _wait_for_cdp_page(opener, process, active_port): - deadline = time.monotonic() + 15 +def _wait_for_cdp_page(opener, process, active_port, page_url, evaluate): + """Wait for the intended document, not just a listening socket or cached target title.""" + deadline = time.monotonic() + 30 + pages, last_error, document = [], "", None while time.monotonic() < deadline: if active_port.exists(): port = int(active_port.read_text().splitlines()[0]) try: with opener.open(f"http://127.0.0.1:{port}/json/list", timeout=2) as response: pages = json.load(response) - except (TimeoutError, urllib.error.URLError): - pages = [] - page = next((p for p in pages if p.get("title") == "Synthetic target audit"), None) - if page is not None: - return port, page + page = next((p for p in pages if p.get("type") == "page" and p.get("url") == page_url), None) + if page is not None: + document = evaluate(port, page) + if document.get("url") == page_url and document.get("ready"): + return port, page + except OSError as error: + last_error = str(error) assert process.poll() is None, "Synthetic Chromium exited before CDP was ready" time.sleep(0.05) - pytest.fail("Synthetic Chromium CDP page was not ready before the deadline") + seen = [{key: page.get(key) for key in ("type", "url", "title")} for page in pages] + pytest.fail(f"Synthetic Chromium CDP page was not ready before the deadline; " + f"expected_url={page_url!r}; active_port_exists={active_port.exists()}; " + f"pages={seen!r}; document={document!r}; last_error={last_error!r}") @pytest.mark.parametrize("error", [TimeoutError("startup timeout"), urllib.error.URLError("starting")]) def test_cdp_readiness_retries_temporary_connection_failure(tmp_path, monkeypatch, error): active_port = tmp_path / "DevToolsActivePort" active_port.write_text("9222\n") - page = {"id": "synthetic", "title": "Synthetic target audit"} + page = {"id": "synthetic", "type": "page", "url": "file:///synthetic.html", "title": ""} opener = Mock() opener.open.side_effect = [error, io.BytesIO(json.dumps([page]).encode())] process = Mock() process.poll.return_value = None monkeypatch.setattr(time, "sleep", lambda seconds: None) - assert _wait_for_cdp_page(opener, process, active_port) == (9222, page) + evaluate = Mock(return_value={"url": page["url"], "ready": True}) + assert _wait_for_cdp_page(opener, process, active_port, page["url"], evaluate) == (9222, page) assert opener.open.call_count == 2 +def test_cdp_readiness_matches_page_url_not_a_cached_or_duplicate_title(tmp_path): + active_port = tmp_path / "DevToolsActivePort" + active_port.write_text("9222\n") + page = {"id": "synthetic", "type": "page", "url": "file:///synthetic.html", "title": ""} + unrelated = {**page, "url": "about:blank", "title": "Synthetic target audit"} + opener = Mock() + opener.open.return_value = io.BytesIO(json.dumps([unrelated, page]).encode()) + process = Mock() + process.poll.return_value = None + evaluate = Mock(return_value={"url": page["url"], "ready": True}) + assert _wait_for_cdp_page(opener, process, active_port, page["url"], evaluate) == (9222, page) + evaluate.assert_called_once_with(9222, page) + + +def test_cdp_readiness_waits_for_the_actual_document_to_finish_loading(tmp_path, monkeypatch): + active_port = tmp_path / "DevToolsActivePort" + active_port.write_text("9222\n") + page = {"type": "page", "url": "http://127.0.0.1/controls.html"} + opener = Mock() + opener.open.side_effect = [io.BytesIO(json.dumps([page]).encode()) for _ in range(3)] + evaluate = Mock(side_effect=[{"url": "about:blank", "ready": True}, + {"url": page["url"], "ready": False}, + {"url": page["url"], "ready": True}]) + process = Mock() + process.poll.return_value = None + monkeypatch.setattr(time, "sleep", lambda seconds: None) + assert _wait_for_cdp_page(opener, process, active_port, page["url"], evaluate) == (9222, page) + assert evaluate.call_count == 3 + + def test_cdp_readiness_stops_at_deadline(tmp_path, monkeypatch): active_port = tmp_path / "DevToolsActivePort" active_port.write_text("9222\n") @@ -56,14 +96,14 @@ def test_cdp_readiness_stops_at_deadline(tmp_path, monkeypatch): process.poll.return_value = None clock = [0.0] monkeypatch.setattr(time, "monotonic", lambda: clock[0]) - monkeypatch.setattr(time, "sleep", lambda seconds: clock.__setitem__(0, 15.0)) - with pytest.raises(pytest.fail.Exception, match="before the deadline"): - _wait_for_cdp_page(opener, process, active_port) + monkeypatch.setattr(time, "sleep", lambda seconds: clock.__setitem__(0, 30.0)) + with pytest.raises(pytest.fail.Exception, match="last_error='still starting'"): + _wait_for_cdp_page(opener, process, active_port, "file:///synthetic.html", Mock()) assert opener.open.call_count == 1 def test_real_cdp_discovers_click_handlers_without_promoting_plain_text(tmp_path): - chrome = shutil.which("chromium") or shutil.which("chrome") + chrome = shutil.which("google-chrome") or shutil.which("chrome") or shutil.which("chromium") if chrome is None: pytest.skip("Optional Chromium executable is unavailable") html = '''Synthetic target audit @@ -78,26 +118,42 @@ def test_real_cdp_discovers_click_handlers_without_promoting_plain_text(tmp_path ''' profile = tmp_path / "chrome-profile" - process = subprocess.Popen([ - chrome, "--headless", "--no-sandbox", "--disable-dev-shm-usage", "--no-first-run", - "--disable-background-networking", "--disable-component-update", "--remote-debugging-port=0", - f"--user-data-dir={profile}", "data:text/html," + urllib.parse.quote(html), - ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - try: - opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - port, page = _wait_for_cdp_page(opener, process, profile / "DevToolsActivePort") - namespace = {"__name__": "cdp_smoke"} - exec(VM_SCRIPT, namespace) - snapshot = namespace["evaluate"](port, page) - controls = {e["name"]: e for e in snapshot["elements"]} - assert set(controls) == {"Metrics", "Inventory", "Save"} - assert controls["Inventory"]["parent_context"] == ["navigation", "Workspace"] - assert controls["Metrics"]["role"] == "push_button" - assert snapshot["title"] == "Synthetic target audit" # Discovery never invokes handlers. - finally: - process.terminate() + page_file = tmp_path / "controls.html" + page_file.write_text(html, encoding="utf-8") + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), partial( + http.server.SimpleHTTPRequestHandler, directory=str(tmp_path))) + thread = threading.Thread(target=server.serve_forever, daemon=True) + page_url = f"http://127.0.0.1:{server.server_port}/controls.html" + startup_log = tmp_path / "chrome-startup.log" + with server, startup_log.open("w") as log: + process = subprocess.Popen([ + chrome, "--headless", "--no-sandbox", "--disable-dev-shm-usage", "--no-first-run", + "--disable-background-networking", "--disable-component-update", "--remote-debugging-port=0", + f"--user-data-dir={profile}", page_url, + ], stdout=subprocess.DEVNULL, stderr=log) + thread.start() try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + namespace = {"__name__": "cdp_smoke"} + exec(VM_SCRIPT, namespace) + expression = namespace["EXPRESSION"] + namespace["EXPRESSION"] = "({url:location.href, ready:document.readyState === 'complete'})" + port, page = _wait_for_cdp_page(opener, process, profile / "DevToolsActivePort", + page_url, namespace["evaluate"]) + namespace["EXPRESSION"] = expression + snapshot = namespace["evaluate"](port, page) + controls = {e["name"]: e for e in snapshot["elements"]} + assert set(controls) == {"Metrics", "Inventory", "Save"}, snapshot + assert controls["Inventory"]["parent_context"] == ["navigation", "Workspace"] + assert controls["Metrics"]["role"] == "push_button" + assert snapshot["title"] == "Synthetic target audit" # Discovery never invokes handlers. + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + server.shutdown() + thread.join(timeout=5) + print("Chromium startup log:\n" + startup_log.read_text(errors="replace")[-4000:])