Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ This file indexes governance tickets without taking ownership of
| **ticket-022** | [`README.md`](./ticket-022/README.md) | - | - | - | - | - |
| **ticket-023** | [`README.md`](./ticket-023/README.md) | - | - | [`ai-codex.md`](./ticket-023/ai-codex.md) | - | - |
| **ticket-024** | [`README.md`](./ticket-024/README.md) | - | - | - | - | - |
| **ticket-025** | [`README.md`](./ticket-025/README.md) | - | - | - | - | - |
<!-- AUTO:TICKET_INDEX:END -->
27 changes: 27 additions & 0 deletions project/ticket-025/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Ticket 025: Fallback selector resolution for GUI input and select

- **ID**: ticket-025
- **Owner**: agent:antigravity
- **Status**: IN_PROGRESS
- **Workflow state**: EDIT
- **Created**: 2026-10-02

## Goal and scope

Resolve selectors with fallback strategies in `GUI_INPUT` and `GUI_SELECT` commands before executing fill/clear/select operations, and fail fast when the element is not found, avoiding 5000ms Playwright timeout stalls and uncaught exceptions.

## Acceptance criteria

- [x] AC-01: `GUI_INPUT` uses `_find_element_with_logging` to resolve selectors and fallback candidates before calling fill.
- [x] AC-02: `GUI_INPUT` returns clean failure without Playwright timeout when selector cannot be found.
- [x] AC-03: `GUI_SELECT` uses `_find_element_with_logging` for selector resolution.
- [x] AC-04: Unit test `tests/test_gui_input_fallback.py` passes.
- [x] AC-05: Governance checks pass.

## Authorization

SESSION_EXECUTION_AUTHORIZATION: User requested sequential testing of all functionalities with testql, fixing the library directly if GUI testing fails, and merging the changes.

## Evidence boundary

Operational test receipts and browser execution logs remain local state.
85 changes: 85 additions & 0 deletions project/ticket-025/intent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
{
"schema": "new-project.intent/v3",
"ticket": "ticket-025",
"summary": "Fallback selector resolution for GUI input and select",
"workstream": "core",
"classification": {
"kind": "BUG",
"priority": "P1",
"origin": "regression"
},
"allowedPaths": [
"project/ticket-025/**",
"TODO.md",
"project/TICKETS.md",
"testql/interpreter/_gui.py",
"tests/test_gui_input_fallback.py"
],
"forbiddenPaths": [
"project/ticket-*/user-*.md"
],
"stacks": [
"python"
],
"dependsOn": [
"ticket-024"
],
"conflictsWith": [],
"integrationTicket": null,
"delivery": {
"acceptedBaseSha": "1e56f78908ee0b261b1e9091da1185bfc84dc560",
"targetBranch": "main",
"outcome": "Resolve selectors with fallback strategies in GUI_INPUT and GUI_SELECT commands before executing fill/clear/select operations, failing fast when not found without 5000ms Playwright timeout stalls.",
"nonGoals": [
"No changes to desktop driver",
"No changes to CDP attachment protocol"
],
"complexity": "XS",
"estimatedMinutes": 10,
"budgets": {
"maxImplementationFiles": 2,
"maxAffectedComponents": 1,
"maxPublicInterfaceChanges": 0,
"maxRuntimeDependencies": 0
},
"architecture": {
"status": "accepted",
"decision": "In _cmd_gui_input and _cmd_gui_select, resolve target selector and element via _find_element_with_logging before calling fill/clear/select, and record clean StepStatus.FAILED when element is not found.",
"components": [
{
"name": "gui-commands",
"paths": [
"testql/interpreter/_gui.py",
"tests/test_gui_input_fallback.py"
]
}
],
"responsibilityChanges": false,
"interfaceChanges": [],
"dataChanges": [],
"ui": {
"impact": "none",
"states": [],
"evidence": []
},
"rollback": "Revert selector resolution in _cmd_gui_input and _cmd_gui_select."
},
"runtimeDependencies": [],
"validation": [
{
"criterion": "AC-01",
"commands": [
"pytest tests/test_gui_input_fallback.py"
],
"evidence": "GUI_INPUT resolves selectors with fallbacks and fails fast when not found."
},
{
"criterion": "AC-02",
"commands": [
"bash project/governance-check.sh"
],
"evidence": "Governance check passes."
}
]
}
}
65 changes: 47 additions & 18 deletions testql/interpreter/_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,21 +730,30 @@ def _cmd_gui_input(self, args: str, line: OqlLine) -> None:
))
return

resolved_selector, element = self._find_element_with_logging(selector, "input")
if resolved_selector is None:
self.out.fail(f'GUI_INPUT "{selector}": Element not found')
self.results.append(StepResult(
name=f'GUI_INPUT "{selector}"',
status=StepStatus.FAILED,
message="Element not found after trying fallback selectors",
))
return

try:
if self._gui_driver == "playwright":
self._gui_page.fill(selector, text)
timeout = self.timeout_ms if self.timeout_ms else 30000
element.fill(text, timeout=timeout)
elif self._gui_driver == "selenium":
from selenium.webdriver.common.by import By
elem = self._gui_page.find_element(By.CSS_SELECTOR, selector)
elem.clear()
elem.send_keys(text)
element.clear()
element.send_keys(text)

self.out.step("⌨️", f'GUI_INPUT "{selector}" → "{display_text}"')
self.out.step("⌨️", f'GUI_INPUT "{resolved_selector}" → "{display_text}"')
self.results.append(StepResult(
name=f'GUI_INPUT "{selector}"', status=StepStatus.PASSED
))
except Exception as e:
self.out.fail(f'GUI_INPUT "{selector}" error: {e}')
self.out.fail(f'GUI_INPUT "{resolved_selector}" error: {e}')
self.results.append(StepResult(
name=f'GUI_INPUT "{selector}"',
status=StepStatus.ERROR,
Expand Down Expand Up @@ -940,6 +949,8 @@ def _cmd_gui_select(self, args: str, line: OqlLine) -> None:
self.results.append(StepResult(name="GUI_SELECT", status=StepStatus.ERROR, message="selector and value required"))
return
selector, value = parts[0], parts[1]
if selector.startswith(('["', "['")) and selector.endswith(('"]', "']")):
selector = f"[{selector[2:-2]}]"
name = f'GUI_SELECT "{selector}"'
if self.dry_run:
self.out.step("🔽", f'{name} → "{value}" (dry-run)')
Expand All @@ -949,25 +960,36 @@ def _cmd_gui_select(self, args: str, line: OqlLine) -> None:
self.out.fail("GUI_SELECT: No active GUI session")
self.results.append(StepResult(name=name, status=StepStatus.ERROR, message="No active GUI session"))
return

resolved_selector, element = self._find_element_with_logging(selector, "select")
if resolved_selector is None:
self.out.fail(f'{name}: Element not found')
self.results.append(StepResult(
name=name,
status=StepStatus.FAILED,
message="Element not found after trying fallback selectors",
))
return

try:
if self._gui_driver == "playwright":
selected = self._gui_page.select_option(selector, value=value)
timeout = self.timeout_ms if self.timeout_ms else 30000
selected = self._gui_page.select_option(resolved_selector, value=value, timeout=timeout)
if not selected:
selected = self._gui_page.select_option(selector, label=value)
selected = self._gui_page.select_option(resolved_selector, label=value, timeout=timeout)
if not selected:
raise ValueError(f"option not found: {value}")
elif self._gui_driver == "selenium":
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import Select
select = Select(self._gui_page.find_element(By.CSS_SELECTOR, selector))
select = Select(element)
try:
select.select_by_value(value)
except Exception:
select.select_by_visible_text(value)
self.out.step("🔽", f'{name} → "{value}"')
self.out.step("🔽", f'GUI_SELECT "{resolved_selector}" → "{value}"')
self.results.append(StepResult(name=name, status=StepStatus.PASSED))
except Exception as e:
self.out.fail(f'{name} error: {e}')
self.out.fail(f'GUI_SELECT "{resolved_selector}" error: {e}')
self.results.append(StepResult(name=name, status=StepStatus.ERROR, message=str(e)))

def _cmd_select(self, args: str, line: OqlLine) -> None:
Expand Down Expand Up @@ -1703,17 +1725,24 @@ def _compare_value(actual: str, op: str, expected: str) -> bool:
return False

def _read_gui_value(self, selector: str) -> str:
if selector.startswith(('["', "['")) and selector.endswith(('"]', "']")):
selector = f"[{selector[2:-2]}]"
resolved = self._resolve_selector_with_fallback(selector) or selector
timeout = min(self._gui_operation_timeout(), 250)
if self._gui_driver == "playwright":
locator = self._gui_page.locator(selector).first
locator = self._gui_page.locator(resolved).first
try:
return locator.input_value()
return locator.input_value(timeout=timeout)
except Exception:
value = locator.get_attribute("value")
return value or ""
try:
value = locator.get_attribute("value", timeout=timeout)
return value or ""
except Exception:
return ""
if self._gui_driver == "selenium":
from selenium.webdriver.common.by import By

elem = self._gui_page.find_element(By.CSS_SELECTOR, selector)
elem = self._gui_page.find_element(By.CSS_SELECTOR, resolved)
value = elem.get_attribute("value")
return value or ""
return ""
Expand Down
83 changes: 83 additions & 0 deletions tests/test_gui_input_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Tests for GUI_INPUT and GUI_SELECT fallback selector resolution and fast-fail."""

from unittest.mock import MagicMock
import pytest
from testql.base import StepStatus
from testql.interpreter import OqlInterpreter
from testql.interpreter._parser import OqlLine


@pytest.fixture
def interpreter():
interp = OqlInterpreter()
interp.out = MagicMock()
return interp


def test_gui_input_dry_run(interpreter):
interpreter.dry_run = True
line = OqlLine(number=1, command="GUI_INPUT", args='"#missing-input" "test"', raw='GUI_INPUT "#missing-input" "test"')
interpreter._cmd_gui_input(line.args, line)

assert len(interpreter.results) == 1
assert interpreter.results[-1].status == StepStatus.PASSED


def test_gui_input_not_found_fast_fail(interpreter):
"""When element is not found after fallbacks, GUI_INPUT fails gracefully without timing out."""
interpreter.dry_run = False
mock_page = MagicMock()
# is_visible returns False for all fallback probes
mock_page.is_visible.return_value = False
interpreter._gui_page = mock_page
interpreter._gui_driver = "playwright"

line = OqlLine(number=1, command="GUI_INPUT", args='"#non-existent-input" "hello"', raw='GUI_INPUT "#non-existent-input" "hello"')
interpreter._cmd_gui_input(line.args, line)

assert len(interpreter.results) == 1
assert interpreter.results[-1].status == StepStatus.FAILED
assert "Element not found" in interpreter.results[-1].message
# fill should not have been called
mock_page.fill.assert_not_called()


def test_gui_input_fallback_found(interpreter):
"""When original selector fails but fallback succeeds, GUI_INPUT fills resolved element."""
interpreter.dry_run = False
mock_page = MagicMock()
mock_locator = MagicMock()
mock_page.locator.return_value = mock_locator

# selector is '#add-name' -> fallback '.add-name' is visible
def is_visible_mock(selector, timeout=None):
return selector == ".add-name"

mock_page.is_visible.side_effect = is_visible_mock
interpreter._gui_page = mock_page
interpreter._gui_driver = "playwright"

line = OqlLine(number=1, command="GUI_INPUT", args='"#add-name" "test user"', raw='GUI_INPUT "#add-name" "test user"')
interpreter._cmd_gui_input(line.args, line)

assert len(interpreter.results) == 1
assert interpreter.results[-1].status == StepStatus.PASSED
mock_page.locator.assert_called_with(".add-name")
mock_locator.fill.assert_called_once_with("test user", timeout=30000)


def test_gui_select_not_found_fast_fail(interpreter):
"""When select element is not found after fallbacks, GUI_SELECT fails gracefully."""
interpreter.dry_run = False
mock_page = MagicMock()
mock_page.is_visible.return_value = False
interpreter._gui_page = mock_page
interpreter._gui_driver = "playwright"

line = OqlLine(number=1, command="GUI_SELECT", args='"#missing-select" "val1"', raw='GUI_SELECT "#missing-select" "val1"')
interpreter._cmd_gui_select(line.args, line)

assert len(interpreter.results) == 1
assert interpreter.results[-1].status == StepStatus.FAILED
assert "Element not found" in interpreter.results[-1].message
mock_page.select_option.assert_not_called()
Loading