Skip to main content

max / quasi

4.2 KB · 140 lines History Blame Raw
1 #!/usr/bin/env python3
2 """Run `index.html` in a headless Firefox and report what mocha said.
3
4 Standard library only, on purpose. WebDriver is a REST protocol over HTTP, so a
5 runner for it needs no package -- start geckodriver, POST a session, navigate,
6 read the title mocha writes. Adding a client library here would put a
7 `requirements.txt` beside a suite whose whole argument is that it needs no
8 package manager.
9
10 Firefox rather than Chrome, and that is not a preference. Google publishes
11 `chrome-for-testing` for linux64 and no Linux arm64 build at all, and astra is
12 aarch64; the Chromium in apt there is the snap shim, which cuts against taking
13 snaps off that machine. Firefox plus geckodriver runs on both boxes, so the
14 suite has one story rather than two.
15
16 Usage:
17
18 python3 crates/quasi-webview/tests/browser/run.py
19
20 Needs `firefox` on PATH and `geckodriver` on PATH or in `~/.local/bin`. Exits 0
21 when every test passed, 1 when any failed, and 2 when the run could not happen
22 -- a missing driver is not a red suite.
23 """
24
25 import json
26 import pathlib
27 import shutil
28 import subprocess
29 import sys
30 import time
31 import urllib.error
32 import urllib.request
33
34 PORT = 4444
35 BASE = f"http://127.0.0.1:{PORT}"
36 PAGE = pathlib.Path(__file__).resolve().parent / "index.html"
37
38
39 def driver():
40 """geckodriver, wherever this machine keeps it."""
41 found = shutil.which("geckodriver")
42 if found:
43 return found
44 local = pathlib.Path.home() / ".local" / "bin" / "geckodriver"
45 if local.exists():
46 return str(local)
47 sys.exit(
48 "geckodriver not found. Put it on PATH or in ~/.local/bin:\n"
49 " https://github.com/mozilla/geckodriver/releases"
50 )
51
52
53 def call(method, path, body=None):
54 data = json.dumps(body).encode() if body is not None else None
55 request = urllib.request.Request(
56 BASE + path,
57 data=data,
58 method=method,
59 headers={"Content-Type": "application/json"},
60 )
61 with urllib.request.urlopen(request, timeout=60) as answer:
62 return json.loads(answer.read() or b"{}")
63
64
65 def listening():
66 """Wait for the driver to answer, rather than sleeping a guessed amount."""
67 for _ in range(80):
68 try:
69 urllib.request.urlopen(BASE + "/status", timeout=2)
70 return True
71 except Exception:
72 time.sleep(0.25)
73 return False
74
75
76 def verdict(session):
77 """Poll the title mocha writes when the run ends."""
78 for _ in range(120):
79 said = call("GET", f"/session/{session}/title")["value"]
80 if said.startswith(("PASS", "FAIL")):
81 return said
82 time.sleep(0.25)
83 return "TIMEOUT"
84
85
86 def main():
87 if not shutil.which("firefox"):
88 sys.exit("firefox not found on PATH.")
89
90 gecko = subprocess.Popen(
91 [driver(), "--port", str(PORT), "--log", "error"],
92 stdout=subprocess.DEVNULL,
93 stderr=subprocess.DEVNULL,
94 )
95 try:
96 if not listening():
97 sys.exit(f"geckodriver did not start listening on {PORT}.")
98
99 opened = call(
100 "POST",
101 "/session",
102 {
103 "capabilities": {
104 "alwaysMatch": {
105 "browserName": "firefox",
106 "moz:firefoxOptions": {"args": ["-headless"]},
107 }
108 }
109 },
110 )["value"]
111 session = opened["sessionId"]
112 print(f"firefox {opened['capabilities'].get('browserVersion')}, headless")
113
114 try:
115 call("POST", f"/session/{session}/url", {"url": PAGE.as_uri()})
116 said = verdict(session)
117 report = call(
118 "POST",
119 f"/session/{session}/execute/sync",
120 {
121 "script": "return document.getElementById('mocha').innerText;",
122 "args": [],
123 },
124 )["value"]
125 finally:
126 call("DELETE", f"/session/{session}")
127 finally:
128 gecko.terminate()
129
130 print(said)
131 if not said.startswith("PASS"):
132 # The rendered report, which is where a failure says what it wanted.
133 print("\n".join(line for line in report.splitlines() if line.strip()))
134 return 1
135 return 0
136
137
138 if __name__ == "__main__":
139 sys.exit(main())
140