diff --git a/project/ticket-020/README.md b/project/ticket-020/README.md new file mode 100644 index 0000000..eb45a29 --- /dev/null +++ b/project/ticket-020/README.md @@ -0,0 +1,28 @@ +# Ticket 020: Resolve relative URLs and optional input values in GUI scenarios + +- **ID**: ticket-020 +- **Owner**: agent:antigravity +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-02 + +## Goal and scope + +Fix TestQL GUI interpreter when handling relative URL paths in GUI_START / NAVIGATE +and support optional input values in FLOW / GUI_INPUT steps. Validate all GUI scenario +suites sequentially against DisplayNet. + +## Acceptance criteria + +- [ ] AC-01: GUI_START resolves relative paths (e.g. /connect-id) against base_url instead of failing with local executable not found. +- [ ] AC-02: GUI_INPUT supports omitted text (or '-' placeholder) without throwing 'GUI_INPUT requires selector and text'. +- [ ] AC-03: Sequentially execute and pass TestQL test-gui-* scenario suites against DisplayNet. +- [ ] AC-04: Existing TestQL unit tests pass and governance checks succeed. + +## 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-020/intent.json b/project/ticket-020/intent.json new file mode 100644 index 0000000..72f5706 --- /dev/null +++ b/project/ticket-020/intent.json @@ -0,0 +1,83 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-020", + "summary": "Resolve relative URLs and optional input values in GUI scenarios", + "workstream": "core", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "regression" + }, + "allowedPaths": [ + "project/ticket-020/**", + "TODO.md", + "project/TICKETS.md", + "testql/interpreter/_gui.py", + "tests/test_gui_relative_url.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "python" + ], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "24ae3a97424d9d9694bd1576393d8e4c55893c88", + "targetBranch": "main", + "outcome": "Allow TestQL GUI scenarios to launch with relative URL routes resolved against base_url, and support optional input values in GUI_INPUT.", + "nonGoals": [ + "No breaking changes to desktop backend launches", + "No changes to unsupported external drivers" + ], + "complexity": "XS", + "estimatedMinutes": 10, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "In _start_playwright and _start_selenium, resolve relative paths against base_url or api_url when path is not an existing file on disk; in _cmd_gui_input accept single-argument selector with empty text.", + "components": [ + { + "name": "gui-interpreter", + "paths": [ + "testql/interpreter/_gui.py", + "tests/test_gui_relative_url.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the GUI interpreter resolution changes." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "pytest tests/test_gui_relative_url.py" + ], + "evidence": "GUI_START resolves relative URLs with base_url." + }, + { + "criterion": "AC-02", + "commands": [ + "bash project/governance-check.sh --actor agent" + ], + "evidence": "Governance check passes." + } + ] + } +} diff --git a/testql/interpreter/_gui.py b/testql/interpreter/_gui.py index bb3c604..886c060 100644 --- a/testql/interpreter/_gui.py +++ b/testql/interpreter/_gui.py @@ -336,6 +336,21 @@ def _cmd_gui_start(self, args: str, line: OqlLine) -> None: def _start_playwright(self, app_path: str, extra_args: str) -> None: """Start Playwright and navigate to app_path.""" cdp_url = self.vars.get("cdp_url") or self.vars.get("browser.cdp_url") + if ( + not ( + app_path.startswith(("http://", "https://", "about:", "file://")) + or cdp_url + or app_path in ("current", ".") + ) + and not Path(app_path).expanduser().is_file() + ): + base_url = str( + self.vars.get("base_url") + or getattr(self, "api_url", None) + or "http://localhost:8100" + ) + app_path = f"{base_url.rstrip('/')}/{app_path.lstrip('/')}" if app_path else base_url + if ( app_path.startswith(("http://", "https://", "about:")) or cdp_url @@ -476,7 +491,18 @@ def _start_selenium(self, app_path: str, extra_args: str) -> None: """Start Selenium WebDriver.""" from selenium import webdriver - if app_path.startswith(("http://", "https://", "about:")): + if ( + not app_path.startswith(("http://", "https://", "about:", "file://")) + and not Path(app_path).expanduser().is_file() + ): + base_url = str( + self.vars.get("base_url") + or getattr(self, "api_url", None) + or "http://localhost:8100" + ) + app_path = f"{base_url.rstrip('/')}/{app_path.lstrip('/')}" if app_path else base_url + + if app_path.startswith(("http://", "https://", "about:", "file://")): # Web app headless = str(self.vars.get("headless", "true")).lower() == "true" options = webdriver.ChromeOptions() @@ -527,7 +553,7 @@ def _cmd_gui_navigate(self, args: str, line: OqlLine) -> None: # Handle relative paths if base_url is set target = path if not (path.startswith("http://") or path.startswith("https://")): - base_url = self.vars.get("base_url", "http://localhost:8100") + base_url = str(self.vars.get("base_url") or getattr(self, "api_url", None) or "http://localhost:8100") target = f"{base_url.rstrip('/')}/{path.lstrip('/')}" timeout = self._gui_operation_timeout(15000) @@ -547,7 +573,7 @@ def _cmd_gui_navigate(self, args: str, line: OqlLine) -> None: elif self._gui_driver == "selenium": target = path if not (path.startswith("http://") or path.startswith("https://")): - base_url = self.vars.get("base_url", "http://localhost:8100") + base_url = str(self.vars.get("base_url") or getattr(self, "api_url", None) or "http://localhost:8100") target = f"{base_url.rstrip('/')}/{path.lstrip('/')}" self._gui_page.get(target) @@ -634,12 +660,13 @@ def _cmd_gui_input(self, args: str, line: OqlLine) -> None: GUI_INPUT "input#username" "testuser" """ parts = args.strip().split(None, 1) - if len(parts) < 2: - self.out.fail(f"L{line.number}: GUI_INPUT requires selector and text") + if not parts: + self.out.fail(f"L{line.number}: GUI_INPUT requires selector") return selector = parts[0].strip('"\'') - text = parts[1].strip('"\'') + raw_text = parts[1].strip('"\'') if len(parts) > 1 else "" + text = "" if raw_text == "-" else raw_text display_text = "***REDACTED***" if getattr(self, "is_secret_value", lambda value: False)(text) else text[:20] if self.dry_run: diff --git a/tests/test_gui_relative_url.py b/tests/test_gui_relative_url.py new file mode 100644 index 0000000..6680921 --- /dev/null +++ b/tests/test_gui_relative_url.py @@ -0,0 +1,81 @@ +"""Unit tests for relative URL handling and optional input values in GUI mixin.""" + +from __future__ import annotations + +import pytest + +from testql.interpreter import OqlInterpreter +from testql.interpreter._parser import OqlLine + + +def test_gui_start_relative_url_dry_run() -> None: + """Test GUI_START accepts relative routes in dry-run mode.""" + interp = OqlInterpreter( + api_url="http://localhost:8100", + variables={"base_url": "http://localhost:8100"}, + dry_run=True, + quiet=True, + ) + line = OqlLine(number=1, command="GUI_START", args='"/connect-id"', raw='GUI_START "/connect-id"') + interp._cmd_gui_start(line.args, line) + + assert interp.results[-1].status.value == "passed" + + +def test_gui_input_selector_only_dry_run() -> None: + """Test GUI_INPUT accepts selector without text argument (defaults to empty string).""" + interp = OqlInterpreter(api_url="http://localhost:8100", dry_run=True, quiet=True) + line = OqlLine(number=1, command="GUI_INPUT", args='"#add-name"', raw='INPUT "#add-name"') + interp._cmd_gui_input(line.args, line) + + assert interp.results[-1].status.value == "passed" + + +def test_gui_input_dash_placeholder_dry_run() -> None: + """Test GUI_INPUT treats '-' placeholder as empty string.""" + interp = OqlInterpreter(api_url="http://localhost:8100", dry_run=True, quiet=True) + line = OqlLine(number=1, command="GUI_INPUT", args='"#add-name" -', raw='INPUT "#add-name" -') + interp._cmd_gui_input(line.args, line) + + assert interp.results[-1].status.value == "passed" + + +def test_start_playwright_relative_path_resolution(monkeypatch: pytest.MonkeyPatch) -> None: + """Test _start_playwright resolves relative paths against base_url.""" + interp = OqlInterpreter(api_url="http://example.com:8100", dry_run=False, quiet=True) + interp._gui_driver = "playwright" + interp._gui_playwright_backend = "python" + + opened_urls: list[str] = [] + + class MockPage: + url = "" + + def set_default_timeout(self, t: int) -> None: + pass + + def set_default_navigation_timeout(self, t: int) -> None: + pass + + def goto(self, url: str, **kwargs: object) -> None: + opened_urls.append(url) + + class MockBrowser: + def new_page(self) -> MockPage: + return MockPage() + + class MockChromium: + def launch(self, **kwargs: object) -> MockBrowser: + return MockBrowser() + + class MockPlaywright: + chromium = MockChromium() + + def start(self) -> MockPlaywright: + return self + + import playwright.sync_api + monkeypatch.setattr(playwright.sync_api, "sync_playwright", lambda: MockPlaywright()) + + interp._start_playwright("/connect-id", "") + assert opened_urls == ["http://example.com:8100/connect-id"]