diff --git a/project/TICKETS.md b/project/TICKETS.md index 2be9006..460f896 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -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) | - | - | - | - | - | diff --git a/project/ticket-025/README.md b/project/ticket-025/README.md new file mode 100644 index 0000000..d3e3477 --- /dev/null +++ b/project/ticket-025/README.md @@ -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. diff --git a/project/ticket-025/intent.json b/project/ticket-025/intent.json new file mode 100644 index 0000000..7508953 --- /dev/null +++ b/project/ticket-025/intent.json @@ -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." + } + ] + } +} diff --git a/testql/interpreter/_gui.py b/testql/interpreter/_gui.py index bd832b4..d4cb419 100644 --- a/testql/interpreter/_gui.py +++ b/testql/interpreter/_gui.py @@ -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, @@ -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)') @@ -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: @@ -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 "" diff --git a/tests/test_gui_input_fallback.py b/tests/test_gui_input_fallback.py new file mode 100644 index 0000000..0908929 --- /dev/null +++ b/tests/test_gui_input_fallback.py @@ -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()