158 lines
5.7 KiB
Python
158 lines
5.7 KiB
Python
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
"""Control channel between the client tests and the server Firefox.
|
|
|
|
The client side of the backward compatibility tests runs as a browser mochitest
|
|
inside the Firefox under test, and cannot act on the separate Firefox acting as
|
|
the DevTools server. This module exposes the actions it needs over plain HTTP:
|
|
|
|
POST /command {"name": "open-tab", "args": {"url": "..."}}
|
|
|
|
The pages those actions load are served by the regular mochitest HTTP server,
|
|
see FIXTURE_ROOT in helper-backward-compat.js.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
from marionette_driver.addons import Addons
|
|
from marionette_driver.by import By
|
|
|
|
from .servers import DesktopServer, find_free_port
|
|
|
|
# The test extension is installed through the AddonManager rather than fetched
|
|
# over HTTP, so unlike the fixture pages it stays next to the harness.
|
|
EXTENSION_DIR = os.path.join(
|
|
os.path.dirname(os.path.realpath(__file__)), "fixtures", "extension"
|
|
)
|
|
|
|
|
|
class ControlServer:
|
|
"""HTTP control channel for acting on the server Firefox.
|
|
|
|
:param server: the provisioned server instance, used to run the commands.
|
|
"""
|
|
|
|
def __init__(self, server: DesktopServer) -> None:
|
|
self.server = server
|
|
self.port = None
|
|
self._httpd = None
|
|
self._thread = None
|
|
# A command can stay pending for a long time: the click which hits a
|
|
# breakpoint only returns once the test resumes. Requests are therefore
|
|
# served on threads, and this serializes access to the Marionette
|
|
# client, which is not thread safe.
|
|
self._lock = threading.Lock()
|
|
|
|
def start(self) -> None:
|
|
self.port = find_free_port()
|
|
self._httpd = ThreadingHTTPServer(("127.0.0.1", self.port), _make_handler(self))
|
|
self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
if self._httpd:
|
|
self._httpd.shutdown()
|
|
self._httpd.server_close()
|
|
self._httpd = None
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
return f"http://127.0.0.1:{self.port}"
|
|
|
|
def run_command(self, name: str, args: dict) -> dict:
|
|
handler = getattr(self, f"_cmd_{name.replace('-', '_')}", None)
|
|
if handler is None:
|
|
raise ValueError(f"Unknown command '{name}'")
|
|
|
|
with self._lock:
|
|
return handler(**args)
|
|
|
|
def _cmd_open_tab(self, url: str) -> dict:
|
|
marionette = self.server.marionette
|
|
# Tests run in the same session one after the other, and a test which
|
|
# failed early may have left the current browsing context discarded.
|
|
marionette.switch_to_window(marionette.window_handles[0])
|
|
handle = marionette.open(type="tab", focus=True)["handle"]
|
|
marionette.switch_to_window(handle)
|
|
marionette.navigate(url)
|
|
return {"handle": handle}
|
|
|
|
def _cmd_close_tab(self, handle: str) -> dict:
|
|
marionette = self.server.marionette
|
|
marionette.switch_to_window(handle)
|
|
# Closing a window discards the current browsing context, WebDriver
|
|
# expects the client to explicitly switch to a remaining one.
|
|
remaining = marionette.close()
|
|
if remaining:
|
|
marionette.switch_to_window(remaining[0])
|
|
return {}
|
|
|
|
def _cmd_navigate(self, url: str, handle: str | None = None) -> dict:
|
|
marionette = self.server.marionette
|
|
if handle:
|
|
marionette.switch_to_window(handle)
|
|
marionette.navigate(url)
|
|
return {}
|
|
|
|
def _cmd_reload(self, handle: str | None = None) -> dict:
|
|
marionette = self.server.marionette
|
|
if handle:
|
|
marionette.switch_to_window(handle)
|
|
marionette.refresh()
|
|
return {}
|
|
|
|
def _cmd_click(self, selector: str, handle: str | None = None) -> dict:
|
|
marionette = self.server.marionette
|
|
if handle:
|
|
marionette.switch_to_window(handle)
|
|
marionette.find_element(By.CSS_SELECTOR, selector).click()
|
|
return {}
|
|
|
|
def _cmd_install_extension(self) -> dict:
|
|
addon_id = Addons(self.server.marionette).install(EXTENSION_DIR, temp=True)
|
|
return {"addonId": addon_id}
|
|
|
|
def _cmd_uninstall_extension(self, addonId: str) -> dict:
|
|
Addons(self.server.marionette).uninstall(addonId)
|
|
return {}
|
|
|
|
|
|
def _make_handler(control: ControlServer) -> type[BaseHTTPRequestHandler]:
|
|
class Handler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
def _respond(self, status, body, content_type="application/json"):
|
|
payload = body if isinstance(body, bytes) else body.encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def do_POST(self):
|
|
if self.path != "/command":
|
|
self._respond(404, json.dumps({"error": "not found"}))
|
|
return
|
|
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
request = json.loads(self.rfile.read(length) or b"{}")
|
|
try:
|
|
result = control.run_command(
|
|
request["name"], request.get("args", {}) or {}
|
|
)
|
|
self._respond(200, json.dumps({"result": result}))
|
|
except Exception as e:
|
|
self._respond(500, json.dumps({"error": str(e)}))
|
|
|
|
return Handler
|