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
28 changes: 28 additions & 0 deletions project/ticket-020/README.md
Original file line number Diff line number Diff line change
@@ -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.
83 changes: 83 additions & 0 deletions project/ticket-020/intent.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
}
39 changes: 33 additions & 6 deletions testql/interpreter/_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
81 changes: 81 additions & 0 deletions tests/test_gui_relative_url.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading