diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..92249e6 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,29 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: '.python-version' + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: uv sync --dev + + - name: Run tests + run: uv run pytest tests/ -v diff --git a/build.py b/build.py index 59a6809..23c76ab 100644 --- a/build.py +++ b/build.py @@ -89,6 +89,17 @@ def get_icon_file(system: str) -> str: def run_pyinstaller(name: str, icon: str): + hiddenimports = [ + "tools.ai_scriptless", + "tools.ai_scriptless.commands", + "tools.ai_scriptless.elements", + "tools.ai_scriptless.item_key", + "tools.ai_scriptless.persistence", + "tools.ai_scriptless.script", + "tools.ai_scriptless.step_path", + "tools.ai_scriptless.tree", + "tools.ai_scriptless.variables", + ] PyInstaller.__main__.run([ 'main.py', '--onefile', @@ -99,6 +110,7 @@ def run_pyinstaller(name: str, icon: str): f'--icon={icon}', '--clean', '--noconfirm', + *[f'--hidden-import={module}' for module in hiddenimports], ]) diff --git a/config/perfecto.py b/config/perfecto.py index 2f59883..1cbde76 100644 --- a/config/perfecto.py +++ b/config/perfecto.py @@ -15,6 +15,14 @@ HELP_BASE_CONTENT_URL = "https://help.perfecto.io/perfecto-help/content/" +def get_cloud_app_url(cloud_name: str) -> str: + return f"https://{cloud_name}.app.perfectomobile.com" + + +def get_ai_scriptless_lab_url(cloud_name: str) -> str: + return f"{get_cloud_app_url(cloud_name)}/lab/scriptless-mobile/" + + def get_tenant_management_api_url(cloud_name: str) -> str: return f"https://{cloud_name}.app.perfectomobile.com/tenant-management-webapp/rest/v1/tenant-management/tenants/current" @@ -71,5 +79,20 @@ def get_ai_scriptless_api_url(cloud_name: str) -> str: return f"https://{cloud_name}.app.perfectomobile.com/native-automation-webapp/rest/v1/native-automation" +def get_ai_scriptless_draft_api_url(cloud_name: str) -> str: + return f"https://{cloud_name}.app.perfectomobile.com/native-automation-webapp/rest/v1/draft-management/draft" + + def get_ai_scriptless_execution_api_url(cloud_name: str) -> str: return f"https://{cloud_name}.perfectomobile.com/scriptless-mobile-engine/script-executor/api/executions" + + +def get_ai_scriptless_command_repository_url(cloud_name: str) -> str: + return f"https://{cloud_name}.perfectomobile.com/scriptless-mobile-engine/command-repository/api" + + +def get_repository_management_api_url(cloud_name: str) -> str: + return ( + f"https://{cloud_name}.app.perfectomobile.com" + "/repository-management-webapp/rest/v2/repository-management" + ) diff --git a/dist/perfecto-mcp-amd64.app/Contents/MacOS/perfecto-mcp b/dist/perfecto-mcp-amd64.app/Contents/MacOS/perfecto-mcp index 8dd672b..dcd1263 100644 Binary files a/dist/perfecto-mcp-amd64.app/Contents/MacOS/perfecto-mcp and b/dist/perfecto-mcp-amd64.app/Contents/MacOS/perfecto-mcp differ diff --git a/dist/perfecto-mcp-arm64.app/Contents/MacOS/perfecto-mcp b/dist/perfecto-mcp-arm64.app/Contents/MacOS/perfecto-mcp index 50f8dc7..3be2fcf 100644 Binary files a/dist/perfecto-mcp-arm64.app/Contents/MacOS/perfecto-mcp and b/dist/perfecto-mcp-arm64.app/Contents/MacOS/perfecto-mcp differ diff --git a/dist/perfecto-mcp-linux-amd64 b/dist/perfecto-mcp-linux-amd64 index a5846b3..0d07776 100755 Binary files a/dist/perfecto-mcp-linux-amd64 and b/dist/perfecto-mcp-linux-amd64 differ diff --git a/dist/perfecto-mcp-linux-amd64.sha256 b/dist/perfecto-mcp-linux-amd64.sha256 index 702156d..d543211 100644 --- a/dist/perfecto-mcp-linux-amd64.sha256 +++ b/dist/perfecto-mcp-linux-amd64.sha256 @@ -1 +1 @@ -891b5868edb7db6eeed2412bae6051452520d66393f1f93799835f6609f71761 perfecto-mcp-linux-amd64 +f52839d8062d5b1602a40b1d7dd7a9e51a75fce60501e8c1e208a7af6e1022ab perfecto-mcp-linux-amd64 diff --git a/dist/perfecto-mcp-linux-arm64 b/dist/perfecto-mcp-linux-arm64 index 6e58bff..765fd2f 100755 Binary files a/dist/perfecto-mcp-linux-arm64 and b/dist/perfecto-mcp-linux-arm64 differ diff --git a/dist/perfecto-mcp-linux-arm64.sha256 b/dist/perfecto-mcp-linux-arm64.sha256 index f8029cd..ad2e3fe 100644 --- a/dist/perfecto-mcp-linux-arm64.sha256 +++ b/dist/perfecto-mcp-linux-arm64.sha256 @@ -1 +1 @@ -f8ddcb5a3b77b3926d244be06ae1aff1024ba4f9db5c2b837002a3378e157a50 perfecto-mcp-linux-arm64 +7271e3c6695e21d1164b97aa3a6aeb3fc7315f85912f80a97f19e36be9b92ea5 perfecto-mcp-linux-arm64 diff --git a/dist/perfecto-mcp-windows-amd64.exe b/dist/perfecto-mcp-windows-amd64.exe index eab8383..3301e8f 100644 Binary files a/dist/perfecto-mcp-windows-amd64.exe and b/dist/perfecto-mcp-windows-amd64.exe differ diff --git a/formatters/ai_scriptless.py b/formatters/ai_scriptless.py index 5f49d97..0dcab52 100644 --- a/formatters/ai_scriptless.py +++ b/formatters/ai_scriptless.py @@ -1,5 +1,43 @@ from typing import List, Any, Optional +import copy + +from models.ai_scriptless import ( + CommandCatalogEntry, + CommandDefinitionSummary, + ScriptFlowElement, + ScriptParameter, + ScriptVariableSummary, + SnapshotListResult, + SnapshotSummary, + TestStructure, +) +from tools.ai_scriptless.elements import normalize_if_statement_aliases + +PRIMARY_AI_COMMAND_IDS = ( + "ai_user-action", + "ai_validation", + "ai_visual-comparison", +) + + +def command_selection_policy_info() -> List[str]: + """Context returned with list_commands so agents load policy when choosing command_ids.""" + return [ + "Command selection policy (when authoring tests with add_command / modify_command):", + "Default: use only these primary AI command_ids: " + + ", ".join(PRIMARY_AI_COMMAND_IDS) + ".", + " • ai_user-action — user interactions (open browser/app, navigate to URL, tap, type, dismiss overlays); " + "argument: action (natural language).", + " • ai_validation — checkpoints and assertions; argument: validation (natural language).", + " • ai_visual-comparison — visual/baseline comparison; argument: name.", + "Prefer ai_user-action for navigation (e.g. open browser and go to URL), not browser_goto / browser_open.", + "Do not use browser_*, touch_tap, webpage.element_*, checkpoint_text, etc. unless the user explicitly " + "requests a non-AI command or agreed that AI commands cannot meet a documented requirement.", + "Structural helpers (add_logical_step, add_loop, add_condition, comment, wait) are OK; " + "keep observable steps AI-driven when possible.", + "Call get_command_definitions only for the AI command_ids you will use.", + ] def format_ai_scriptless_tests_filter_values(tests: dict[str, Any], params: Optional[dict] = None) -> dict[str, Any]: filter_values = { @@ -57,3 +95,283 @@ def format_ai_scriptless_tests(tests: dict[str, Any], params: Optional[dict] = N offset = len(formatted_ai_scriptless_tests) - 1 return formatted_ai_scriptless_tests[skip:offset] + + +def _command_id(command: Optional[str], subcommand: Optional[str]) -> Optional[str]: + if not command: + return None + sub = subcommand or "" + if sub: + return f"{command}_{sub}".replace("/", "_") + return command.replace("/", "_") + + +def _definitions_map(command_definitions: Optional[list]) -> dict[str, dict[str, Any]]: + if not command_definitions: + return {} + return {item["commandId"]: item for item in command_definitions if "commandId" in item} + + +def _argument_value(element: dict[str, Any], argument_name: str) -> Optional[str]: + for argument in element.get("arguments", []): + if argument.get("name") == argument_name: + data = argument.get("data", {}) + return data.get("value") + return None + + +def _definition_display_name(command_id: Optional[str], definitions_map: dict[str, dict[str, Any]]) -> Optional[str]: + if not command_id or command_id not in definitions_map: + return None + definition = definitions_map[command_id] + display = definition.get("data", {}).get("display", {}) + return display.get("name") or definition.get("name") + + +def _step_display_name(element: dict[str, Any], definitions_map: dict[str, dict[str, Any]]) -> str: + element_type = element.get("@type", "") + command = element.get("command") + subcommand = element.get("subcommand") + command_id = _command_id(command, subcommand) + + if command == "ai" and subcommand == "user-action": + action_text = _argument_value(element, "action") + if action_text: + return action_text + + if element_type == "Loop": + iterator = element.get("iterator", {}) + count = iterator.get("count") + if count is not None: + return f"Loop ({count})" + return "Loop" + + if element_type == "IfStatement": + expression = element.get("expression") or element.get("label") + if expression: + return f"Condition ({expression})" + return "Condition" + + if element_type == "LogicalStep": + label = element.get("label") + if label: + return label + return "Step" + + if element_type == "Branch": + clause = element.get("clause", "") + return clause.title() if clause else "Branch" + + display_name = _definition_display_name(command_id, definitions_map) + if display_name: + return display_name + + if command: + if subcommand: + return f"{command}/{subcommand}" + return command + + return element_type or "Unknown" + + +def _format_flow_element( + element: dict[str, Any], + definitions_map: dict[str, dict[str, Any]], + step_path: str, +) -> ScriptFlowElement: + element_type = element.get("@type", "") + command = element.get("command") + subcommand = element.get("subcommand") + children: List[ScriptFlowElement] = [] + + if element_type == "IfStatement": + for branch_index, branch in enumerate(element.get("branches", [])): + branch_path = f"{step_path}.b{branch_index}" + branch_label = branch.get("clause", "Branch") + branch_children = [ + _format_flow_element(child, definitions_map, f"{branch_path}.{child_index}") + for child_index, child in enumerate(branch.get("flowElements", [])) + ] + children.append(ScriptFlowElement( + type="Branch", + name=branch_label.title() if branch_label else "Branch", + active=branch.get("active", True), + step_path=branch_path, + children=branch_children, + )) + else: + for child_index, child in enumerate(element.get("flowElements", [])): + children.append( + _format_flow_element(child, definitions_map, f"{step_path}.{child_index}") + ) + + return ScriptFlowElement( + type=element_type, + name=_step_display_name(element, definitions_map), + command=command, + subcommand=subcommand, + active=element.get("active", True), + step_path=step_path, + children=children, + ) + + +def _format_root_flow_elements( + flow_elements: list[dict[str, Any]], + definitions_map: dict[str, dict[str, Any]], +) -> List[ScriptFlowElement]: + return [ + _format_flow_element(element, definitions_map, str(index)) + for index, element in enumerate(flow_elements) + ] + + +def format_test_structure(payload: dict[str, Any], params: Optional[dict] = None) -> TestStructure: + item_key = params.get("item_key", "") if params else "" + script = copy.deepcopy(payload.get("script", {})) + normalize_if_statement_aliases(script) + definitions_map = _definitions_map(payload.get("commandDefinitions")) + + parameters = [] + for parameter in script.get("parameters", []): + data = parameter.get("data", {}) + parameters.append(ScriptParameter( + name=data.get("name", parameter.get("name", "")), + type=data.get("@type", "Unknown"), + )) + + flow_elements = _format_root_flow_elements(script.get("flowElements", []), definitions_map) + + info = script.get("info", {}) + return TestStructure( + item_key=item_key, + parameters=parameters, + model_version=info.get("modelVersion"), + flow_elements=flow_elements, + ) + + +def _flatten_command_catalog(node: dict[str, Any], category: Optional[str] = None) -> List[CommandCatalogEntry]: + entries: List[CommandCatalogEntry] = [] + node_name = node.get("name") + node_category = node_name if node.get("children") is not None else category + + if "commandId" in node: + entries.append(CommandCatalogEntry( + command_id=node["commandId"], + name=node.get("name", node["commandId"]), + path=node.get("path", ""), + status=node.get("status"), + category=category, + )) + + for child in node.get("children", []): + entries.extend(_flatten_command_catalog(child, node_category)) + + return entries + + +def format_command_catalog(catalog: dict[str, Any], params: Optional[dict] = None) -> List[CommandCatalogEntry]: + return _flatten_command_catalog(catalog) + + +def _parameter_names(parameters: Optional[list]) -> List[str]: + if not parameters: + return [] + return [param.get("name", param.get("parameterName", "")) for param in parameters if param.get("name") or param.get("parameterName")] + + +def _variable_type_label(data: dict[str, Any]) -> str: + data_type = data.get("@type", "Unknown") + if data_type == "StringData": + return "secured_string" if data.get("secured") else "string" + mapping = { + "BooleanData": "boolean", + "IntegerData": "number", + "HandsetData": "device", + "MediaData": "media", + "TableData": "datatable", + } + return mapping.get(data_type, data_type) + + +def format_test_variables(variables: Any, params: Optional[dict] = None) -> List[ScriptVariableSummary]: + if not isinstance(variables, list): + return [] + + formatted: List[ScriptVariableSummary] = [] + for variable in variables: + if not isinstance(variable, dict): + continue + data = variable.get("data", {}) + value = data.get("value") + if data.get("secured") and value: + value = "" + formatted.append(ScriptVariableSummary( + name=data.get("name", ""), + type=_variable_type_label(data), + value=value, + secured=bool(data.get("secured")), + set_at_runtime=variable.get("@type") == "Parameter", + )) + return formatted + + +def format_snapshots_list(response: Any, params: Optional[dict] = None) -> SnapshotListResult: + snapshots = response.get("snapshots", []) if isinstance(response, dict) else response + if not isinstance(snapshots, list): + snapshots = [] + + formatted: List[SnapshotSummary] = [] + for snapshot in snapshots: + if not isinstance(snapshot, dict): + continue + key = snapshot.get("key", "") + creation = snapshot.get("creationTime") or snapshot.get("createdTime") or {} + created_time = creation.get("formatted") if isinstance(creation, dict) else creation + formatted.append(SnapshotSummary( + key=key, + version=snapshot.get("version"), + comment=snapshot.get("comment"), + created_by=snapshot.get("createdBy"), + created_time=created_time, + is_current=key == "", + )) + + formatted.sort(key=lambda entry: (0 if entry.is_current else 1, entry.key)) + + test_id = params.get("test_id") if params else None + return SnapshotListResult( + test_id=test_id, + count=len(formatted), + snapshots=formatted, + notes=[ + "Every POST script save (including save_test without comment) adds a new UUID entry to snapshot history.", + "The '' entry marks the live editable script; use view_test_structure with test_id for its structure.", + "Open historical versions with view_snapshot using a UUID key from this list, not ''.", + "The comment argument on save_test/save_test_as labels '' (UI: Save with comment); it does not skip version creation.", + ], + ) + + +def format_command_definitions(response: Any, params: Optional[dict] = None) -> List[CommandDefinitionSummary]: + definitions = response if isinstance(response, list) else response.get("definitions", response.get("items", [])) + if not isinstance(definitions, list): + definitions = [definitions] if definitions else [] + + summaries: List[CommandDefinitionSummary] = [] + for definition in definitions: + if not isinstance(definition, dict): + continue + command_id = definition.get("commandId", "") + data = definition.get("data", definition) + display = data.get("display", {}) + summaries.append(CommandDefinitionSummary( + command_id=command_id, + name=display.get("name") or definition.get("name") or command_id, + mandatory_parameters=_parameter_names(data.get("mandatoryParameters")), + optional_parameters=_parameter_names(data.get("optionalParameters")), + help_text=display.get("helpText") or data.get("helpText"), + raw=definition, + )) + return summaries diff --git a/formatters/user.py b/formatters/user.py index 50397b3..d8c2aba 100644 --- a/formatters/user.py +++ b/formatters/user.py @@ -1,5 +1,6 @@ from typing import List, Any, Optional +from config.perfecto import get_cloud_app_url from models.user import User @@ -7,13 +8,17 @@ def format_users(users: dict[str, Any], params: Optional[dict] = None) -> List[U first_name = users.get('firstName') or '' last_name = users.get('lastName') or '' display_name = f"{first_name} {last_name}".strip() or users.get("username", "Unknown") - + cloud_name = (params or {}).get("cloud_name") or "" + cloud_url = get_cloud_app_url(cloud_name) if cloud_name else "" + formatted_users = [ User( username=users.get("username") or "unknown", display_name=display_name, first_name=first_name, last_name=last_name, + cloud_name=cloud_name, + cloud_url=cloud_url, ) ] return formatted_users diff --git a/models/ai_scriptless.py b/models/ai_scriptless.py new file mode 100644 index 0000000..97c4820 --- /dev/null +++ b/models/ai_scriptless.py @@ -0,0 +1,72 @@ +from typing import Any, List, Optional + +from pydantic import BaseModel, Field + + +class ScriptFlowElement(BaseModel): + type: str = Field(description="Perfecto @type (Action, Validation, Loop, etc.)") + name: str = Field(description="Display name for the step") + command: Optional[str] = Field(description="Command namespace", default=None) + subcommand: Optional[str] = Field(description="Command subcommand", default=None) + active: bool = Field(description="Whether the step is enabled (not excluded)", default=True) + step_path: Optional[str] = Field( + description="Dot-separated positional path (e.g. 0, 2.0, 5.b0.1); derived from tree position", + default=None, + ) + children: List["ScriptFlowElement"] = Field(description="Nested flow elements", default_factory=list) + + +class ScriptParameter(BaseModel): + name: str = Field(description="Parameter name") + type: str = Field(description="Parameter data type") + + +class ScriptVariableSummary(BaseModel): + name: str = Field(description="Variable name") + type: str = Field(description="Variable type (string, number, boolean, secured_string, etc.)") + value: Optional[Any] = Field(description="Variable value when readable", default=None) + secured: bool = Field(description="Whether the value is secured", default=False) + set_at_runtime: bool = Field(description="True when value is provided at execution time", default=False) + + +class TestStructure(BaseModel): + item_key: str = Field(description="Script itemKey") + parameters: List[ScriptParameter] = Field(description="Test parameters", default_factory=list) + model_version: Optional[str] = Field(description="Script model version", default=None) + flow_elements: List[ScriptFlowElement] = Field(description="Root flow elements", default_factory=list) + + +class CommandCatalogEntry(BaseModel): + command_id: str = Field(description="Command identifier for definitions API") + name: str = Field(description="Display name") + path: str = Field(description="Catalog path") + status: Optional[str] = Field(description="Command status (GA, DRAFT, etc.)", default=None) + category: Optional[str] = Field(description="Parent category name", default=None) + + +class SnapshotSummary(BaseModel): + key: str = Field(description="Snapshot identifier (UUID for history, or '' for the live script marker)") + version: Optional[str] = Field(description="Snapshot version label", default=None) + comment: Optional[str] = Field(description="User comment from save with comment (typically on '' only)", default=None) + created_by: Optional[str] = Field(description="User who created the snapshot", default=None) + created_time: Optional[str] = Field(description="Creation timestamp", default=None) + is_current: bool = Field(description="True when key is '' (live script marker, not openable via view_snapshot)", default=False) + + +class SnapshotListResult(BaseModel): + test_id: Optional[str] = Field(description="Test itemKey queried", default=None) + count: int = Field(description="Number of snapshot entries returned") + snapshots: List[SnapshotSummary] = Field(description="Snapshot entries including '' marker", default_factory=list) + notes: List[str] = Field(description="Behavior notes for interpreting snapshot history", default_factory=list) + + +class CommandDefinitionSummary(BaseModel): + command_id: str = Field(description="Command identifier") + name: str = Field(description="Display name") + mandatory_parameters: List[str] = Field(description="Required parameter names", default_factory=list) + optional_parameters: List[str] = Field(description="Optional parameter names", default_factory=list) + help_text: Optional[str] = Field(description="Help text", default=None) + raw: Optional[dict[str, Any]] = Field(description="Full definition payload", default=None) + + +ScriptFlowElement.model_rebuild() \ No newline at end of file diff --git a/models/user.py b/models/user.py index 68b2c59..46c7bf6 100644 --- a/models/user.py +++ b/models/user.py @@ -5,4 +5,6 @@ class User(BaseModel): username: str = Field(description="The unique identifier for the user") display_name: str = Field(description="Display name of the user") first_name: str = Field(description="First name of the user") - last_name: str = Field(description="Last name of the user") \ No newline at end of file + last_name: str = Field(description="Last name of the user") + cloud_name: str = Field(description="Perfecto cloud name from MCP configuration (PERFECTO_CLOUD_NAME)") + cloud_url: str = Field(description="Perfecto cloud portal URL (https://{cloud_name}.app.perfectomobile.com)") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f7764f6..0285ce7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,3 +27,8 @@ include = ["tools", "config", "models", "formatters", "resources"] [tool.setuptools.package-data] "resources" = ["*.png"] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f1d39c6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import pytest + +from config.token import PerfectoToken + + +@pytest.fixture +def perfecto_token() -> PerfectoToken: + return PerfectoToken("test-token", "demo") diff --git a/tests/test_ai_scriptless_commands.py b/tests/test_ai_scriptless_commands.py new file mode 100644 index 0000000..6370716 --- /dev/null +++ b/tests/test_ai_scriptless_commands.py @@ -0,0 +1,46 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from formatters.ai_scriptless import PRIMARY_AI_COMMAND_IDS +from tools.ai_scriptless.commands import COMMAND_SPECS, get_command_spec + + +class TestCommandSpecRegistry: + def test_known_commands_are_registered(self): + for command_id in ( + *PRIMARY_AI_COMMAND_IDS, + "comment", + "wait", + "handset_ready", + "touch_tap", + "checkpoint_text", + "checkpoint_image", + ): + assert command_id in COMMAND_SPECS + + def test_validation_commands_use_ignore_error_policy(self): + for command_id in ("ai_validation", "checkpoint_text", "checkpoint_image"): + assert get_command_spec(command_id).error_policy == "IGNORE" + + def test_unknown_command_falls_back_to_handset_dut(self): + spec = get_command_spec("custom_unknown_step") + assert spec.element_type == "Action" + assert spec.default_arguments_merged() == {"handsetId": ("VARIABLE", "DUT")} + + def test_wait_spec_normalizes_duration_alias(self): + spec = get_command_spec("wait") + normalized = spec.normalize_argument_names({"waitDuration": "5"}) + assert normalized == {"duration": "5"} diff --git a/tests/test_ai_scriptless_flow_element_counts.py b/tests/test_ai_scriptless_flow_element_counts.py new file mode 100644 index 0000000..ef78919 --- /dev/null +++ b/tests/test_ai_scriptless_flow_element_counts.py @@ -0,0 +1,122 @@ +""" +numOfFlowElements on nested containers (Branch, LogicalStep, Loop, IfStatement). + +Perfecto wire format (observed on beta API): +- Script / LogicalStep / Loop / Branch: len(flowElements) +- IfStatement: 3 + direct children across all branches (not recursive) +- Leaf Action/Validation: no numOfFlowElements field +""" + +from __future__ import annotations + +from tools.ai_scriptless.elements import ( + build_flow_element, + build_if_statement, + build_logical_step, + build_loop, + new_empty_script, +) +from tools.ai_scriptless.tree import ( + delete_element_by_path, + insert_flow_element, + update_flow_element_counts, +) + + +def _comment(text: str) -> dict: + return build_flow_element("comment", {"text": text}) + + +class TestNestedFlowElementCounts: + def test_empty_if_statement_counts(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + update_flow_element_counts(script) + ifs = script["flowElements"][0] + assert ifs["numOfFlowElements"] == 3 + assert ifs["branches"][0]["numOfFlowElements"] == 0 + assert ifs["branches"][0]["empty"] is True + + def test_insert_into_then_updates_branch_and_if_counts(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + insert_flow_element(script, _comment("a"), parent_path="0.b0") + ifs = script["flowElements"][0] + assert ifs["branches"][0]["numOfFlowElements"] == 1 + assert ifs["branches"][0]["empty"] is False + assert ifs["numOfFlowElements"] == 4 + + def test_insert_into_else_increments_if_count(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + insert_flow_element(script, _comment("else"), parent_path="0.b1") + ifs = script["flowElements"][0] + assert ifs["branches"][1]["numOfFlowElements"] == 1 + assert ifs["numOfFlowElements"] == 4 + + def test_two_direct_then_children_gives_if_num_five(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + insert_flow_element(script, _comment("a"), parent_path="0.b0") + insert_flow_element(script, _comment("b"), parent_path="0.b0") + ifs = script["flowElements"][0] + assert ifs["branches"][0]["numOfFlowElements"] == 2 + assert ifs["numOfFlowElements"] == 5 + + def test_nested_if_counts_direct_child_only_not_recursive(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement("outer", "Outer")] + inner = build_if_statement("inner", "Inner") + insert_flow_element(script, inner, parent_path="0.b0") + insert_flow_element(script, _comment("deep"), parent_path="0.b0.0.b0") + outer = script["flowElements"][0] + inner_ifs = outer["branches"][0]["flowElements"][0] + assert outer["numOfFlowElements"] == 4 + assert outer["branches"][0]["numOfFlowElements"] == 1 + assert inner_ifs["numOfFlowElements"] == 4 + assert inner_ifs["branches"][0]["numOfFlowElements"] == 1 + + def test_logical_step_count_matches_children(self): + script = new_empty_script() + group = build_logical_step("G") + group["flowElements"] = [_comment("a"), build_loop(2)] + script["flowElements"] = [group] + update_flow_element_counts(script) + assert group["numOfFlowElements"] == 2 + assert group["flowElements"][1]["numOfFlowElements"] == 0 + + def test_delete_from_then_decrements_counts(self): + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + insert_flow_element(script, _comment("a"), parent_path="0.b0") + insert_flow_element(script, _comment("b"), parent_path="0.b0") + delete_element_by_path(script, "0.b0.0") + ifs = script["flowElements"][0] + assert ifs["branches"][0]["numOfFlowElements"] == 1 + assert ifs["numOfFlowElements"] == 4 + + def test_parent_path_insert_updates_logical_step_count(self): + script = new_empty_script() + script["flowElements"] = [build_logical_step("G")] + insert_flow_element(script, _comment("inside"), parent_path="0") + group = script["flowElements"][0] + assert group["numOfFlowElements"] == 1 + + def test_leaf_actions_have_no_num_of_flow_elements(self): + script = new_empty_script() + insert_flow_element(script, _comment("x")) + leaf = script["flowElements"][0] + assert "numOfFlowElements" not in leaf or leaf.get("numOfFlowElements") is None + + def test_persist_prep_matches_perfecto_formula(self): + """Simulate wrong counts; update_flow_element_counts repairs before persist.""" + script = new_empty_script() + script["flowElements"] = [build_if_statement()] + insert_flow_element(script, _comment("a"), parent_path="0.b0") + insert_flow_element(script, _comment("b"), parent_path="0.b0") + ifs = script["flowElements"][0] + ifs["numOfFlowElements"] = 3 + ifs["branches"][0]["numOfFlowElements"] = 0 + update_flow_element_counts(script) + assert ifs["numOfFlowElements"] == 5 + assert ifs["branches"][0]["numOfFlowElements"] == 2 diff --git a/tests/test_ai_scriptless_formatters.py b/tests/test_ai_scriptless_formatters.py new file mode 100644 index 0000000..50005d3 --- /dev/null +++ b/tests/test_ai_scriptless_formatters.py @@ -0,0 +1,232 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from formatters.ai_scriptless import ( + _command_id, + command_selection_policy_info, + format_ai_scriptless_tests, + format_ai_scriptless_tests_filter_values, + format_command_catalog, + format_command_definitions, + format_snapshots_list, + format_test_structure, + format_test_variables, +) +from tools.ai_scriptless_script import ( + build_flow_element, + build_if_statement, + build_logical_step, + new_empty_script, +) + + +def _tree_api_payload() -> dict: + script = new_empty_script() + tap = build_flow_element("ai_user-action", {"action": "Tap login"}) + tap["uuid"] = "ignored-in-formatter" + group = build_logical_step("Setup") + group["flowElements"] = [build_flow_element("comment", {"text": "inside group"})] + condition = build_if_statement("x == 1", "Check x") + condition["branches"][0]["flowElements"] = [ + build_flow_element("ai_validation", {"validation": "OK"}), + ] + script["flowElements"] = [tap, group, condition] + return { + "script": script, + "commandDefinitions": [ + { + "commandId": "comment", + "name": "Comment", + "data": {"display": {"name": "Comment step"}}, + }, + ], + } + + +def _tests_tree_response() -> dict: + return { + "items": [ + { + "visibility": "PRIVATE", + "items": [ + { + "type": "CONTAINER", + "items": [ + { + "type": "SIMPLE", + "key": "PRIVATE:My Folder/Login.xml", + "name": "Login.xml", + "createdBy": "alice", + "modifiedBy": "bob", + "creationTime": {"formatted": "2024-01-01"}, + "modificationTime": {"formatted": "2024-01-02"}, + }, + ], + }, + ], + }, + ], + } + + +class TestCommandSelectionPolicy: + def test_includes_primary_ai_command_ids(self): + lines = command_selection_policy_info() + assert any("ai_user-action" in line for line in lines) + assert any("ai_validation" in line for line in lines) + assert any("ai_visual-comparison" in line for line in lines) + + +class TestFormatTestStructure: + def test_formats_nested_flow_with_step_paths(self): + structure = format_test_structure( + _tree_api_payload(), + params={"item_key": "PRIVATE:My Folder/Login.xml"}, + ) + assert structure.item_key == "PRIVATE:My Folder/Login.xml" + assert structure.parameters[0].name == "DUT" + assert structure.flow_elements[0].name == "Tap login" + assert structure.flow_elements[0].step_path == "0" + assert structure.flow_elements[1].name == "Setup" + assert structure.flow_elements[1].children[0].step_path == "1.0" + assert structure.flow_elements[2].name == "Condition (x == 1)" + then_branch = structure.flow_elements[2].children[0] + assert then_branch.type == "Branch" + assert then_branch.step_path == "2.b0" + assert then_branch.children[0].step_path == "2.b0.0" + + def test_uses_definition_display_name_for_non_ai_commands(self): + structure = format_test_structure(_tree_api_payload()) + comment_step = structure.flow_elements[1].children[0] + assert comment_step.name == "Comment step" + + +class TestFormatCommandCatalog: + def test_flattens_nested_catalog(self): + catalog = { + "name": "Root", + "children": [ + { + "name": "AI", + "children": [ + { + "commandId": "ai_user-action", + "name": "User action", + "path": "/ai/user-action", + "status": "GA", + }, + ], + }, + ], + } + entries = format_command_catalog(catalog) + assert len(entries) == 1 + assert entries[0].command_id == "ai_user-action" + assert entries[0].category == "AI" + + +class TestFormatTestVariables: + def test_formats_secured_and_runtime_variables(self): + variables = [ + { + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "plain", + "value": "hello", + "secured": False, + }, + }, + { + "@type": "Parameter", + "data": { + "@type": "StringData", + "name": "secret", + "value": "hidden", + "secured": True, + }, + }, + ] + formatted = format_test_variables(variables) + assert formatted[0].name == "plain" + assert formatted[0].value == "hello" + assert formatted[0].set_at_runtime is False + assert formatted[1].value == "" + assert formatted[1].set_at_runtime is True + + def test_returns_empty_list_for_invalid_input(self): + assert format_test_variables(None) == [] + assert format_test_variables("bad") == [] + + +class TestFormatSnapshotsList: + def test_marks_current_entry_and_sorts_first(self): + result = format_snapshots_list( + { + "snapshots": [ + {"key": "uuid-1", "createdBy": "alice"}, + {"key": "", "comment": "latest"}, + ], + }, + params={"test_id": "PRIVATE:Folder/Test.xml"}, + ) + assert result.test_id == "PRIVATE:Folder/Test.xml" + assert result.count == 2 + assert result.snapshots[0].is_current is True + assert result.snapshots[0].key == "" + assert len(result.notes) >= 1 + + +class TestFormatCommandDefinitions: + def test_parses_definition_payload(self): + definitions = format_command_definitions({ + "definitions": [ + { + "commandId": "ai_validation", + "data": { + "display": {"name": "AI validation", "helpText": "Assert with AI"}, + "mandatoryParameters": [{"name": "validation"}], + "optionalParameters": [{"name": "handsetId"}], + }, + }, + ], + }) + assert definitions[0].command_id == "ai_validation" + assert definitions[0].name == "AI validation" + assert definitions[0].mandatory_parameters == ["validation"] + assert definitions[0].optional_parameters == ["handsetId"] + + +class TestFormatTestsTree: + def test_extracts_filter_values(self): + values = format_ai_scriptless_tests_filter_values(_tests_tree_response()) + assert "Login" in values["test_name"] + assert "alice" in values["owner_list"] + assert "bob" in values["owner_list"] + + def test_applies_pagination_and_visibility_filter(self): + formatted = format_ai_scriptless_tests( + _tests_tree_response(), + params={"page_size": 10, "skip": 0, "filters": {"visibility": "PRIVATE"}}, + ) + assert len(formatted) == 1 + assert "PRIVATE:My Folder/Login.xml" in formatted[0] + assert "name:Login" in formatted[0] + + +class TestFormatterCommandId: + def test_replaces_slashes_in_subcommand(self): + assert _command_id("webpage", "element/click") == "webpage_element_click" diff --git a/tests/test_ai_scriptless_if_statement_tree.py b/tests/test_ai_scriptless_if_statement_tree.py new file mode 100644 index 0000000..9611466 --- /dev/null +++ b/tests/test_ai_scriptless_if_statement_tree.py @@ -0,0 +1,341 @@ +""" +IfStatement tree contract: branches[] vs thenClause/elseClause. + +Documents failure modes without normalization and expected behavior after +normalize_if_statement_aliases + tree mutations. +""" + +from __future__ import annotations + +import asyncio +import copy + +import pytest + +from formatters.ai_scriptless import _format_root_flow_elements, format_test_structure +from models.result import BaseResult +from tools.ai_scriptless import persistence +from tools.ai_scriptless.elements import ( + build_branch, + build_flow_element, + build_if_statement, + build_logical_step, + new_empty_script, + normalize_if_statement_aliases, + strip_non_api_script_fields, +) +from tools.ai_scriptless.persistence import _persist_script +from tools.ai_scriptless.tree import ( + delete_element_by_path, + find_element_by_path, + find_step_path_for_element, + insert_flow_element, + move_element_by_path, +) + + +def _comment(text: str) -> dict: + return build_flow_element("comment", {"text": text}) + + +def _api_style_if_statement( + *, + then_in_branch: list | None = None, + then_in_clause: list | None = None, + else_in_branch: list | None = None, + else_in_clause: list | None = None, + label: str = "API", +) -> dict: + """Perfecto-like payload: branches and clauses are separate objects.""" + then_branch = build_branch("THEN") + else_branch = build_branch("ELSE") + then_clause = build_branch("THEN") + else_clause = build_branch("ELSE") + if then_in_branch is not None: + then_branch["flowElements"] = list(then_in_branch) + if then_in_clause is not None: + then_clause["flowElements"] = list(then_in_clause) + if else_in_branch is not None: + else_branch["flowElements"] = list(else_in_branch) + if else_in_clause is not None: + else_clause["flowElements"] = list(else_in_clause) + return { + "@type": "IfStatement", + "branches": [then_branch, else_branch], + "thenClause": then_clause, + "elseClause": else_clause, + "label": label, + "active": True, + } + + +def _script_with_if_statement(ifs: dict) -> dict: + script = new_empty_script() + script["flowElements"] = [ifs] + return script + + +def _then_child_commands(ifs: dict) -> list[str | None]: + return [ + el.get("command") + for el in ifs["branches"][0].get("flowElements", []) + ] + + +def _clause_child_commands(ifs: dict) -> list[str | None]: + return [ + el.get("command") + for el in ifs["thenClause"].get("flowElements", []) + ] + + +class TestIfStatementBuilderContract: + def test_new_builder_aliases_clauses_to_branches(self): + ifs = build_if_statement("x", "Check") + assert ifs["thenClause"] is ifs["branches"][0] + assert ifs["elseClause"] is ifs["branches"][1] + + def test_mutation_through_branch_updates_clause(self): + ifs = build_if_statement() + ifs["branches"][0]["flowElements"].append(_comment("a")) + assert len(ifs["thenClause"]["flowElements"]) == 1 + + +class TestIfStatementWithoutNormalize: + """Documents broken or inconsistent behavior when API payload is not normalized.""" + + def test_insert_into_then_branch_does_not_update_then_clause(self): + ifs = _api_style_if_statement() + script = _script_with_if_statement(ifs) + insert_flow_element(script, _comment("orphan"), parent_path="0.b0") + assert len(ifs["branches"][0]["flowElements"]) == 1 + assert len(ifs["thenClause"]["flowElements"]) == 0 + + def test_formatter_reads_branches_not_clauses(self): + ifs = _api_style_if_statement( + then_in_clause=[_comment("only in clause")], + ) + script = _script_with_if_statement(ifs) + formatted = _format_root_flow_elements(script["flowElements"], {}) + then_branch = formatted[0].children[0] + assert then_branch.children == [] + + def test_find_step_path_ignores_then_clause_object(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("c")]) + script = _script_with_if_statement(ifs) + assert find_step_path_for_element(script, ifs["thenClause"]) is None + + def test_deepcopy_breaks_nothing_if_already_aliased(self): + ifs = build_if_statement() + ifs["branches"][0]["flowElements"].append(_comment("x")) + copied = copy.deepcopy(_script_with_if_statement(ifs)) + condition = copied["flowElements"][0] + assert condition["thenClause"] is condition["branches"][0] + assert len(condition["thenClause"]["flowElements"]) == 1 + + def test_deepcopy_keeps_duplicate_trees_separate(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("c")]) + copied = copy.deepcopy(_script_with_if_statement(ifs)) + condition = copied["flowElements"][0] + assert condition["thenClause"] is not condition["branches"][0] + assert len(condition["branches"][0]["flowElements"]) == 0 + assert len(condition["thenClause"]["flowElements"]) == 1 + + +class TestNormalizeIfStatementAliases: + def test_syncs_children_from_then_clause_when_branch_empty(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("from clause")]) + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + assert ifs["thenClause"] is ifs["branches"][0] + assert _then_child_commands(ifs) == ["comment"] + + def test_syncs_children_from_else_clause_when_branch_empty(self): + ifs = _api_style_if_statement(else_in_clause=[_comment("else")]) + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + assert ifs["elseClause"] is ifs["branches"][1] + assert len(ifs["branches"][1]["flowElements"]) == 1 + + def test_prefers_branch_when_both_have_same_length_different_children(self): + ifs = _api_style_if_statement( + then_in_branch=[_comment("branch wins")], + then_in_clause=[_comment("clause loses")], + ) + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + texts = [ + el["arguments"][0]["data"]["value"] + for el in ifs["branches"][0]["flowElements"] + ] + assert texts == ["branch wins"] + assert ifs["thenClause"] is ifs["branches"][0] + + def test_prefers_clause_when_it_has_more_children(self): + ifs = _api_style_if_statement( + then_in_branch=[_comment("one")], + then_in_clause=[_comment("one"), _comment("two")], + ) + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + assert len(ifs["branches"][0]["flowElements"]) == 2 + assert ifs["thenClause"] is ifs["branches"][0] + + def test_nested_if_inside_then_branch(self): + inner = _api_style_if_statement(then_in_clause=[_comment("inner")]) + outer = build_if_statement("outer", "Outer") + outer["branches"][0]["flowElements"] = [inner] + outer["thenClause"] = build_branch("THEN") + script = _script_with_if_statement(outer) + normalize_if_statement_aliases(script) + nested = outer["branches"][0]["flowElements"][0] + assert nested["thenClause"] is nested["branches"][0] + assert len(nested["branches"][0]["flowElements"]) == 1 + + def test_nested_if_inside_logical_step(self): + inner = _api_style_if_statement(then_in_clause=[_comment("deep")]) + group = build_logical_step("G") + group["flowElements"] = [inner] + script = new_empty_script() + script["flowElements"] = [group] + normalize_if_statement_aliases(script) + nested = group["flowElements"][0] + assert nested["thenClause"] is nested["branches"][0] + + +class TestIfStatementTreeAfterNormalize: + @pytest.fixture + def script(self) -> dict: + ifs = _api_style_if_statement() + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + return script + + def test_insert_into_then_updates_clause(self, script: dict): + insert_flow_element(script, _comment("step"), parent_path="0.b0") + ifs = script["flowElements"][0] + assert _then_child_commands(ifs) == ["comment"] + assert _clause_child_commands(ifs) == ["comment"] + + def test_delete_from_then_clears_clause(self, script: dict): + insert_flow_element(script, _comment("step"), parent_path="0.b0") + delete_element_by_path(script, "0.b0.0") + ifs = script["flowElements"][0] + assert _then_child_commands(ifs) == [] + assert _clause_child_commands(ifs) == [] + + def test_move_from_then_to_else(self, script: dict): + insert_flow_element(script, _comment("movable"), parent_path="0.b0") + move_element_by_path(script, "0.b0.0", parent_path="0.b1") + ifs = script["flowElements"][0] + assert _then_child_commands(ifs) == [] + assert [el.get("command") for el in ifs["branches"][1]["flowElements"]] == ["comment"] + assert ifs["elseClause"] is ifs["branches"][1] + + def test_find_paths_after_insert(self, script: dict): + insert_flow_element(script, _comment("step"), parent_path="0.b0") + ifs = script["flowElements"][0] + child = ifs["branches"][0]["flowElements"][0] + assert find_step_path_for_element(script, child) == "0.b0.0" + assert find_step_path_for_element(script, ifs["branches"][0]) == "0.b0" + assert find_step_path_for_element(script, ifs["thenClause"]) == "0.b0" + + def test_find_element_round_trip(self, script: dict): + insert_flow_element(script, _comment("step"), parent_path="0.b0") + located = find_element_by_path(script, "0.b0.0") + assert located is not None + _, _, element = located + assert element["command"] == "comment" + + def test_insert_nested_condition_in_then(self, script: dict): + inner = build_if_statement("inner", "Inner") + insert_flow_element(script, inner, parent_path="0.b0") + nested = script["flowElements"][0]["branches"][0]["flowElements"][0] + assert nested["thenClause"] is nested["branches"][0] + insert_flow_element(script, _comment("deep"), parent_path="0.b0.0.b0") + assert len(nested["branches"][0]["flowElements"]) == 1 + + +class TestIfStatementPersistPreparation: + def test_persist_deepcopy_keeps_alias_and_children(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + if endpoint and "draft-management" in endpoint: + return BaseResult(result={"key": "d1"}) + captured["save_script"] = kwargs["json"]["script"] + return BaseResult(result={"status": "success"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + ifs = _api_style_if_statement() + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + insert_flow_element(script, _comment("persist me"), parent_path="0.b0") + + asyncio.run( + _persist_script(perfecto_token, "PRIVATE:F/T.xml", script, script) + ) + + saved = captured["save_script"]["flowElements"][0] + assert len(saved["branches"][0]["flowElements"]) == 1 + assert len(saved["thenClause"]["flowElements"]) == 1 + assert saved["branches"][0]["flowElements"][0]["command"] == "comment" + + def test_persist_renormalizes_branch_only_edits(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + if endpoint and "draft-management" in endpoint: + return BaseResult(result={"key": "d1"}) + captured["save_script"] = kwargs["json"]["script"] + return BaseResult(result={"status": "success"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + ifs = _api_style_if_statement() + script = _script_with_if_statement(ifs) + insert_flow_element(script, _comment("branch only"), parent_path="0.b0") + assert len(ifs["thenClause"]["flowElements"]) == 0 + + asyncio.run( + _persist_script(perfecto_token, "PRIVATE:F/T.xml", script, script) + ) + + saved = captured["save_script"]["flowElements"][0] + assert len(saved["thenClause"]["flowElements"]) == 1 + assert saved["thenClause"] is saved["branches"][0] + + def test_strip_non_api_fields_with_aliased_branches_is_safe(self): + ifs = build_if_statement() + ifs["branches"][0]["uuid"] = "remove-me" + ifs["branches"][0]["flowElements"] = [_comment("x")] + script = _script_with_if_statement(ifs) + strip_non_api_script_fields(script) + assert "uuid" not in ifs["branches"][0] + assert len(ifs["thenClause"]["flowElements"]) == 1 + + +class TestIfStatementFormatterAfterNormalize: + def test_formatter_shows_then_children_after_normalize(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("visible")]) + script = _script_with_if_statement(ifs) + normalize_if_statement_aliases(script) + structure = format_test_structure({"script": script}, {"item_key": "PRIVATE:F/T.xml"}) + then_branch = structure.flow_elements[0].children[0] + assert len(then_branch.children) == 1 + assert then_branch.children[0].command == "comment" + + def test_formatter_on_raw_api_payload_without_normalize_hides_clause_only_children(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("hidden")]) + script = _script_with_if_statement(ifs) + formatted = _format_root_flow_elements(script["flowElements"], {}) + then_branch = formatted[0].children[0] + assert then_branch.children == [] + + def test_format_test_structure_normalizes_before_display(self): + ifs = _api_style_if_statement(then_in_clause=[_comment("visible via normalize")]) + script = _script_with_if_statement(ifs) + structure = format_test_structure({"script": script}, {"item_key": "PRIVATE:F/T.xml"}) + then_branch = structure.flow_elements[0].children[0] + assert len(then_branch.children) == 1 diff --git a/tests/test_ai_scriptless_manager.py b/tests/test_ai_scriptless_manager.py new file mode 100644 index 0000000..d2e19f7 --- /dev/null +++ b/tests/test_ai_scriptless_manager.py @@ -0,0 +1,1438 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import asyncio +import copy +import json + +import httpx +import pytest + +from config.perfecto import SUPPORT_MESSAGE +from models.result import BaseResult +from tools import ai_scriptless_manager +from tools.ai_scriptless.elements import ( + build_flow_element, + build_if_statement, + build_logical_step, + new_empty_script, +) +from tools.ai_scriptless_manager import AiScriptlessManager, STEP_PATH_REFRESH_NOTES + +TEST_ID = "PRIVATE:Folder/Test.xml" + + +def _tests_tree_response() -> dict: + return { + "items": [ + { + "visibility": "PRIVATE", + "items": [ + { + "type": "CONTAINER", + "items": [ + { + "type": "SIMPLE", + "key": "PRIVATE:My Folder/Login.xml", + "name": "Login.xml", + "createdBy": "alice", + "modifiedBy": "bob", + "creationTime": {"formatted": "2024-01-01"}, + "modificationTime": {"formatted": "2024-01-02"}, + }, + ], + }, + ], + }, + ], + } + + +def _script_payload() -> dict: + script = new_empty_script() + script["flowElements"] = [build_flow_element("wait")] + return {"script": script, "commandDefinitions": []} + + +def _script_with_logical_group() -> dict: + group = build_logical_step("Setup") + group["flowElements"] = [] + script = new_empty_script() + script["flowElements"] = [group] + script["numOfFlowElements"] = 1 + return script + + +def _script_with_steps(*command_ids: str) -> dict: + script = new_empty_script() + script["flowElements"] = [build_flow_element(command_id) for command_id in command_ids] + script["numOfFlowElements"] = len(script["flowElements"]) + return script + + +def _mock_load_and_mutate(monkeypatch, initial_script: dict | None = None, captured: dict | None = None): + base_script = copy.deepcopy(initial_script or new_empty_script()) + + async def fake_load_and_mutate(_token, test_id, mutator, snapshot_comment=None): + script = copy.deepcopy(base_script) + try: + mutator(script) + except ValueError as exc: + return BaseResult(error=str(exc)) + if captured is not None: + captured["script"] = script + captured["test_id"] = test_id + captured["snapshot_comment"] = snapshot_comment + return BaseResult(result={"item_key": test_id, "draft_key": "draft-1", "status": "ok"}) + + monkeypatch.setattr(ai_scriptless_manager, "load_and_mutate", fake_load_and_mutate) + + +def _assert_step_path_notes(result: BaseResult) -> None: + assert result.error is None + for note in STEP_PATH_REFRESH_NOTES: + assert note in result.result["notes"] + + +class TestExecuteTestDeviceMapping: + def test_real_device_accepts_snake_case_device_id(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-1"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "real", + {"device_id": "DEVICE-123"}, + )) + + assert result.error is None + assert captured["json"]["params"]["DUT"] == "DEVICE-123" + assert captured["json"]["testKey"] == "PRIVATE:Folder/Test.xml" + + def test_real_device_accepts_perfecto_device_id_key(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-1"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "real", + {"deviceId": "DEVICE-456"}, + )) + + assert result.error is None + assert captured["json"]["params"]["DUT"] == "DEVICE-456" + + def test_real_device_requires_device_id(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "real", + {}, + )) + assert "device_id could not be found" in result.error + + def test_virtual_device_serializes_capabilities_json(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-2"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "virtual", + { + "platform_name": "Android", + "manufacturer": "Google", + "model": "Pixel 8", + "platform_version": "14", + }, + )) + + assert result.error is None + dut = json.loads(captured["json"]["params"]["DUT"]) + assert dut["platformName"] == "Android" + assert dut["manufacturer"] == "Google" + + def test_virtual_device_sends_null_for_unspecified_capabilities(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-3"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "virtual", + {"platform_name": "Android"}, + )) + + assert result.error is None + dut = json.loads(captured["json"]["params"]["DUT"]) + assert dut["platformName"] == "Android" + assert dut["manufacturer"] is None + assert dut["model"] is None + + def test_invalid_device_type_returns_error(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + "PRIVATE:Folder/Test.xml", + "unknown", + {"device_id": "x"}, + )) + assert result.error == "Invalid device_type or device_under_test value." + + def test_desktop_device_serializes_capabilities_json(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-desktop"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + TEST_ID, + "desktop", + { + "platform_name": "Windows", + "platform_version": "11", + "browser_name": "Chrome", + "browser_version": "120", + "resolution": "1920x1080", + "location": "US", + }, + )) + + assert result.error is None + dut = json.loads(captured["json"]["params"]["DUT"]) + assert dut["platformName"] == "Windows" + assert dut["browserName"] == "Chrome" + assert dut["resolution"] == "1920x1080" + + def test_desktop_device_sends_null_for_unspecified_capabilities(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-desktop-partial"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.execute_test( + TEST_ID, + "desktop", + {"platform_name": "Windows"}, + )) + + assert result.error is None + dut = json.loads(captured["json"]["params"]["DUT"]) + assert dut["platformName"] == "Windows" + assert dut["browserName"] is None + assert dut["location"] is None + + +class TestListAndReadOperations: + def test_list_tests_returns_paginated_items(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request( + _token, + method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + captured["endpoint"] = endpoint + from formatters.ai_scriptless import format_ai_scriptless_tests + formatted = format_ai_scriptless_tests( + _tests_tree_response(), + result_formatter_params, + ) + return BaseResult(result=formatted) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + monkeypatch.setattr( + ai_scriptless_manager.perfecto, + "get_ai_scriptless_api_url", + lambda _cloud: "https://demo.app.perfectomobile.com/perfectomobile/ai-scriptless/api", + ) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_tests({"page_index": 1, "visibility": "PRIVATE"})) + + assert result.error is None + assert result.result.count == 1 + assert "PRIVATE:My Folder/Login.xml" in result.result.items[0] + assert result.info is not None + assert captured["endpoint"].endswith("/scripts/tree") + + def test_list_tests_propagates_api_error(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **_kwargs): + return BaseResult(error="tree unavailable") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_tests({})) + assert result.error == "tree unavailable" + + def test_list_filter_values_returns_requested_filters(self, perfecto_token, monkeypatch): + async def fake_api_request( + _token, + _method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + from formatters.ai_scriptless import format_ai_scriptless_tests_filter_values + formatted = format_ai_scriptless_tests_filter_values(_tests_tree_response()) + return BaseResult(result=formatted) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_filter_values(["test_name", "owner_list"])) + + assert result.error is None + assert "Login" in result.result["test_name"] + assert "alice" in result.result["owner_list"] + + def test_list_filter_values_rejects_unknown_filter(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **kwargs): + from formatters.ai_scriptless import format_ai_scriptless_tests_filter_values + return BaseResult(result=format_ai_scriptless_tests_filter_values(_tests_tree_response())) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_filter_values(["bad_filter"])) + + assert "invalid filter_names" in result.error + assert result.warning is not None + + def test_list_commands_appends_selection_policy(self, perfecto_token, monkeypatch): + catalog = { + "name": "Root", + "children": [ + { + "commandId": "ai_user-action", + "name": "User action", + "path": "/ai/user-action", + }, + ], + } + + async def fake_api_request( + _token, + _method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + from formatters.ai_scriptless import format_command_catalog + return BaseResult(result=format_command_catalog(catalog)) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_commands()) + + assert result.error is None + assert result.result[0].command_id == "ai_user-action" + assert any("ai_user-action" in line for line in result.info) + + def test_list_commands_checkpoint_query_param(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["endpoint"] = endpoint + return BaseResult(result=[]) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_commands(checkpoint=True)) + + assert result.error is None + assert "checkpoint=true" in captured["endpoint"] + + def test_view_test_structure_formats_script(self, perfecto_token, monkeypatch): + async def fake_api_request( + _token, + _method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + from formatters.ai_scriptless import format_test_structure + return BaseResult(result=format_test_structure( + _script_payload(), + result_formatter_params, + )) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.view_test_structure(TEST_ID)) + + assert result.error is None + assert result.result.item_key == TEST_ID + assert result.result.flow_elements[0].step_path == "0" + assert result.info is not None + + def test_view_test_structure_requires_test_id(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.view_test_structure("")) + assert result.error == "test_id is required (itemKey from list_tests)" + + def test_get_command_definitions_posts_ids(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + from formatters.ai_scriptless import format_command_definitions + return BaseResult(result=format_command_definitions({ + "definitions": [{ + "commandId": "wait", + "data": {"display": {"name": "Wait"}, "mandatoryParameters": [], "optionalParameters": []}, + }], + })) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.get_command_definitions(["wait"])) + + assert result.error is None + assert captured["json"]["commandIds"] == ["wait"] + assert result.result[0].command_id == "wait" + + def test_view_snapshot_fetches_historical_snapshot(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request( + _token, + method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + captured["endpoint"] = endpoint + from formatters.ai_scriptless import format_test_structure + return BaseResult(result=format_test_structure( + _script_payload(), + result_formatter_params, + )) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.view_snapshot("PRIVATE:Folder/Test.xml@uuid-1")) + + assert result.error is None + assert "snapshots?itemKey=" in captured["endpoint"] + assert result.result.flow_elements[0].step_path == "0" + + +class TestManagerValidation: + def test_add_command_requires_test_id(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command("", "ai_user-action")) + assert result.error == "test_id is required" + + def test_add_command_requires_command_id(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command("PRIVATE:Folder/Test.xml", "")) + assert "command_id is required" in result.error + + def test_view_snapshot_rejects_current_marker(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.view_snapshot("")) + assert "not a historical snapshot" in result.error + + def test_modify_command_requires_arguments(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.modify_command(TEST_ID, "0", {})) + assert result.error == "arguments is required" + + def test_save_test_requires_test_id(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.save_test("")) + assert result.error == "test_id is required" + + def test_create_test_requires_name(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.create_test("")) + assert result.error == "name is required" + + def test_save_test_as_requires_name(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.save_test_as(TEST_ID, "")) + assert result.error == "name is required" + + +class TestCommandMutations: + def test_add_command_inserts_and_returns_step_path(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command( + TEST_ID, + "wait", + arguments={"duration": "3"}, + )) + + _assert_step_path_notes(result) + assert result.result["command_id"] == "wait" + assert result.result["step_path"] == "0" + assert len(captured["script"]["flowElements"]) == 1 + assert captured["script"]["flowElements"][0]["command"] == "wait" + + def test_modify_command_updates_arguments(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait"), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.modify_command( + TEST_ID, + "0", + {"duration": "5"}, + )) + + _assert_step_path_notes(result) + args = { + a["name"]: a["data"]["value"] + for a in captured["script"]["flowElements"][0]["arguments"] + } + assert args["duration"] == "5" + + def test_modify_command_returns_error_for_missing_step_path(self, perfecto_token, monkeypatch): + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait")) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.modify_command(TEST_ID, "9", {"duration": "1"})) + + assert result.error == "step_path not found: 9" + + def test_delete_command_removes_step(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait", "comment"), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.delete_command(TEST_ID, "0")) + + _assert_step_path_notes(result) + assert len(captured["script"]["flowElements"]) == 1 + assert captured["script"]["flowElements"][0]["command"] == "comment" + + def test_set_command_enabled(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait"), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.set_command_enabled(TEST_ID, "0", False)) + + _assert_step_path_notes(result) + assert result.result["active"] is False + assert captured["script"]["flowElements"][0]["active"] is False + + def test_move_command_requires_target_path(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_command(TEST_ID, "0")) + assert result.error == "after_path or parent_path is required" + + def test_move_command_reorders_within_root(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait", "comment"), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_command(TEST_ID, "0", after_path="0")) + + _assert_step_path_notes(result) + commands = [e["command"] for e in captured["script"]["flowElements"]] + assert commands == ["comment", "wait"] + + def test_add_command_after_path(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait", "comment"), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command( + TEST_ID, + "ai_user-action", + arguments={"action": "Tap"}, + after_path="0", + )) + + _assert_step_path_notes(result) + assert result.result["step_path"] == "1" + assert result.result["command_id"] == "ai_user-action" + assert len(captured["script"]["flowElements"]) == 3 + assert captured["script"]["flowElements"][1]["command"] == "ai" + + def test_add_command_inside_logical_step(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_logical_group(), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command( + TEST_ID, + "comment", + arguments={"text": "inside"}, + parent_path="0", + )) + + _assert_step_path_notes(result) + nested = captured["script"]["flowElements"][0]["flowElements"] + assert len(nested) == 1 + assert nested[0]["command"] == "comment" + assert result.result["step_path"] == "0.0" + + def test_add_command_propagates_load_and_mutate_error(self, perfecto_token, monkeypatch): + async def fake_load_and_mutate(*_args, **_kwargs): + return BaseResult(error="persist failed") + + monkeypatch.setattr(ai_scriptless_manager, "load_and_mutate", fake_load_and_mutate) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_command(TEST_ID, "wait")) + assert result.error == "persist failed" + + def test_move_command_into_logical_step(self, perfecto_token, monkeypatch): + captured: dict = {} + group = build_logical_step("Setup") + script = new_empty_script() + script["flowElements"] = [group, build_flow_element("wait")] + script["numOfFlowElements"] = 2 + _mock_load_and_mutate(monkeypatch, script, captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_command(TEST_ID, "1", parent_path="0")) + + _assert_step_path_notes(result) + assert captured["script"]["flowElements"][0]["flowElements"][0]["command"] == "wait" + assert len(captured["script"]["flowElements"]) == 1 + + +class TestStructureMutations: + def test_add_logical_step(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_logical_step(TEST_ID, label="Setup")) + + _assert_step_path_notes(result) + assert result.result["structure_type"] == "LogicalStep" + assert captured["script"]["flowElements"][0]["@type"] == "LogicalStep" + + def test_add_logical_step_inside_container(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_logical_group(), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_logical_step(TEST_ID, label="Nested", parent_path="0")) + + _assert_step_path_notes(result) + nested = captured["script"]["flowElements"][0]["flowElements"] + assert len(nested) == 1 + assert nested[0]["@type"] == "LogicalStep" + assert result.result["step_path"] == "0.0" + + def test_add_loop_rejects_invalid_count(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_loop(TEST_ID, count=0)) + assert result.error == "count must be at least 1" + + def test_add_loop_returns_count(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_loop(TEST_ID, count=3)) + + _assert_step_path_notes(result) + assert result.result["count"] == 3 + assert captured["script"]["flowElements"][0]["iterator"]["count"] == 3 + + def test_add_loop_inside_logical_step(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_logical_group(), captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_loop(TEST_ID, count=2, parent_path="0")) + + _assert_step_path_notes(result) + nested = captured["script"]["flowElements"][0]["flowElements"] + assert len(nested) == 1 + assert nested[0]["@type"] == "Loop" + assert nested[0]["iterator"]["count"] == 2 + assert result.result["step_path"] == "0.0" + + def test_add_condition_with_expression(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_condition(TEST_ID, expression="x == 1", label="Check")) + + _assert_step_path_notes(result) + assert result.result["expression"] == "x == 1" + assert captured["script"]["flowElements"][0]["@type"] == "IfStatement" + + def test_add_condition_inside_then_branch(self, perfecto_token, monkeypatch): + captured: dict = {} + script = new_empty_script() + script["flowElements"] = [build_if_statement("x == 1", "Check")] + _mock_load_and_mutate(monkeypatch, script, captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_condition( + TEST_ID, + expression="y == 2", + label="Nested", + parent_path="0.b0", + )) + + _assert_step_path_notes(result) + then_branch = captured["script"]["flowElements"][0]["branches"][0] + assert then_branch["flowElements"][0]["@type"] == "IfStatement" + assert result.result["step_path"] == "0.b0.0" + + def test_set_condition_expression(self, perfecto_token, monkeypatch): + captured: dict = {} + script = new_empty_script() + script["flowElements"] = [build_if_statement("old", "If")] + _mock_load_and_mutate(monkeypatch, script, captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.set_condition_expression(TEST_ID, "0", "new == true")) + + _assert_step_path_notes(result) + assert result.result["expression"] == "new == true" + assert captured["script"]["flowElements"][0]["expression"] == "new == true" + + def test_set_condition_expression_requires_expression(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.set_condition_expression(TEST_ID, "0", "")) + assert result.error == "expression is required" + + +class TestVariableOperations: + def test_list_test_variables(self, perfecto_token, monkeypatch): + script = new_empty_script() + script["variables"] = [{ + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "token", + "value": "abc", + "secured": False, + "description": None, + "displayName": None, + }, + }] + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": script}) + + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_test_variables(TEST_ID)) + + assert result.error is None + assert len(result.result) == 1 + assert result.result[0].name == "token" + + def test_list_test_variables_propagates_fetch_error(self, perfecto_token, monkeypatch): + async def fake_fetch(_token, _test_id): + return BaseResult(error="script missing") + + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_test_variables(TEST_ID)) + assert result.error == "script missing" + + def test_add_test_variable(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.add_test_variable( + TEST_ID, "count", "number", 42, set_at_runtime=True, + )) + + assert result.error is None + assert result.result["name"] == "count" + assert result.result["type"] == "number" + assert result.result["set_at_runtime"] is True + assert captured["script"]["variables"][0]["@type"] == "Parameter" + + def test_modify_test_variable_requires_change(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.modify_test_variable(TEST_ID, "token")) + assert "At least one of value, variable_type, or set_at_runtime is required" in result.error + + def test_modify_test_variable(self, perfecto_token, monkeypatch): + captured: dict = {} + script = new_empty_script() + script["variables"] = [{ + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "token", + "value": "old", + "secured": False, + "description": None, + "displayName": None, + }, + }] + _mock_load_and_mutate(monkeypatch, script, captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.modify_test_variable(TEST_ID, "token", value="new")) + + assert result.error is None + assert captured["script"]["variables"][0]["data"]["value"] == "new" + + def test_delete_test_variable(self, perfecto_token, monkeypatch): + captured: dict = {} + script = new_empty_script() + script["variables"] = [{ + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "token", + "value": "x", + "secured": False, + "description": None, + "displayName": None, + }, + }] + _mock_load_and_mutate(monkeypatch, script, captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.delete_test_variable(TEST_ID, "token")) + + assert result.error is None + assert captured["script"]["variables"] == [] + + +class TestRepositoryOperations: + def test_create_test_persists_empty_script(self, perfecto_token, monkeypatch): + persisted: dict = {} + + async def fake_persist(_token, item_key, script, saved_script=None, snapshot_comment=None): + persisted["item_key"] = item_key + persisted["flow_count"] = len(script.get("flowElements", [])) + return BaseResult(result={"draft_key": "d-1", "status": "ok"}) + + monkeypatch.setattr(ai_scriptless_manager, "persist_script", fake_persist) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.create_test("Login", folder="My Folder")) + + assert result.error is None + assert persisted["item_key"] == "PRIVATE:My Folder/Login.xml" + assert persisted["flow_count"] == 0 + assert result.info is not None + + def test_save_test_passes_snapshot_comment(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.save_test(TEST_ID, comment="checkpoint")) + + assert result.error is None + assert captured["snapshot_comment"] == "checkpoint" + + def test_save_test_as_fetches_and_persists_under_new_key(self, perfecto_token, monkeypatch): + source_script = _script_with_steps("wait") + persisted: dict = {} + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": source_script}) + + async def fake_persist(_token, item_key, script, saved_script=None, snapshot_comment=None): + persisted["item_key"] = item_key + persisted["flow_count"] = len(script.get("flowElements", [])) + persisted["comment"] = snapshot_comment + return BaseResult(result={"draft_key": "d-2", "status": "ok"}) + + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + monkeypatch.setattr(ai_scriptless_manager, "persist_script", fake_persist) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.save_test_as( + TEST_ID, + "Copy", + folder="Archive", + visibility="PUBLIC", + comment="branched", + )) + + assert result.error is None + assert persisted["item_key"] == "PUBLIC:Archive/Copy.xml" + assert persisted["flow_count"] == 1 + assert persisted["comment"] == "branched" + + def test_save_test_as_propagates_fetch_error(self, perfecto_token, monkeypatch): + async def fake_fetch(_token, _test_id): + return BaseResult(error="source missing") + + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.save_test_as(TEST_ID, "Copy")) + assert result.error == "source missing" + + def test_move_test_propagates_api_error(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **_kwargs): + return BaseResult(error="move denied") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_test(TEST_ID, "Archive")) + assert result.error == "move denied" + + def test_list_snapshots_rejects_invalid_item_key(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_snapshots("invalid")) + assert "Invalid itemKey format" in result.error + + def test_move_test_builds_target_item_key(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["method"] = method + captured["json"] = kwargs.get("json") + return BaseResult(result={"status": "moved"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_test(TEST_ID, "Archive", visibility="PUBLIC")) + + assert result.error is None + assert captured["method"] == "PATCH" + assert captured["json"]["folderType"] == "PRIVATE" + assert captured["json"]["targetFolderType"] == "PUBLIC" + assert result.result["target_item_key"] == "PUBLIC:Archive/Test.xml" + assert result.result["source_item_key"] == TEST_ID + + def test_move_test_rejects_invalid_item_key(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.move_test("not-an-item-key", "Archive")) + assert "Invalid itemKey format" in result.error + + def test_delete_test_calls_repository_api(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["method"] = method + captured["params"] = kwargs.get("params") + return BaseResult(result={"status": "deleted"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.delete_test(TEST_ID)) + + assert result.error is None + assert captured["method"] == "DELETE" + assert captured["params"]["itemKey"] == TEST_ID + + def test_delete_test_propagates_api_error(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **_kwargs): + return BaseResult(error="delete denied") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.delete_test(TEST_ID)) + assert result.error == "delete denied" + + def test_list_snapshots_posts_search_body(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["method"] = method + captured["json"] = kwargs.get("json") + return BaseResult(result=[]) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_snapshots(TEST_ID)) + + assert result.error is None + assert captured["method"] == "POST" + assert captured["json"]["folderType"] == "PRIVATE" + assert captured["json"]["keyDetails"]["artifactId"] == "Folder/Test.xml" + + def test_list_snapshots_propagates_api_error(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **_kwargs): + return BaseResult(error="snapshots unavailable") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.list_snapshots(TEST_ID)) + assert result.error == "snapshots unavailable" + + def test_get_command_definitions_requires_ids(self, perfecto_token): + manager = AiScriptlessManager(perfecto_token, ctx=None) + result = asyncio.run(manager.get_command_definitions([])) + assert result.error == "command_ids is required and must not be empty" + + +def _dispatcher_action_cases() -> list[tuple[str, dict]]: + test_id = TEST_ID + return [ + ("list_tests", {"page_index": 1}), + ("list_filter_values", {"filter_names": ["owner_list"]}), + ("execute_test", { + "test_id": test_id, + "device_type": "real", + "device_under_test": {"device_id": "DEV-9"}, + }), + ("view_test_structure", {"test_id": test_id}), + ("list_commands", {"checkpoint": True}), + ("get_command_definitions", {"command_ids": ["wait"]}), + ("add_command", {"test_id": test_id, "command_id": "comment", "arguments": {"text": "hi"}}), + ("modify_command", {"test_id": test_id, "step_path": "0", "arguments": {"duration": "2"}}), + ("delete_command", {"test_id": test_id, "step_path": "0"}), + ("set_command_enabled", {"test_id": test_id, "step_path": "0", "enabled": False}), + ("save_test", {"test_id": test_id, "comment": "saved"}), + ("create_test", {"name": "New", "folder": "Folder"}), + ("save_test_as", {"test_id": test_id, "name": "Copy", "folder": "Archive"}), + ("add_logical_step", {"test_id": test_id, "label": "Group"}), + ("add_loop", {"test_id": test_id, "count": 2}), + ("add_condition", {"test_id": test_id, "expression": "true", "label": "If"}), + ("set_condition_expression", {"test_id": test_id, "step_path": "1", "expression": "false"}), + ("move_command", {"test_id": test_id, "step_path": "0", "after_path": "0"}), + ("delete_test", {"test_id": test_id}), + ("move_test", {"test_id": test_id, "folder": "Moved"}), + ("list_snapshots", {"test_id": test_id}), + ("view_snapshot", {"snapshot_id": f"{test_id}@hist-1"}), + ("list_test_variables", {"test_id": test_id}), + ("add_test_variable", {"test_id": test_id, "name": "v", "value": "1"}), + ("modify_test_variable", {"test_id": test_id, "name": "token", "value": "new"}), + ("delete_test_variable", {"test_id": test_id, "name": "token"}), + ] + + +def _setup_dispatcher_mocks(monkeypatch, captured: dict | None = None, persisted: dict | None = None): + script = new_empty_script() + script["flowElements"] = [ + build_flow_element("wait"), + build_if_statement("x == 1", "If"), + ] + script["numOfFlowElements"] = 2 + script["variables"] = [{ + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "token", + "value": "old", + "secured": False, + "description": None, + "displayName": None, + }, + }] + + async def fake_api_request( + _token, + method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + if result_formatter is None: + return BaseResult(result={"status": "ok", "executionId": "exec-1"}) + + formatter_name = getattr(result_formatter, "__name__", "") + if formatter_name == "format_ai_scriptless_tests": + raw = _tests_tree_response() + elif formatter_name == "format_ai_scriptless_tests_filter_values": + raw = _tests_tree_response() + elif formatter_name == "format_test_structure": + raw = _script_payload() + elif formatter_name == "format_command_catalog": + raw = { + "name": "Root", + "children": [{"commandId": "wait", "name": "Wait", "path": "/wait"}], + } + elif formatter_name == "format_command_definitions": + raw = { + "definitions": [{ + "commandId": "wait", + "data": { + "display": {"name": "Wait"}, + "mandatoryParameters": [], + "optionalParameters": [], + }, + }], + } + elif formatter_name == "format_snapshots_list": + raw = {"snapshots": [{"key": "", "comment": "live"}]} + else: + raw = {} + + return BaseResult(result=result_formatter(raw, result_formatter_params)) + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": copy.deepcopy(script)}) + + async def fake_persist(_token, item_key, payload, saved_script=None, snapshot_comment=None): + if persisted is not None: + persisted["item_key"] = item_key + persisted["comment"] = snapshot_comment + persisted["flow_count"] = len(payload.get("flowElements", [])) + return BaseResult(result={"draft_key": "d-1", "status": "ok"}) + + async def fake_load_and_mutate(_token, test_id, mutator, snapshot_comment=None): + payload = copy.deepcopy(script) + try: + mutator(payload) + except ValueError as exc: + return BaseResult(error=str(exc)) + if captured is not None: + captured["script"] = payload + captured["snapshot_comment"] = snapshot_comment + return BaseResult(result={"item_key": test_id, "draft_key": "draft-1", "status": "ok"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + monkeypatch.setattr(ai_scriptless_manager, "persist_script", fake_persist) + monkeypatch.setattr(ai_scriptless_manager, "load_and_mutate", fake_load_and_mutate) + + +class TestAiScriptlessDispatcher: + def test_unknown_action_returns_error(self, perfecto_token): + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action="not_a_real_action", args={}, ctx=None)) + assert "not found in AI Scriptless manager tool" in result.error + + def test_routes_add_command(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, captured=captured) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="add_command", + args={ + "test_id": TEST_ID, + "command_id": "wait", + "arguments": {"duration": "1"}, + }, + ctx=None, + )) + + assert result.error is None + assert result.result["command_id"] == "wait" + assert len(captured["script"]["flowElements"]) == 1 + + def test_routes_list_test_variables(self, perfecto_token, monkeypatch): + script = new_empty_script() + script["variables"] = [{ + "@type": "Variable", + "data": { + "@type": "StringData", + "name": "flag", + "value": "on", + "secured": False, + "description": None, + "displayName": None, + }, + }] + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": script}) + + monkeypatch.setattr(ai_scriptless_manager, "fetch_script_payload", fake_fetch) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="list_test_variables", + args={"test_id": TEST_ID}, + ctx=None, + )) + + assert result.error is None + assert result.result[0].name == "flag" + + def test_defaults_none_args_to_empty_dict(self, perfecto_token, monkeypatch): + async def fake_api_request(*_args, **_kwargs): + return BaseResult(error="tree unavailable") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action="list_tests", args=None, ctx=None)) + assert result.error == "tree unavailable" + + def test_routes_move_command(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait", "comment"), captured) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="move_command", + args={"test_id": TEST_ID, "step_path": "0", "after_path": "0"}, + ctx=None, + )) + + assert result.error is None + commands = [e["command"] for e in captured["script"]["flowElements"]] + assert commands == ["comment", "wait"] + + def test_routes_delete_command(self, perfecto_token, monkeypatch): + captured: dict = {} + _mock_load_and_mutate(monkeypatch, _script_with_steps("wait", "comment"), captured) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="delete_command", + args={"test_id": TEST_ID, "step_path": "0"}, + ctx=None, + )) + + assert result.error is None + assert len(captured["script"]["flowElements"]) == 1 + + def test_routes_execute_test(self, perfecto_token, monkeypatch): + captured: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + captured["json"] = kwargs.get("json") + return BaseResult(result={"executionId": "exec-dispatch"}) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="execute_test", + args={ + "test_id": TEST_ID, + "device_type": "real", + "device_under_test": {"device_id": "DEV-1"}, + }, + ctx=None, + )) + + assert result.error is None + assert captured["json"]["params"]["DUT"] == "DEV-1" + + def test_routes_create_test(self, perfecto_token, monkeypatch): + persisted: dict = {} + + async def fake_persist(_token, item_key, script, saved_script=None, snapshot_comment=None): + persisted["item_key"] = item_key + return BaseResult(result={"draft_key": "d-1", "status": "ok"}) + + monkeypatch.setattr(ai_scriptless_manager, "persist_script", fake_persist) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="create_test", + args={"name": "Smoke", "folder": "QA"}, + ctx=None, + )) + + assert result.error is None + assert persisted["item_key"] == "PRIVATE:QA/Smoke.xml" + + @pytest.mark.parametrize("action,args,expected_error", [ + ("modify_command", {"test_id": TEST_ID, "step_path": "0"}, "arguments is required"), + ("delete_test", {"test_id": ""}, "test_id is required"), + ("move_test", {"test_id": "", "folder": "Archive"}, "test_id is required"), + ]) + def test_dispatcher_validation_errors(self, perfecto_token, action, args, expected_error): + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action=action, args=args, ctx=None)) + if expected_error: + assert expected_error in result.error + + def test_http_status_error_returns_formatted_error(self, perfecto_token, monkeypatch): + async def raise_http_error(*_args, **_kwargs): + request = httpx.Request("GET", "https://demo.perfectomobile.com/tree") + response = httpx.Response(503, request=request) + raise httpx.HTTPStatusError("service unavailable", request=request, response=response) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", raise_http_error) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action="list_tests", args={}, ctx=None)) + + assert result.error is not None + assert result.error.startswith("Error:") + assert "HTTPStatusError" in result.error + + def test_unexpected_exception_includes_support_message(self, perfecto_token, monkeypatch): + async def raise_runtime_error(*_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(ai_scriptless_manager, "api_request", raise_runtime_error) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action="list_tests", args={}, ctx=None)) + + assert "boom" in result.error + assert SUPPORT_MESSAGE in result.error + + def test_view_test_structure_end_to_end_with_formatter(self, perfecto_token, monkeypatch): + """api_request mock returns raw payload; formatter runs inside manager call chain.""" + async def fake_api_request( + _token, + _method, + endpoint=None, + result_formatter=None, + result_formatter_params=None, + **kwargs, + ): + from formatters.ai_scriptless import format_test_structure + return BaseResult(result=format_test_structure( + _script_payload(), + result_formatter_params, + )) + + monkeypatch.setattr(ai_scriptless_manager, "api_request", fake_api_request) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="view_test_structure", + args={"test_id": TEST_ID}, + ctx=None, + )) + + assert result.error is None + assert result.result.item_key == TEST_ID + assert result.result.flow_elements[0].step_path == "0" + assert any("AI Scriptless UI" in line for line in result.info) + + def test_routes_list_filter_values(self, perfecto_token, monkeypatch): + _setup_dispatcher_mocks(monkeypatch) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="list_filter_values", + args={"filter_names": ["test_name", "owner_list"]}, + ctx=None, + )) + + assert result.error is None + assert "Login" in result.result["test_name"] + + def test_routes_save_test_as(self, perfecto_token, monkeypatch): + persisted: dict = {} + _setup_dispatcher_mocks(monkeypatch, persisted=persisted) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="save_test_as", + args={ + "test_id": TEST_ID, + "name": "Branch", + "folder": "Copies", + "visibility": "PUBLIC", + "comment": "v2", + }, + ctx=None, + )) + + assert result.error is None + assert persisted["item_key"] == "PUBLIC:Copies/Branch.xml" + assert persisted["comment"] == "v2" + + def test_routes_add_test_variable(self, perfecto_token, monkeypatch): + captured: dict = {} + _setup_dispatcher_mocks(monkeypatch, captured=captured) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool( + action="add_test_variable", + args={ + "test_id": TEST_ID, + "name": "retry", + "variable_type": "number", + "value": 3, + "set_at_runtime": True, + }, + ctx=None, + )) + + assert result.error is None + assert result.result["name"] == "retry" + names = [v["data"]["name"] for v in captured["script"]["variables"]] + assert "retry" in names + + @pytest.mark.parametrize("action,args", _dispatcher_action_cases()) + def test_dispatcher_routes_registered_action(self, perfecto_token, monkeypatch, action, args): + _setup_dispatcher_mocks(monkeypatch) + + tool = _register_tool(perfecto_token) + result = asyncio.run(tool(action=action, args=args, ctx=None)) + + assert "not found in AI Scriptless manager tool" not in (result.error or "") + + +def _register_tool(token): + class _McpStub: + def __init__(self): + self.tools: dict = {} + + def tool(self, *, name, description): + def decorator(fn): + self.tools[name] = fn + return fn + return decorator + + mcp = _McpStub() + ai_scriptless_manager.register(mcp, token) + return mcp.tools["perfecto_ai_scriptless"] diff --git a/tests/test_ai_scriptless_persistence.py b/tests/test_ai_scriptless_persistence.py new file mode 100644 index 0000000..cdd7382 --- /dev/null +++ b/tests/test_ai_scriptless_persistence.py @@ -0,0 +1,225 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import asyncio +import json + +from models.result import BaseResult +from tools.ai_scriptless import persistence +from tools.ai_scriptless.elements import build_branch, build_flow_element, new_empty_script +from tools.ai_scriptless.persistence import load_and_mutate, persist_script +from tools.ai_scriptless.tree import insert_flow_element +from tools.ai_scriptless.script import Script + + +class TestPersistScript: + def test_persist_calls_draft_then_script_save(self, perfecto_token, monkeypatch): + calls: list[dict] = [] + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + calls.append({"method": method, "endpoint": endpoint, "json": kwargs.get("json")}) + if endpoint and "draft-management" in endpoint: + return BaseResult(result={"key": "draft-abc"}) + return BaseResult(result={"status": "saved"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + script = new_empty_script() + script["flowElements"] = [build_flow_element("wait")] + result = asyncio.run( + persist_script(perfecto_token, "PRIVATE:Folder/Test.xml", script) + ) + + assert result.error is None + assert result.result["draft_key"] == "draft-abc" + assert result.result["flow_element_count"] == 1 + assert len(calls) == 2 + assert calls[0]["method"] == "POST" + assert "draft-management" in calls[0]["endpoint"] + assert calls[1]["method"] == "POST" + assert calls[1]["endpoint"].endswith("/script") + assert calls[1]["json"]["draftKey"] == "draft-abc" + assert calls[1]["json"]["itemKey"] == "PRIVATE:Folder/Test.xml" + + def test_persist_strips_uuid_before_draft_payload(self, perfecto_token, monkeypatch): + captured_draft: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + if endpoint and "draft-management" in endpoint: + captured_draft["data"] = json.loads(kwargs["json"]["data"]) + return BaseResult(result={"key": "draft-abc"}) + return BaseResult(result={"status": "saved"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + script = new_empty_script() + element = build_flow_element("comment", {"text": "x"}) + element["uuid"] = "remove-me" + script["flowElements"] = [element] + + asyncio.run(persist_script(perfecto_token, "PRIVATE:Folder/Test.xml", script)) + + unsaved = captured_draft["data"]["unsavedScript"] + assert "uuid" not in unsaved["flowElements"][0] + + def test_persist_accepts_script_aggregate(self, perfecto_token, monkeypatch): + async def fake_api_request(_token, method, endpoint=None, **kwargs): + if endpoint and "draft-management" in endpoint: + return BaseResult(result={"key": "draft-abc"}) + return BaseResult(result={"status": "saved"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + script = Script.empty() + script.flow_elements.append(build_flow_element("wait")) + result = asyncio.run( + persist_script(perfecto_token, "PRIVATE:Folder/Test.xml", script) + ) + + assert result.error is None + assert result.result["flow_element_count"] == 1 + + def test_persist_includes_snapshot_comment_on_save(self, perfecto_token, monkeypatch): + save_payload: dict = {} + + async def fake_api_request(_token, method, endpoint=None, **kwargs): + if endpoint and "draft-management" in endpoint: + return BaseResult(result={"key": "draft-abc"}) + save_payload["json"] = kwargs.get("json") + return BaseResult(result={"status": "saved"}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + asyncio.run( + persist_script( + perfecto_token, + "PRIVATE:Folder/Test.xml", + new_empty_script(), + snapshot_comment="release candidate", + ) + ) + + assert save_payload["json"]["snapshotComment"] == "release candidate" + + def test_persist_fails_when_draft_key_missing(self, perfecto_token, monkeypatch): + async def fake_api_request(_token, method, endpoint=None, **kwargs): + return BaseResult(result={}) + + monkeypatch.setattr(persistence, "api_request", fake_api_request) + + result = asyncio.run( + persist_script(perfecto_token, "PRIVATE:Folder/Test.xml", new_empty_script()) + ) + + assert result.error == "Draft creation failed: missing draft key in response" + + +class TestLoadAndMutate: + def test_load_and_mutate_applies_mutator_and_persists(self, perfecto_token, monkeypatch): + script = new_empty_script() + persisted: dict = {} + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": script}) + + async def fake_persist(_token, item_key, mutated_script, saved_script, snapshot_comment=None): + persisted["item_key"] = item_key + persisted["flow_count"] = len(mutated_script.get("flowElements", [])) + persisted["saved_count"] = len(saved_script.get("flowElements", [])) + return BaseResult(result={"status": "ok"}) + + monkeypatch.setattr(persistence, "fetch_script_payload", fake_fetch) + monkeypatch.setattr(persistence, "_persist_script", fake_persist) + + def mutator(current_script: dict) -> None: + current_script.setdefault("flowElements", []).append(build_flow_element("wait")) + + result = asyncio.run( + load_and_mutate(perfecto_token, "PRIVATE:Folder/Test.xml", mutator) + ) + + assert result.error is None + assert persisted["item_key"] == "PRIVATE:Folder/Test.xml" + assert persisted["flow_count"] == 1 + assert persisted["saved_count"] == 0 + + def test_load_and_mutate_normalizes_if_statement_aliases(self, perfecto_token, monkeypatch): + then_branch = build_branch("THEN") + else_branch = build_branch("ELSE") + then_clause = build_branch("THEN") + api_script = new_empty_script() + api_script["flowElements"] = [{ + "@type": "IfStatement", + "branches": [then_branch, else_branch], + "thenClause": then_clause, + "elseClause": build_branch("ELSE"), + "label": "Probe", + "active": True, + }] + persisted: dict = {} + + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": api_script}) + + async def fake_persist(_token, item_key, mutated_script, saved_script, snapshot_comment=None): + persisted["mutated"] = mutated_script + return BaseResult(result={"status": "ok"}) + + monkeypatch.setattr(persistence, "fetch_script_payload", fake_fetch) + monkeypatch.setattr(persistence, "_persist_script", fake_persist) + + def mutator(script: dict) -> None: + insert_flow_element( + script, + build_flow_element("comment", {"text": "then child"}), + parent_path="0.b0", + ) + + result = asyncio.run( + load_and_mutate(perfecto_token, "PRIVATE:Folder/Test.xml", mutator) + ) + + assert result.error is None + condition = persisted["mutated"]["flowElements"][0] + assert condition["thenClause"] is condition["branches"][0] + assert len(condition["thenClause"]["flowElements"]) == 1 + + def test_load_and_mutate_returns_validation_error(self, perfecto_token, monkeypatch): + async def fake_fetch(_token, _test_id): + return BaseResult(result={"script": new_empty_script()}) + + monkeypatch.setattr(persistence, "fetch_script_payload", fake_fetch) + + def mutator(_script: dict) -> None: + raise ValueError("step_path not found: 9") + + result = asyncio.run( + load_and_mutate(perfecto_token, "PRIVATE:Folder/Test.xml", mutator) + ) + + assert result.error == "step_path not found: 9" + + def test_load_and_mutate_propagates_fetch_error(self, perfecto_token, monkeypatch): + async def fake_fetch(_token, _test_id): + return BaseResult(error="not found") + + monkeypatch.setattr(persistence, "fetch_script_payload", fake_fetch) + + result = asyncio.run( + load_and_mutate(perfecto_token, "PRIVATE:Folder/Test.xml", lambda _s: None) + ) + + assert result.error == "not found" diff --git a/tests/test_ai_scriptless_script.py b/tests/test_ai_scriptless_script.py new file mode 100644 index 0000000..d19fa55 --- /dev/null +++ b/tests/test_ai_scriptless_script.py @@ -0,0 +1,512 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import copy + +import pytest + +from formatters.ai_scriptless import PRIMARY_AI_COMMAND_IDS +from tools.ai_scriptless.elements import build_branch +from tools.ai_scriptless_script import ( + add_script_variable, + build_flow_element, + build_if_statement, + build_item_key, + build_logical_step, + build_loop, + build_move_test_body, + build_snapshot_search_body, + command_id_from_element, + delete_element_by_path, + delete_script_variable, + find_element_by_path, + find_step_path_for_element, + format_test_ui_location, + folder_type, + insert_flow_element, + modify_script_variable, + move_element_by_path, + new_empty_script, + parse_command_id, + set_condition_expression, + set_element_enabled, + split_item_key, + item_key_file_name, + strip_non_api_script_fields, + update_element_arguments, + validate_step_path, +) + + +def _sample_script() -> dict: + """Script with root steps, a logical group, and an if/else branch for tree tests.""" + tap = build_flow_element("ai_user-action", {"action": "Tap login"}) + wait = build_flow_element("wait", {"duration": "2"}) + group = build_logical_step("Setup") + group["flowElements"] = [build_flow_element("comment", {"text": "inside group"})] + condition = build_if_statement("x == 1", "Check x") + then_branch = condition["branches"][0] + then_branch["flowElements"] = [build_flow_element("ai_validation", {"validation": "OK"})] + script = new_empty_script() + script["flowElements"] = [tap, group, wait, condition] + script["numOfFlowElements"] = 4 + return script + + +class TestItemKey: + def test_build_item_key_adds_xml_extension(self): + assert build_item_key("PRIVATE", "My Folder", "Login") == "PRIVATE:My Folder/Login.xml" + + def test_build_item_key_preserves_existing_xml_extension(self): + assert build_item_key("PUBLIC", "Shared", "Login.xml") == "PUBLIC:Shared/Login.xml" + + def test_build_item_key_strips_slashes_from_folder(self): + assert build_item_key("PRIVATE", "/My Folder/", "Test") == "PRIVATE:My Folder/Test.xml" + + def test_split_item_key_round_trip(self): + item_key = "GROUP:Team/Regression/Smoke.xml" + visibility, path = split_item_key(item_key) + assert visibility == "GROUP" + assert path == "Team/Regression/Smoke.xml" + + def test_split_item_key_rejects_missing_visibility(self): + with pytest.raises(ValueError, match="Invalid itemKey format"): + split_item_key("NoVisibilityPrefix") + + def test_folder_type_maps_public_and_group(self): + assert folder_type("PRIVATE") == "PRIVATE" + assert folder_type("GROUP") == "GROUP" + assert folder_type("PUBLIC") == "PUBLIC" + assert folder_type("UNKNOWN") == "PUBLIC" + + def test_item_key_file_name(self): + assert item_key_file_name("PRIVATE:Folder/My Test.xml") == "My Test.xml" + + def test_format_test_ui_location_with_folder(self): + item_key = "PRIVATE:My Folder/Login.xml" + assert format_test_ui_location(item_key) == ( + '"My Tests" → folder "My Folder" → test "Login"' + ) + + def test_format_test_ui_location_without_folder(self): + item_key = "PUBLIC:RootTest.xml" + assert format_test_ui_location(item_key) == '"Public Tests" → test "RootTest"' + + +class TestRepositoryApiBodies: + def test_build_snapshot_search_body(self): + body = build_snapshot_search_body("PRIVATE:My Folder/Login.xml") + assert body == { + "repositoryType": "SCRIPTS", + "keyDetails": {"artifactId": "My Folder/Login.xml", "version": "v0"}, + "folderType": "PRIVATE", + } + + def test_build_move_test_body(self): + body = build_move_test_body("PRIVATE:Old/Login.xml", "Archive", "PUBLIC") + assert body["repositoryType"] == "SCRIPTS" + assert body["keyDetails"] == {"artifactId": "Old/Login.xml", "version": "v0"} + assert body["folderType"] == "PRIVATE" + assert body["targetKeyDetails"] == {"artifactId": "Archive/Login.xml", "version": "v0"} + assert body["targetFolderType"] == "PUBLIC" + assert body["copy"] is False + + +class TestCommandBuilding: + def test_parse_command_id_ai_prefix(self): + assert parse_command_id("ai_user-action") == ("ai", "user-action") + assert parse_command_id("ai_validation") == ("ai", "validation") + + def test_parse_command_id_legacy_format(self): + assert parse_command_id("touch_tap") == ("touch", "tap") + assert parse_command_id("wait") == ("wait", "") + + def test_build_flow_element_ai_action(self): + element = build_flow_element("ai_user-action", {"action": "Open app"}) + assert element["@type"] == "Action" + assert element["command"] == "ai" + assert element["subcommand"] == "user-action" + assert element["errorPolicy"] == "ABORT" + assert element["active"] is True + + def test_build_flow_element_ai_validation(self): + element = build_flow_element("ai_validation", {"validation": "Screen visible"}) + assert element["@type"] == "Validation" + assert element["errorPolicy"] == "IGNORE" + + def test_build_flow_element_default_handset_argument(self): + element = build_flow_element("touch_tap") + handset_args = [arg for arg in element["arguments"] if arg["name"] == "handsetId"] + assert len(handset_args) == 1 + assert handset_args[0]["data"]["dataSource"] == "VARIABLE" + assert handset_args[0]["data"]["value"] == "DUT" + + def test_build_arguments_normalizes_wait_alias(self): + element = build_flow_element("wait", {"waitDuration": "5"}) + duration_args = [arg for arg in element["arguments"] if arg["name"] == "duration"] + assert len(duration_args) == 1 + assert duration_args[0]["data"]["value"] == "5" + assert all(arg["name"] != "waitDuration" for arg in element["arguments"]) + + def test_command_id_from_element(self): + element = build_flow_element("ai_user-action", {"action": "Tap"}) + assert command_id_from_element(element) == "ai_user-action" + + def test_update_element_arguments_merges_values(self): + element = build_flow_element("ai_user-action", {"action": "Old"}) + update_element_arguments(element, {"action": "New"}) + action_args = [arg for arg in element["arguments"] if arg["name"] == "action"] + assert action_args[0]["data"]["value"] == "New" + + +LEGACY_COMMAND_IDS = ( + "comment", + "wait", + "handset_ready", + "touch_tap", + "checkpoint_text", + "checkpoint_image", +) + + +def _assert_command_id_round_trip(command_id: str) -> None: + element = build_flow_element(command_id) + assert command_id_from_element(element) == command_id + command, subcommand = parse_command_id(command_id) + assert element["command"] == command + assert (element.get("subcommand") or "") == subcommand + + +class TestCommandIdRoundTrip: + """Catalog command_id must survive build_flow_element and command_id_from_element.""" + + @pytest.mark.parametrize("command_id", PRIMARY_AI_COMMAND_IDS) + def test_primary_ai_command_ids(self, command_id: str) -> None: + _assert_command_id_round_trip(command_id) + + @pytest.mark.parametrize("command_id", LEGACY_COMMAND_IDS) + def test_legacy_and_structural_command_ids(self, command_id: str) -> None: + _assert_command_id_round_trip(command_id) + + def test_round_trip_with_explicit_arguments(self) -> None: + command_id = "ai_user-action" + element = build_flow_element(command_id, {"action": "Tap login"}) + assert command_id_from_element(element) == command_id + assert element["command"] == "ai" + assert element["subcommand"] == "user-action" + + def test_parse_and_build_agree_on_wire_fields(self) -> None: + for command_id in (*PRIMARY_AI_COMMAND_IDS, *LEGACY_COMMAND_IDS): + command, subcommand = parse_command_id(command_id) + element = build_flow_element(command_id) + assert element["command"] == command + assert (element.get("subcommand") or "") == subcommand + + +class TestStructureBuilders: + def test_new_empty_script_has_dut_parameter(self): + script = new_empty_script() + assert script["@type"] == "Script" + assert script["flowElements"] == [] + assert script["numOfFlowElements"] == 0 + dut = script["parameters"][0]["data"] + assert dut["name"] == "DUT" + assert dut["@type"] == "HandsetData" + + def test_build_logical_step(self): + step = build_logical_step("Group A") + assert step["@type"] == "LogicalStep" + assert step["label"] == "Group A" + assert step["flowElements"] == [] + + def test_build_loop(self): + loop = build_loop(3) + assert loop["@type"] == "Loop" + assert loop["iterator"]["count"] == 3 + + def test_build_if_statement_has_branches(self): + condition = build_if_statement("flag", "Flag check") + assert condition["@type"] == "IfStatement" + assert condition["expression"] == "flag" + assert len(condition["branches"]) == 2 + assert condition["branches"][0]["clause"] == "THEN" + assert condition["branches"][1]["clause"] == "ELSE" + + def test_build_if_statement_clauses_alias_branches(self): + condition = build_if_statement("flag", "Flag check") + assert condition["thenClause"] is condition["branches"][0] + assert condition["elseClause"] is condition["branches"][1] + child = build_flow_element("comment", {"text": "in then"}) + condition["branches"][0]["flowElements"].append(child) + assert len(condition["thenClause"]["flowElements"]) == 1 + + def test_normalize_if_statement_aliases_api_payload(self): + from tools.ai_scriptless.elements import normalize_if_statement_aliases + + then_branch = build_branch("THEN") + else_branch = build_branch("ELSE") + then_clause = build_branch("THEN") + then_clause["flowElements"] = [build_flow_element("comment", {"text": "from clause"})] + script = { + "flowElements": [{ + "@type": "IfStatement", + "branches": [then_branch, else_branch], + "thenClause": then_clause, + "elseClause": build_branch("ELSE"), + }], + } + normalize_if_statement_aliases(script) + condition = script["flowElements"][0] + assert condition["thenClause"] is condition["branches"][0] + assert condition["elseClause"] is condition["branches"][1] + assert len(condition["branches"][0]["flowElements"]) == 1 + assert condition["branches"][0]["flowElements"][0]["command"] == "comment" + + def test_normalize_if_statement_aliases_nested(self): + from tools.ai_scriptless.elements import normalize_if_statement_aliases + + inner = build_if_statement() + inner["branches"] = [build_branch("THEN"), build_branch("ELSE")] + inner["thenClause"] = build_branch("THEN") + inner["elseClause"] = build_branch("ELSE") + group = build_logical_step("group") + group["flowElements"] = [inner] + script = {"flowElements": [group]} + normalize_if_statement_aliases(script) + nested = group["flowElements"][0] + assert nested["thenClause"] is nested["branches"][0] + + +class TestScriptVariables: + def test_add_and_find_variable(self): + script = new_empty_script() + entry = add_script_variable(script, "counter", "number", 0) + assert entry["@type"] == "Variable" + assert entry["data"]["name"] == "counter" + assert entry["data"]["value"] == 0 + assert len(script["variables"]) == 1 + + def test_add_variable_rejects_duplicate(self): + script = new_empty_script() + add_script_variable(script, "token", "string", "abc") + with pytest.raises(ValueError, match="variable already exists"): + add_script_variable(script, "token", "string", "xyz") + + def test_add_variable_rejects_dut_name(self): + script = new_empty_script() + with pytest.raises(ValueError, match="DUT is a test parameter"): + add_script_variable(script, "DUT", "string", "x") + + def test_add_variable_rejects_invalid_name(self): + script = new_empty_script() + with pytest.raises(ValueError, match="cannot begin with a number"): + add_script_variable(script, "1bad", "string", "x") + + def test_modify_script_variable(self): + script = new_empty_script() + add_script_variable(script, "flag", "boolean", True) + modify_script_variable(script, "flag", value=False) + assert script["variables"][0]["data"]["value"] is False + + def test_modify_script_variable_set_at_runtime(self): + script = new_empty_script() + add_script_variable(script, "env", "string", "dev") + modify_script_variable(script, "env", set_at_runtime=True) + assert script["variables"][0]["@type"] == "Parameter" + + def test_delete_script_variable(self): + script = new_empty_script() + add_script_variable(script, "temp", "string", "x") + delete_script_variable(script, "temp") + assert script["variables"] == [] + + def test_coerce_boolean_and_number_types(self): + script = new_empty_script() + add_script_variable(script, "enabled", "boolean", "true") + add_script_variable(script, "retries", "number", "3") + assert script["variables"][0]["data"]["value"] is True + assert script["variables"][1]["data"]["value"] == 3 + + +class TestStepPathValidation: + def test_accepts_valid_paths(self): + for path in ("0", "2.0", "5.b0", "5.b0.1", "1.b1.2"): + validate_step_path(path) + + def test_rejects_empty_or_spaced_paths(self): + with pytest.raises(ValueError, match="step_path must be"): + validate_step_path("") + with pytest.raises(ValueError, match="step_path must be"): + validate_step_path("1. 2") + + def test_rejects_invalid_segments(self): + with pytest.raises(ValueError, match="invalid step_path"): + validate_step_path("a.b") + + +class TestScriptTreeNavigation: + def test_find_element_by_path_root_and_nested(self): + script = _sample_script() + root = find_element_by_path(script, "0") + assert root is not None + _, index, element = root + assert index == 0 + assert element["command"] == "ai" + + nested = find_element_by_path(script, "1.0") + assert nested is not None + _, _, element = nested + assert element["command"] == "comment" + + def test_find_element_by_path_if_branch(self): + script = _sample_script() + branch = find_element_by_path(script, "3.b0") + assert branch is not None + _, _, element = branch + assert element["@type"] == "Branch" + assert element["clause"] == "THEN" + + branch_child = find_element_by_path(script, "3.b0.0") + assert branch_child is not None + _, _, element = branch_child + assert element["subcommand"] == "validation" + + def test_find_element_by_path_returns_none_for_missing(self): + script = _sample_script() + assert find_element_by_path(script, "99") is None + assert find_element_by_path(script, "3.b9") is None + + def test_find_step_path_for_element_round_trip(self): + script = _sample_script() + for expected_path in ("0", "1", "1.0", "3.b0", "3.b0.0"): + located = find_element_by_path(script, expected_path) + assert located is not None + _, _, element = located + assert find_step_path_for_element(script, element) == expected_path + + def test_find_element_by_path_returns_parent_list_and_index(self): + script = _sample_script() + parent_list, index, element = find_element_by_path(script, "0") + assert parent_list is script["flowElements"] + assert index == 0 + assert element is script["flowElements"][0] + + group = script["flowElements"][1] + parent_list, index, element = find_element_by_path(script, "1.0") + assert parent_list is group["flowElements"] + assert index == 0 + assert element is group["flowElements"][0] + + condition = script["flowElements"][3] + parent_list, index, branch = find_element_by_path(script, "3.b0") + assert parent_list is condition["branches"] + assert index == 0 + assert branch is condition["branches"][0] + + +class TestScriptTreeMutations: + def test_insert_flow_element_at_root_end(self): + script = new_empty_script() + element = build_flow_element("wait") + insert_flow_element(script, element) + assert script["numOfFlowElements"] == 1 + assert script["flowElements"][0]["command"] == "wait" + + def test_insert_flow_element_after_path(self): + script = _sample_script() + new_step = build_flow_element("comment", {"text": "inserted"}) + insert_flow_element(script, new_step, after_path="0") + assert find_element_by_path(script, "1") is not None + _, _, element = find_element_by_path(script, "1") + assert element["command"] == "comment" + assert find_step_path_for_element(script, script["flowElements"][0]) == "0" + + def test_insert_flow_element_inside_container(self): + script = _sample_script() + new_step = build_flow_element("comment", {"text": "in group"}) + insert_flow_element(script, new_step, parent_path="1") + located = find_element_by_path(script, "1.1") + assert located is not None + _, _, element = located + assert element["command"] == "comment" + + def test_insert_rejects_non_container_parent(self): + script = _sample_script() + new_step = build_flow_element("wait") + with pytest.raises(ValueError, match="must reference a container"): + insert_flow_element(script, new_step, parent_path="0") + + def test_delete_element_by_path(self): + script = _sample_script() + delete_element_by_path(script, "2") + assert script["numOfFlowElements"] == 3 + assert find_element_by_path(script, "2") is not None + _, _, element = find_element_by_path(script, "2") + assert element["@type"] == "IfStatement" + + def test_set_element_enabled(self): + script = _sample_script() + set_element_enabled(script, "0", False) + _, _, element = find_element_by_path(script, "0") + assert element["active"] is False + + def test_set_condition_expression(self): + script = _sample_script() + set_condition_expression(script, "3", "y > 0") + _, _, element = find_element_by_path(script, "3") + assert element["expression"] == "y > 0" + + def test_set_condition_expression_rejects_non_if(self): + script = _sample_script() + with pytest.raises(ValueError, match="must reference an IfStatement"): + set_condition_expression(script, "0", "x") + + def test_move_element_within_root(self): + script = _sample_script() + move_element_by_path(script, "0", after_path="2") + _, _, first = find_element_by_path(script, "0") + _, _, moved = find_element_by_path(script, "2") + assert first["@type"] == "LogicalStep" + assert moved["command"] == "ai" + assert moved["subcommand"] == "user-action" + + def test_move_element_into_container(self): + script = _sample_script() + move_element_by_path(script, "2", parent_path="1") + located = find_element_by_path(script, "1.1") + assert located is not None + _, _, element = located + assert element["command"] == "wait" + _, _, root_condition = find_element_by_path(script, "2") + assert root_condition["@type"] == "IfStatement" + + +class TestStripNonApiScriptFields: + def test_removes_uuid_from_tree(self): + script = _sample_script() + script["flowElements"][0]["uuid"] = "step-uuid-1" + script["flowElements"][3]["branches"][0]["uuid"] = "branch-uuid" + strip_non_api_script_fields(script) + assert "uuid" not in script["flowElements"][0] + assert "uuid" not in script["flowElements"][3]["branches"][0] + + def test_strip_does_not_mutate_unrelated_fields(self): + script = _sample_script() + original = copy.deepcopy(script) + script["flowElements"][0]["uuid"] = "temp" + strip_non_api_script_fields(script) + script["flowElements"][0].pop("uuid", None) + assert script["flowElements"][0] == original["flowElements"][0] diff --git a/tests/test_ai_scriptless_script_aggregate.py b/tests/test_ai_scriptless_script_aggregate.py new file mode 100644 index 0000000..b4d08df --- /dev/null +++ b/tests/test_ai_scriptless_script_aggregate.py @@ -0,0 +1,101 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from tools.ai_scriptless.elements import build_flow_element, build_logical_step +from tools.ai_scriptless.script import Script + + +def _sample_script() -> Script: + tap = build_flow_element("ai_user-action", {"action": "Tap login"}) + wait = build_flow_element("wait", {"duration": "2"}) + group = build_logical_step("Setup") + group["flowElements"] = [build_flow_element("comment", {"text": "inside group"})] + script = Script.empty() + script.flow_elements.extend([tap, group, wait]) + script.to_dict()["numOfFlowElements"] = 3 + return script + + +class TestScriptFactory: + def test_empty_matches_new_empty_script_shape(self): + script = Script.empty() + payload = script.to_dict() + assert payload["@type"] == "Script" + assert payload["flowElements"] == [] + assert payload["variables"] == [] + assert payload["parameters"][0]["data"]["name"] == "DUT" + + def test_from_dict_copies_payload(self): + original = Script.empty().to_dict() + original["variables"].append({"name": "x"}) + wrapped = Script.from_dict(original) + wrapped.variables.clear() + assert len(original["variables"]) == 1 + + def test_wrap_mutates_shared_dict(self): + payload = Script.empty().to_dict() + wrapped = Script.wrap(payload) + wrapped.add_variable("count", "number", 1) + assert len(payload["variables"]) == 1 + + +class TestScriptTreeOperations: + def test_insert_and_find_step_path(self): + script = _sample_script() + element = build_flow_element("comment", {"text": "new"}) + script.insert_flow_element(element, after_path="0") + assert script.find_step_path_for_element(element) == "1" + assert script.flow_element_count == 4 + + def test_delete_element_by_path(self): + script = _sample_script() + script.delete_element_by_path("2") + assert script.flow_element_count == 2 + assert script.find_element_by_path("2") is None + + def test_set_element_enabled(self): + script = _sample_script() + script.set_element_enabled("0", False) + located = script.find_element_by_path("0") + assert located is not None + assert located[2]["active"] is False + + +class TestScriptVariables: + def test_add_list_modify_delete_variable(self): + script = Script.empty() + script.add_variable("token", "string", "abc") + assert len(script.list_variables()) == 1 + script.modify_variable("token", value="xyz") + assert script.find_variable("token")[1]["data"]["value"] == "xyz" + script.delete_variable("token") + assert script.find_variable("token") is None + + +class TestScriptPersistPrep: + def test_prepare_for_persist_strips_uuid_and_updates_counts(self): + script = _sample_script() + script.flow_elements[0]["uuid"] = "client-only" + script.prepare_for_persist() + assert "uuid" not in script.flow_elements[0] + assert script.to_dict()["numOfFlowElements"] == script.flow_element_count + + def test_copy_is_independent(self): + script = _sample_script() + clone = script.copy() + clone.delete_element_by_path("0") + assert script.flow_element_count == 3 + assert clone.flow_element_count == 2 diff --git a/tests/test_ai_scriptless_value_objects.py b/tests/test_ai_scriptless_value_objects.py new file mode 100644 index 0000000..8890f7b --- /dev/null +++ b/tests/test_ai_scriptless_value_objects.py @@ -0,0 +1,70 @@ +""" +Copyright 2025 Perforce Software, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import pytest + +from tools.ai_scriptless.item_key import ItemKey, build_item_key, split_item_key +from tools.ai_scriptless.step_path import StepPath +from tools.ai_scriptless.tree import find_element_by_path +from tools.ai_scriptless.elements import build_flow_element, new_empty_script + + +class TestItemKey: + def test_build_and_parse_round_trip(self): + key = ItemKey.build("PRIVATE", "My Folder", "Login") + assert str(key) == "PRIVATE:My Folder/Login.xml" + assert key.visibility == "PRIVATE" + assert key.path == "My Folder/Login.xml" + assert key.file_name == "Login.xml" + assert key.folder_path == "My Folder" + assert key.folder_type == "PRIVATE" + + def test_with_folder_preserves_file_name(self): + source = ItemKey.parse("PRIVATE:Old/Login.xml") + target = source.with_folder("Archive", "PUBLIC") + assert str(target) == "PUBLIC:Archive/Login.xml" + + def test_legacy_helpers_match_item_key(self): + built = build_item_key("GROUP", "Team", "Smoke") + assert built == str(ItemKey.build("GROUP", "Team", "Smoke")) + assert split_item_key(built) == ("GROUP", "Team/Smoke.xml") + + +class TestStepPath: + def test_parse_and_str_round_trip(self): + path = StepPath.parse("5.b0.1") + assert path.parts == ("5", "b0", "1") + assert str(path) == "5.b0.1" + + def test_build_nested_paths(self): + root = StepPath.root_index(2) + branch = root.branch(0) + child = branch.child_index(1) + assert str(child) == "2.b0.1" + + @pytest.mark.parametrize("invalid", ["", "1. 2", "a.b"]) + def test_parse_rejects_invalid_paths(self, invalid: str): + with pytest.raises(ValueError): + StepPath.parse(invalid) + + def test_find_element_by_path_accepts_step_path_object(self): + script = new_empty_script() + script["flowElements"] = [build_flow_element("wait")] + located = find_element_by_path(script, StepPath.root_index(0)) + assert located is not None + _, index, element = located + assert index == 0 + assert element["command"] == "wait" diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..a6f5657 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Perfecto MCP tool managers and AI Scriptless domain package.""" diff --git a/tools/ai_scriptless/__init__.py b/tools/ai_scriptless/__init__.py new file mode 100644 index 0000000..7f960c0 --- /dev/null +++ b/tools/ai_scriptless/__init__.py @@ -0,0 +1,125 @@ +from tools.ai_scriptless.commands import ( + COMMAND_SPECS, + CommandSpec, + command_id_from_element, + get_command_spec, + parse_command_id, +) +from tools.ai_scriptless.elements import ( + build_arguments, + build_branch, + build_flow_element, + build_if_statement, + build_logical_step, + build_loop, + new_empty_script, + strip_non_api_script_fields, + update_element_arguments, +) +from tools.ai_scriptless.item_key import ( + VISIBILITY_UI_ROOT, + ItemKey, + build_item_key, + build_move_test_body, + build_snapshot_search_body, + folder_type, + format_test_ui_location, + item_key_file_name, + split_item_key, +) +from tools.ai_scriptless.persistence import ( + _persist_script, + fetch_script_payload, + load_and_mutate, + persist_script, + script_write_lock, +) +from tools.ai_scriptless.script import Script, ScriptInput, coerce_script_dict +from tools.ai_scriptless.step_path import ( + STEP_PATH_PATTERN, + StepPath, + coerce_step_path, +) +from tools.ai_scriptless.tree import ( + CONTAINER_TYPES, + delete_element_by_path, + find_container_by_path, + find_element_by_path, + find_step_path_for_element, + insert_flow_element, + move_element_by_path, + set_condition_expression, + set_element_enabled, + update_flow_element_counts, + validate_step_path, +) +from tools.ai_scriptless.variables import ( + SUPPORTED_VARIABLE_TYPES, + VARIABLE_TYPE_ALIASES, + add_script_variable, + build_variable_data, + build_variable_entry, + delete_script_variable, + find_variable, + list_script_variables, + modify_script_variable, + validate_variable_name, +) + +__all__ = [ + "COMMAND_SPECS", + "CONTAINER_TYPES", + "CommandSpec", + "ItemKey", + "STEP_PATH_PATTERN", + "Script", + "ScriptInput", + "SUPPORTED_VARIABLE_TYPES", + "VARIABLE_TYPE_ALIASES", + "VISIBILITY_UI_ROOT", + "_persist_script", + "add_script_variable", + "build_arguments", + "build_branch", + "build_flow_element", + "build_if_statement", + "build_item_key", + "build_logical_step", + "build_loop", + "build_move_test_body", + "build_snapshot_search_body", + "build_variable_data", + "build_variable_entry", + "StepPath", + "coerce_script_dict", + "coerce_step_path", + "command_id_from_element", + "delete_element_by_path", + "delete_script_variable", + "fetch_script_payload", + "find_container_by_path", + "find_element_by_path", + "find_step_path_for_element", + "find_variable", + "folder_type", + "format_test_ui_location", + "get_command_spec", + "insert_flow_element", + "item_key_file_name", + "list_script_variables", + "load_and_mutate", + "modify_script_variable", + "move_element_by_path", + "new_empty_script", + "persist_script", + "parse_command_id", + "script_write_lock", + "set_condition_expression", + "set_element_enabled", + "split_item_key", + "strip_non_api_script_fields", + "update_element_arguments", + "update_flow_element_counts", + "validate_step_path", + "validate_variable_name", +] diff --git a/tools/ai_scriptless/commands.py b/tools/ai_scriptless/commands.py new file mode 100644 index 0000000..df5ecfc --- /dev/null +++ b/tools/ai_scriptless/commands.py @@ -0,0 +1,102 @@ +from dataclasses import dataclass, field +from typing import Any + +_HANDSET_DUT: dict[str, tuple[str, Any]] = {"handsetId": ("VARIABLE", "DUT")} + + +@dataclass(frozen=True) +class CommandSpec: + command_id: str + element_type: str + default_arguments: dict[str, tuple[str, Any]] + argument_aliases: dict[str, str] = field(default_factory=dict) + + @property + def error_policy(self) -> str: + return "IGNORE" if self.element_type == "Validation" else "ABORT" + + def default_arguments_merged(self) -> dict[str, tuple[str, Any]]: + return dict(self.default_arguments) + + def normalize_argument_names(self, arguments: dict[str, Any]) -> dict[str, Any]: + normalized: dict[str, Any] = {} + for name, value in arguments.items(): + normalized[self.argument_aliases.get(name, name)] = value + return normalized + + def drop_superseded_aliases(self, arguments: dict[str, Any]) -> None: + for alias, canonical in self.argument_aliases.items(): + if alias != canonical and canonical in arguments: + arguments.pop(alias, None) + + +def parse_command_id(command_id: str) -> tuple[str, str]: + if command_id.startswith("ai_"): + return "ai", command_id[3:] + if "_" in command_id: + command, subcommand = command_id.split("_", 1) + return command, subcommand + return command_id, "" + + +def command_id_from_element(element: dict[str, Any]) -> str: + command = element.get("command", "") + subcommand = element.get("subcommand") or "" + if subcommand: + return f"{command}_{subcommand}" + return command + + +def infer_element_type(command: str, subcommand: str) -> str: + if command == "ai" and subcommand == "validation": + return "Validation" + if command == "checkpoint": + return "Validation" + return "Action" + + +def _command_spec( + command_id: str, + element_type: str, + default_arguments: dict[str, tuple[str, Any]], + argument_aliases: dict[str, str] | None = None, +) -> CommandSpec: + return CommandSpec( + command_id=command_id, + element_type=element_type, + default_arguments=default_arguments, + argument_aliases=argument_aliases or {}, + ) + + +COMMAND_SPECS: dict[str, CommandSpec] = { + spec.command_id: spec + for spec in ( + _command_spec("ai_user-action", "Action", {**_HANDSET_DUT, "action": ("CONSTANT", "")}), + _command_spec("ai_validation", "Validation", {**_HANDSET_DUT, "validation": ("CONSTANT", "")}), + _command_spec("ai_visual-comparison", "Action", {**_HANDSET_DUT, "name": ("CONSTANT", "")}), + _command_spec("comment", "Action", {"text": ("CONSTANT", "")}), + _command_spec( + "wait", + "Action", + {"duration": ("CONSTANT", "1")}, + argument_aliases={"waitDuration": "duration"}, + ), + _command_spec("handset_ready", "Action", dict(_HANDSET_DUT)), + _command_spec("touch_tap", "Action", dict(_HANDSET_DUT)), + _command_spec("checkpoint_text", "Validation", dict(_HANDSET_DUT)), + _command_spec("checkpoint_image", "Validation", dict(_HANDSET_DUT)), + ) +} + + +def get_command_spec(command_id: str) -> CommandSpec: + registered = COMMAND_SPECS.get(command_id) + if registered is not None: + return registered + command, subcommand = parse_command_id(command_id) + return CommandSpec( + command_id=command_id, + element_type=infer_element_type(command, subcommand), + default_arguments=dict(_HANDSET_DUT), + ) diff --git a/tools/ai_scriptless/elements.py b/tools/ai_scriptless/elements.py new file mode 100644 index 0000000..e7e58b0 --- /dev/null +++ b/tools/ai_scriptless/elements.py @@ -0,0 +1,203 @@ +from typing import Any, Optional + +from tools.ai_scriptless.commands import ( + command_id_from_element, + get_command_spec, + parse_command_id, +) + + +def _make_argument(name: str, value: Any, data_source: str = "CONSTANT") -> dict[str, Any]: + if data_source == "VARIABLE": + data: dict[str, Any] = { + "@type": "VariableArgumentData", + "dataSource": "VARIABLE", + "value": value, + } + else: + data = { + "@type": "ConstantArgumentData", + "dataSource": "CONSTANT", + "secured": False, + "value": value, + } + return {"@type": "FunctionArgument", "name": name, "data": data} + + +def build_arguments(command_id: str, arguments: Optional[dict[str, Any]]) -> list[dict[str, Any]]: + spec = get_command_spec(command_id) + merged = spec.default_arguments_merged() + if arguments: + for name, value in spec.normalize_argument_names(arguments).items(): + if isinstance(value, dict) and "data_source" in value: + merged[name] = (value["data_source"], value.get("value")) + else: + merged[name] = ("CONSTANT", value) + spec.drop_superseded_aliases(merged) + return [_make_argument(name, value, source) for name, (source, value) in merged.items()] + + +def build_flow_element(command_id: str, arguments: Optional[dict[str, Any]] = None) -> dict[str, Any]: + command, subcommand = parse_command_id(command_id) + spec = get_command_spec(command_id) + return { + "@type": spec.element_type, + "validations": [], + "errorPolicy": spec.error_policy, + "command": command, + "subcommand": subcommand, + "arguments": build_arguments(command_id, arguments), + "comment": None, + "status": None, + "active": True, + } + + +def build_branch(clause: str) -> dict[str, Any]: + return { + "@type": "Branch", + "clause": clause, + "flowElements": [], + "numOfFlowElements": 0, + "empty": True, + "active": True, + "comment": None, + "status": None, + } + + +def build_logical_step(label: Optional[str] = None) -> dict[str, Any]: + return { + "@type": "LogicalStep", + "flowElements": [], + "active": True, + "label": label or "", + "comment": None, + "status": None, + } + + +def build_loop(count: int = 1) -> dict[str, Any]: + return { + "@type": "Loop", + "iterator": {"@type": "RepeatIterator", "count": count}, + "flowElements": [], + "active": True, + "comment": None, + "status": None, + } + + +def build_if_statement(expression: Optional[str] = None, label: Optional[str] = None) -> dict[str, Any]: + then_branch = build_branch("THEN") + else_branch = build_branch("ELSE") + statement: dict[str, Any] = { + "@type": "IfStatement", + "branches": [then_branch, else_branch], + # thenClause/elseClause must alias branches[0]/[1] — tree navigation and API layout share the same Branch objects. + "thenClause": then_branch, + "elseClause": else_branch, + "label": label or "", + "numOfFlowElements": 3, + "comment": None, + "status": None, + "active": True, + } + if expression: + statement["expression"] = expression + return statement + + +def new_empty_script() -> dict[str, Any]: + return { + "@type": "Script", + "parameters": [{ + "@type": "Parameter", + "data": { + "@type": "HandsetData", + "key": None, + "value": None, + "secured": False, + "description": None, + "displayName": None, + "name": "DUT", + }, + }], + "info": {"@type": "ScriptInfo", "description": "", "modelVersion": "1.0"}, + "options": {"@type": "ScriptOptions", "automaticAllocation": True}, + "variables": [], + "flowElements": [], + "numOfFlowElements": 0, + } + + +def normalize_if_statement_aliases(script: dict[str, Any]) -> None: + """Re-alias thenClause/elseClause to branches[0]/[1] after API load. + + Perfecto returns duplicate Branch trees; tree navigation edits branches[] only. + On save, the API canonicalizes from branches[] (thenClause is reconciled on read). + When counts match but content differs, branches[] wins — same rule as sync_branch_children. + If only thenClause has children and branches[0] is empty, copy clause children first + or Perfecto will drop them on persist. + """ + + def sync_branch_children(branch: dict[str, Any], clause: Optional[dict[str, Any]]) -> None: + if clause is None or clause is branch: + return + branch_children = branch.get("flowElements", []) + clause_children = clause.get("flowElements", []) + if not branch_children and clause_children: + branch["flowElements"] = clause_children + elif branch_children and clause_children and branch_children is not clause_children: + if len(clause_children) > len(branch_children): + branch["flowElements"] = clause_children + + def walk(flow_elements: list[dict[str, Any]]) -> None: + for element in flow_elements: + element_type = element.get("@type") + if element_type in ("LogicalStep", "Loop"): + walk(element.get("flowElements", [])) + elif element_type == "IfStatement": + branches = element.get("branches", []) + if len(branches) >= 2: + sync_branch_children(branches[0], element.get("thenClause")) + sync_branch_children(branches[1], element.get("elseClause")) + element["thenClause"] = branches[0] + element["elseClause"] = branches[1] + for branch in branches: + walk(branch.get("flowElements", [])) + + walk(script.get("flowElements", [])) + + +def strip_non_api_script_fields(script: dict[str, Any]) -> None: + def walk_element(element: dict[str, Any]) -> None: + element.pop("uuid", None) + for child in element.get("flowElements", []): + walk_element(child) + if element.get("@type") == "IfStatement": + for branch in element.get("branches", []): + walk_element(branch) + for clause_key in ("thenClause", "elseClause"): + clause = element.get(clause_key) + if clause: + walk_element(clause) + + for element in script.get("flowElements", []): + walk_element(element) + + +def update_element_arguments(element: dict[str, Any], arguments: dict[str, Any]) -> None: + command_id = command_id_from_element(element) + spec = get_command_spec(command_id) + existing = {argument["name"]: argument for argument in element.get("arguments", [])} + for name, value in spec.normalize_argument_names(arguments).items(): + if isinstance(value, dict) and "data_source" in value: + source = value["data_source"] + argument_value = value.get("value") + else: + source = "CONSTANT" + argument_value = value + existing[name] = _make_argument(name, argument_value, source) + spec.drop_superseded_aliases(existing) + element["arguments"] = list(existing.values()) diff --git a/tools/ai_scriptless/item_key.py b/tools/ai_scriptless/item_key.py new file mode 100644 index 0000000..3267999 --- /dev/null +++ b/tools/ai_scriptless/item_key.py @@ -0,0 +1,112 @@ +from dataclasses import dataclass +from typing import Any + + +VISIBILITY_UI_ROOT = { + "PRIVATE": "My Tests", + "PUBLIC": "Public Tests", + "GROUP": "Group Tests", +} + + +@dataclass(frozen=True) +class ItemKey: + value: str + + @classmethod + def parse(cls, item_key: str) -> "ItemKey": + visibility, _, path = item_key.partition(":") + if not path: + raise ValueError(f"Invalid itemKey format (expected VISIBILITY:path): {item_key}") + return cls(item_key) + + @classmethod + def build(cls, visibility: str, folder: str, name: str) -> "ItemKey": + test_name = name if name.endswith(".xml") else f"{name}.xml" + folder_path = folder.strip("/") + if folder_path: + return cls(f"{visibility}:{folder_path}/{test_name}") + return cls(f"{visibility}:{test_name}") + + @property + def visibility(self) -> str: + return self.value.partition(":")[0] + + @property + def path(self) -> str: + _, _, path = self.value.partition(":") + return path + + @property + def file_name(self) -> str: + return self.path.rsplit("/", 1)[-1] + + @property + def folder_path(self) -> str: + path = self.path + return path.rsplit("/", 1)[0] if "/" in path else "" + + @property + def folder_type(self) -> str: + return folder_type(self.visibility) + + def with_folder(self, folder: str, visibility: str) -> "ItemKey": + return ItemKey.build(visibility, folder.strip("/"), self.file_name.removesuffix(".xml")) + + def __str__(self) -> str: + return self.value + + +def build_item_key(visibility: str, folder: str, name: str) -> str: + return str(ItemKey.build(visibility, folder, name)) + + +def split_item_key(item_key: str) -> tuple[str, str]: + parsed = ItemKey.parse(item_key) + return parsed.visibility, parsed.path + + +def folder_type(visibility: str) -> str: + if visibility in ("PRIVATE", "GROUP"): + return visibility + return "PUBLIC" + + +def item_key_file_name(item_key: str) -> str: + return ItemKey.parse(item_key).file_name + + +def format_test_ui_location(item_key: str) -> str: + """Map itemKey to folder/test labels shown in the AI Scriptless Open Test UI.""" + key = ItemKey.parse(item_key) + root = VISIBILITY_UI_ROOT.get(key.visibility, key.visibility) + file_name = key.file_name.removesuffix(".xml") + if key.folder_path: + return f'"{root}" → folder "{key.folder_path}" → test "{file_name}"' + return f'"{root}" → test "{file_name}"' + + +def build_snapshot_search_body(test_id: str) -> dict[str, Any]: + key = ItemKey.parse(test_id) + return { + "repositoryType": "SCRIPTS", + "keyDetails": {"artifactId": key.path, "version": "v0"}, + "folderType": key.folder_type, + } + + +def build_move_test_body( + test_id: str, + folder: str, + visibility: str, +) -> dict[str, Any]: + source = ItemKey.parse(test_id) + target = source.with_folder(folder, visibility) + return { + "repositoryType": "SCRIPTS", + "keyDetails": {"artifactId": source.path, "version": "v0"}, + "folderType": source.folder_type, + "targetKeyDetails": {"artifactId": target.path, "version": "v0"}, + "targetFolderType": target.folder_type, + "copy": False, + } diff --git a/tools/ai_scriptless/persistence.py b/tools/ai_scriptless/persistence.py new file mode 100644 index 0000000..ff8259c --- /dev/null +++ b/tools/ai_scriptless/persistence.py @@ -0,0 +1,159 @@ +import asyncio +import copy +import json +from contextlib import asynccontextmanager +from typing import Any, Optional +from urllib.parse import quote + +from config import perfecto +from config.token import PerfectoToken +from models.result import BaseResult +from tools.ai_scriptless.elements import normalize_if_statement_aliases, strip_non_api_script_fields +from tools.ai_scriptless.script import ScriptInput, coerce_script_dict +from tools.ai_scriptless.tree import update_flow_element_counts +from tools.utils import api_request + + +async def fetch_script_payload(token: PerfectoToken, test_id: str) -> BaseResult: + script_url = perfecto.get_ai_scriptless_api_url(token.cloud_name) + script_url = script_url + f"/script?itemKey={quote(test_id, safe='')}" + return await api_request(token, "GET", endpoint=script_url) + + +_script_write_locks: dict[str, asyncio.Lock] = {} +_script_write_locks_guard = asyncio.Lock() + + +async def _get_script_write_lock(item_key: str) -> asyncio.Lock: + async with _script_write_locks_guard: + lock = _script_write_locks.get(item_key) + if lock is None: + lock = asyncio.Lock() + _script_write_locks[item_key] = lock + return lock + + +@asynccontextmanager +async def script_write_lock(item_key: str): + lock = await _get_script_write_lock(item_key) + async with lock: + yield + + +async def _persist_script( + token: PerfectoToken, + item_key: str, + script: ScriptInput, + saved_script: Optional[ScriptInput] = None, + snapshot_comment: Optional[str] = None, +) -> BaseResult: + working_script = copy.deepcopy(coerce_script_dict(script)) + baseline_script = copy.deepcopy(coerce_script_dict(saved_script or script)) + normalize_if_statement_aliases(working_script) + strip_non_api_script_fields(working_script) + strip_non_api_script_fields(baseline_script) + update_flow_element_counts(working_script) + + draft_url = perfecto.get_ai_scriptless_draft_api_url(token.cloud_name) + draft_data = json.dumps({ + "unsavedScript": working_script, + "savedScript": baseline_script, + }) + draft_result = await api_request( + token, + "POST", + endpoint=draft_url, + json={ + "path": item_key, + "type": "MOBILE_IDE_SCRIPT", + "data": draft_data, + }, + ) + if draft_result.error: + return draft_result + draft_key = draft_result.result.get("key") + if not draft_key: + return BaseResult(error="Draft creation failed: missing draft key in response") + + script_url = perfecto.get_ai_scriptless_api_url(token.cloud_name) + "/script" + save_body: dict[str, Any] = { + "script": working_script, + "itemKey": item_key, + "draftKey": draft_key, + } + if snapshot_comment: + save_body["snapshotComment"] = snapshot_comment + save_result = await api_request( + token, + "POST", + endpoint=script_url, + json=save_body, + ) + if save_result.error: + return save_result + + result: dict[str, Any] = { + "item_key": item_key, + "draft_key": draft_key, + "status": save_result.result.get("status", "success") if isinstance(save_result.result, dict) else "success", + "flow_element_count": len(working_script.get("flowElements", [])), + } + if snapshot_comment: + result["snapshot_comment"] = snapshot_comment + result["notes"] = [ + "Perfecto adds a new snapshot history entry on every script save.", + "Use list_snapshots with test_id to see version history after saving.", + ] + if snapshot_comment: + result["notes"].append( + "The comment labels the '' entry in list_snapshots (UI: Save with comment)." + ) + else: + result["notes"].append( + "Saving without comment still creates a history entry; pass comment to label the current version." + ) + return BaseResult(result=result) + + +async def persist_script( + token: PerfectoToken, + item_key: str, + script: ScriptInput, + saved_script: Optional[ScriptInput] = None, + snapshot_comment: Optional[str] = None, +) -> BaseResult: + async with script_write_lock(item_key): + return await _persist_script( + token, + item_key, + script, + saved_script, + snapshot_comment, + ) + + +async def load_and_mutate( + token: PerfectoToken, + test_id: str, + mutator, + snapshot_comment: Optional[str] = None, +) -> BaseResult: + async with script_write_lock(test_id): + payload_result = await fetch_script_payload(token, test_id) + if payload_result.error: + return payload_result + payload = payload_result.result + script = copy.deepcopy(payload.get("script", {})) + saved_script = copy.deepcopy(payload.get("script", {})) + normalize_if_statement_aliases(script) + try: + mutator(script) + except ValueError as exc: + return BaseResult(error=str(exc)) + return await _persist_script( + token, + test_id, + script, + saved_script, + snapshot_comment, + ) diff --git a/tools/ai_scriptless/script.py b/tools/ai_scriptless/script.py new file mode 100644 index 0000000..f52dc0e --- /dev/null +++ b/tools/ai_scriptless/script.py @@ -0,0 +1,154 @@ +import copy +from typing import Any, Optional, Union + +from tools.ai_scriptless.elements import new_empty_script, strip_non_api_script_fields +from tools.ai_scriptless.step_path import StepPathInput +from tools.ai_scriptless.tree import ( + delete_element_by_path, + find_container_by_path, + find_element_by_path, + find_step_path_for_element, + insert_flow_element, + move_element_by_path, + set_condition_expression, + set_element_enabled, + update_flow_element_counts, +) +from tools.ai_scriptless.variables import ( + add_script_variable, + delete_script_variable, + find_variable, + list_script_variables, + modify_script_variable, +) + +ScriptInput = Union["Script", dict[str, Any]] + + +def coerce_script_dict(script: ScriptInput) -> dict[str, Any]: + if isinstance(script, Script): + return script.to_dict() + return script + + +class Script: + """In-memory aggregate root for an AI Scriptless script payload.""" + + def __init__(self, data: dict[str, Any]) -> None: + self._data = data + + @classmethod + def empty(cls) -> "Script": + return cls(new_empty_script()) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Script": + return cls(copy.deepcopy(data)) + + @classmethod + def wrap(cls, data: dict[str, Any]) -> "Script": + """Wrap an existing dict for in-place mutation (e.g. load_and_mutate).""" + return cls(data) + + def to_dict(self) -> dict[str, Any]: + return self._data + + def copy(self) -> "Script": + return Script.from_dict(self._data) + + @property + def flow_elements(self) -> list[dict[str, Any]]: + return self._data.setdefault("flowElements", []) + + @property + def variables(self) -> list[dict[str, Any]]: + return self._data.setdefault("variables", []) + + @property + def flow_element_count(self) -> int: + return len(self.flow_elements) + + def prepare_for_persist(self) -> None: + strip_non_api_script_fields(self._data) + update_flow_element_counts(self._data) + + def find_step_path_for_element(self, target: dict[str, Any]) -> Optional[str]: + return find_step_path_for_element(self._data, target) + + def find_element_by_path( + self, + step_path: StepPathInput, + ) -> Optional[tuple[list[dict[str, Any]], int, dict[str, Any]]]: + return find_element_by_path(self._data, step_path) + + def find_container_by_path(self, step_path: StepPathInput) -> Optional[dict[str, Any]]: + return find_container_by_path(self._data, step_path) + + def insert_flow_element( + self, + element: dict[str, Any], + after_path: StepPathInput = None, + parent_path: StepPathInput = None, + ) -> None: + insert_flow_element(self._data, element, after_path=after_path, parent_path=parent_path) + + def delete_element_by_path(self, step_path: StepPathInput) -> None: + delete_element_by_path(self._data, step_path) + + def set_element_enabled(self, step_path: StepPathInput, enabled: bool) -> None: + set_element_enabled(self._data, step_path, enabled) + + def set_condition_expression(self, step_path: StepPathInput, expression: str) -> None: + set_condition_expression(self._data, step_path, expression) + + def move_element_by_path( + self, + step_path: StepPathInput, + after_path: StepPathInput = None, + parent_path: StepPathInput = None, + ) -> None: + move_element_by_path( + self._data, + step_path, + after_path=after_path, + parent_path=parent_path, + ) + + def list_variables(self) -> list[dict[str, Any]]: + return list_script_variables(self._data) + + def find_variable(self, variable_name: str) -> Optional[tuple[int, dict[str, Any]]]: + return find_variable(self._data, variable_name) + + def add_variable( + self, + name: str, + variable_type: str, + value: Any, + set_at_runtime: bool = False, + ) -> dict[str, Any]: + return add_script_variable( + self._data, + name, + variable_type, + value, + set_at_runtime=set_at_runtime, + ) + + def modify_variable( + self, + variable_name: str, + value: Optional[Any] = None, + variable_type: Optional[str] = None, + set_at_runtime: Optional[bool] = None, + ) -> dict[str, Any]: + return modify_script_variable( + self._data, + variable_name, + value=value, + variable_type=variable_type, + set_at_runtime=set_at_runtime, + ) + + def delete_variable(self, variable_name: str) -> None: + delete_script_variable(self._data, variable_name) diff --git a/tools/ai_scriptless/step_path.py b/tools/ai_scriptless/step_path.py new file mode 100644 index 0000000..61d4ba5 --- /dev/null +++ b/tools/ai_scriptless/step_path.py @@ -0,0 +1,58 @@ +import re +from dataclasses import dataclass +from typing import Optional, Union + +# Dot-separated positional paths (no spaces). Examples: 0, 2.0, 5.b0, 5.b0.1 +# Segments are either a 0-based index (0, 1, 2) or a branch marker (b0=Then, b1=Else). +STEP_PATH_PATTERN = re.compile(r"^(?:\d+|b\d+)(?:\.(?:\d+|b\d+))*$") + +StepPathInput = Union[str, "StepPath", None] + + +@dataclass(frozen=True) +class StepPath: + parts: tuple[str, ...] + + @classmethod + def parse(cls, step_path: str) -> "StepPath": + if not step_path or step_path.strip() != step_path or " " in step_path: + raise ValueError( + "step_path must be a dot-separated positional path without spaces " + "(e.g. 0, 2.0, 5.b0, 5.b0.1)" + ) + if not STEP_PATH_PATTERN.match(step_path): + raise ValueError( + f"invalid step_path: {step_path!r}. Use dot-separated indices, e.g. 0, 2.0, 5.b0.1" + ) + return cls(tuple(step_path.split("."))) + + @classmethod + def root_index(cls, index: int) -> "StepPath": + return cls((str(index),)) + + def child_index(self, index: int) -> "StepPath": + return StepPath((*self.parts, str(index))) + + def branch(self, branch_index: int) -> "StepPath": + return StepPath((*self.parts, f"b{branch_index}")) + + @property + def parent_prefix(self) -> str: + if not self.parts: + return "" + return ".".join(self.parts) + "." + + def __str__(self) -> str: + return ".".join(self.parts) + + +def coerce_step_path(step_path: StepPathInput) -> Optional[StepPath]: + if step_path is None: + return None + if isinstance(step_path, StepPath): + return step_path + return StepPath.parse(step_path) + + +def validate_step_path(step_path: str) -> None: + StepPath.parse(step_path) diff --git a/tools/ai_scriptless/tree.py b/tools/ai_scriptless/tree.py new file mode 100644 index 0000000..9cb46d0 --- /dev/null +++ b/tools/ai_scriptless/tree.py @@ -0,0 +1,220 @@ +from typing import Any, Optional + +from tools.ai_scriptless.step_path import StepPath, StepPathInput, coerce_step_path + + +CONTAINER_TYPES = frozenset({"LogicalStep", "Loop", "Branch"}) + + +def _update_branch_container(branch: dict[str, Any]) -> None: + children = branch.setdefault("flowElements", []) + for child in children: + _update_element_counts(child) + count = len(children) + branch["numOfFlowElements"] = count + if "empty" in branch: + branch["empty"] = count == 0 + + +def _update_element_counts(element: dict[str, Any]) -> None: + element_type = element.get("@type") + if element_type == "IfStatement": + branches = element.get("branches", []) + direct_in_branches = 0 + for branch in branches: + _update_branch_container(branch) + direct_in_branches += len(branch.get("flowElements", [])) + # Perfecto: 3 + direct step count in Then/Else (nested steps count inside their own IfStatement). + element["numOfFlowElements"] = 3 + direct_in_branches + elif element_type in ("LogicalStep", "Loop"): + children = element.setdefault("flowElements", []) + for child in children: + _update_element_counts(child) + element["numOfFlowElements"] = len(children) + elif element_type == "Branch": + _update_branch_container(element) + + +def validate_step_path(step_path: str) -> None: + StepPath.parse(step_path) + + +def find_step_path_for_element(script: dict[str, Any], target: dict[str, Any]) -> Optional[str]: + def walk( + flow_elements: list[dict[str, Any]], + parent: Optional[StepPath], + ) -> Optional[StepPath]: + for index, element in enumerate(flow_elements): + step_path = ( + StepPath.root_index(index) + if parent is None + else parent.child_index(index) + ) + if element is target: + return step_path + nested = walk(element.get("flowElements", []), step_path) + if nested: + return nested + if element.get("@type") == "IfStatement": + for branch_index, branch in enumerate(element.get("branches", [])): + branch_path = step_path.branch(branch_index) + if branch is target: + return branch_path + nested = walk(branch.get("flowElements", []), branch_path) + if nested: + return nested + return None + + located = walk(script.get("flowElements", []), None) + return str(located) if located is not None else None + + +def find_element_by_path( + script: dict[str, Any], + step_path: StepPathInput, +) -> Optional[tuple[list[dict[str, Any]], int, dict[str, Any]]]: + path = coerce_step_path(step_path) + if path is None: + return None + parts = path.parts + current_list = script.get("flowElements", []) + parent_list: Optional[list[dict[str, Any]]] = None + parent_index: Optional[int] = None + current_element: Optional[dict[str, Any]] = None + + for part_index, part in enumerate(parts): + is_last = part_index == len(parts) - 1 + + if part.startswith("b"): + if current_element is None or current_element.get("@type") != "IfStatement": + return None + branch_index = int(part[1:]) + branches = current_element.get("branches", []) + if branch_index >= len(branches): + return None + if is_last: + parent_list = branches + parent_index = branch_index + current_element = branches[branch_index] + else: + current_element = branches[branch_index] + current_list = current_element.get("flowElements", []) + continue + + index = int(part) + if index >= len(current_list): + return None + if is_last: + parent_list = current_list + parent_index = index + current_element = current_list[index] + else: + current_element = current_list[index] + next_part = parts[part_index + 1] + if not next_part.startswith("b"): + current_list = current_element.get("flowElements", []) + + if current_element is None or parent_list is None or parent_index is None: + return None + return parent_list, parent_index, current_element + + +def find_container_by_path(script: dict[str, Any], step_path: StepPathInput) -> Optional[dict[str, Any]]: + located = find_element_by_path(script, step_path) + if located is None: + return None + _, _, element = located + return element + + +def update_flow_element_counts(script: dict[str, Any]) -> None: + for element in script.get("flowElements", []): + _update_element_counts(element) + script["numOfFlowElements"] = len(script.get("flowElements", [])) + + +def insert_flow_element( + script: dict[str, Any], + element: dict[str, Any], + after_path: StepPathInput = None, + parent_path: StepPathInput = None, +) -> None: + if parent_path: + parent = find_container_by_path(script, parent_path) + if parent is None: + raise ValueError(f"parent_path not found: {parent_path}") + if parent.get("@type") not in CONTAINER_TYPES: + raise ValueError( + f"parent_path must reference a container (LogicalStep, Loop, Branch): {parent_path}" + ) + parent.setdefault("flowElements", []).append(element) + elif after_path: + located = find_element_by_path(script, after_path) + if located is None: + raise ValueError(f"after_path not found: {after_path}") + elements, index, _ = located + elements.insert(index + 1, element) + else: + script.setdefault("flowElements", []).append(element) + update_flow_element_counts(script) + + +def delete_element_by_path(script: dict[str, Any], step_path: StepPathInput) -> None: + located = find_element_by_path(script, step_path) + if located is None: + raise ValueError(f"step_path not found: {step_path}") + elements, index, _ = located + elements.pop(index) + update_flow_element_counts(script) + + +def set_element_enabled(script: dict[str, Any], step_path: StepPathInput, enabled: bool) -> None: + located = find_element_by_path(script, step_path) + if located is None: + raise ValueError(f"step_path not found: {step_path}") + _, _, element = located + element["active"] = enabled + + +def set_condition_expression(script: dict[str, Any], step_path: StepPathInput, expression: str) -> None: + located = find_element_by_path(script, step_path) + if located is None: + raise ValueError(f"step_path not found: {step_path}") + _, _, element = located + if element.get("@type") != "IfStatement": + raise ValueError(f"step_path must reference an IfStatement: {step_path}") + element["expression"] = expression + + +def move_element_by_path( + script: dict[str, Any], + step_path: StepPathInput, + after_path: StepPathInput = None, + parent_path: StepPathInput = None, +) -> None: + located = find_element_by_path(script, step_path) + if located is None: + raise ValueError(f"step_path not found: {step_path}") + source_list, source_index, element = located + source_list.pop(source_index) + + if parent_path: + parent = find_container_by_path(script, parent_path) + if parent is None: + raise ValueError(f"parent_path not found: {parent_path}") + if parent.get("@type") not in CONTAINER_TYPES: + raise ValueError( + f"parent_path must reference a container (LogicalStep, Loop, Branch): {parent_path}" + ) + parent.setdefault("flowElements", []).append(element) + elif after_path: + target = find_element_by_path(script, after_path) + if target is None: + raise ValueError(f"after_path not found: {after_path}") + target_list, target_index, _ = target + if target_list is source_list and source_index < target_index: + target_index -= 1 + target_list.insert(target_index + 1, element) + else: + script.setdefault("flowElements", []).append(element) + update_flow_element_counts(script) diff --git a/tools/ai_scriptless/variables.py b/tools/ai_scriptless/variables.py new file mode 100644 index 0000000..59379ba --- /dev/null +++ b/tools/ai_scriptless/variables.py @@ -0,0 +1,153 @@ +from typing import Any, Optional + + +VARIABLE_TYPE_ALIASES = { + "string": "StringData", + "secured_string": "StringData", + "number": "IntegerData", + "boolean": "BooleanData", + "device": "HandsetData", + "media": "MediaData", + "datatable": "TableData", +} + +SUPPORTED_VARIABLE_TYPES = frozenset({"string", "secured_string", "number", "boolean"}) + + +def validate_variable_name(name: str) -> None: + if not name or not name.strip(): + raise ValueError("name is required") + if name[0].isdigit(): + raise ValueError("name cannot begin with a number") + if not all(char.isalnum() or char == "_" for char in name): + raise ValueError("name may contain only letters, numbers, and underscore") + + +def _coerce_variable_value(variable_type: str, value: Any) -> Any: + if variable_type == "boolean": + if isinstance(value, bool): + return value + normalized = str(value).lower() + if normalized not in ("true", "false"): + raise ValueError("boolean value must be true or false") + return normalized == "true" + if variable_type == "number": + try: + numeric = int(value) + except (TypeError, ValueError) as exc: + raise ValueError("number value must be an integer") from exc + return numeric + return "" if value is None else str(value) + + +def build_variable_data(variable_type: str, name: str, value: Any) -> dict[str, Any]: + if variable_type not in SUPPORTED_VARIABLE_TYPES: + raise ValueError( + f"Unsupported variable type: {variable_type}. " + f"Supported types: {', '.join(sorted(SUPPORTED_VARIABLE_TYPES))}" + ) + validate_variable_name(name) + coerced_value = _coerce_variable_value(variable_type, value) + data_type = VARIABLE_TYPE_ALIASES[variable_type] + data: dict[str, Any] = { + "@type": data_type, + "description": None, + "displayName": None, + "name": name, + "secured": variable_type == "secured_string", + "value": coerced_value, + } + if data_type == "HandsetData": + data["key"] = None + if data_type == "TableData": + data["columns"] = [] + return data + + +def build_variable_entry( + name: str, + variable_type: str, + value: Any, + set_at_runtime: bool = False, +) -> dict[str, Any]: + return { + "@type": "Parameter" if set_at_runtime else "Variable", + "data": build_variable_data(variable_type, name, value), + } + + +def find_variable(script: dict[str, Any], variable_name: str) -> Optional[tuple[int, dict[str, Any]]]: + for index, variable in enumerate(script.get("variables", [])): + data = variable.get("data", {}) + if data.get("name") == variable_name: + return index, variable + return None + + +def list_script_variables(script: dict[str, Any]) -> list[dict[str, Any]]: + return list(script.get("variables", [])) + + +def add_script_variable( + script: dict[str, Any], + name: str, + variable_type: str, + value: Any, + set_at_runtime: bool = False, +) -> dict[str, Any]: + validate_variable_name(name) + if find_variable(script, name): + raise ValueError(f"variable already exists: {name}") + if name == "DUT": + raise ValueError("DUT is a test parameter, not a script variable") + entry = build_variable_entry(name, variable_type, value, set_at_runtime) + script.setdefault("variables", []).append(entry) + return entry + + +def modify_script_variable( + script: dict[str, Any], + variable_name: str, + value: Optional[Any] = None, + variable_type: Optional[str] = None, + set_at_runtime: Optional[bool] = None, +) -> dict[str, Any]: + located = find_variable(script, variable_name) + if located is None: + raise ValueError(f"variable not found: {variable_name}") + _, variable = located + current_type = _variable_type_from_data(variable.get("data", {})) + target_type = variable_type or current_type + if target_type not in SUPPORTED_VARIABLE_TYPES: + raise ValueError( + f"Unsupported variable type: {target_type}. " + f"Supported types: {', '.join(sorted(SUPPORTED_VARIABLE_TYPES))}" + ) + current_value = variable.get("data", {}).get("value") + target_value = current_value if value is None else value + variable["data"] = build_variable_data(target_type, variable_name, target_value) + if set_at_runtime is not None: + variable["@type"] = "Parameter" if set_at_runtime else "Variable" + return variable + + +def delete_script_variable(script: dict[str, Any], variable_name: str) -> None: + located = find_variable(script, variable_name) + if located is None: + raise ValueError(f"variable not found: {variable_name}") + index, _ = located + script.get("variables", []).pop(index) + + +def _variable_type_from_data(data: dict[str, Any]) -> str: + data_type = data.get("@type", "") + if data_type == "StringData": + return "secured_string" if data.get("secured") else "string" + reverse = { + "BooleanData": "boolean", + "IntegerData": "number", + "HandsetData": "device", + "MediaData": "media", + "TableData": "datatable", + } + return reverse.get(data_type, "string") diff --git a/tools/ai_scriptless_commands.py b/tools/ai_scriptless_commands.py new file mode 100644 index 0000000..e09ccfd --- /dev/null +++ b/tools/ai_scriptless_commands.py @@ -0,0 +1,20 @@ +"""Backward-compatible facade. Implementation lives in tools.ai_scriptless.commands.""" + +from tools.ai_scriptless.commands import * # noqa: F403 +from tools.ai_scriptless.commands import ( + COMMAND_SPECS, + CommandSpec, + command_id_from_element, + get_command_spec, + infer_element_type, + parse_command_id, +) + +__all__ = [ + "COMMAND_SPECS", + "CommandSpec", + "command_id_from_element", + "get_command_spec", + "infer_element_type", + "parse_command_id", +] diff --git a/tools/ai_scriptless_manager.py b/tools/ai_scriptless_manager.py index 05c613f..9bbb153 100644 --- a/tools/ai_scriptless_manager.py +++ b/tools/ai_scriptless_manager.py @@ -1,6 +1,7 @@ import json import traceback from typing import Optional, Any, Dict +from urllib.parse import quote import httpx from mcp.server.fastmcp import Context @@ -10,11 +11,74 @@ from config.perfecto import TOOLS_PREFIX, SUPPORT_MESSAGE from config.token import PerfectoToken, token_verify from formatters.ai_scriptless import format_ai_scriptless_tests, \ - format_ai_scriptless_tests_filter_values + format_ai_scriptless_tests_filter_values, command_selection_policy_info, \ + format_command_catalog, format_command_definitions, format_snapshots_list, \ + format_test_structure, format_test_variables from models.manager import Manager from models.result import BaseResult, PaginationResult +from tools.ai_scriptless_script import ( + add_script_variable, + build_flow_element, + build_if_statement, + build_item_key, + build_logical_step, + build_loop, + build_move_test_body, + build_snapshot_search_body, + delete_script_variable, + delete_element_by_path, + fetch_script_payload, + find_element_by_path, + find_step_path_for_element, + insert_flow_element, + load_and_mutate, + modify_script_variable, + move_element_by_path, + new_empty_script, + persist_script, + script_write_lock, + set_condition_expression, + set_element_enabled, + split_item_key, + item_key_file_name, + format_test_ui_location, + update_element_arguments, +) from tools.utils import api_request +STEP_PATH_REFRESH_NOTES = [ + "step_path values are dot-separated positional paths (e.g. 0, 2.0, 5.b0.1); Perfecto does not persist them.", + "After this operation, step paths may have changed. Call view_test_structure before the next edit; " + "do not reuse step_path values from this response.", +] + +def _append_step_path_refresh_notes(result: BaseResult) -> BaseResult: + if result.error or not isinstance(result.result, dict): + return result + notes = result.result.setdefault("notes", []) + for note in STEP_PATH_REFRESH_NOTES: + if note not in notes: + notes.append(note) + return result + + +def _append_ui_access_info( + result: BaseResult, + cloud_name: str, + test_id: Optional[str] = None, +) -> BaseResult: + if result.error: + return result + lab_url = perfecto.get_ai_scriptless_lab_url(cloud_name) + lines = [f"AI Scriptless UI (no per-test deep link): [{lab_url}]({lab_url})"] + if test_id: + lines.append( + "If you need to open it in the UI: Tests → Open or Manage tests, then navigate to " + f"{format_test_ui_location(test_id)}" + ) + result.append_info(lines) + return result + class AiScriptlessManager(Manager): def __init__(self, token: Optional[PerfectoToken], ctx: Context): @@ -33,21 +97,23 @@ async def list_tests(self, args: dict[str, Any]) -> BaseResult: result_formatter_params={"page_size": page_size, "skip": skip, "filters": args}) + items = tests_result.result or [] page_result = PaginationResult( - items=tests_result.result, - count=len(tests_result.result), + items=items, + count=len(items), page=page_index, offset=skip, next_offset=skip + page_size, - has_more=page_size - len(tests_result.result) <= 0, + has_more=page_size - len(items) <= 0, ) - return BaseResult( + result = BaseResult( result=page_result, error=tests_result.error, warning=tests_result.warning, info=tests_result.info, ) + return _append_ui_access_info(result, self.token.cloud_name) @token_verify async def list_filter_values(self, filter_names: list[str]) -> BaseResult: @@ -143,6 +209,439 @@ async def execute_test(self, test_id: str, device_type: str, device_under_test: error="Invalid device_type or device_under_test value." ) + @token_verify + async def list_commands(self, checkpoint: bool = False) -> BaseResult: + commands_url = perfecto.get_ai_scriptless_command_repository_url(self.token.cloud_name) + commands_url = commands_url + "/commands" + if checkpoint: + commands_url = commands_url + "?checkpoint=true" + result = await api_request(self.token, "GET", endpoint=commands_url, + result_formatter=format_command_catalog) + if not result.error: + result.append_info(command_selection_policy_info()) + return result + + @token_verify + async def get_command_definitions(self, command_ids: list[str]) -> BaseResult: + if not command_ids: + return BaseResult(error="command_ids is required and must not be empty") + definitions_url = perfecto.get_ai_scriptless_command_repository_url(self.token.cloud_name) + definitions_url = definitions_url + "/commands/definitions" + return await api_request(self.token, "POST", endpoint=definitions_url, + json={"commandIds": command_ids}, + result_formatter=format_command_definitions) + + @token_verify + async def view_test_structure(self, test_id: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required (itemKey from list_tests)") + script_url = perfecto.get_ai_scriptless_api_url(self.token.cloud_name) + script_url = script_url + f"/script?itemKey={quote(test_id, safe='')}" + result = await api_request(self.token, "GET", endpoint=script_url, + result_formatter=format_test_structure, + result_formatter_params={"item_key": test_id}) + return _append_ui_access_info(result, self.token.cloud_name, test_id) + + @token_verify + async def add_command( + self, + test_id: str, + command_id: str, + arguments: Optional[dict[str, Any]] = None, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not command_id: + return BaseResult(error="command_id is required (from list_commands)") + + element = build_flow_element(command_id, arguments) + inserted_path: dict[str, Optional[str]] = {"step_path": None} + + def mutator(script: dict[str, Any]) -> None: + insert_flow_element(script, element, after_path=after_path, parent_path=parent_path) + inserted_path["step_path"] = find_step_path_for_element(script, element) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["step_path"] = inserted_path["step_path"] + result.result["command_id"] = command_id + return _append_step_path_refresh_notes(result) + + @token_verify + async def modify_command(self, test_id: str, step_path: str, arguments: dict[str, Any]) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not step_path: + return BaseResult(error="step_path is required (from view_test_structure)") + if not arguments: + return BaseResult(error="arguments is required") + + def mutator(script: dict[str, Any]) -> None: + located = find_element_by_path(script, step_path) + if located is None: + raise ValueError(f"step_path not found: {step_path}") + _, _, element = located + update_element_arguments(element, arguments) + + return _append_step_path_refresh_notes( + await load_and_mutate(self.token, test_id, mutator) + ) + + @token_verify + async def delete_command(self, test_id: str, step_path: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not step_path: + return BaseResult(error="step_path is required (from view_test_structure)") + + def mutator(script: dict[str, Any]) -> None: + delete_element_by_path(script, step_path) + + return _append_step_path_refresh_notes( + await load_and_mutate(self.token, test_id, mutator) + ) + + @token_verify + async def set_command_enabled(self, test_id: str, step_path: str, enabled: bool) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not step_path: + return BaseResult(error="step_path is required (from view_test_structure)") + + def mutator(script: dict[str, Any]) -> None: + set_element_enabled(script, step_path, enabled) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["step_path"] = step_path + result.result["active"] = enabled + return _append_step_path_refresh_notes(result) + + @token_verify + async def save_test(self, test_id: str, comment: Optional[str] = None) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + + def mutator(_script: dict[str, Any]) -> None: + pass + + return await load_and_mutate( + self.token, + test_id, + mutator, + snapshot_comment=comment, + ) + + @token_verify + async def create_test(self, name: str, folder: str = "My Folder", visibility: str = "PRIVATE") -> BaseResult: + if not name: + return BaseResult(error="name is required") + item_key = build_item_key(visibility, folder, name) + script = new_empty_script() + result = await persist_script(self.token, item_key, script) + return _append_ui_access_info(result, self.token.cloud_name, item_key) + + @token_verify + async def save_test_as( + self, + test_id: str, + name: str, + folder: str = "My Folder", + visibility: str = "PRIVATE", + comment: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not name: + return BaseResult(error="name is required") + async with script_write_lock(test_id): + payload_result = await fetch_script_payload(self.token, test_id) + if payload_result.error: + return payload_result + script = payload_result.result.get("script", {}) + item_key = build_item_key(visibility, folder, name) + result = _append_step_path_refresh_notes( + await persist_script(self.token, item_key, script, snapshot_comment=comment) + ) + return _append_ui_access_info(result, self.token.cloud_name, item_key) + + async def _add_structure( + self, + test_id: str, + element: dict[str, Any], + structure_type: str, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + inserted_path: dict[str, Optional[str]] = {"step_path": None} + + def mutator(script: dict[str, Any]) -> None: + insert_flow_element(script, element, after_path=after_path, parent_path=parent_path) + inserted_path["step_path"] = find_step_path_for_element(script, element) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["step_path"] = inserted_path["step_path"] + result.result["structure_type"] = structure_type + return _append_step_path_refresh_notes(result) + + @token_verify + async def add_logical_step( + self, + test_id: str, + label: Optional[str] = None, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + element = build_logical_step(label) + return await self._add_structure(test_id, element, "LogicalStep", after_path, parent_path) + + @token_verify + async def add_loop( + self, + test_id: str, + count: int = 1, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if count < 1: + return BaseResult(error="count must be at least 1") + element = build_loop(count) + result = await self._add_structure(test_id, element, "Loop", after_path, parent_path) + if not result.error: + result.result["count"] = count + return result + + @token_verify + async def add_condition( + self, + test_id: str, + expression: Optional[str] = None, + label: Optional[str] = None, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + element = build_if_statement(expression, label) + result = await self._add_structure(test_id, element, "IfStatement", after_path, parent_path) + if not result.error and expression: + result.result["expression"] = expression + return result + + @token_verify + async def set_condition_expression(self, test_id: str, step_path: str, expression: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not step_path: + return BaseResult(error="step_path is required (IfStatement path from view_test_structure)") + if not expression: + return BaseResult(error="expression is required") + + def mutator(script: dict[str, Any]) -> None: + set_condition_expression(script, step_path, expression) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["step_path"] = step_path + result.result["expression"] = expression + return _append_step_path_refresh_notes(result) + + @token_verify + async def move_command( + self, + test_id: str, + step_path: str, + after_path: Optional[str] = None, + parent_path: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not step_path: + return BaseResult(error="step_path is required") + if not after_path and not parent_path: + return BaseResult(error="after_path or parent_path is required") + + def mutator(script: dict[str, Any]) -> None: + move_element_by_path(script, step_path, after_path=after_path, parent_path=parent_path) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["step_path"] = step_path + return _append_step_path_refresh_notes(result) + + @token_verify + async def delete_test(self, test_id: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required (itemKey from list_tests)") + delete_url = perfecto.get_ai_scriptless_api_url(self.token.cloud_name) + "/repositories" + return await api_request( + self.token, + "DELETE", + endpoint=delete_url, + params={"itemKey": test_id}, + ) + + @token_verify + async def move_test( + self, + test_id: str, + folder: str, + visibility: Optional[str] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required (itemKey from list_tests)") + + try: + source_visibility, _ = split_item_key(test_id) + except ValueError as exc: + return BaseResult(error=str(exc)) + + target_visibility = visibility or source_visibility + move_url = perfecto.get_repository_management_api_url(self.token.cloud_name) + "/artifacts" + body = build_move_test_body(test_id, folder, target_visibility) + result = await api_request(self.token, "PATCH", endpoint=move_url, json=body) + if result.error: + return result + target_item_key = build_item_key( + target_visibility, + folder, + item_key_file_name(test_id).removesuffix(".xml"), + ) + result.result = { + "source_item_key": test_id, + "target_item_key": target_item_key, + "folder": folder, + "visibility": target_visibility, + } + return result + + @token_verify + async def list_snapshots(self, test_id: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required (itemKey from list_tests)") + try: + body = build_snapshot_search_body(test_id) + except ValueError as exc: + return BaseResult(error=str(exc)) + snapshots_url = ( + perfecto.get_repository_management_api_url(self.token.cloud_name) + "/snapshots/search" + ) + return await api_request( + self.token, + "POST", + endpoint=snapshots_url, + json=body, + result_formatter=format_snapshots_list, + result_formatter_params={"test_id": test_id}, + ) + + @token_verify + async def view_snapshot(self, snapshot_id: str) -> BaseResult: + if not snapshot_id: + return BaseResult(error="snapshot_id is required (key from list_snapshots)") + if snapshot_id == "": + return BaseResult( + error="snapshot_id '' is the live script marker, not a historical snapshot. " + "Use view_test_structure with test_id for the current editable script." + ) + return await api_request( + self.token, + "GET", + endpoint=perfecto.get_ai_scriptless_api_url(self.token.cloud_name) + + f"/snapshots?itemKey={quote(snapshot_id, safe='')}", + result_formatter=format_test_structure, + result_formatter_params={"item_key": snapshot_id}, + ) + + @token_verify + async def list_test_variables(self, test_id: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required (itemKey from list_tests)") + payload_result = await fetch_script_payload(self.token, test_id) + if payload_result.error: + return payload_result + script = payload_result.result.get("script", {}) + variables = format_test_variables(script.get("variables", [])) + return BaseResult(result=variables) + + @token_verify + async def add_test_variable( + self, + test_id: str, + name: str, + variable_type: str = "string", + value: Any = "", + set_at_runtime: bool = False, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not name: + return BaseResult(error="name is required") + + def mutator(script: dict[str, Any]) -> None: + add_script_variable(script, name, variable_type, value, set_at_runtime) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["name"] = name + result.result["type"] = variable_type + result.result["set_at_runtime"] = set_at_runtime + return result + + @token_verify + async def modify_test_variable( + self, + test_id: str, + name: str, + value: Optional[Any] = None, + variable_type: Optional[str] = None, + set_at_runtime: Optional[bool] = None, + ) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not name: + return BaseResult(error="name is required") + if value is None and variable_type is None and set_at_runtime is None: + return BaseResult(error="At least one of value, variable_type, or set_at_runtime is required") + + def mutator(script: dict[str, Any]) -> None: + modify_script_variable(script, name, value, variable_type, set_at_runtime) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["name"] = name + return result + + @token_verify + async def delete_test_variable(self, test_id: str, name: str) -> BaseResult: + if not test_id: + return BaseResult(error="test_id is required") + if not name: + return BaseResult(error="name is required") + + def mutator(script: dict[str, Any]) -> None: + delete_script_variable(script, name) + + result = await load_and_mutate(self.token, test_id, mutator) + if result.error: + return result + result.result["name"] = name + return result + def register(mcp, token: Optional[PerfectoToken]): @mcp.tool( @@ -161,28 +660,153 @@ def register(mcp, token: Optional[PerfectoToken]): filter_names (list[str], values=['test_name', 'owner_list']): The filter name list. - execute_test: Execute a preconfigured AI Scriptless Test. args(dict): Dictionary with the following required parameters: - test_id (str): Test ID from list_tests() + test_id (str): Test ID from list_tests device_type (str, default='real', values=['real', 'virtual', 'desktop']: The device type. device_under_test (dict, required): Device configuration object. - When device_type='real': {device_id: str} (Get from list_real_devices()). - When device_type='virtual': {platform_name: str, manufacturer: str, model: str, platform_version: str} (Get from list_virtual_devices()). + When device_type='real': {device_id: str} (Get from list_real_devices). + When device_type='virtual': {platform_name: str, manufacturer: str, model: str, platform_version: str} (Get from list_virtual_devices). When device_type='desktop': {platform_name: str, platform_version: str, browser_name: str, - browser_version: str, resolution: str, location: str} (Get from list_desktop_devices()). + browser_version: str, resolution: str, location: str} (Get from list_desktop_devices). +- view_test_structure: View the hierarchical structure of an AI Scriptless test. Each step has step_path (dot-separated positional path, e.g. 0, 2.0, 5.b0.1). + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests (e.g. PRIVATE:My Folder/My Test.xml). +- list_commands: List available AI Scriptless commands from the command repository. + Returns the catalog in result and command selection policy in info (read info before add_command when authoring tests). + args(dict): Dictionary with the following optional parameters: + checkpoint (bool, default=false): If true, list checkpoint commands only. +- get_command_definitions: Get parameter definitions for one or more commands. + args(dict): Dictionary with the following required parameters: + command_ids (list[str]): Command IDs from list_commands (typically ai_user-action, ai_validation, ai_visual-comparison). +- add_command: Add a command to a test and persist it. + args(dict): Dictionary with the following parameters: + test_id (str, required): Test itemKey from list_tests. + command_id (str, required): Command ID from list_commands. + arguments (dict, optional): Command argument names to values. + after_path (str, optional): Insert after this step (step_path from view_test_structure). + parent_path (str, optional): Insert inside a container (step_path of LogicalStep, Loop, or Branch). +- modify_command: Update command arguments and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + step_path (str): Step path from view_test_structure (e.g. 0, 2.0, 5.b0.1). + arguments (dict): Argument names to new values. +- delete_command: Remove a command from a test and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + step_path (str): Step path from view_test_structure. +- set_command_enabled: Enable or disable (exclude/include) a command and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + step_path (str): Step path from view_test_structure. + enabled (bool): True to include, false to exclude. +- save_test: Persist the current test script. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + comment (str, optional): Labels the current version on '' in list_snapshots (UI: Save with comment). Every save also adds a UUID history entry. +- create_test: Create a new empty test with a DUT parameter. + args(dict): Dictionary with the following required parameters: + name (str): Test name without .xml extension. + folder (str, default='My Folder'): Target folder. + visibility (str, default='PRIVATE', values=['PUBLIC', 'PRIVATE']): Test visibility. +- save_test_as: Copy a test to a new itemKey and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Source test itemKey from list_tests. + name (str): New test name without .xml extension. + folder (str, default='My Folder'): Target folder. + visibility (str, default='PRIVATE', values=['PUBLIC', 'PRIVATE']): Test visibility. + comment (str, optional): Labels the current version on '' in list_snapshots for the saved copy. +- add_logical_step: Add a LogicalStep group container and persist. + args(dict): Dictionary with the following parameters: + test_id (str, required): Test itemKey from list_tests. + label (str, optional): Group label. + after_path (str, optional): Insert after this step path. + parent_path (str, optional): Insert inside a container step path. +- add_loop: Add a Loop container and persist. + args(dict): Dictionary with the following parameters: + test_id (str, required): Test itemKey from list_tests. + count (int, default=1): RepeatIterator count. + after_path (str, optional): Insert after this step path. + parent_path (str, optional): Insert inside a container step path. +- add_condition: Add an IfStatement condition with Then/Else branches and persist. + args(dict): Dictionary with the following parameters: + test_id (str, required): Test itemKey from list_tests. + expression (str, optional): Condition expression. + label (str, optional): Condition label. + after_path (str, optional): Insert after this step path. + parent_path (str, optional): Insert inside a container step path. +- set_condition_expression: Set the expression on an IfStatement and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + step_path (str): IfStatement path from view_test_structure (e.g. 5). + expression (str): Condition expression. +- move_command: Move a step to a new position and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + step_path (str): Step path to move. + after_path (str, optional): Insert after this sibling step path. + parent_path (str, optional): Move into this container step path (LogicalStep, Loop, or Branch). +- delete_test: Delete an AI Scriptless test from the repository. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. +- move_test: Move a test to another folder (same or different visibility). + args(dict): Dictionary with the following required parameters: + test_id (str): Source test itemKey from list_tests. + folder (str): Target folder path without visibility prefix (e.g. 'My Folder', 'MCP Archive', or 'My Folder/SubFolder'). The test file keeps its name. If the path does not exist, the API creates the nested folder segments automatically (the new folder may not appear as a CONTAINER in list_tests until it contains tests). + visibility (str, optional): Target visibility; defaults to the source test visibility. + Returns source_item_key and target_item_key; use target_item_key for view_test_structure, execute_test, and other actions after the move. +- list_snapshots: List snapshot history for a test (includes '' marker plus UUID historical versions). + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + Returns notes explaining that every save adds history entries and how '' vs UUID keys work. +- view_snapshot: View the hierarchical structure of a historical snapshot (same format as view_test_structure). + args(dict): Dictionary with the following required parameters: + snapshot_id (str): UUID key from list_snapshots (not ''; use view_test_structure for the live script). +- list_test_variables: List script variables configured on a test (distinct from DUT parameters). + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. +- add_test_variable: Add a script variable and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + name (str): Variable name (letters, numbers, underscore; cannot start with a number). + variable_type (str, default='string', values=['string', 'secured_string', 'number', 'boolean']): Variable type. + value (any, default=''): Variable value. + set_at_runtime (bool, default=false): When true, value is supplied at execution time. +- modify_test_variable: Update a script variable and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + name (str): Existing variable name. + value (any, optional): New value. + variable_type (str, optional): New type (string, secured_string, number, boolean). + set_at_runtime (bool, optional): Toggle runtime parameter behavior. +- delete_test_variable: Remove a script variable and persist. + args(dict): Dictionary with the following required parameters: + test_id (str): Test itemKey from list_tests. + name (str): Variable name to delete. Hints: +- LICENSE: AI Scriptless actions require a Perfecto AI license on your cloud (administrator opt-in via feature toggle). Without it, AI commands and related MCP operations will not work. Desktop web test authoring additionally requires the Desktop Web license. +- COVERAGE: DataTables, Scheduler (scheduled jobs), Embedded tests, and other advanced UI capabilities (folder management, rename test, restore snapshot, download as Appium, AI Assistant, Object Spy, per-step error policy, etc.) are not yet supported by this MCP tool. +- HELP: For product behavior and workarounds, use the perfecto_help tool: Filter by category_id='perfecto', subcategory_id_list=['ide']. +- UI_ACCESS: No per-test URL exists. Only UI entry: cloud_url/lab/scriptless-mobile/ (cloud_url from perfecto_user read_user). For debugging or unsupported MCP tasks, link the lab URL and tell the user to open the test via Tests → Open or Manage tests using the folder tree and test name from list_tests (itemKey is MCP-only; the UI shows folders and names, not itemKey). Never invent other scriptless URLs. +- When authoring or editing test steps, call list_commands first and follow the command selection policy in the info field. +- step_path is a dot-separated positional path without spaces (0-based indices; b0=Then branch, b1=Else). Example: root step 3 is "3"; first step inside Then of condition at 5 is "5.b0.0". Perfecto does not persist paths; they change when steps are inserted, moved, or deleted. Always call view_test_structure before the next structure edit; do not reuse step_path from a previous mutation response. +- Use parent_path on add_command with the step_path of a LogicalStep, Loop, or Branch from view_test_structure. +- Use add_logical_step, add_loop, and add_condition to build control-flow structures matching the UI toolbar Group, Loop, and Condition actions. +- Script variables (list_test_variables, add/modify/delete_test_variable) are stored in script.variables[] and are distinct from the DUT parameter in script.parameters[]. +- Snapshot behavior: every save creates a UUID history entry; comment on save_test labels ''. See list_snapshots notes for details. +- Edits persist immediately via the internal draft→script pipeline (each persist also adds snapshot history; save_test is available to re-persist unchanged content). - IMPORTANT: Always call list_filter_values first to get valid filter values before using any filters in list_tests. This ensures you're using the correct test name, list of owners users or other filter values that actually exist in the system. - If in any result has_next_page is true, ask the user if they want to see the next page or access all pages before making a subsequent call. - Before executing a test, follow this validation workflow: - 1. list_tests() (get and validate test_id). + 1. list_tests (get and validate test_id). 2. Get device configuration based on device_type: - - 'real': list_real_devices() (get device_id). - - 'virtual': list_virtual_devices() (get platform_name, manufacturer, model, platform_version). - - 'desktop': list_desktop_devices() (get platform_name, platform_version, browser_name, browser_version, resolution, location). - 3. On real device use read_real_device_info() (verify device is available and not in use). - 4. execute_test() (execute the test). - 5. list_report_executions() with report name equal to test name and list_live_executions() when the device it's in use (monitor execution progress). + - 'real': list_real_devices (get device_id). + - 'virtual': list_virtual_devices (get platform_name, manufacturer, model, platform_version). + - 'desktop': list_desktop_devices (get platform_name, platform_version, browser_name, browser_version, resolution, location). + 3. On real device use read_real_device_info (verify device is available and not in use). + 4. execute_test (execute the test). + 5. list_report_executions with report name equal to test name and list_live_executions when the device it's in use (monitor execution progress). - Always check before running a test_id if the device_type and device_under_test exist and is available (when it's a real device), not use device in use or malfunctioning. -- Always monitor a real device's operation while it's in use by checking the information with read_real_device_info(). +- Always monitor a real device's operation while it's in use by checking the information with read_real_device_info. - Always stop the execution by stopping the live execution (make sure it's the correct execution, such as the execution name or user ID). """ ) @@ -204,6 +828,126 @@ async def ai_scriptless( return await ai_scriptless_manager.execute_test(args.get("test_id", ""), args.get("device_type", ""), args.get("device_under_test", {})) + case "view_test_structure": + return await ai_scriptless_manager.view_test_structure(args.get("test_id", "")) + case "list_commands": + return await ai_scriptless_manager.list_commands(args.get("checkpoint", False)) + case "get_command_definitions": + return await ai_scriptless_manager.get_command_definitions(args.get("command_ids", [])) + case "add_command": + return await ai_scriptless_manager.add_command( + args.get("test_id", ""), + args.get("command_id", ""), + args.get("arguments"), + args.get("after_path"), + args.get("parent_path"), + ) + case "modify_command": + return await ai_scriptless_manager.modify_command( + args.get("test_id", ""), + args.get("step_path", ""), + args.get("arguments", {}), + ) + case "delete_command": + return await ai_scriptless_manager.delete_command( + args.get("test_id", ""), + args.get("step_path", ""), + ) + case "set_command_enabled": + return await ai_scriptless_manager.set_command_enabled( + args.get("test_id", ""), + args.get("step_path", ""), + args.get("enabled", True), + ) + case "save_test": + return await ai_scriptless_manager.save_test( + args.get("test_id", ""), + args.get("comment"), + ) + case "create_test": + return await ai_scriptless_manager.create_test( + args.get("name", ""), + args.get("folder", "My Folder"), + args.get("visibility", "PRIVATE"), + ) + case "save_test_as": + return await ai_scriptless_manager.save_test_as( + args.get("test_id", ""), + args.get("name", ""), + args.get("folder", "My Folder"), + args.get("visibility", "PRIVATE"), + args.get("comment"), + ) + case "add_logical_step": + return await ai_scriptless_manager.add_logical_step( + args.get("test_id", ""), + args.get("label"), + args.get("after_path"), + args.get("parent_path"), + ) + case "add_loop": + return await ai_scriptless_manager.add_loop( + args.get("test_id", ""), + args.get("count", 1), + args.get("after_path"), + args.get("parent_path"), + ) + case "add_condition": + return await ai_scriptless_manager.add_condition( + args.get("test_id", ""), + args.get("expression"), + args.get("label"), + args.get("after_path"), + args.get("parent_path"), + ) + case "set_condition_expression": + return await ai_scriptless_manager.set_condition_expression( + args.get("test_id", ""), + args.get("step_path", ""), + args.get("expression", ""), + ) + case "move_command": + return await ai_scriptless_manager.move_command( + args.get("test_id", ""), + args.get("step_path", ""), + args.get("after_path"), + args.get("parent_path"), + ) + case "delete_test": + return await ai_scriptless_manager.delete_test(args.get("test_id", "")) + case "move_test": + return await ai_scriptless_manager.move_test( + args.get("test_id", ""), + args.get("folder", ""), + args.get("visibility"), + ) + case "list_snapshots": + return await ai_scriptless_manager.list_snapshots(args.get("test_id", "")) + case "view_snapshot": + return await ai_scriptless_manager.view_snapshot(args.get("snapshot_id", "")) + case "list_test_variables": + return await ai_scriptless_manager.list_test_variables(args.get("test_id", "")) + case "add_test_variable": + return await ai_scriptless_manager.add_test_variable( + args.get("test_id", ""), + args.get("name", ""), + args.get("variable_type", "string"), + args.get("value", ""), + args.get("set_at_runtime", False), + ) + case "modify_test_variable": + return await ai_scriptless_manager.modify_test_variable( + args.get("test_id", ""), + args.get("name", ""), + args.get("value"), + args.get("variable_type"), + args.get("set_at_runtime"), + ) + case "delete_test_variable": + return await ai_scriptless_manager.delete_test_variable( + args.get("test_id", ""), + args.get("name", ""), + ) case _: return BaseResult( error=f"Action {action} not found in AI Scriptless manager tool" diff --git a/tools/ai_scriptless_script.py b/tools/ai_scriptless_script.py new file mode 100644 index 0000000..6c9f635 --- /dev/null +++ b/tools/ai_scriptless_script.py @@ -0,0 +1,4 @@ +"""Backward-compatible facade. Implementation lives in tools.ai_scriptless.""" + +from tools.ai_scriptless import * # noqa: F403 +from tools.ai_scriptless import __all__ as __all__ diff --git a/tools/user_manager.py b/tools/user_manager.py index f6f816d..ae0df8d 100644 --- a/tools/user_manager.py +++ b/tools/user_manager.py @@ -6,7 +6,7 @@ from pydantic import Field from config import perfecto -from config.perfecto import TOOLS_PREFIX, SUPPORT_MESSAGE +from config.perfecto import TOOLS_PREFIX, SUPPORT_MESSAGE, get_cloud_app_url from config.token import PerfectoToken, token_verify from formatters.user import format_users from models.manager import Manager @@ -22,7 +22,17 @@ def __init__(self, token: Optional[PerfectoToken], ctx: Context): async def read_user(self) -> BaseResult: user_url = perfecto.get_user_management_api_url(self.token.cloud_name) user_url = user_url + "/current" - return await api_request(self.token, "GET", endpoint=user_url, result_formatter=format_users) + cloud_url = get_cloud_app_url(self.token.cloud_name) + result = await api_request( + self.token, + "GET", + endpoint=user_url, + result_formatter=format_users, + result_formatter_params={"cloud_name": self.token.cloud_name}, + ) + if not result.error: + result.append_info([f"Connected Perfecto cloud: [{cloud_url}]({cloud_url})"]) + return result def register(mcp, token: Optional[PerfectoToken]): @@ -31,7 +41,9 @@ def register(mcp, token: Optional[PerfectoToken]): description=""" Operations on user information. Actions: -- read_user: Read a current user information from Perfecto. +- read_user: Read the current user and connected Perfecto cloud environment (cloud_name, cloud_url from PERFECTO_CLOUD_NAME). +Hints: +- Always render cloud_url as a markdown link when presenting the environment to the user. """ ) async def user( diff --git a/tools/utils.py b/tools/utils.py index 49402d1..dc9d79a 100644 --- a/tools/utils.py +++ b/tools/utils.py @@ -2,6 +2,7 @@ Simple utilities for Perfecto MCP tools. """ import base64 +import json import os import platform import sys @@ -52,7 +53,13 @@ async def api_request(token: Optional[PerfectoToken], method: str, endpoint: str try: resp = await client.request(method, endpoint, headers=headers, **kwargs) resp.raise_for_status() - result = resp.json() + if not resp.content or not resp.content.strip(): + result = None + else: + try: + result = resp.json() + except json.JSONDecodeError: + result = resp.text error = None if isinstance(result, list) and len(result) > 0 and "userMessage" in result[0]: # It's an error final_result = None diff --git a/uv.lock b/uv.lock index ad5591c..9aaba1e 100644 --- a/uv.lock +++ b/uv.lock @@ -164,6 +164,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -386,6 +395,11 @@ dependencies = [ { name = "pyinstaller" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" }, @@ -397,6 +411,18 @@ requires-dist = [ { name = "pyinstaller", specifier = ">=6.0.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.11.9" @@ -541,6 +567,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/26/23b4cfc77d7f808c69f59070e1e8293a579ec281a547c61562357160b346/pyinstaller_hooks_contrib-2025.9-py3-none-any.whl", hash = "sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038", size = 444283, upload-time = "2025-09-24T11:21:33.67Z" }, ] +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1"