diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml new file mode 100644 index 000000000..0d99d492c --- /dev/null +++ b/.github/workflows/ui_tests.yml @@ -0,0 +1,132 @@ +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 + + - name: Run UI tests (all, with accessibility) + run: | + dbus-run-session -- bash -c ' + set -e + + # Start Xvfb + Xvfb :99 -screen 0 1280x1024x24 -ac & + export DISPLAY=:99 + sleep 1 + + # 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 & + 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 + + echo "DISPLAY=$DISPLAY" + echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" + + hatch run ui:test --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 411f106c7..fd65e2ed7 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. | @@ -506,7 +505,6 @@ 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). | | | 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. | | @@ -518,10 +516,6 @@ These are the manual test cases for the client software release cycle, covering | `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..3da3aaf0c --- /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.6.* 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/helpers.py b/test/ui/helpers.py new file mode 100644 index 000000000..c58833069 --- /dev/null +++ b/test/ui/helpers.py @@ -0,0 +1,177 @@ +# 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, 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: + 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] + # 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}") + + +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] + 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, baseline, 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 new file mode 100644 index 000000000..22f35149f --- /dev/null +++ b/test/ui/test_bundle_gui_submit.py @@ -0,0 +1,169 @@ +# 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 time +from typing import Generator + +import pytest + +from ui.helpers import SubmitterDialog + +pytest_plugins = ["mock_server.conftest"] + +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"]}}, + }, + } + ], +} + + +@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") + + 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[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 + + +class TestSubmitterOpens: + """Verify the submitter dialog opens with expected elements.""" + + 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: SubmitterDialog): + assert gui_submit.locator('static_text[name="Farm"]').exists() + + 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: SubmitterDialog): + assert gui_submit.locator('static_text[name="Queue"]').exists() + + 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: SubmitterDialog): + name_field = gui_submit.locator('text_field[name="Name"]') + assert name_field.exists() + assert gui_submit.job_name == "Test Render Job" + + def test_has_tabs(self, gui_submit: SubmitterDialog): + tab_group = gui_submit.locator("tab_group") + assert tab_group.exists() + tab_names = ( + "Shared job settings", + "Job-specific settings", + "Job attachments", + "Host requirements", + ) + 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() + assert gui_submit.button("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" + + with SubmitterDialog.open(bundle_dir, submitter_env) as app: + time.sleep(2) + app.export_bundle() + + 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" 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..68d9bef6a --- /dev/null +++ b/test/ui/test_config_gui_log_level.py @@ -0,0 +1,165 @@ +# 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 pytest + +from ui.helpers import ConfigDialog + + +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, + ) + + +# ── Basic dialog tests ──────────────────────────────────────────────────── + + +class TestConfigGuiOpens: + 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") + 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 ──────────────────────────────────────────── + + +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) + with ConfigDialog.open() as app: + assert app.log_level == level + 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) + 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) + + +# ── 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) + 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) + + +# ── 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: + 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) + + +# ── 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) + + 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)