From ebf2906bb3d3708cfb9cca4ce8ed2c4fc9bf3e66 Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:50:17 -0700 Subject: [PATCH 1/9] feat: add UI tests with mock Deadline server and CI workflow - Add mock Deadline server (test/mock_server/) for local testing - Add UI tests for config gui, CLI operations, and bundle gui-submit - Add hatch ui environment with PySide6 and xa11y dependencies - Add GitHub Actions workflow for UI tests on Linux, macOS, and Windows - Fix mock server GetFarm response to include costScaleFactor - Add list_queue_environments endpoint to mock server Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- .github/workflows/ui_tests.yml | 95 ++++ DEVELOPMENT.md | 12 - hatch.toml | 9 + requirements-ui-testing.txt | 5 + test/mock_server/__init__.py | 1 + test/mock_server/__main__.py | 21 + test/mock_server/_server.py | 494 +++++++++++++++++++++ test/mock_server/_store.py | 640 +++++++++++++++++++++++++++ test/mock_server/conftest.py | 46 ++ test/ui/__init__.py | 1 + test/ui/conftest.py | 61 +++ test/ui/test_bundle_gui_submit.py | 214 +++++++++ test/ui/test_cli_mock_server.py | 261 +++++++++++ test/ui/test_config_gui_log_level.py | 232 ++++++++++ 14 files changed, 2080 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/ui_tests.yml create mode 100644 requirements-ui-testing.txt create mode 100644 test/mock_server/__init__.py create mode 100644 test/mock_server/__main__.py create mode 100644 test/mock_server/_server.py create mode 100644 test/mock_server/_store.py create mode 100644 test/mock_server/conftest.py create mode 100644 test/ui/__init__.py create mode 100644 test/ui/conftest.py create mode 100644 test/ui/test_bundle_gui_submit.py create mode 100644 test/ui/test_cli_mock_server.py create mode 100644 test/ui/test_config_gui_log_level.py diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml new file mode 100644 index 000000000..5473f0344 --- /dev/null +++ b/.github/workflows/ui_tests.yml @@ -0,0 +1,95 @@ +name: UI Tests + +on: + push: + branches: [mainline, release] + pull_request: + branches: [mainline, release, feature_assets_cli, 'patch_*', 'feature_*'] + +jobs: + ui-test-linux: + name: UI Tests (Linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + xvfb dbus dbus-x11 at-spi2-core \ + libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 \ + libxcb-keysyms1 libxcb-image0 libxcb-render-util0 \ + libxcb-xkb1 libegl1 libglib2.0-0 libfontconfig1 \ + libdbus-1-3 libx11-xcb1 libxcb-render0 libxcb-shape0 \ + libxcb-xfixes0 + + - name: Install hatch + run: pip install hatch + + - name: Ensure management.localhost resolves + run: echo "127.0.0.1 management.localhost" | sudo tee -a /etc/hosts + + - name: Run UI tests (CLI only) + run: hatch run ui:test test/ui/test_cli_mock_server.py --timeout=120 + + ui-test-macos: + name: UI Tests (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install hatch + run: pip install hatch + + - name: Create hatch ui environment + run: hatch env create ui + + - name: Ensure management.localhost resolves + run: echo "127.0.0.1 management.localhost" | sudo tee -a /etc/hosts + + - name: Grant accessibility TCC permission + run: | + HATCH_PYTHON=$(hatch -e ui run python -c "import os,sys; print(os.path.realpath(sys.executable))") + SETUP_PYTHON=$(python3 -c "import os,sys; print(os.path.realpath(sys.executable))") + for PYTHON_PATH in "$SETUP_PYTHON" "$HATCH_PYTHON"; do + echo "Granting TCC kTCCServiceAccessibility to: $PYTHON_PATH" + sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \ + "INSERT OR REPLACE INTO access(service,client,client_type,auth_value,auth_reason,auth_version,csreq,policy_id,indirect_object_identifier_type,indirect_object_identifier,indirect_object_code_identity,flags,last_modified) \ + VALUES('kTCCServiceAccessibility','${PYTHON_PATH}',1,2,4,1,NULL,NULL,0,'UNUSED',NULL,0,$(date +%s));" + done + sudo launchctl stop com.apple.tccd 2>/dev/null || true + sleep 2 + + - name: Run UI tests (all) + env: + QT_ACCESSIBILITY: "1" + run: hatch run ui:test --timeout=120 + + ui-test-windows: + name: UI Tests (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install hatch + run: pip install hatch + + - name: Ensure management.localhost resolves + run: | + Add-Content -Path "$env:SystemRoot\System32\drivers\etc\hosts" -Value "`n127.0.0.1 management.localhost" + shell: pwsh + + - name: Run UI tests (CLI only) + env: + QT_ACCESSIBILITY: "1" + run: hatch run ui:test test/ui/test_cli_mock_server.py --timeout=120 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 76d28a0d4..430199559 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -496,7 +496,6 @@ These are the manual test cases for the client software release cycle, covering | Verify Deadline CLI can be successfully installed using the staged individual installer | Run the staged individual installer and verify that it can install. | | | Verify correct version of Deadline CLI is being tested | Verify correct version using `deadline --version` command. | | | Verify user can modify workstation configuration settings using Deadline GUI (`deadline config gui`) | Run `deadline config gui` and verify dialogue loads as expected. Verify User can authenticate using DCM Profile using Login button. Using a DCM Profile, verify correct farm/queue resources are pulled, and the existing config can be modified. Verify User can Logout of DCM Profile using Logout button. Verify User can authenticate using an AWS Profile. Using an AWS Profile, verify correct farm/queue resources are pulled, and the existing config can be modified. | | -| Verify user can modify workstation configuration settings using `deadline config set` | Once settings are modified, verify settings were modified correctly using: 1. `deadline config show` 2. `deadline config get ` 3. `deadline config gui` (verify modified settings appear correctly in the GUI). Verify settings can be modified back to default using `deadline config clear`. | | | Verify user can authenticate/login using DCM Profile: `deadline auth login` | In deadline config gui, you should see the profile name with a green checkmark beside it in the bottom left. | Would need to set using DCM profile first. | | Verify user can logout of DCM Profile: `deadline auth logout` | In deadline config gui, you should see the profile name with a red 'X' beside it in the bottom left. There should be a button to log in on the right. | | | `deadline auth login` using AWS profile | Run `deadline config gui`, select an AWS profile that uses IAM credentials, and run `deadline auth login`. Confirm that there is an error like "Logging in is only supported for AWS Profiles created by Deadline Cloud monitor". | Verify Login is not supported when using AWS profile. | @@ -507,27 +506,16 @@ These are the manual test cases for the client software release cycle, covering | Submit a render job using `--output json` option (cancel) | Launch GUI Submitter using `deadline bundle gui-submit --output json > output.txt`. Click submit. Immediately click cancel before the submission completes. Close the submitter. Open output.txt and ensure it is JSON exactly like: `{"status": "CANCELED"}` | | | Submit a render job using a submitter name | Launch GUI Submitter using `deadline bundle gui-submit --submitter-name Testing `. Click submit. Wait for the submission to complete. Click Ok. Ensure the submission process exits. | | | Verify 'Load a different job bundle' button in GUI Submitter | Launch GUI Submitter using `deadline bundle gui-submit --browse`, select a job bundle. Verify correct defaults/details. Hit 'Load a different job bundle' button and select a second job bundle. Verify correct defaults/details for the second bundle. | | -| Verify 'Export job bundle' button in GUI Submitter | | | | Verify all GUI Submitter dialogue controls work | Verify all dropdown options, menus, input fields, toggles, checkboxes, radio buttons work as expected. Verify all tabs: Shared job settings, Job-specific settings, Job attachments, Host requirements (both 'Run on all worker hosts' and 'Run on worker hosts that meet the following requirements' options). | | | `deadline job download-output` | Verify job output can be successfully downloaded using command. | | | Verify download output for a job in DCM browser | With the Deadline CLI registered as the download handler, verify download output for a job in DCM browser. | macOS: handler not currently supported, must use direct CLI commands. | | Test Deadline Cloud release candidate against currently released DCC Submitter | A Blender manual install might be easiest. Build deadline-cloud from the release candidate branch and pip install it into the submitter dependencies instead of the latest in PyPi. | | | `deadline job cancel` | Verify existing/running jobs can be successfully canceled. Verify job shows as Canceled in DCM. | | -| `deadline job get` | Verify correct information is displayed. | | -| `deadline job list` | Verify correct information is displayed (can check DCM for info). | | | `deadline job trace-schedule` | | | -| `deadline fleet get` | Verify correct information is displayed. | | -| `deadline fleet list` | Verify correct information is displayed. | | -| `deadline worker get` | Verify correct information is displayed. | Include `--fleet-id` and `--worker-id` parameter. | -| `deadline worker list` | Verify correct information is displayed. | Include `--fleet-id` parameter. | | `deadline handle-web-url --install` | | macOS: Verify 'Installing the web URL handler is not supported on OS darwin' message appears. | | `deadline handle-web-url --uninstall` | | macOS: Verify 'Uninstalling the web URL handler is not supported on OS darwin' message appears. | | `deadline --help` | Verify correct information is displayed. | | | `deadline -h` | Verify correct information is displayed. | | -| `deadline --log-level ERROR` | Must include command after level (e.g. `deadline --log-level ERROR farm list`). | | -| `deadline --log-level WARNING` | | | -| `deadline --log-level INFO` | | | -| `deadline --log-level DEBUG` | | | ## Job Attachments Tests diff --git a/hatch.toml b/hatch.toml index 76cca53fc..f349d9262 100644 --- a/hatch.toml +++ b/hatch.toml @@ -26,6 +26,15 @@ pre-install-commands = [ test = "pytest --xfail-tb --no-cov -vvv --numprocesses=1 {args:test/integ}" proxy-test = "sudo -E env PATH=$PATH bash scripts/run_proxy_integ_tests.sh {args:test/integ/cli/}" +[envs.ui] +features = ["gui"] +pre-install-commands = [ + "pip install -r requirements-ui-testing.txt", +] + +[envs.ui.scripts] +test = "pytest --no-cov -vvv --numprocesses=1 -o \"testpaths=test/ui\" --confcutdir=test/ui {args:test/ui}" + [envs.installer] pre-install-commands = ["pip install -r requirements-installer.txt"] diff --git a/requirements-ui-testing.txt b/requirements-ui-testing.txt new file mode 100644 index 000000000..723f0ebbe --- /dev/null +++ b/requirements-ui-testing.txt @@ -0,0 +1,5 @@ +pytest == 9.* +pytest-timeout >= 2.3 +pytest-cov >= 4 +pytest-xdist >= 3 +xa11y >= 0.5.3 diff --git a/test/mock_server/__init__.py b/test/mock_server/__init__.py new file mode 100644 index 000000000..8d929cc86 --- /dev/null +++ b/test/mock_server/__init__.py @@ -0,0 +1 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/test/mock_server/__main__.py b/test/mock_server/__main__.py new file mode 100644 index 000000000..a0fbf4afb --- /dev/null +++ b/test/mock_server/__main__.py @@ -0,0 +1,21 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Standalone entry point: python -m test.mock_server""" + +from ._server import create_server + + +def main() -> None: + server = create_server(port=8000) + _, port = server.server_address # type: ignore[misc] + print(f"Mock Deadline server listening on http://localhost:{port}") + print("Set AWS_ENDPOINT_URL_DEADLINE=http://localhost:8000 to use with the deadline CLI") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down.") + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/test/mock_server/_server.py b/test/mock_server/_server.py new file mode 100644 index 000000000..6c46ff6b7 --- /dev/null +++ b/test/mock_server/_server.py @@ -0,0 +1,494 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""HTTP server implementing the Deadline Cloud REST API (rest-json protocol).""" + +from __future__ import annotations + +import json +import re +import threading +import traceback +from datetime import datetime +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any, Callable, List, Optional, Tuple +from urllib.parse import parse_qs, urlparse + +import botocore.session +from botocore.model import ServiceModel +from botocore.validate import ParamValidator + +from ._store import ConflictError, ResourceNotFoundError, Store + +# Route = (method, regex_pattern, handler_name) +# Path params are captured as named groups in the regex. +Route = Tuple[str, re.Pattern, str] + +API_PREFIX = "/2023-10-12" + +_ROUTES: List[Tuple[str, str, str]] = [ + # Farms + ("POST", f"{API_PREFIX}/farms", "create_farm"), + ("GET", f"{API_PREFIX}/farms/(?P[^/]+)", "get_farm"), + ("GET", f"{API_PREFIX}/farms", "list_farms"), + ("PATCH", f"{API_PREFIX}/farms/(?P[^/]+)", "update_farm"), + ("DELETE", f"{API_PREFIX}/farms/(?P[^/]+)", "delete_farm"), + # Queues + ("POST", f"{API_PREFIX}/farms/(?P[^/]+)/queues", "create_queue"), + ("GET", f"{API_PREFIX}/farms/(?P[^/]+)/queues/(?P[^/]+)", "get_queue"), + ("GET", f"{API_PREFIX}/farms/(?P[^/]+)/queues", "list_queues"), + ("PATCH", f"{API_PREFIX}/farms/(?P[^/]+)/queues/(?P[^/]+)", "update_queue"), + ("DELETE", f"{API_PREFIX}/farms/(?P[^/]+)/queues/(?P[^/]+)", "delete_queue"), + # Fleets + ("POST", f"{API_PREFIX}/farms/(?P[^/]+)/fleets", "create_fleet"), + ("GET", f"{API_PREFIX}/farms/(?P[^/]+)/fleets/(?P[^/]+)", "get_fleet"), + ("GET", f"{API_PREFIX}/farms/(?P[^/]+)/fleets", "list_fleets"), + ("PATCH", f"{API_PREFIX}/farms/(?P[^/]+)/fleets/(?P[^/]+)", "update_fleet"), + ("DELETE", f"{API_PREFIX}/farms/(?P[^/]+)/fleets/(?P[^/]+)", "delete_fleet"), + # Queue Environments + ( + "GET", + f"{API_PREFIX}/farms/(?P[^/]+)/queues/(?P[^/]+)/environments", + "list_queue_environments", + ), + # Queue-Fleet Associations + ("PUT", f"{API_PREFIX}/farms/(?P[^/]+)/queue-fleet-associations", "create_qfa"), + ( + "GET", + f"{API_PREFIX}/farms/(?P[^/]+)/queue-fleet-associations" + f"/(?P[^/]+)/(?P[^/]+)", + "get_qfa", + ), + ("GET", f"{API_PREFIX}/farms/(?P[^/]+)/queue-fleet-associations", "list_qfas"), + ( + "PATCH", + f"{API_PREFIX}/farms/(?P[^/]+)/queue-fleet-associations" + f"/(?P[^/]+)/(?P[^/]+)", + "update_qfa", + ), + ( + "DELETE", + f"{API_PREFIX}/farms/(?P[^/]+)/queue-fleet-associations" + f"/(?P[^/]+)/(?P[^/]+)", + "delete_qfa", + ), + # Jobs + ( + "POST", + f"{API_PREFIX}/farms/(?P[^/]+)/queues/(?P[^/]+)/jobs", + "create_job", + ), + ( + "GET", + f"{API_PREFIX}/farms/(?P[^/]+)/queues/(?P[^/]+)/jobs/(?P[^/]+)", + "get_job", + ), + ( + "GET", + f"{API_PREFIX}/farms/(?P[^/]+)/queues/(?P[^/]+)/jobs", + "list_jobs", + ), + # Steps + ( + "GET", + f"{API_PREFIX}/farms/(?P[^/]+)/queues/(?P[^/]+)" + f"/jobs/(?P[^/]+)/steps/(?P[^/]+)", + "get_step", + ), + ( + "GET", + f"{API_PREFIX}/farms/(?P[^/]+)/queues/(?P[^/]+)" + f"/jobs/(?P[^/]+)/steps", + "list_steps", + ), + # Tasks + ( + "GET", + f"{API_PREFIX}/farms/(?P[^/]+)/queues/(?P[^/]+)" + f"/jobs/(?P[^/]+)/steps/(?P[^/]+)/tasks/(?P[^/]+)", + "get_task", + ), + ( + "GET", + f"{API_PREFIX}/farms/(?P[^/]+)/queues/(?P[^/]+)" + f"/jobs/(?P[^/]+)/steps/(?P[^/]+)/tasks", + "list_tasks", + ), + # Workers + ( + "POST", + f"{API_PREFIX}/farms/(?P[^/]+)/fleets/(?P[^/]+)/workers", + "create_worker", + ), + ( + "GET", + f"{API_PREFIX}/farms/(?P[^/]+)/fleets/(?P[^/]+)" + f"/workers/(?P[^/]+)", + "get_worker", + ), + ( + "GET", + f"{API_PREFIX}/farms/(?P[^/]+)/fleets/(?P[^/]+)/workers", + "list_workers", + ), + # Search + ( + "POST", + f"{API_PREFIX}/farms/(?P[^/]+)/search/jobs", + "search_jobs", + ), + ( + "POST", + f"{API_PREFIX}/farms/(?P[^/]+)/search/workers", + "search_workers", + ), +] + +COMPILED_ROUTES: List[Route] = [ + (method, re.compile(f"^{pattern}$"), handler) for method, pattern, handler in _ROUTES +] + + +def _json_serial(obj: Any) -> str: + if isinstance(obj, datetime): + return obj.isoformat() + raise TypeError(f"Type {type(obj)} not serializable") + + +class _ResponseValidator: + """Validates response dicts against the botocore Deadline service model.""" + + def __init__(self) -> None: + session = botocore.session.get_session() + loader = session.get_component("data_loader") + api_data = loader.load_service_model("deadline", "service-2") + self._service_model = ServiceModel(api_data) + self._validator = ParamValidator() + + def validate(self, operation_name: str, response: dict) -> None: + op_model = self._service_model.operation_model(operation_name) + output_shape = op_model.output_shape + if output_shape is None: + return + report = self._validator.validate(response, output_shape) + if report.has_errors(): + raise ValueError( + f"Mock server response validation failed for {operation_name}: " + f"{report.generate_report()}" + ) + + +# Map handler names to Deadline API operation names for response validation +_HANDLER_TO_OPERATION = { + "create_farm": "CreateFarm", + "get_farm": "GetFarm", + "list_farms": "ListFarms", + "update_farm": "UpdateFarm", + "delete_farm": "DeleteFarm", + "create_queue": "CreateQueue", + "get_queue": "GetQueue", + "list_queues": "ListQueues", + "update_queue": "UpdateQueue", + "delete_queue": "DeleteQueue", + "create_fleet": "CreateFleet", + "get_fleet": "GetFleet", + "list_fleets": "ListFleets", + "update_fleet": "UpdateFleet", + "delete_fleet": "DeleteFleet", + "create_qfa": "CreateQueueFleetAssociation", + "list_queue_environments": "ListQueueEnvironments", + "get_qfa": "GetQueueFleetAssociation", + "list_qfas": "ListQueueFleetAssociations", + "update_qfa": "UpdateQueueFleetAssociation", + "delete_qfa": "DeleteQueueFleetAssociation", + "create_job": "CreateJob", + "get_job": "GetJob", + "list_jobs": "ListJobs", + "get_step": "GetStep", + "list_steps": "ListSteps", + "get_task": "GetTask", + "list_tasks": "ListTasks", + "create_worker": "CreateWorker", + "get_worker": "GetWorker", + "list_workers": "ListWorkers", + "search_jobs": "SearchJobs", + "search_workers": "SearchWorkers", +} + + +class MockDeadlineHandler(BaseHTTPRequestHandler): + """HTTP request handler for the mock Deadline server.""" + + store: Store + response_validator: _ResponseValidator + + def log_message(self, format: str, *args: Any) -> None: + # Silence request logging by default + pass + + def _read_body(self) -> dict: + length = int(self.headers.get("Content-Length", 0)) + if length == 0: + return {} + return json.loads(self.rfile.read(length)) + + def _send_json(self, status: int, body: dict) -> None: + data = json.dumps(body, default=_json_serial).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _send_error(self, status: int, code: str, message: str) -> None: + data = json.dumps({"message": message}).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("x-amzn-errortype", code) + self.end_headers() + self.wfile.write(data) + + def _dispatch(self, method: str) -> None: + parsed = urlparse(self.path) + path = parsed.path + query = parse_qs(parsed.query) + + for route_method, pattern, handler_name in COMPILED_ROUTES: + if route_method != method: + continue + m = pattern.match(path) + if not m: + continue + + path_params = m.groupdict() + body = self._read_body() if method in ("POST", "PUT", "PATCH") else {} + query_single = {k: v[0] for k, v in query.items()} + + try: + handler_fn: Callable = getattr(self, f"_handle_{handler_name}") + result = handler_fn(path_params, body, query_single) + # Validate response against botocore service model + op_name = _HANDLER_TO_OPERATION.get(handler_name) + if op_name: + self.response_validator.validate(op_name, result) + self._send_json(200, result) + except ResourceNotFoundError as e: + self._send_error(404, "ResourceNotFoundException", str(e)) + except ConflictError as e: + self._send_error(409, "ConflictException", str(e)) + except Exception as e: + traceback.print_exc() + self._send_error(500, "InternalServerException", str(e)) + return + + self._send_error(404, "NotFoundException", f"No route for {method} {path}") + + def do_GET(self) -> None: + self._dispatch("GET") + + def do_POST(self) -> None: + self._dispatch("POST") + + def do_PUT(self) -> None: + self._dispatch("PUT") + + def do_PATCH(self) -> None: + self._dispatch("PATCH") + + def do_DELETE(self) -> None: + self._dispatch("DELETE") + + # ── Farm handlers ── + + def _handle_create_farm(self, path: dict, body: dict, query: dict) -> dict: + return self.store.create_farm(body) + + def _handle_get_farm(self, path: dict, body: dict, query: dict) -> dict: + return self.store.get_farm(path["farmId"]) + + def _handle_list_farms(self, path: dict, body: dict, query: dict) -> dict: + return self.store.list_farms( + max_results=int(query.get("maxResults", 100)), + next_token=query.get("nextToken"), + ) + + def _handle_update_farm(self, path: dict, body: dict, query: dict) -> dict: + return self.store.update_farm(path["farmId"], body) + + def _handle_delete_farm(self, path: dict, body: dict, query: dict) -> dict: + return self.store.delete_farm(path["farmId"]) + + # ── Queue handlers ── + + def _handle_create_queue(self, path: dict, body: dict, query: dict) -> dict: + return self.store.create_queue(path["farmId"], body) + + def _handle_get_queue(self, path: dict, body: dict, query: dict) -> dict: + return self.store.get_queue(path["farmId"], path["queueId"]) + + def _handle_list_queues(self, path: dict, body: dict, query: dict) -> dict: + return self.store.list_queues( + path["farmId"], + max_results=int(query.get("maxResults", 100)), + next_token=query.get("nextToken"), + ) + + def _handle_update_queue(self, path: dict, body: dict, query: dict) -> dict: + return self.store.update_queue(path["farmId"], path["queueId"], body) + + def _handle_delete_queue(self, path: dict, body: dict, query: dict) -> dict: + return self.store.delete_queue(path["farmId"], path["queueId"]) + + # ── Fleet handlers ── + + def _handle_create_fleet(self, path: dict, body: dict, query: dict) -> dict: + return self.store.create_fleet(path["farmId"], body) + + def _handle_get_fleet(self, path: dict, body: dict, query: dict) -> dict: + return self.store.get_fleet(path["farmId"], path["fleetId"]) + + def _handle_list_fleets(self, path: dict, body: dict, query: dict) -> dict: + return self.store.list_fleets( + path["farmId"], + max_results=int(query.get("maxResults", 100)), + next_token=query.get("nextToken"), + ) + + def _handle_update_fleet(self, path: dict, body: dict, query: dict) -> dict: + return self.store.update_fleet(path["farmId"], path["fleetId"], body) + + def _handle_delete_fleet(self, path: dict, body: dict, query: dict) -> dict: + return self.store.delete_fleet(path["farmId"], path["fleetId"]) + + # ── Queue-Fleet Association handlers ── + + def _handle_list_queue_environments(self, path: dict, body: dict, query: dict) -> dict: + return {"environments": []} + + def _handle_create_qfa(self, path: dict, body: dict, query: dict) -> dict: + return self.store.create_qfa(path["farmId"], body) + + def _handle_get_qfa(self, path: dict, body: dict, query: dict) -> dict: + return self.store.get_qfa(path["farmId"], path["queueId"], path["fleetId"]) + + def _handle_list_qfas(self, path: dict, body: dict, query: dict) -> dict: + return self.store.list_qfas( + path["farmId"], + queue_id=query.get("queueId"), + fleet_id=query.get("fleetId"), + max_results=int(query.get("maxResults", 100)), + next_token=query.get("nextToken"), + ) + + def _handle_update_qfa(self, path: dict, body: dict, query: dict) -> dict: + return self.store.update_qfa(path["farmId"], path["queueId"], path["fleetId"], body) + + def _handle_delete_qfa(self, path: dict, body: dict, query: dict) -> dict: + return self.store.delete_qfa(path["farmId"], path["queueId"], path["fleetId"]) + + # ── Job handlers ── + + def _handle_create_job(self, path: dict, body: dict, query: dict) -> dict: + return self.store.create_job(path["farmId"], path["queueId"], body) + + def _handle_get_job(self, path: dict, body: dict, query: dict) -> dict: + return self.store.get_job(path["farmId"], path["queueId"], path["jobId"]) + + def _handle_list_jobs(self, path: dict, body: dict, query: dict) -> dict: + return self.store.list_jobs( + path["farmId"], + path["queueId"], + max_results=int(query.get("maxResults", 100)), + next_token=query.get("nextToken"), + ) + + # ── Step handlers ── + + def _handle_get_step(self, path: dict, body: dict, query: dict) -> dict: + return self.store.get_step(path["farmId"], path["queueId"], path["jobId"], path["stepId"]) + + def _handle_list_steps(self, path: dict, body: dict, query: dict) -> dict: + return self.store.list_steps( + path["farmId"], + path["queueId"], + path["jobId"], + max_results=int(query.get("maxResults", 100)), + next_token=query.get("nextToken"), + ) + + # ── Task handlers ── + + def _handle_get_task(self, path: dict, body: dict, query: dict) -> dict: + return self.store.get_task( + path["farmId"], + path["queueId"], + path["jobId"], + path["stepId"], + path["taskId"], + ) + + def _handle_list_tasks(self, path: dict, body: dict, query: dict) -> dict: + return self.store.list_tasks( + path["farmId"], + path["queueId"], + path["jobId"], + path["stepId"], + max_results=int(query.get("maxResults", 100)), + next_token=query.get("nextToken"), + ) + + # ── Worker handlers ── + + def _handle_create_worker(self, path: dict, body: dict, query: dict) -> dict: + return self.store.create_worker(path["farmId"], path["fleetId"], body) + + def _handle_get_worker(self, path: dict, body: dict, query: dict) -> dict: + return self.store.get_worker(path["farmId"], path["fleetId"], path["workerId"]) + + def _handle_list_workers(self, path: dict, body: dict, query: dict) -> dict: + return self.store.list_workers( + path["farmId"], + path["fleetId"], + max_results=int(query.get("maxResults", 100)), + next_token=query.get("nextToken"), + ) + + # ── Search handlers ── + + def _handle_search_jobs(self, path: dict, body: dict, query: dict) -> dict: + return self.store.search_jobs(path["farmId"], body) + + def _handle_search_workers(self, path: dict, body: dict, query: dict) -> dict: + return self.store.search_workers(path["farmId"], body) + + +def create_server(port: int = 0, store: Optional[Store] = None) -> HTTPServer: + """Create a mock Deadline server. Port 0 picks a free port. + + Binds to 127.0.0.1 so that both ``localhost`` and ``management.localhost`` + (the host-prefix botocore prepends for Deadline API calls) resolve correctly. + """ + if store is None: + store = Store() + validator = _ResponseValidator() + + MockDeadlineHandler.store = store + MockDeadlineHandler.response_validator = validator + + server = HTTPServer(("127.0.0.1", port), MockDeadlineHandler) + return server + + +def start_server_thread(port: int = 0, store: Optional[Store] = None) -> Tuple[HTTPServer, str]: + """Start the mock server in a daemon thread. Returns (server, base_url). + + The base_url uses ``localhost`` (not ``127.0.0.1``) because botocore prepends + ``management.`` to the hostname for Deadline API calls, and + ``management.localhost`` resolves to 127.0.0.1 on Linux and macOS. + """ + server = create_server(port, store) + _, actual_port = server.server_address # type: ignore[misc] + base_url = f"http://localhost:{actual_port}" + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, base_url diff --git a/test/mock_server/_store.py b/test/mock_server/_store.py new file mode 100644 index 000000000..6ca03a5f3 --- /dev/null +++ b/test/mock_server/_store.py @@ -0,0 +1,640 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""In-memory data store for the mock Deadline server.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + + +class ResourceNotFoundError(Exception): + def __init__(self, resource_type: str, resource_id: str): + self.resource_type = resource_type + self.resource_id = resource_id + super().__init__(f"Resource of type {resource_type} with id {resource_id} does not exist.") + + +class ConflictError(Exception): + def __init__(self, message: str): + super().__init__(message) + + +class Store: + """Dict-based in-memory storage for Deadline resources.""" + + def __init__(self) -> None: + self._id_counter = 0 + self._farms: Dict[str, dict] = {} + self._queues: Dict[Tuple[str, str], dict] = {} + self._fleets: Dict[Tuple[str, str], dict] = {} + self._qfas: Dict[Tuple[str, str, str], dict] = {} # (farmId, queueId, fleetId) + self._jobs: Dict[Tuple[str, str, str], dict] = {} # (farmId, queueId, jobId) + self._steps: Dict[Tuple[str, str, str, str], dict] = {} + self._tasks: Dict[Tuple[str, str, str, str, str], dict] = {} + self._workers: Dict[Tuple[str, str, str], dict] = {} # (farmId, fleetId, workerId) + + def _gen_id(self, prefix: str) -> str: + self._id_counter += 1 + return f"{prefix}-{self._id_counter:032x}" + + def _now(self) -> datetime: + return datetime.now(timezone.utc) + + # ── Farms ── + + def create_farm(self, body: dict) -> dict: + farm_id = self._gen_id("farm") + now = self._now() + self._farms[farm_id] = { + "farmId": farm_id, + "displayName": body["displayName"], + "description": body.get("description", ""), + "kmsKeyArn": body.get("kmsKeyArn", ""), + "costScaleFactor": body.get("costScaleFactor", 1.0), + "createdAt": now, + "createdBy": "mock-user", + "updatedAt": now, + "updatedBy": "mock-user", + } + return {"farmId": farm_id} + + def get_farm(self, farm_id: str) -> dict: + if farm_id not in self._farms: + raise ResourceNotFoundError("Farm", farm_id) + return dict(self._farms[farm_id]) + + _FARM_SUMMARY_KEYS = { + "farmId", + "displayName", + "kmsKeyArn", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy", + } + + def list_farms(self, max_results: int = 100, next_token: Optional[str] = None) -> dict: + items = [self._pick(f, self._FARM_SUMMARY_KEYS) for f in self._farms.values()] + return self._paginate(items, "farms", max_results, next_token) + + def update_farm(self, farm_id: str, body: dict) -> dict: + if farm_id not in self._farms: + raise ResourceNotFoundError("Farm", farm_id) + farm = self._farms[farm_id] + for key in ("displayName", "description"): + if key in body: + farm[key] = body[key] + farm["updatedAt"] = self._now() + return {} + + def delete_farm(self, farm_id: str) -> dict: + if farm_id not in self._farms: + raise ResourceNotFoundError("Farm", farm_id) + del self._farms[farm_id] + return {} + + # ── Queues ── + + def create_queue(self, farm_id: str, body: dict) -> dict: + self._require_farm(farm_id) + queue_id = self._gen_id("queue") + now = self._now() + self._queues[(farm_id, queue_id)] = { + "queueId": queue_id, + "farmId": farm_id, + "displayName": body["displayName"], + "description": body.get("description", ""), + "status": "IDLE", + "defaultBudgetAction": body.get("defaultBudgetAction", "NONE"), + "createdAt": now, + "createdBy": "mock-user", + "updatedAt": now, + "updatedBy": "mock-user", + **{k: body[k] for k in ("jobAttachmentSettings", "roleArn") if k in body}, + } + return {"queueId": queue_id} + + def get_queue(self, farm_id: str, queue_id: str) -> dict: + key = (farm_id, queue_id) + if key not in self._queues: + raise ResourceNotFoundError("Queue", queue_id) + return dict(self._queues[key]) + + _QUEUE_SUMMARY_KEYS = { + "farmId", + "queueId", + "displayName", + "status", + "defaultBudgetAction", + "blockedReason", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy", + } + + def list_queues( + self, farm_id: str, max_results: int = 100, next_token: Optional[str] = None + ) -> dict: + items = [ + self._pick(q, self._QUEUE_SUMMARY_KEYS) + for k, q in self._queues.items() + if k[0] == farm_id + ] + return self._paginate(items, "queues", max_results, next_token) + + def update_queue(self, farm_id: str, queue_id: str, body: dict) -> dict: + key = (farm_id, queue_id) + if key not in self._queues: + raise ResourceNotFoundError("Queue", queue_id) + queue = self._queues[key] + for k in ("displayName", "description", "defaultBudgetAction", "roleArn"): + if k in body: + queue[k] = body[k] + queue["updatedAt"] = self._now() + return {} + + def delete_queue(self, farm_id: str, queue_id: str) -> dict: + key = (farm_id, queue_id) + if key not in self._queues: + raise ResourceNotFoundError("Queue", queue_id) + del self._queues[key] + return {} + + # ── Fleets ── + + def create_fleet(self, farm_id: str, body: dict) -> dict: + self._require_farm(farm_id) + fleet_id = self._gen_id("fleet") + now = self._now() + self._fleets[(farm_id, fleet_id)] = { + "fleetId": fleet_id, + "farmId": farm_id, + "displayName": body["displayName"], + "description": body.get("description", ""), + "status": "ACTIVE", + "workerCount": 0, + "minWorkerCount": body.get("minWorkerCount", 0), + "maxWorkerCount": body.get("maxWorkerCount", 1), + "configuration": body.get( + "configuration", + { + "customerManaged": { + "mode": "NO_SCALING", + "workerCapabilities": { + "vCpuCount": {"min": 1}, + "memoryMiB": {"min": 1024}, + "osFamily": "LINUX", + "cpuArchitectureType": "x86_64", + }, + } + }, + ), + "roleArn": body.get("roleArn", "arn:aws:iam::000000000000:role/mock"), + "createdAt": now, + "createdBy": "mock-user", + "updatedAt": now, + "updatedBy": "mock-user", + } + return {"fleetId": fleet_id} + + def get_fleet(self, farm_id: str, fleet_id: str) -> dict: + key = (farm_id, fleet_id) + if key not in self._fleets: + raise ResourceNotFoundError("Fleet", fleet_id) + return dict(self._fleets[key]) + + _FLEET_SUMMARY_KEYS = { + "fleetId", + "farmId", + "displayName", + "status", + "statusMessage", + "autoScalingStatus", + "targetWorkerCount", + "workerCount", + "minWorkerCount", + "maxWorkerCount", + "configuration", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy", + } + + def list_fleets( + self, farm_id: str, max_results: int = 100, next_token: Optional[str] = None + ) -> dict: + items = [ + self._pick(f, self._FLEET_SUMMARY_KEYS) + for k, f in self._fleets.items() + if k[0] == farm_id + ] + return self._paginate(items, "fleets", max_results, next_token) + + def update_fleet(self, farm_id: str, fleet_id: str, body: dict) -> dict: + key = (farm_id, fleet_id) + if key not in self._fleets: + raise ResourceNotFoundError("Fleet", fleet_id) + fleet = self._fleets[key] + for k in ( + "displayName", + "description", + "roleArn", + "minWorkerCount", + "maxWorkerCount", + "configuration", + ): + if k in body: + fleet[k] = body[k] + fleet["updatedAt"] = self._now() + return {} + + def delete_fleet(self, farm_id: str, fleet_id: str) -> dict: + key = (farm_id, fleet_id) + if key not in self._fleets: + raise ResourceNotFoundError("Fleet", fleet_id) + del self._fleets[key] + return {} + + # ── Queue-Fleet Associations ── + + def create_qfa(self, farm_id: str, body: dict) -> dict: + queue_id = body["queueId"] + fleet_id = body["fleetId"] + self._require_farm(farm_id) + if (farm_id, queue_id) not in self._queues: + raise ResourceNotFoundError("Queue", queue_id) + if (farm_id, fleet_id) not in self._fleets: + raise ResourceNotFoundError("Fleet", fleet_id) + key = (farm_id, queue_id, fleet_id) + if key in self._qfas: + raise ConflictError( + f"QueueFleetAssociation for queue {queue_id} and fleet {fleet_id} already exists." + ) + now = self._now() + self._qfas[key] = { + "queueId": queue_id, + "fleetId": fleet_id, + "status": "ACTIVE", + "createdAt": now, + "createdBy": "mock-user", + "updatedAt": now, + "updatedBy": "mock-user", + } + return {} + + def get_qfa(self, farm_id: str, queue_id: str, fleet_id: str) -> dict: + key = (farm_id, queue_id, fleet_id) + if key not in self._qfas: + raise ResourceNotFoundError("QueueFleetAssociation", f"{queue_id}/{fleet_id}") + return dict(self._qfas[key]) + + _QFA_SUMMARY_KEYS = { + "queueId", + "fleetId", + "status", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy", + } + + def list_qfas( + self, + farm_id: str, + queue_id: Optional[str] = None, + fleet_id: Optional[str] = None, + max_results: int = 100, + next_token: Optional[str] = None, + ) -> dict: + items = [ + self._pick(q, self._QFA_SUMMARY_KEYS) + for k, q in self._qfas.items() + if k[0] == farm_id + and (queue_id is None or k[1] == queue_id) + and (fleet_id is None or k[2] == fleet_id) + ] + return self._paginate(items, "queueFleetAssociations", max_results, next_token) + + def update_qfa(self, farm_id: str, queue_id: str, fleet_id: str, body: dict) -> dict: + key = (farm_id, queue_id, fleet_id) + if key not in self._qfas: + raise ResourceNotFoundError("QueueFleetAssociation", f"{queue_id}/{fleet_id}") + if "status" in body: + self._qfas[key]["status"] = body["status"] + self._qfas[key]["updatedAt"] = self._now() + return {} + + def delete_qfa(self, farm_id: str, queue_id: str, fleet_id: str) -> dict: + key = (farm_id, queue_id, fleet_id) + if key not in self._qfas: + raise ResourceNotFoundError("QueueFleetAssociation", f"{queue_id}/{fleet_id}") + del self._qfas[key] + return {} + + # ── Jobs ── + + def create_job(self, farm_id: str, queue_id: str, body: dict) -> dict: + self._require_farm(farm_id) + if (farm_id, queue_id) not in self._queues: + raise ResourceNotFoundError("Queue", queue_id) + job_id = self._gen_id("job") + now = self._now() + self._jobs[(farm_id, queue_id, job_id)] = { + "jobId": job_id, + "name": body.get("nameOverride", "MockJob"), + "lifecycleStatus": "CREATE_COMPLETE", + "lifecycleStatusMessage": "", + "priority": body.get("priority", 50), + "createdAt": now, + "createdBy": "mock-user", + "taskRunStatus": "READY", + "taskRunStatusCounts": {"READY": 1}, + } + # Create a default step and task for the job + step_id = self._gen_id("step") + self._steps[(farm_id, queue_id, job_id, step_id)] = { + "stepId": step_id, + "name": "Step0", + "lifecycleStatus": "CREATE_COMPLETE", + "taskRunStatus": "READY", + "taskRunStatusCounts": {"READY": 1}, + "createdAt": now, + "createdBy": "mock-user", + } + task_id = self._gen_id("task") + self._tasks[(farm_id, queue_id, job_id, step_id, task_id)] = { + "taskId": task_id, + "runStatus": "READY", + "createdAt": now, + "createdBy": "mock-user", + } + return {"jobId": job_id} + + def get_job(self, farm_id: str, queue_id: str, job_id: str) -> dict: + key = (farm_id, queue_id, job_id) + if key not in self._jobs: + raise ResourceNotFoundError("Job", job_id) + return dict(self._jobs[key]) + + _JOB_SUMMARY_KEYS = { + "jobId", + "name", + "lifecycleStatus", + "lifecycleStatusMessage", + "priority", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy", + "startedAt", + "endedAt", + "taskRunStatus", + "targetTaskRunStatus", + "taskRunStatusCounts", + "taskFailureRetryCount", + "maxFailedTasksCount", + "maxRetriesPerTask", + "maxWorkerCount", + "sourceJobId", + } + + def list_jobs( + self, farm_id: str, queue_id: str, max_results: int = 100, next_token: Optional[str] = None + ) -> dict: + items = [ + self._pick(j, self._JOB_SUMMARY_KEYS) + for k, j in self._jobs.items() + if k[0] == farm_id and k[1] == queue_id + ] + return self._paginate(items, "jobs", max_results, next_token) + + # ── Steps ── + + def get_step(self, farm_id: str, queue_id: str, job_id: str, step_id: str) -> dict: + key = (farm_id, queue_id, job_id, step_id) + if key not in self._steps: + raise ResourceNotFoundError("Step", step_id) + return dict(self._steps[key]) + + _STEP_SUMMARY_KEYS = { + "stepId", + "name", + "lifecycleStatus", + "lifecycleStatusMessage", + "taskRunStatus", + "taskRunStatusCounts", + "taskFailureRetryCount", + "targetTaskRunStatus", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy", + "startedAt", + "endedAt", + "dependencyCounts", + } + + def list_steps( + self, + farm_id: str, + queue_id: str, + job_id: str, + max_results: int = 100, + next_token: Optional[str] = None, + ) -> dict: + items = [ + self._pick(s, self._STEP_SUMMARY_KEYS) + for k, s in self._steps.items() + if k[:3] == (farm_id, queue_id, job_id) + ] + return self._paginate(items, "steps", max_results, next_token) + + # ── Tasks ── + + def get_task( + self, farm_id: str, queue_id: str, job_id: str, step_id: str, task_id: str + ) -> dict: + key = (farm_id, queue_id, job_id, step_id, task_id) + if key not in self._tasks: + raise ResourceNotFoundError("Task", task_id) + return dict(self._tasks[key]) + + _TASK_SUMMARY_KEYS = { + "taskId", + "createdAt", + "createdBy", + "runStatus", + "targetRunStatus", + "failureRetryCount", + "parameters", + "startedAt", + "endedAt", + "updatedAt", + "updatedBy", + "latestSessionActionId", + } + + def list_tasks( + self, + farm_id: str, + queue_id: str, + job_id: str, + step_id: str, + max_results: int = 100, + next_token: Optional[str] = None, + ) -> dict: + items = [ + self._pick(t, self._TASK_SUMMARY_KEYS) + for k, t in self._tasks.items() + if k[:4] == (farm_id, queue_id, job_id, step_id) + ] + return self._paginate(items, "tasks", max_results, next_token) + + # ── Workers ── + + def create_worker(self, farm_id: str, fleet_id: str, body: dict) -> dict: + self._require_farm(farm_id) + if (farm_id, fleet_id) not in self._fleets: + raise ResourceNotFoundError("Fleet", fleet_id) + worker_id = self._gen_id("worker") + now = self._now() + self._workers[(farm_id, fleet_id, worker_id)] = { + "farmId": farm_id, + "fleetId": fleet_id, + "workerId": worker_id, + "status": body.get("status", "CREATED"), + "createdAt": now, + "createdBy": "mock-user", + "updatedAt": now, + "updatedBy": "mock-user", + } + return {"workerId": worker_id} + + def get_worker(self, farm_id: str, fleet_id: str, worker_id: str) -> dict: + key = (farm_id, fleet_id, worker_id) + if key not in self._workers: + raise ResourceNotFoundError("Worker", worker_id) + return dict(self._workers[key]) + + _WORKER_SUMMARY_KEYS = { + "workerId", + "farmId", + "fleetId", + "status", + "createdAt", + "createdBy", + "updatedAt", + "updatedBy", + } + + def list_workers( + self, + farm_id: str, + fleet_id: str, + max_results: int = 100, + next_token: Optional[str] = None, + ) -> dict: + items = [ + self._pick(w, self._WORKER_SUMMARY_KEYS) + for k, w in self._workers.items() + if k[0] == farm_id and k[1] == fleet_id + ] + return self._paginate(items, "workers", max_results, next_token) + + # ── Search APIs ── + + _JOB_SEARCH_KEYS = { + "jobId", + "queueId", + "name", + "lifecycleStatus", + "lifecycleStatusMessage", + "taskRunStatus", + "targetTaskRunStatus", + "taskRunStatusCounts", + "taskFailureRetryCount", + "priority", + "maxFailedTasksCount", + "maxRetriesPerTask", + "createdBy", + "createdAt", + "endedAt", + "startedAt", + "updatedAt", + "updatedBy", + "maxWorkerCount", + "sourceJobId", + } + + def search_jobs( + self, + farm_id: str, + body: dict, + ) -> dict: + queue_ids = body.get("queueIds", []) + item_offset = body.get("itemOffset", 0) + page_size = body.get("pageSize", 100) + items = [] + for k, j in self._jobs.items(): + if k[0] == farm_id and k[1] in queue_ids: + entry = self._pick(j, self._JOB_SEARCH_KEYS) + entry["queueId"] = k[1] + items.append(entry) + total = len(items) + page = items[item_offset : item_offset + page_size] + result: Dict[str, Any] = {"jobs": page, "totalResults": total} + if item_offset + page_size < total: + result["nextItemOffset"] = item_offset + page_size + return result + + _WORKER_SEARCH_KEYS = { + "fleetId", + "workerId", + "status", + "createdBy", + "createdAt", + "updatedBy", + "updatedAt", + } + + def search_workers( + self, + farm_id: str, + body: dict, + ) -> dict: + fleet_ids = body.get("fleetIds", []) + item_offset = body.get("itemOffset", 0) + page_size = body.get("pageSize", 100) + items = [ + self._pick(w, self._WORKER_SEARCH_KEYS) + for k, w in self._workers.items() + if k[0] == farm_id and k[1] in fleet_ids + ] + total = len(items) + page = items[item_offset : item_offset + page_size] + result: Dict[str, Any] = {"workers": page, "totalResults": total} + if item_offset + page_size < total: + result["nextItemOffset"] = item_offset + page_size + return result + + # ── Helpers ── + + def _require_farm(self, farm_id: str) -> None: + if farm_id not in self._farms: + raise ResourceNotFoundError("Farm", farm_id) + + @staticmethod + def _paginate(items: List[dict], key: str, max_results: int, next_token: Optional[str]) -> dict: + start = int(next_token) if next_token else 0 + page = items[start : start + max_results] + result: Dict[str, Any] = {key: page} + if start + max_results < len(items): + result["nextToken"] = str(start + max_results) + return result + + @staticmethod + def _pick(d: dict, keys: set) -> dict: + return {k: v for k, v in d.items() if k in keys} diff --git a/test/mock_server/conftest.py b/test/mock_server/conftest.py new file mode 100644 index 000000000..cb84694d0 --- /dev/null +++ b/test/mock_server/conftest.py @@ -0,0 +1,46 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Pytest fixtures for the mock Deadline server.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Generator + +import pytest + +from ._server import start_server_thread +from ._store import Store + + +@pytest.fixture() +def mock_deadline_store() -> Store: + """Provide a fresh in-memory store, useful for pre-populating data.""" + return Store() + + +@pytest.fixture() +def mock_deadline_server( + mock_deadline_store: Store, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> Generator[Store, None, None]: + """Start a mock Deadline server and configure env vars to point boto3 at it. + + Yields the Store so tests can pre-populate or inspect data. + Sets up a minimal Deadline config file to avoid reading the user's real config. + """ + server, base_url = start_server_thread(port=0, store=mock_deadline_store) + + monkeypatch.setenv("AWS_ENDPOINT_URL_DEADLINE", base_url) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setenv("AWS_DEFAULT_REGION", "us-west-2") + + config_path = str(tmp_path / "config") + with open(config_path, "w") as f: + f.write("[defaults]\n") + monkeypatch.setenv("DEADLINE_CONFIG_FILE_PATH", config_path) + + try: + yield mock_deadline_store + finally: + server.shutdown() diff --git a/test/ui/__init__.py b/test/ui/__init__.py new file mode 100644 index 000000000..8d929cc86 --- /dev/null +++ b/test/ui/__init__.py @@ -0,0 +1 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/test/ui/conftest.py b/test/ui/conftest.py new file mode 100644 index 000000000..9db54fb16 --- /dev/null +++ b/test/ui/conftest.py @@ -0,0 +1,61 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Fixtures for UI tests that run ``deadline`` as a subprocess and inspect via xa11y.""" + +from __future__ import annotations + +import subprocess +import time +from typing import Generator + +import pytest +import xa11y + +DIALOG_TITLE = "AWS Deadline Cloud workstation configuration" +STARTUP_TIMEOUT = 10 +ACTION_SETTLE = 0.5 + + +@pytest.fixture() +def config_gui() -> Generator[xa11y.App, None, None]: + """Launch ``deadline config gui`` and yield the xa11y App handle. + + Ensures the dialog is visible before yielding and kills the process on teardown. + """ + proc = subprocess.Popen( + ["deadline", "config", "gui"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + # Wait for the app to appear in the accessibility tree + app = None + end = time.monotonic() + STARTUP_TIMEOUT + while time.monotonic() < end: + for a in xa11y.App.list(): # type: ignore[attr-defined] + if a.pid == proc.pid: + app = xa11y.App.by_name(a.name) # type: ignore[attr-defined] + break + if app: + break + time.sleep(0.5) + + if app is None: + proc.kill() + proc.wait() + pytest.fail(f"deadline config gui did not appear within {STARTUP_TIMEOUT}s") + + dialog = app.locator(f'dialog[name="{DIALOG_TITLE}"]') + dialog.wait_attached(timeout=STARTUP_TIMEOUT) + + yield app + + # Teardown: close dialog if still open, then kill process + try: + if dialog.exists(): + app.locator('button[name="Cancel"]').press() + time.sleep(ACTION_SETTLE) + except xa11y.XA11yError: + pass # Dialog may already be closed or process exiting + proc.kill() + proc.wait() diff --git a/test/ui/test_bundle_gui_submit.py b/test/ui/test_bundle_gui_submit.py new file mode 100644 index 000000000..e1057438d --- /dev/null +++ b/test/ui/test_bundle_gui_submit.py @@ -0,0 +1,214 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +UI tests for ``deadline bundle gui-submit`` against the mock Deadline server. + +Launches the real GUI submitter as a subprocess pointed at the mock server, +then inspects the accessibility tree with xa11y. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from typing import Generator + +import pytest +import xa11y + +pytest_plugins = ["mock_server.conftest"] + +DIALOG_TITLE = "Deadline Cloud JobBundle Submitter" +STARTUP_TIMEOUT = 15 +ACTION_SETTLE = 1.0 + +SAMPLE_TEMPLATE = { + "specificationVersion": "jobtemplate-2023-09", + "name": "Test Render Job", + "description": "A test job for UI verification", + "steps": [ + { + "name": "RenderStep", + "script": { + "actions": {"onRun": {"command": "bash", "args": ["-c", "echo hello"]}}, + }, + } + ], +} + + +def _find_app(pid: int) -> xa11y.App: + """Wait for the xa11y app to appear for the given PID.""" + end = time.monotonic() + STARTUP_TIMEOUT + while time.monotonic() < end: + for a in xa11y.App.list(): # type: ignore[attr-defined] + if a.pid == pid: + return xa11y.App.by_name(a.name) # type: ignore[attr-defined] + time.sleep(0.5) + raise TimeoutError(f"No accessibility app found for PID {pid}") + + +def _kill(proc: subprocess.Popen) -> None: + proc.kill() + proc.wait() + + +@pytest.fixture() +def bundle_dir(tmp_path) -> str: + """Create a minimal job bundle directory.""" + d = tmp_path / "bundle" + d.mkdir() + (d / "template.json").write_text(json.dumps(SAMPLE_TEMPLATE)) + return str(d) + + +@pytest.fixture() +def submitter_env(mock_deadline_server, tmp_path) -> dict: + """Env dict for the gui-submit subprocess, pointed at the mock server. + + Pre-creates a farm and queue in the mock store and writes a deadline + config file referencing them. The job history dir is set to a temp + directory so exported bundles are easy to find and clean up. + """ + farm = mock_deadline_server.create_farm({"displayName": "TestFarm"}) + queue = mock_deadline_server.create_queue(farm["farmId"], {"displayName": "TestQueue"}) + + job_history_dir = str(tmp_path / "job_history") + + # The config file uses a hierarchical section structure: + # [defaults] -> aws_profile_name + # [profile-(default) defaults] -> farm_id + # [profile-(default) farm-XXX defaults] -> queue_id + # [profile-(default) settings] -> job_history_dir + config_path = str(tmp_path / "deadline_config") + with open(config_path, "w") as f: + f.write( + f"[defaults]\n" + f"aws_profile_name = (default)\n" + f"\n" + f"[profile-(default) defaults]\n" + f"farm_id = {farm['farmId']}\n" + f"\n" + f"[profile-(default) {farm['farmId']} defaults]\n" + f"queue_id = {queue['queueId']}\n" + f"\n" + f"[profile-(default) settings]\n" + f"job_history_dir = {job_history_dir}\n" + ) + + env = os.environ.copy() + env["AWS_ACCESS_KEY_ID"] = "testing" + env["AWS_SECRET_ACCESS_KEY"] = "testing" + env["AWS_DEFAULT_REGION"] = "us-west-2" + env["DEADLINE_CONFIG_FILE_PATH"] = config_path + return env + + +@pytest.fixture() +def gui_submit(bundle_dir, submitter_env) -> Generator[xa11y.App, None, None]: + """Launch ``deadline bundle gui-submit `` and yield the xa11y App.""" + proc = subprocess.Popen( + [sys.executable, "-m", "deadline", "bundle", "gui-submit", bundle_dir], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=submitter_env, + ) + try: + app = _find_app(proc.pid) + app.locator(f'dialog[name="{DIALOG_TITLE}"]').wait_attached(timeout=STARTUP_TIMEOUT) + # Let async farm/queue name resolution complete + time.sleep(3) + yield app + finally: + _kill(proc) + + +class TestSubmitterOpens: + """Verify the submitter dialog opens with expected elements.""" + + def test_dialog_is_visible(self, gui_submit: xa11y.App): + dialog = gui_submit.locator(f'dialog[name="{DIALOG_TITLE}"]') + assert dialog.exists() + assert dialog.element().visible + + def test_farm_label_visible(self, gui_submit: xa11y.App): + assert gui_submit.locator('static_text[name="Farm"]').exists() + + def test_farm_name_resolved(self, gui_submit: xa11y.App): + farm_display = gui_submit.locator( + 'group[name="Deadline Cloud settings"] > static_text[name="TestFarm"]' + ) + assert farm_display.exists(), "Farm name should resolve to 'TestFarm' via mock server" + + def test_queue_label_visible(self, gui_submit: xa11y.App): + assert gui_submit.locator('static_text[name="Queue"]').exists() + + def test_queue_name_resolved(self, gui_submit: xa11y.App): + queue_display = gui_submit.locator( + 'group[name="Deadline Cloud settings"] > static_text[name="TestQueue"]' + ) + assert queue_display.exists(), "Queue name should resolve to 'TestQueue' via mock server" + + def test_job_name_displayed(self, gui_submit: xa11y.App): + name_field = gui_submit.locator('text_field[name="Name"]') + assert name_field.exists() + assert name_field.element().value == "Test Render Job" + + def test_has_tabs(self, gui_submit: xa11y.App): + tab_group = gui_submit.locator("tab_group") + assert tab_group.exists() + for tab_name in ( + "Shared job settings", + "Job-specific settings", + "Job attachments", + "Host requirements", + ): + assert gui_submit.locator(f'radio_button[name="{tab_name}"]').exists(), ( + f"Tab '{tab_name}' not found" + ) + + def test_has_submit_and_export_buttons(self, gui_submit: xa11y.App): + assert gui_submit.locator('button[name="Submit"]').exists() + assert gui_submit.locator('button[name="Export bundle"]').exists() + + +class TestExportBundle: + """Verify that clicking 'Export bundle' saves a job bundle.""" + + def test_export_creates_bundle(self, bundle_dir, submitter_env, tmp_path): + job_history_dir = tmp_path / "job_history" + + proc = subprocess.Popen( + [sys.executable, "-m", "deadline", "bundle", "gui-submit", bundle_dir], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=submitter_env, + ) + try: + app = _find_app(proc.pid) + app.locator(f'dialog[name="{DIALOG_TITLE}"]').wait_attached(timeout=STARTUP_TIMEOUT) + time.sleep(2) + + # Click "Export bundle" + app.locator('button[name="Export bundle"]').press() + time.sleep(ACTION_SETTLE) + + # The export shows an info dialog — dismiss it + ok_button = app.locator('button[name="OK"]') + if ok_button.exists(): + ok_button.press() + time.sleep(ACTION_SETTLE) + + # Verify a bundle was saved in the job history dir + assert job_history_dir.exists(), "Job history directory was not created" + templates = list(job_history_dir.rglob("template.*")) + assert len(templates) > 0, "No template file found in exported bundle" + + with open(templates[0]) as f: + exported = json.load(f) + assert exported["name"] == "Test Render Job" + finally: + _kill(proc) diff --git a/test/ui/test_cli_mock_server.py b/test/ui/test_cli_mock_server.py new file mode 100644 index 000000000..9ef18d2be --- /dev/null +++ b/test/ui/test_cli_mock_server.py @@ -0,0 +1,261 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +CLI integration tests against the mock Deadline server. + +Uses ``aws deadline`` (AWS CLI) for create/delete operations and +``deadline`` (project CLI) for get/list operations. +""" + +from __future__ import annotations + +import json +import subprocess +import sys + +import pytest + +pytest_plugins = ["mock_server.conftest"] + +PYTHONPATH = "src" + + +def _aws(*args: str, env: dict) -> dict: + """Run ``aws deadline --output json`` and return parsed JSON.""" + result = subprocess.run( + ["aws", "deadline", *args, "--output", "json"], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + assert result.returncode == 0, f"aws deadline {' '.join(args)} failed:\n{result.stderr}" + return json.loads(result.stdout) if result.stdout.strip() else {} + + +def _deadline(*args: str, env: dict) -> str: + """Run ``deadline `` via the local source and return stdout.""" + result = subprocess.run( + [sys.executable, "-m", "deadline", *args], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + assert result.returncode == 0, ( + f"deadline {' '.join(args)} failed:\n{result.stdout}\n{result.stderr}" + ) + return result.stdout + + +@pytest.fixture() +def cli_env(mock_deadline_server, monkeypatch, tmp_path) -> dict: + """Env dict for subprocess calls, pointing at the mock server.""" + import os + + env = os.environ.copy() + env["AWS_ACCESS_KEY_ID"] = "testing" + env["AWS_SECRET_ACCESS_KEY"] = "testing" + env["AWS_DEFAULT_REGION"] = "us-west-2" + env["AWS_ENDPOINT_URL_DEADLINE"] = os.environ["AWS_ENDPOINT_URL_DEADLINE"] + env["PYTHONPATH"] = PYTHONPATH + + config_path = str(tmp_path / "deadline_config") + with open(config_path, "w") as f: + f.write("[defaults]\n") + env["DEADLINE_CONFIG_FILE_PATH"] = config_path + + return env + + +class TestFarmCLI: + def test_crud(self, cli_env: dict): + # Create + r = _aws("create-farm", "--display-name", "TestFarm", env=cli_env) + farm_id = r["farmId"] + assert farm_id.startswith("farm-") + + # Get + out = _deadline("farm", "get", "--farm-id", farm_id, env=cli_env) + assert "TestFarm" in out + assert farm_id in out + + # List + out = _deadline("farm", "list", env=cli_env) + assert farm_id in out + assert "TestFarm" in out + + # Delete + _aws("delete-farm", "--farm-id", farm_id, env=cli_env) + out = _deadline("farm", "list", env=cli_env) + assert farm_id not in out + + +class TestQueueCLI: + def test_crud(self, cli_env: dict): + farm_id = _aws("create-farm", "--display-name", "F", env=cli_env)["farmId"] + + # Create + r = _aws("create-queue", "--farm-id", farm_id, "--display-name", "TestQueue", env=cli_env) + queue_id = r["queueId"] + assert queue_id.startswith("queue-") + + # Get + out = _deadline("queue", "get", "--farm-id", farm_id, "--queue-id", queue_id, env=cli_env) + assert "TestQueue" in out + assert queue_id in out + + # List + out = _deadline("queue", "list", "--farm-id", farm_id, env=cli_env) + assert queue_id in out + + # Delete + _aws("delete-queue", "--farm-id", farm_id, "--queue-id", queue_id, env=cli_env) + out = _deadline("queue", "list", "--farm-id", farm_id, env=cli_env) + assert queue_id not in out + + +class TestFleetCLI: + def test_crud(self, cli_env: dict): + farm_id = _aws("create-farm", "--display-name", "F", env=cli_env)["farmId"] + + # Create + config = json.dumps( + { + "customerManaged": { + "mode": "NO_SCALING", + "workerCapabilities": { + "vCpuCount": {"min": 1}, + "memoryMiB": {"min": 1024}, + "osFamily": "LINUX", + "cpuArchitectureType": "x86_64", + }, + } + } + ) + r = _aws( + "create-fleet", + "--farm-id", + farm_id, + "--display-name", + "TestFleet", + "--role-arn", + "arn:aws:iam::000000000000:role/mock", + "--max-worker-count", + "5", + "--configuration", + config, + env=cli_env, + ) + fleet_id = r["fleetId"] + assert fleet_id.startswith("fleet-") + + # Get + out = _deadline("fleet", "get", "--farm-id", farm_id, "--fleet-id", fleet_id, env=cli_env) + assert "TestFleet" in out + assert fleet_id in out + + # List + out = _deadline("fleet", "list", "--farm-id", farm_id, env=cli_env) + assert fleet_id in out + + # Delete + _aws("delete-fleet", "--farm-id", farm_id, "--fleet-id", fleet_id, env=cli_env) + out = _deadline("fleet", "list", "--farm-id", farm_id, env=cli_env) + assert fleet_id not in out + + +class TestWorkerCLI: + def test_list_and_get(self, cli_env: dict, mock_deadline_server): + farm_id = _aws("create-farm", "--display-name", "F", env=cli_env)["farmId"] + config = json.dumps( + { + "customerManaged": { + "mode": "NO_SCALING", + "workerCapabilities": { + "vCpuCount": {"min": 1}, + "memoryMiB": {"min": 1024}, + "osFamily": "LINUX", + "cpuArchitectureType": "x86_64", + }, + } + } + ) + fleet_id = _aws( + "create-fleet", + "--farm-id", + farm_id, + "--display-name", + "Fl", + "--role-arn", + "arn:aws:iam::000000000000:role/mock", + "--max-worker-count", + "5", + "--configuration", + config, + env=cli_env, + )["fleetId"] + + # Workers are created by the service, not the user — seed via the store + worker = mock_deadline_server.create_worker(farm_id, fleet_id, {}) + worker_id = worker["workerId"] + + # List + out = _deadline("worker", "list", "--farm-id", farm_id, "--fleet-id", fleet_id, env=cli_env) + assert worker_id in out + + # Get + out = _deadline( + "worker", + "get", + "--farm-id", + farm_id, + "--fleet-id", + fleet_id, + "--worker-id", + worker_id, + env=cli_env, + ) + assert worker_id in out + + +class TestJobCLI: + def test_create_and_read(self, cli_env: dict): + farm_id = _aws("create-farm", "--display-name", "F", env=cli_env)["farmId"] + queue_id = _aws("create-queue", "--farm-id", farm_id, "--display-name", "Q", env=cli_env)[ + "queueId" + ] + + # Create job + r = _aws( + "create-job", + "--farm-id", + farm_id, + "--queue-id", + queue_id, + "--template", + "{}", + "--priority", + "75", + env=cli_env, + ) + job_id = r["jobId"] + assert job_id.startswith("job-") + + # Get + out = _deadline( + "job", + "get", + "--farm-id", + farm_id, + "--queue-id", + queue_id, + "--job-id", + job_id, + env=cli_env, + ) + assert job_id in out + + # List + out = _deadline("job", "list", "--farm-id", farm_id, "--queue-id", queue_id, env=cli_env) + assert job_id in out diff --git a/test/ui/test_config_gui_log_level.py b/test/ui/test_config_gui_log_level.py new file mode 100644 index 000000000..0d372ad4d --- /dev/null +++ b/test/ui/test_config_gui_log_level.py @@ -0,0 +1,232 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""UI tests for ``deadline config gui`` — log level setting. + +These tests launch the real GUI as a subprocess and inspect it through +the accessibility tree using xa11y. + +NOTE: On macOS, Qt combo box popup items cannot be reliably activated +via accessibility APIs. We work around this by changing the config +value with ``deadline config set`` before opening the GUI, then +asserting the GUI reflects the change. +""" + +from __future__ import annotations + +import subprocess +import time + +import pytest +import xa11y + +DIALOG_TITLE = "AWS Deadline Cloud workstation configuration" +STARTUP_TIMEOUT = 10 +ACTION_SETTLE = 0.5 + +# The log level combo box is the 2nd combo_box inside "General settings" +# (1st = conflict resolution, 2nd = log level, 3rd = language) +_LOG_LEVEL_COMBO_NTH = 2 + + +def _get_log_level_value(app: xa11y.App) -> str: + """Return the current text shown in the log-level combo box.""" + general = app.locator('group[name="General settings"]').element() + combos = [c for c in general.children() if c.role == "combo_box"] # type: ignore[operator] + return combos[_LOG_LEVEL_COMBO_NTH - 1].name + + +def _cli_get(setting: str) -> str: + return subprocess.check_output( + ["deadline", "config", "get", setting], text=True, stderr=subprocess.DEVNULL + ).strip() + + +def _cli_set(setting: str, value: str) -> None: + subprocess.check_call( + ["deadline", "config", "set", setting, value], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def _find_app_name(pid: int) -> str: + """Return the xa11y app name for the given PID.""" + end = time.monotonic() + STARTUP_TIMEOUT + while time.monotonic() < end: + for a in xa11y.App.list(): # type: ignore[attr-defined] + if a.pid == pid: + return a.name + time.sleep(0.5) + raise TimeoutError(f"No accessibility app found for PID {pid}") + + +def _open_config_gui(): + """Launch ``deadline config gui`` and return (proc, app).""" + proc = subprocess.Popen( + ["deadline", "config", "gui"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + app_name = _find_app_name(proc.pid) + app = xa11y.App.by_name(app_name) # type: ignore[attr-defined] + app.locator(f'dialog[name="{DIALOG_TITLE}"]').wait_attached(timeout=STARTUP_TIMEOUT) + return proc, app + except Exception: + proc.kill() + proc.wait() + raise + + +def _close_config_gui(proc, app, button="Cancel"): + """Click a button and clean up the process.""" + try: + app.locator(f'button[name="{button}"]').press() + time.sleep(ACTION_SETTLE) + except xa11y.XA11yError: + pass # Button may not exist if dialog already closed + proc.kill() + proc.wait() + + +# ── Basic dialog tests ──────────────────────────────────────────────────── + + +class TestConfigGuiOpens: + def test_dialog_is_visible(self, config_gui: xa11y.App): + dialog = config_gui.locator(f'dialog[name="{DIALOG_TITLE}"]') + assert dialog.exists() + assert dialog.element().visible + + def test_has_ok_cancel_apply_buttons(self, config_gui: xa11y.App): + for name in ("Ok", "Cancel", "Apply"): + assert config_gui.locator(f'button[name="{name}"]').exists() + + def test_has_general_settings_group(self, config_gui: xa11y.App): + assert config_gui.locator('group[name="General settings"]').exists() + + def test_shows_current_log_level(self, config_gui: xa11y.App): + expected = _cli_get("settings.log_level") + assert _get_log_level_value(config_gui) == expected + + +# ── Log level round-trip tests ──────────────────────────────────────────── + + +class TestLogLevelRoundTrip: + """Set a log level via CLI, open GUI, verify it displays correctly.""" + + @pytest.mark.parametrize("level", ["ERROR", "WARNING", "INFO", "DEBUG"]) + def test_each_level_displays_correctly(self, level: str): + original = _cli_get("settings.log_level") + try: + _cli_set("settings.log_level", level) + proc, app = _open_config_gui() + try: + assert _get_log_level_value(app) == level + finally: + _close_config_gui(proc, app) + finally: + _cli_set("settings.log_level", original) + + +# ── Cancel does not save ────────────────────────────────────────────────── + + +class TestCancelDoesNotSave: + """Change a setting via CLI, open GUI, click Cancel, verify the + on-disk value is unchanged.""" + + def test_cancel_leaves_config_unchanged(self): + original = _cli_get("settings.log_level") + new_level = "DEBUG" if original != "DEBUG" else "INFO" + try: + _cli_set("settings.log_level", new_level) + proc, app = _open_config_gui() + try: + assert _get_log_level_value(app) == new_level + _close_config_gui(proc, app, button="Cancel") + except Exception: + _close_config_gui(proc, app) + raise + assert _cli_get("settings.log_level") == new_level + finally: + _cli_set("settings.log_level", original) + + +# ── Ok saves ────────────────────────────────────────────────────────────── + + +class TestOkSaves: + """Set a value via CLI, open GUI, click Ok, confirm it persists.""" + + def test_ok_persists_value(self): + original = _cli_get("settings.log_level") + new_level = "DEBUG" if original != "DEBUG" else "INFO" + try: + _cli_set("settings.log_level", new_level) + proc, app = _open_config_gui() + try: + assert _get_log_level_value(app) == new_level + _close_config_gui(proc, app, button="Ok") + except Exception: + _close_config_gui(proc, app) + raise + assert _cli_get("settings.log_level") == new_level + finally: + _cli_set("settings.log_level", original) + + +# ── Reopen after cancel shows original value ────────────────────────────── + + +class TestReopenAfterCancel: + """Open GUI → Cancel → reopen → verify the original value is still shown.""" + + def test_reopen_shows_same_value(self): + original = _cli_get("settings.log_level") + try: + # First open + cancel + proc, app = _open_config_gui() + first_value = _get_log_level_value(app) + _close_config_gui(proc, app, button="Cancel") + + # Second open — should show the same value + proc2, app2 = _open_config_gui() + try: + assert _get_log_level_value(app2) == first_value + finally: + _close_config_gui(proc2, app2) + finally: + _cli_set("settings.log_level", original) + + +# ── Full workflow: change, Ok, reopen, verify ───────────────────────────── + + +class TestChangeOkReopen: + """Change log level via CLI → open GUI → Ok → reopen → verify persisted.""" + + def test_change_persists_across_reopens(self): + original = _cli_get("settings.log_level") + new_level = "DEBUG" if original != "DEBUG" else "INFO" + try: + _cli_set("settings.log_level", new_level) + + # Open and click Ok + proc, app = _open_config_gui() + try: + assert _get_log_level_value(app) == new_level + _close_config_gui(proc, app, button="Ok") + except Exception: + _close_config_gui(proc, app) + raise + + # Reopen and verify + proc2, app2 = _open_config_gui() + try: + assert _get_log_level_value(app2) == new_level + finally: + _close_config_gui(proc2, app2) + finally: + _cli_set("settings.log_level", original) From d710965c306b6ef3a450caea96dcf74e960a11f3 Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:36:46 -0700 Subject: [PATCH 2/9] refactor: extract page object helpers for UI tests - Add test/ui/helpers.py with DeadlineApp base class, ConfigDialog and SubmitterDialog page objects that encapsulate process lifecycle and xa11y interaction - Remove test/ui/conftest.py (config_gui fixture inlined) - Deduplicate find_app, close/kill, and dialog constants across test files - close() waits for clean process exit before force-killing - Always use python -m deadline for subprocess launch consistency - Run all UI tests on Linux under xvfb (not just CLI tests) - Pin xa11y to 0.6.* - Add test_button_exits_cleanly to verify Ok/Cancel exit the process Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- .github/workflows/ui_tests.yml | 6 +- requirements-ui-testing.txt | 2 +- test/ui/conftest.py | 61 ---------- test/ui/helpers.py | 164 +++++++++++++++++++++++++++ test/ui/test_bundle_gui_submit.py | 94 ++++----------- test/ui/test_config_gui_log_level.py | 163 ++++++++------------------ 6 files changed, 236 insertions(+), 254 deletions(-) delete mode 100644 test/ui/conftest.py create mode 100644 test/ui/helpers.py diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 5473f0344..2a1e9dd19 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -33,8 +33,10 @@ jobs: - name: Ensure management.localhost resolves run: echo "127.0.0.1 management.localhost" | sudo tee -a /etc/hosts - - name: Run UI tests (CLI only) - run: hatch run ui:test test/ui/test_cli_mock_server.py --timeout=120 + - name: Run UI tests + env: + QT_ACCESSIBILITY: "1" + run: xvfb-run hatch run ui:test --timeout=120 ui-test-macos: name: UI Tests (macOS) diff --git a/requirements-ui-testing.txt b/requirements-ui-testing.txt index 723f0ebbe..3da3aaf0c 100644 --- a/requirements-ui-testing.txt +++ b/requirements-ui-testing.txt @@ -2,4 +2,4 @@ pytest == 9.* pytest-timeout >= 2.3 pytest-cov >= 4 pytest-xdist >= 3 -xa11y >= 0.5.3 +xa11y == 0.6.* diff --git a/test/ui/conftest.py b/test/ui/conftest.py deleted file mode 100644 index 9db54fb16..000000000 --- a/test/ui/conftest.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - -"""Fixtures for UI tests that run ``deadline`` as a subprocess and inspect via xa11y.""" - -from __future__ import annotations - -import subprocess -import time -from typing import Generator - -import pytest -import xa11y - -DIALOG_TITLE = "AWS Deadline Cloud workstation configuration" -STARTUP_TIMEOUT = 10 -ACTION_SETTLE = 0.5 - - -@pytest.fixture() -def config_gui() -> Generator[xa11y.App, None, None]: - """Launch ``deadline config gui`` and yield the xa11y App handle. - - Ensures the dialog is visible before yielding and kills the process on teardown. - """ - proc = subprocess.Popen( - ["deadline", "config", "gui"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - # Wait for the app to appear in the accessibility tree - app = None - end = time.monotonic() + STARTUP_TIMEOUT - while time.monotonic() < end: - for a in xa11y.App.list(): # type: ignore[attr-defined] - if a.pid == proc.pid: - app = xa11y.App.by_name(a.name) # type: ignore[attr-defined] - break - if app: - break - time.sleep(0.5) - - if app is None: - proc.kill() - proc.wait() - pytest.fail(f"deadline config gui did not appear within {STARTUP_TIMEOUT}s") - - dialog = app.locator(f'dialog[name="{DIALOG_TITLE}"]') - dialog.wait_attached(timeout=STARTUP_TIMEOUT) - - yield app - - # Teardown: close dialog if still open, then kill process - try: - if dialog.exists(): - app.locator('button[name="Cancel"]').press() - time.sleep(ACTION_SETTLE) - except xa11y.XA11yError: - pass # Dialog may already be closed or process exiting - proc.kill() - proc.wait() diff --git a/test/ui/helpers.py b/test/ui/helpers.py new file mode 100644 index 000000000..11dc15173 --- /dev/null +++ b/test/ui/helpers.py @@ -0,0 +1,164 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Page object helpers for UI tests. + +Provides a base ``DeadlineApp`` class that wraps a deadline GUI subprocess +and its xa11y accessibility handle, plus dialog-specific subclasses +(``ConfigDialog``, ``SubmitterDialog``) with convenience accessors. +""" + +from __future__ import annotations + +import subprocess +import sys +import time +from typing import Optional, Sequence, TypeVar + +import xa11y + +STARTUP_TIMEOUT = 15 +ACTION_SETTLE = 0.5 + +_T = TypeVar("_T", bound="DeadlineApp") + + +def find_app(pid: int, timeout: float = STARTUP_TIMEOUT) -> xa11y.App: + """Wait for an xa11y app to appear for the given PID.""" + end = time.monotonic() + timeout + while time.monotonic() < end: + for a in xa11y.App.list(): # type: ignore[attr-defined] + if a.pid == pid: + return xa11y.App.by_name(a.name) # type: ignore[attr-defined] + time.sleep(0.5) + raise TimeoutError(f"No accessibility app found for PID {pid}") + + +class DeadlineApp: + """Base page object wrapping a deadline GUI subprocess + xa11y handle. + + Use as a context manager for automatic cleanup:: + + with DeadlineApp.launch(["config", "gui"], dialog="...") as app: + app.button("Ok").press() + """ + + def __init__(self, proc: subprocess.Popen, xa11y_app: xa11y.App, dialog_name: str): + self.proc = proc + self._app = xa11y_app + self.dialog_name = dialog_name + + @classmethod + def launch( + cls: type[_T], + args: Sequence[str], + dialog: str, + env: Optional[dict] = None, + timeout: float = STARTUP_TIMEOUT, + ) -> _T: + """Launch a deadline subprocess and wait for its dialog to appear. + + Args: + args: CLI arguments after ``deadline``. + dialog: Accessibility name of the dialog to wait for. + env: Environment variables for the subprocess. + timeout: Seconds to wait for the app and dialog. + """ + cmd = [sys.executable, "-m", "deadline", *args] + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + try: + app = find_app(proc.pid, timeout) + app.locator(f'dialog[name="{dialog}"]').wait_attached(timeout=timeout) + except Exception: + proc.kill() + proc.wait() + raise + return cls(proc, app, dialog) + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + # ── Locator shortcuts ── + + def locator(self, selector: str): + return self._app.locator(selector) + + def dialog(self): + return self.locator(f'dialog[name="{self.dialog_name}"]') + + def button(self, name: str): + return self.locator(f'button[name="{name}"]') + + # ── Lifecycle ── + + def close(self, button_name: str = "Cancel"): + """Click a button to dismiss the dialog, then kill the process.""" + try: + self.button(button_name).press() + time.sleep(ACTION_SETTLE) + self.proc.wait(timeout=5) + return + except xa11y.XA11yError: + # Expected when the dialog was already closed or the process is exiting; + # teardown should not fail in this case. + pass + except subprocess.TimeoutExpired: + pass # Process didn't exit in time; force-kill below. + self.kill() + + def kill(self): + self.proc.kill() + self.proc.wait() + + +class ConfigDialog(DeadlineApp): + """Page object for ``deadline config gui``.""" + + DIALOG = "AWS Deadline Cloud workstation configuration" + + @classmethod + def open(cls, timeout: float = STARTUP_TIMEOUT) -> "ConfigDialog": + return cls.launch(["config", "gui"], dialog=cls.DIALOG, timeout=timeout) + + @property + def log_level(self) -> str: + """Return the current text shown in the log-level combo box.""" + general = self.locator('group[name="General settings"]').element() + combos = [c for c in general.children() if c.role == "combo_box"] # type: ignore[operator] + # 1st = conflict resolution, 2nd = log level, 3rd = language + return combos[1].name + + +class SubmitterDialog(DeadlineApp): + """Page object for ``deadline bundle gui-submit``.""" + + DIALOG = "Deadline Cloud JobBundle Submitter" + + @classmethod + def open( + cls, + bundle_dir: str, + env: dict, + timeout: float = STARTUP_TIMEOUT, + ) -> "SubmitterDialog": + return cls.launch( + ["bundle", "gui-submit", bundle_dir], + dialog=cls.DIALOG, + env=env, + timeout=timeout, + ) + + @property + def job_name(self) -> str: + return self.locator('text_field[name="Name"]').element().value + + def export_bundle(self): + """Click 'Export bundle' and dismiss the confirmation dialog.""" + self.button("Export bundle").press() + time.sleep(ACTION_SETTLE) + ok = self.button("OK") + ok.wait_attached(timeout=5) + ok.press() + time.sleep(ACTION_SETTLE) diff --git a/test/ui/test_bundle_gui_submit.py b/test/ui/test_bundle_gui_submit.py index e1057438d..9b46c87c1 100644 --- a/test/ui/test_bundle_gui_submit.py +++ b/test/ui/test_bundle_gui_submit.py @@ -11,19 +11,14 @@ import json import os -import subprocess -import sys import time from typing import Generator import pytest -import xa11y -pytest_plugins = ["mock_server.conftest"] +from ui.helpers import SubmitterDialog -DIALOG_TITLE = "Deadline Cloud JobBundle Submitter" -STARTUP_TIMEOUT = 15 -ACTION_SETTLE = 1.0 +pytest_plugins = ["mock_server.conftest"] SAMPLE_TEMPLATE = { "specificationVersion": "jobtemplate-2023-09", @@ -40,22 +35,6 @@ } -def _find_app(pid: int) -> xa11y.App: - """Wait for the xa11y app to appear for the given PID.""" - end = time.monotonic() + STARTUP_TIMEOUT - while time.monotonic() < end: - for a in xa11y.App.list(): # type: ignore[attr-defined] - if a.pid == pid: - return xa11y.App.by_name(a.name) # type: ignore[attr-defined] - time.sleep(0.5) - raise TimeoutError(f"No accessibility app found for PID {pid}") - - -def _kill(proc: subprocess.Popen) -> None: - proc.kill() - proc.wait() - - @pytest.fixture() def bundle_dir(tmp_path) -> str: """Create a minimal job bundle directory.""" @@ -78,11 +57,6 @@ def submitter_env(mock_deadline_server, tmp_path) -> dict: job_history_dir = str(tmp_path / "job_history") - # The config file uses a hierarchical section structure: - # [defaults] -> aws_profile_name - # [profile-(default) defaults] -> farm_id - # [profile-(default) farm-XXX defaults] -> queue_id - # [profile-(default) settings] -> job_history_dir config_path = str(tmp_path / "deadline_config") with open(config_path, "w") as f: f.write( @@ -108,56 +82,46 @@ def submitter_env(mock_deadline_server, tmp_path) -> dict: @pytest.fixture() -def gui_submit(bundle_dir, submitter_env) -> Generator[xa11y.App, None, None]: - """Launch ``deadline bundle gui-submit `` and yield the xa11y App.""" - proc = subprocess.Popen( - [sys.executable, "-m", "deadline", "bundle", "gui-submit", bundle_dir], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=submitter_env, - ) - try: - app = _find_app(proc.pid) - app.locator(f'dialog[name="{DIALOG_TITLE}"]').wait_attached(timeout=STARTUP_TIMEOUT) +def gui_submit(bundle_dir, submitter_env) -> Generator[SubmitterDialog, None, None]: + """Launch ``deadline bundle gui-submit `` and yield the page object.""" + with SubmitterDialog.open(bundle_dir, submitter_env) as app: # Let async farm/queue name resolution complete time.sleep(3) yield app - finally: - _kill(proc) class TestSubmitterOpens: """Verify the submitter dialog opens with expected elements.""" - def test_dialog_is_visible(self, gui_submit: xa11y.App): - dialog = gui_submit.locator(f'dialog[name="{DIALOG_TITLE}"]') + def test_dialog_is_visible(self, gui_submit: SubmitterDialog): + dialog = gui_submit.dialog() assert dialog.exists() assert dialog.element().visible - def test_farm_label_visible(self, gui_submit: xa11y.App): + def test_farm_label_visible(self, gui_submit: SubmitterDialog): assert gui_submit.locator('static_text[name="Farm"]').exists() - def test_farm_name_resolved(self, gui_submit: xa11y.App): + def test_farm_name_resolved(self, gui_submit: SubmitterDialog): farm_display = gui_submit.locator( 'group[name="Deadline Cloud settings"] > static_text[name="TestFarm"]' ) assert farm_display.exists(), "Farm name should resolve to 'TestFarm' via mock server" - def test_queue_label_visible(self, gui_submit: xa11y.App): + def test_queue_label_visible(self, gui_submit: SubmitterDialog): assert gui_submit.locator('static_text[name="Queue"]').exists() - def test_queue_name_resolved(self, gui_submit: xa11y.App): + def test_queue_name_resolved(self, gui_submit: SubmitterDialog): queue_display = gui_submit.locator( 'group[name="Deadline Cloud settings"] > static_text[name="TestQueue"]' ) assert queue_display.exists(), "Queue name should resolve to 'TestQueue' via mock server" - def test_job_name_displayed(self, gui_submit: xa11y.App): + def test_job_name_displayed(self, gui_submit: SubmitterDialog): name_field = gui_submit.locator('text_field[name="Name"]') assert name_field.exists() - assert name_field.element().value == "Test Render Job" + assert gui_submit.job_name == "Test Render Job" - def test_has_tabs(self, gui_submit: xa11y.App): + def test_has_tabs(self, gui_submit: SubmitterDialog): tab_group = gui_submit.locator("tab_group") assert tab_group.exists() for tab_name in ( @@ -170,9 +134,9 @@ def test_has_tabs(self, gui_submit: xa11y.App): f"Tab '{tab_name}' not found" ) - def test_has_submit_and_export_buttons(self, gui_submit: xa11y.App): - assert gui_submit.locator('button[name="Submit"]').exists() - assert gui_submit.locator('button[name="Export bundle"]').exists() + def test_has_submit_and_export_buttons(self, gui_submit: SubmitterDialog): + assert gui_submit.button("Submit").exists() + assert gui_submit.button("Export bundle").exists() class TestExportBundle: @@ -181,28 +145,10 @@ class TestExportBundle: def test_export_creates_bundle(self, bundle_dir, submitter_env, tmp_path): job_history_dir = tmp_path / "job_history" - proc = subprocess.Popen( - [sys.executable, "-m", "deadline", "bundle", "gui-submit", bundle_dir], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=submitter_env, - ) - try: - app = _find_app(proc.pid) - app.locator(f'dialog[name="{DIALOG_TITLE}"]').wait_attached(timeout=STARTUP_TIMEOUT) + with SubmitterDialog.open(bundle_dir, submitter_env) as app: time.sleep(2) + app.export_bundle() - # Click "Export bundle" - app.locator('button[name="Export bundle"]').press() - time.sleep(ACTION_SETTLE) - - # The export shows an info dialog — dismiss it - ok_button = app.locator('button[name="OK"]') - if ok_button.exists(): - ok_button.press() - time.sleep(ACTION_SETTLE) - - # Verify a bundle was saved in the job history dir assert job_history_dir.exists(), "Job history directory was not created" templates = list(job_history_dir.rglob("template.*")) assert len(templates) > 0, "No template file found in exported bundle" @@ -210,5 +156,3 @@ def test_export_creates_bundle(self, bundle_dir, submitter_env, tmp_path): with open(templates[0]) as f: exported = json.load(f) assert exported["name"] == "Test Render Job" - finally: - _kill(proc) diff --git a/test/ui/test_config_gui_log_level.py b/test/ui/test_config_gui_log_level.py index 0d372ad4d..68d9bef6a 100644 --- a/test/ui/test_config_gui_log_level.py +++ b/test/ui/test_config_gui_log_level.py @@ -14,25 +14,10 @@ from __future__ import annotations import subprocess -import time import pytest -import xa11y -DIALOG_TITLE = "AWS Deadline Cloud workstation configuration" -STARTUP_TIMEOUT = 10 -ACTION_SETTLE = 0.5 - -# The log level combo box is the 2nd combo_box inside "General settings" -# (1st = conflict resolution, 2nd = log level, 3rd = language) -_LOG_LEVEL_COMBO_NTH = 2 - - -def _get_log_level_value(app: xa11y.App) -> str: - """Return the current text shown in the log-level combo box.""" - general = app.locator('group[name="General settings"]').element() - combos = [c for c in general.children() if c.role == "combo_box"] # type: ignore[operator] - return combos[_LOG_LEVEL_COMBO_NTH - 1].name +from ui.helpers import ConfigDialog def _cli_get(setting: str) -> str: @@ -49,65 +34,38 @@ def _cli_set(setting: str, value: str) -> None: ) -def _find_app_name(pid: int) -> str: - """Return the xa11y app name for the given PID.""" - end = time.monotonic() + STARTUP_TIMEOUT - while time.monotonic() < end: - for a in xa11y.App.list(): # type: ignore[attr-defined] - if a.pid == pid: - return a.name - time.sleep(0.5) - raise TimeoutError(f"No accessibility app found for PID {pid}") - - -def _open_config_gui(): - """Launch ``deadline config gui`` and return (proc, app).""" - proc = subprocess.Popen( - ["deadline", "config", "gui"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - try: - app_name = _find_app_name(proc.pid) - app = xa11y.App.by_name(app_name) # type: ignore[attr-defined] - app.locator(f'dialog[name="{DIALOG_TITLE}"]').wait_attached(timeout=STARTUP_TIMEOUT) - return proc, app - except Exception: - proc.kill() - proc.wait() - raise - - -def _close_config_gui(proc, app, button="Cancel"): - """Click a button and clean up the process.""" - try: - app.locator(f'button[name="{button}"]').press() - time.sleep(ACTION_SETTLE) - except xa11y.XA11yError: - pass # Button may not exist if dialog already closed - proc.kill() - proc.wait() - - # ── Basic dialog tests ──────────────────────────────────────────────────── class TestConfigGuiOpens: - def test_dialog_is_visible(self, config_gui: xa11y.App): - dialog = config_gui.locator(f'dialog[name="{DIALOG_TITLE}"]') - assert dialog.exists() - assert dialog.element().visible - - def test_has_ok_cancel_apply_buttons(self, config_gui: xa11y.App): - for name in ("Ok", "Cancel", "Apply"): - assert config_gui.locator(f'button[name="{name}"]').exists() - - def test_has_general_settings_group(self, config_gui: xa11y.App): - assert config_gui.locator('group[name="General settings"]').exists() - - def test_shows_current_log_level(self, config_gui: xa11y.App): + def test_dialog_is_visible(self): + with ConfigDialog.open() as app: + dialog = app.dialog() + assert dialog.exists() + assert dialog.element().visible + + def test_has_ok_cancel_apply_buttons(self): + with ConfigDialog.open() as app: + for name in ("Ok", "Cancel", "Apply"): + assert app.button(name).exists() + + def test_has_general_settings_group(self): + with ConfigDialog.open() as app: + assert app.locator('group[name="General settings"]').exists() + + def test_shows_current_log_level(self): expected = _cli_get("settings.log_level") - assert _get_log_level_value(config_gui) == expected + with ConfigDialog.open() as app: + assert app.log_level == expected + + @pytest.mark.parametrize("button", ["Ok", "Cancel"]) + def test_button_exits_cleanly(self, button: str): + app = ConfigDialog.open() + try: + app.button(button).press() + assert app.proc.wait(timeout=10) == 0, f"Process did not exit cleanly after {button}" + finally: + app.kill() # ── Log level round-trip tests ──────────────────────────────────────────── @@ -121,11 +79,8 @@ def test_each_level_displays_correctly(self, level: str): original = _cli_get("settings.log_level") try: _cli_set("settings.log_level", level) - proc, app = _open_config_gui() - try: - assert _get_log_level_value(app) == level - finally: - _close_config_gui(proc, app) + with ConfigDialog.open() as app: + assert app.log_level == level finally: _cli_set("settings.log_level", original) @@ -142,13 +97,9 @@ def test_cancel_leaves_config_unchanged(self): new_level = "DEBUG" if original != "DEBUG" else "INFO" try: _cli_set("settings.log_level", new_level) - proc, app = _open_config_gui() - try: - assert _get_log_level_value(app) == new_level - _close_config_gui(proc, app, button="Cancel") - except Exception: - _close_config_gui(proc, app) - raise + with ConfigDialog.open() as app: + assert app.log_level == new_level + app.close("Cancel") assert _cli_get("settings.log_level") == new_level finally: _cli_set("settings.log_level", original) @@ -165,13 +116,9 @@ def test_ok_persists_value(self): new_level = "DEBUG" if original != "DEBUG" else "INFO" try: _cli_set("settings.log_level", new_level) - proc, app = _open_config_gui() - try: - assert _get_log_level_value(app) == new_level - _close_config_gui(proc, app, button="Ok") - except Exception: - _close_config_gui(proc, app) - raise + with ConfigDialog.open() as app: + assert app.log_level == new_level + app.close("Ok") assert _cli_get("settings.log_level") == new_level finally: _cli_set("settings.log_level", original) @@ -186,17 +133,12 @@ class TestReopenAfterCancel: def test_reopen_shows_same_value(self): original = _cli_get("settings.log_level") try: - # First open + cancel - proc, app = _open_config_gui() - first_value = _get_log_level_value(app) - _close_config_gui(proc, app, button="Cancel") - - # Second open — should show the same value - proc2, app2 = _open_config_gui() - try: - assert _get_log_level_value(app2) == first_value - finally: - _close_config_gui(proc2, app2) + with ConfigDialog.open() as app: + first_value = app.log_level + app.close("Cancel") + + with ConfigDialog.open() as app: + assert app.log_level == first_value finally: _cli_set("settings.log_level", original) @@ -213,20 +155,11 @@ def test_change_persists_across_reopens(self): try: _cli_set("settings.log_level", new_level) - # Open and click Ok - proc, app = _open_config_gui() - try: - assert _get_log_level_value(app) == new_level - _close_config_gui(proc, app, button="Ok") - except Exception: - _close_config_gui(proc, app) - raise - - # Reopen and verify - proc2, app2 = _open_config_gui() - try: - assert _get_log_level_value(app2) == new_level - finally: - _close_config_gui(proc2, app2) + with ConfigDialog.open() as app: + assert app.log_level == new_level + app.close("Ok") + + with ConfigDialog.open() as app: + assert app.log_level == new_level finally: _cli_set("settings.log_level", original) From 695dc04e444474aa1756539f9e769b65ee6f6a93 Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:01:50 -0700 Subject: [PATCH 3/9] fix: revert Linux CI to CLI-only tests xa11y accessibility APIs don't work under xvfb on Linux. GUI tests only run on macOS (and Windows once supported). Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- .github/workflows/ui_tests.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 2a1e9dd19..5473f0344 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -33,10 +33,8 @@ jobs: - name: Ensure management.localhost resolves run: echo "127.0.0.1 management.localhost" | sudo tee -a /etc/hosts - - name: Run UI tests - env: - QT_ACCESSIBILITY: "1" - run: xvfb-run hatch run ui:test --timeout=120 + - name: Run UI tests (CLI only) + run: hatch run ui:test test/ui/test_cli_mock_server.py --timeout=120 ui-test-macos: name: UI Tests (macOS) From c8e7609188317f2b6a7a6b1f7c18a75e4ef345d8 Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:47:02 -0700 Subject: [PATCH 4/9] fix: enable AT-SPI2 accessibility on Linux CI for GUI tests Set up dbus-run-session, Xvfb, at-spi-bus-launcher, and at-spi2-registryd so xa11y can interact with Qt accessibility tree on Linux. Based on xa11y's own CI setup. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- .github/workflows/ui_tests.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 5473f0344..c68289e7e 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -36,6 +36,35 @@ jobs: - name: Run UI tests (CLI only) run: hatch run ui:test test/ui/test_cli_mock_server.py --timeout=120 + - name: Run UI tests (all, with accessibility) + env: + QT_ACCESSIBILITY: "1" + NO_AT_BRIDGE: "0" + ACCESSIBILITY_ENABLED: "1" + run: | + dbus-run-session -- bash -c ' + # Start Xvfb + Xvfb :99 -screen 0 1280x1024x24 -ac & + export DISPLAY=:99 + sleep 1 + + # Start AT-SPI2 accessibility bus + /usr/libexec/at-spi-bus-launcher --launch-immediately & + sleep 1 + /usr/libexec/at-spi2-registryd & + sleep 1 + + # Enable accessibility on the AT-SPI bus + dbus-send --session --print-reply --dest=org.a11y.Bus /org/a11y/bus \ + org.freedesktop.DBus.Properties.Set \ + string:org.a11y.Status string:IsEnabled variant:boolean:true 2>/dev/null || true + dbus-send --session --print-reply --dest=org.a11y.Bus /org/a11y/bus \ + org.freedesktop.DBus.Properties.Set \ + string:org.a11y.Status string:ScreenReaderEnabled variant:boolean:true 2>/dev/null || true + + hatch run ui:test --timeout=120 + ' + ui-test-macos: name: UI Tests (macOS) runs-on: macos-latest From 7608ddb3abf6af38bd4a54347636bbe921c6ed46 Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:08:07 -0700 Subject: [PATCH 5/9] fix: export AT-SPI env vars inside dbus-run-session The env vars must be exported inside the dbus-run-session bash block, not in the outer workflow env. Also add QT_LINUX_ACCESSIBILITY_ALWAYS_ON and AT_SPI_CLIENT to match xa11y's CI setup. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- .github/workflows/ui_tests.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index c68289e7e..0d99d492c 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -37,18 +37,23 @@ jobs: run: hatch run ui:test test/ui/test_cli_mock_server.py --timeout=120 - name: Run UI tests (all, with accessibility) - env: - QT_ACCESSIBILITY: "1" - NO_AT_BRIDGE: "0" - ACCESSIBILITY_ENABLED: "1" run: | dbus-run-session -- bash -c ' + set -e + # Start Xvfb Xvfb :99 -screen 0 1280x1024x24 -ac & export DISPLAY=:99 sleep 1 - # Start AT-SPI2 accessibility bus + # Enable AT-SPI2 accessibility + export NO_AT_BRIDGE=0 + export AT_SPI_CLIENT=true + export ACCESSIBILITY_ENABLED=1 + export QT_ACCESSIBILITY=1 + export QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1 + + # Start AT-SPI2 bus and registry /usr/libexec/at-spi-bus-launcher --launch-immediately & sleep 1 /usr/libexec/at-spi2-registryd & @@ -62,6 +67,9 @@ jobs: org.freedesktop.DBus.Properties.Set \ string:org.a11y.Status string:ScreenReaderEnabled variant:boolean:true 2>/dev/null || true + echo "DISPLAY=$DISPLAY" + echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" + hatch run ui:test --timeout=120 ' From d2c808850240553152dda58d389513944d00749e Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:24:46 -0700 Subject: [PATCH 6/9] fix: add diagnostics to find_app for Linux CI debugging Include list of visible accessibility apps in the TimeoutError message to help diagnose AT-SPI registration issues. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- test/ui/helpers.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/ui/helpers.py b/test/ui/helpers.py index 11dc15173..f3373531a 100644 --- a/test/ui/helpers.py +++ b/test/ui/helpers.py @@ -25,12 +25,17 @@ def find_app(pid: int, timeout: float = STARTUP_TIMEOUT) -> xa11y.App: """Wait for an xa11y app to appear for the given PID.""" end = time.monotonic() + timeout + visible_apps: list = [] while time.monotonic() < end: - for a in xa11y.App.list(): # type: ignore[attr-defined] + visible_apps = xa11y.App.list() # type: ignore[attr-defined] + for a in visible_apps: if a.pid == pid: return xa11y.App.by_name(a.name) # type: ignore[attr-defined] time.sleep(0.5) - raise TimeoutError(f"No accessibility app found for PID {pid}") + app_info = [(a.name, a.pid) for a in visible_apps] + raise TimeoutError( + f"No accessibility app found for PID {pid}. Visible apps: {app_info}" + ) class DeadlineApp: From cb7939654a42ba09b6ece04a307e16294c2b5d14 Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:35:35 -0700 Subject: [PATCH 7/9] fix: fall back to name-based app matching for Linux AT-SPI Linux AT-SPI reports PID 1 for all apps instead of the actual process PID. Capture a baseline of visible apps before launching, then match by finding the newly appeared app name. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- test/ui/helpers.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/ui/helpers.py b/test/ui/helpers.py index f3373531a..c58833069 100644 --- a/test/ui/helpers.py +++ b/test/ui/helpers.py @@ -22,8 +22,12 @@ _T = TypeVar("_T", bound="DeadlineApp") -def find_app(pid: int, timeout: float = STARTUP_TIMEOUT) -> xa11y.App: - """Wait for an xa11y app to appear for the given PID.""" +def find_app(pid: int, baseline_names: set, timeout: float = STARTUP_TIMEOUT) -> xa11y.App: + """Wait for an xa11y app to appear for the given PID. + + Falls back to matching by name if PID matching fails, since Linux + AT-SPI may report incorrect PIDs for applications. + """ end = time.monotonic() + timeout visible_apps: list = [] while time.monotonic() < end: @@ -31,11 +35,14 @@ def find_app(pid: int, timeout: float = STARTUP_TIMEOUT) -> xa11y.App: for a in visible_apps: if a.pid == pid: return xa11y.App.by_name(a.name) # type: ignore[attr-defined] + # AT-SPI on Linux often reports PID 1; fall back to returning + # any app that wasn't present at baseline. + for a in visible_apps: + if a.name not in baseline_names: + return xa11y.App.by_name(a.name) # type: ignore[attr-defined] time.sleep(0.5) app_info = [(a.name, a.pid) for a in visible_apps] - raise TimeoutError( - f"No accessibility app found for PID {pid}. Visible apps: {app_info}" - ) + raise TimeoutError(f"No accessibility app found for PID {pid}. Visible apps: {app_info}") class DeadlineApp: @@ -69,9 +76,10 @@ def launch( timeout: Seconds to wait for the app and dialog. """ cmd = [sys.executable, "-m", "deadline", *args] + baseline = {a.name for a in xa11y.App.list()} # type: ignore[attr-defined] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) try: - app = find_app(proc.pid, timeout) + app = find_app(proc.pid, baseline, timeout) app.locator(f'dialog[name="{dialog}"]').wait_attached(timeout=timeout) except Exception: proc.kill() From 05a1e1a34faf7d3222750033c15faa3e62a52f54 Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:55:35 -0700 Subject: [PATCH 8/9] fix: handle platform difference in Qt tab role names Qt exposes tab widgets as radio_button on macOS but page_tab on Linux AT-SPI. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- test/ui/test_bundle_gui_submit.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/ui/test_bundle_gui_submit.py b/test/ui/test_bundle_gui_submit.py index 9b46c87c1..1e660e1d2 100644 --- a/test/ui/test_bundle_gui_submit.py +++ b/test/ui/test_bundle_gui_submit.py @@ -11,6 +11,7 @@ import json import os +import sys import time from typing import Generator @@ -124,13 +125,15 @@ def test_job_name_displayed(self, gui_submit: SubmitterDialog): def test_has_tabs(self, gui_submit: SubmitterDialog): tab_group = gui_submit.locator("tab_group") assert tab_group.exists() + # Qt exposes tabs as radio_button on macOS, page_tab on Linux + tab_role = "page_tab" if sys.platform == "linux" else "radio_button" for tab_name in ( "Shared job settings", "Job-specific settings", "Job attachments", "Host requirements", ): - assert gui_submit.locator(f'radio_button[name="{tab_name}"]').exists(), ( + assert gui_submit.locator(f'{tab_role}[name="{tab_name}"]').exists(), ( f"Tab '{tab_name}' not found" ) From 0a6d42eb63aac779f74e6d8e9675d6214e880846 Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:52:54 -0700 Subject: [PATCH 9/9] fix: try multiple tab role names and dump diagnostics on failure Qt may expose tabs as radio_button, page_tab, or tab depending on platform. Try all three and dump children roles on failure. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- test/ui/test_bundle_gui_submit.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/test/ui/test_bundle_gui_submit.py b/test/ui/test_bundle_gui_submit.py index 1e660e1d2..22f35149f 100644 --- a/test/ui/test_bundle_gui_submit.py +++ b/test/ui/test_bundle_gui_submit.py @@ -11,7 +11,6 @@ import json import os -import sys import time from typing import Generator @@ -125,17 +124,26 @@ def test_job_name_displayed(self, gui_submit: SubmitterDialog): def test_has_tabs(self, gui_submit: SubmitterDialog): tab_group = gui_submit.locator("tab_group") assert tab_group.exists() - # Qt exposes tabs as radio_button on macOS, page_tab on Linux - tab_role = "page_tab" if sys.platform == "linux" else "radio_button" - for tab_name in ( + tab_names = ( "Shared job settings", "Job-specific settings", "Job attachments", "Host requirements", - ): - assert gui_submit.locator(f'{tab_role}[name="{tab_name}"]').exists(), ( - f"Tab '{tab_name}' not found" + ) + for tab_name in tab_names: + # Qt exposes tabs as radio_button on macOS, page_tab on Linux/Windows + found = ( + gui_submit.locator(f'radio_button[name="{tab_name}"]').exists() + or gui_submit.locator(f'page_tab[name="{tab_name}"]').exists() + or gui_submit.locator(f'tab[name="{tab_name}"]').exists() ) + if not found: + # Dump the tab_group children for debugging + children = tab_group.element().children() + child_info = [(c.role, c.name) for c in children] + raise AssertionError( + f"Tab '{tab_name}' not found. Tab group children: {child_info}" + ) def test_has_submit_and_export_buttons(self, gui_submit: SubmitterDialog): assert gui_submit.button("Submit").exists()