diff --git a/project/ticket-021/README.md b/project/ticket-021/README.md new file mode 100644 index 0000000..2cf4623 --- /dev/null +++ b/project/ticket-021/README.md @@ -0,0 +1,27 @@ +# Ticket 021: Normalize attribute selectors parsed as lists in TOON tables + +- **ID**: ticket-021 +- **Owner**: agent:antigravity +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-02 + +## Goal and scope + +Normalize CSS attribute selectors like `[data-action='foo']` when parsed into +single-element lists by the TOON table parser. Reconstruct proper CSS attribute +selector strings in `_testtoon_parser.py`, `_gui_expand.py`, and `_gui.py`. + +## Acceptance criteria + +- [ ] AC-01: Attribute selectors in FLOW/GUI tables expand to `[data-action='...']` rather than list representations. +- [ ] AC-02: `GUI_CLICK` and element finding in `_gui.py` normalize any lingering list repr selectors. +- [ ] AC-03: `tests/test_toon_attribute_selector.py` passes and 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-021/intent.json b/project/ticket-021/intent.json new file mode 100644 index 0000000..09628b5 --- /dev/null +++ b/project/ticket-021/intent.json @@ -0,0 +1,89 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-021", + "summary": "Normalize attribute selectors parsed as lists in TOON tables", + "workstream": "core", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "regression" + }, + "allowedPaths": [ + "project/ticket-021/**", + "TODO.md", + "project/TICKETS.md", + "testql/interpreter/_testtoon_parser.py", + "testql/interpreter/_gui.py", + "testql/interpreter/_gui_expand.py", + "tests/test_toon_attribute_selector.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "python" + ], + "dependsOn": [ + "ticket-020" + ], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "3bc2815277d6b2db7eeb4a8ecba6612ed7c64cea", + "targetBranch": "main", + "outcome": "Normalize CSS attribute selectors like [data-action='foo'] when parsed into single-element lists by TOON table parser, ensuring valid selectors are emitted for GUI commands.", + "nonGoals": [ + "No changes to general TOON array parsing for non-selector columns", + "No changes to unrelated commands" + ], + "complexity": "S", + "estimatedMinutes": 20, + "budgets": { + "maxImplementationFiles": 4, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Add normalization for attribute selectors parsed as list representations in _testtoon_parser, _gui_expand, and _gui element resolution.", + "components": [ + { + "name": "toon-parser", + "paths": [ + "testql/interpreter/_testtoon_parser.py", + "testql/interpreter/_gui.py", + "testql/interpreter/_gui_expand.py", + "tests/test_toon_attribute_selector.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the selector normalization logic." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "pytest tests/test_toon_attribute_selector.py" + ], + "evidence": "Attribute selectors in TOON tables expand to valid CSS selectors." + }, + { + "criterion": "AC-02", + "commands": [ + "bash project/governance-check.sh --actor agent" + ], + "evidence": "Governance check passes with zero errors." + } + ] + } +} diff --git a/testql/interpreter/_gui.py b/testql/interpreter/_gui.py index 886c060..a7ea6f7 100644 --- a/testql/interpreter/_gui.py +++ b/testql/interpreter/_gui.py @@ -210,6 +210,8 @@ def _find_element_with_logging(self, selector: str, action: str) -> tuple[str, A Returns (working_selector, element) or (None, None) if not found. """ + if selector.startswith(('["', "['")) and selector.endswith(('"]', "']")): + selector = f"[{selector[2:-2]}]" resolved = self._resolve_selector_with_fallback(selector) if resolved is None: @@ -602,6 +604,8 @@ def _cmd_gui_click(self, args: str, line: OqlLine) -> None: GUI_CLICK "button#submit" """ selector = args.strip().strip('"\'') + if selector.startswith(('["', "['")) and selector.endswith(('"]', "']")): + selector = f"[{selector[2:-2]}]" if not selector: self.out.fail(f"L{line.number}: GUI_CLICK requires selector") return @@ -665,6 +669,8 @@ def _cmd_gui_input(self, args: str, line: OqlLine) -> None: return selector = parts[0].strip('"\'') + if selector.startswith(('["', "['")) and selector.endswith(('"]', "']")): + selector = f"[{selector[2:-2]}]" 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] diff --git a/testql/interpreter/_gui_expand.py b/testql/interpreter/_gui_expand.py index d645515..b502936 100644 --- a/testql/interpreter/_gui_expand.py +++ b/testql/interpreter/_gui_expand.py @@ -35,7 +35,16 @@ def quote_gui_token(token: str, *, empty: str = '""') -> str: def gui_row_fields(row: dict[str, object]) -> tuple[str, str, object, object]: action = str(row.get("action", "")).strip().lower() - selector = str(row.get("selector") or row.get("target") or "").strip() + raw_sel = row.get("selector") or row.get("target") or "" + if isinstance(raw_sel, list): + if len(raw_sel) == 1: + selector = f"[{raw_sel[0]}]" + else: + selector = f"[{', '.join(str(x) for x in raw_sel)}]" + elif isinstance(raw_sel, str) and raw_sel.startswith(('["', "['")) and raw_sel.endswith(('"]', "']")): + selector = f"[{raw_sel[2:-2]}]" + else: + selector = str(raw_sel).strip() value = row.get("value") if row.get("value") not in (None, "-") else row.get("text") wait_ms = row.get("wait_ms") return action, selector, value, wait_ms diff --git a/testql/interpreter/_testtoon_parser.py b/testql/interpreter/_testtoon_parser.py index 2d8dc63..6c0ad87 100644 --- a/testql/interpreter/_testtoon_parser.py +++ b/testql/interpreter/_testtoon_parser.py @@ -422,6 +422,13 @@ def _expand_flow(section: ToonSection, lines: list[OqlLine], line_num: int) -> i for row in section.rows: command = str(row.get('command', '')).upper() target = row.get('target', '') + if isinstance(target, list): + if len(target) == 1: + target = f"[{target[0]}]" + else: + target = f"[{', '.join(str(x) for x in target)}]" + elif isinstance(target, str) and target.startswith(('["', "['")) and target.endswith(('"]', "']")): + target = f"[{target[2:-2]}]" extra = '' # Prefer explicit value/text columns (typed text input) @@ -437,7 +444,7 @@ def _expand_flow(section: ToonSection, lines: list[OqlLine], line_num: int) -> i if isinstance(meta, dict): pairs = ', '.join(f'"{k}": "{v}"' for k, v in meta.items()) extra = f' {{{pairs}}}' - elif meta: + elif meta and meta != '-' and str(meta).lower() != 'null': extra = f' {meta}' args = f'"{target}"{extra}' diff --git a/tests/test_toon_attribute_selector.py b/tests/test_toon_attribute_selector.py new file mode 100644 index 0000000..199347a --- /dev/null +++ b/tests/test_toon_attribute_selector.py @@ -0,0 +1,47 @@ +"""Tests for normalizing attribute selectors in TOON flow and GUI tables.""" + +from __future__ import annotations + +from testql.interpreter._gui_expand import gui_row_fields +from testql.interpreter._testtoon_parser import _expand_flow +from testql.interpreter.testtoon_parser import ToonSection + + +def test_expand_flow_reconstructs_attribute_selector() -> None: + """When TOON parses [data-action='search-devices'] as a 1-item list, reconstruct selector string.""" + section = ToonSection( + type="FLOW", + columns=["command", "target", "meta"], + rows=[{"command": "click", "target": ["data-action='search-devices'"], "meta": None}], + ) + lines: list = [] + _expand_flow(section, lines, 1) + + assert len(lines) == 1 + assert lines[0].command == "CLICK" + assert lines[0].args == '"[data-action=\'search-devices\']"' + assert lines[0].raw == 'CLICK "[data-action=\'search-devices\']"' + + +def test_expand_flow_cleans_dash_meta() -> None: + """When meta is '-' or None, do not append trailing dash to args.""" + section = ToonSection( + type="FLOW", + columns=["command", "target", "meta"], + rows=[{"command": "click", "target": "#btn-test", "meta": "-"}], + ) + lines: list = [] + _expand_flow(section, lines, 1) + + assert len(lines) == 1 + assert lines[0].args == '"#btn-test"' + assert lines[0].raw == 'CLICK "#btn-test"' + + +def test_gui_row_fields_normalizes_list_selector() -> None: + """gui_row_fields converts list-wrapped selector to CSS attribute selector.""" + action, selector, value, wait_ms = gui_row_fields({ + "action": "click", + "selector": ["data-testid='save'"], + }) + assert selector == "[data-testid='save']"