fxcss 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fxcss/__init__.py +3 -0
- fxcss/__main__.py +6 -0
- fxcss/audit.py +595 -0
- fxcss/catalogue.py +385 -0
- fxcss/cli.py +558 -0
- fxcss/compare.py +189 -0
- fxcss/core.py +999 -0
- fxcss/fetch.py +255 -0
- fxcss/probe.py +220 -0
- fxcss-0.6.1.dist-info/METADATA +572 -0
- fxcss-0.6.1.dist-info/RECORD +15 -0
- fxcss-0.6.1.dist-info/WHEEL +5 -0
- fxcss-0.6.1.dist-info/entry_points.txt +2 -0
- fxcss-0.6.1.dist-info/licenses/LICENSE +21 -0
- fxcss-0.6.1.dist-info/top_level.txt +1 -0
fxcss/core.py
ADDED
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared machinery for the fxcss toolkit.
|
|
3
|
+
|
|
4
|
+
Drives Firefox over Marionette, its built-in automation protocol. Marionette is
|
|
5
|
+
plain TCP with length-prefixed JSON, so nothing outside the Python standard
|
|
6
|
+
library is needed -- no Selenium, no geckodriver, and therefore no
|
|
7
|
+
driver-to-browser version matching to keep working.
|
|
8
|
+
|
|
9
|
+
Two things here are worth knowing before changing anything:
|
|
10
|
+
|
|
11
|
+
* Screenshots are taken in Marionette's *chrome* context, which captures the
|
|
12
|
+
browser window's own document. An ordinary WebDriver screenshot only captures
|
|
13
|
+
page content, so toolbars and tabs would never appear.
|
|
14
|
+
* Native popup widgets (context menus, the app menu) are separate OS-level
|
|
15
|
+
windows and are absent from those screenshots. See README.md.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import socket
|
|
25
|
+
import subprocess
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
MARIONETTE_DEFAULT_PORT = 2828
|
|
31
|
+
WINDOW_WIDTH = 1280
|
|
32
|
+
# Tall enough for the chrome, a strip of page content, and the find bar docked
|
|
33
|
+
# at the bottom -- without a screenful of empty page padding in every capture.
|
|
34
|
+
WINDOW_HEIGHT = 480
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MarionetteError(RuntimeError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def free_port():
|
|
42
|
+
"""Pick an unused port for this session's Marionette listener.
|
|
43
|
+
|
|
44
|
+
Firefox's default is a fixed 2828. If a previous run leaked a browser (a
|
|
45
|
+
hard kill skips cleanup), a new session would silently attach to that stale
|
|
46
|
+
browser instead of its own -- which looks like the theme mysteriously not
|
|
47
|
+
applying. A per-session port makes that impossible and lets several
|
|
48
|
+
sessions run at once.
|
|
49
|
+
"""
|
|
50
|
+
with socket.socket() as s:
|
|
51
|
+
s.bind(("127.0.0.1", 0))
|
|
52
|
+
return s.getsockname()[1]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Marionette:
|
|
56
|
+
"""Minimal Marionette client. Wire framing is '<byte-length>:<json>'."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, host="127.0.0.1", port=MARIONETTE_DEFAULT_PORT):
|
|
59
|
+
self.host, self.port = host, port
|
|
60
|
+
self.sock = None
|
|
61
|
+
self._msgid = 0
|
|
62
|
+
self._buf = b""
|
|
63
|
+
|
|
64
|
+
def connect(self, timeout=120):
|
|
65
|
+
deadline = time.time() + timeout
|
|
66
|
+
last = None
|
|
67
|
+
while time.time() < deadline:
|
|
68
|
+
try:
|
|
69
|
+
self.sock = socket.create_connection((self.host, self.port), timeout=30)
|
|
70
|
+
self.sock.settimeout(180)
|
|
71
|
+
break
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
last = exc
|
|
74
|
+
time.sleep(0.5)
|
|
75
|
+
else:
|
|
76
|
+
raise MarionetteError(f"could not connect to Marionette in {timeout}s: {last}")
|
|
77
|
+
|
|
78
|
+
handshake = self._recv()
|
|
79
|
+
if "marionetteProtocol" not in handshake:
|
|
80
|
+
raise MarionetteError(f"unexpected Marionette handshake: {handshake}")
|
|
81
|
+
self.command("WebDriver:NewSession", {"capabilities": {}})
|
|
82
|
+
|
|
83
|
+
def _read_more(self):
|
|
84
|
+
chunk = self.sock.recv(1 << 16)
|
|
85
|
+
if not chunk:
|
|
86
|
+
raise MarionetteError("Marionette connection closed unexpectedly")
|
|
87
|
+
self._buf += chunk
|
|
88
|
+
|
|
89
|
+
def _recv(self):
|
|
90
|
+
while b":" not in self._buf:
|
|
91
|
+
self._read_more()
|
|
92
|
+
length, _, rest = self._buf.partition(b":")
|
|
93
|
+
need = int(length)
|
|
94
|
+
self._buf = rest
|
|
95
|
+
while len(self._buf) < need:
|
|
96
|
+
self._read_more()
|
|
97
|
+
payload, self._buf = self._buf[:need], self._buf[need:]
|
|
98
|
+
return json.loads(payload.decode("utf-8"))
|
|
99
|
+
|
|
100
|
+
def command(self, name, params=None):
|
|
101
|
+
self._msgid += 1
|
|
102
|
+
msg = json.dumps([0, self._msgid, name, params or {}]).encode("utf-8")
|
|
103
|
+
self.sock.sendall(str(len(msg)).encode("ascii") + b":" + msg)
|
|
104
|
+
while True:
|
|
105
|
+
resp = self._recv()
|
|
106
|
+
if isinstance(resp, list) and len(resp) == 4 and resp[0] == 1:
|
|
107
|
+
_, msgid, error, result = resp
|
|
108
|
+
if msgid != self._msgid:
|
|
109
|
+
continue
|
|
110
|
+
if error:
|
|
111
|
+
raise MarionetteError(f"{name} failed: {error}")
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
def set_context(self, value):
|
|
115
|
+
# Context switching is a Marionette extension rather than a WebDriver
|
|
116
|
+
# spec command, and its namespace has moved between Firefox versions.
|
|
117
|
+
tried = []
|
|
118
|
+
for name in ("Marionette:SetContext", "WebDriver:SetContext", "setContext"):
|
|
119
|
+
try:
|
|
120
|
+
return self.command(name, {"value": value})
|
|
121
|
+
except MarionetteError as exc:
|
|
122
|
+
if "unknown command" not in str(exc):
|
|
123
|
+
raise
|
|
124
|
+
tried.append(name)
|
|
125
|
+
raise MarionetteError(f"no usable SetContext command (tried {tried})")
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _unwrap(result):
|
|
129
|
+
if isinstance(result, dict) and set(result) == {"value"}:
|
|
130
|
+
return result["value"]
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def script(self, source, args=None):
|
|
134
|
+
return self._unwrap(self.command("WebDriver:ExecuteScript", {
|
|
135
|
+
"script": source, "args": args or [],
|
|
136
|
+
"sandbox": "system", "newSandbox": False,
|
|
137
|
+
}))
|
|
138
|
+
|
|
139
|
+
def async_script(self, source, args=None, timeout=30000):
|
|
140
|
+
return self._unwrap(self.command("WebDriver:ExecuteAsyncScript", {
|
|
141
|
+
"script": source, "args": args or [],
|
|
142
|
+
"sandbox": "system", "newSandbox": False, "scriptTimeout": timeout,
|
|
143
|
+
}))
|
|
144
|
+
|
|
145
|
+
def screenshot(self):
|
|
146
|
+
return base64.b64decode(self.command(
|
|
147
|
+
"WebDriver:TakeScreenshot", {"full": True, "hash": False})["value"])
|
|
148
|
+
|
|
149
|
+
def quit(self):
|
|
150
|
+
try:
|
|
151
|
+
self.command("Marionette:Quit", {"flags": ["eForceQuit"]})
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
finally:
|
|
155
|
+
if self.sock:
|
|
156
|
+
try:
|
|
157
|
+
self.sock.close()
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --- profile ---------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
EXTRA_PREFS = """
|
|
165
|
+
user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);
|
|
166
|
+
user_pref("browser.tabs.inTitlebar", 1);
|
|
167
|
+
user_pref("browser.tabs.drawInTitlebar", true);
|
|
168
|
+
user_pref("browser.uidensity", 0);
|
|
169
|
+
user_pref("marionette.port", %(port)d);
|
|
170
|
+
|
|
171
|
+
// Skip everything that would otherwise cover the window on first launch.
|
|
172
|
+
user_pref("browser.startup.page", 0);
|
|
173
|
+
user_pref("browser.startup.homepage", "about:blank");
|
|
174
|
+
user_pref("browser.startup.firstrunSkipsHomepage", true);
|
|
175
|
+
user_pref("browser.aboutwelcome.enabled", false);
|
|
176
|
+
user_pref("browser.shell.checkDefaultBrowser", false);
|
|
177
|
+
user_pref("browser.newtabpage.enabled", false);
|
|
178
|
+
user_pref("browser.messaging-system.whatsNewPanel.enabled", false);
|
|
179
|
+
user_pref("datareporting.policy.dataSubmissionEnabled", false);
|
|
180
|
+
user_pref("datareporting.healthreport.uploadEnabled", false);
|
|
181
|
+
user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);
|
|
182
|
+
user_pref("app.update.auto", false);
|
|
183
|
+
user_pref("app.update.enabled", false);
|
|
184
|
+
user_pref("extensions.update.enabled", false);
|
|
185
|
+
|
|
186
|
+
// Promos and rollout-gated features add toolbar items that come and go with
|
|
187
|
+
// Mozilla's campaigns. Left enabled they can differ between two runs and show
|
|
188
|
+
// up as diffs that have nothing to do with the change under test.
|
|
189
|
+
user_pref("browser.vpn_promo.enabled", false);
|
|
190
|
+
user_pref("browser.promo.focus.enabled", false);
|
|
191
|
+
user_pref("browser.contentblocking.report.hide_vpn_banner", true);
|
|
192
|
+
user_pref("browser.ipProtection.enabled", false);
|
|
193
|
+
user_pref("browser.urlbar.quicksuggest.enabled", false);
|
|
194
|
+
user_pref("browser.urlbar.suggest.quicksuggest.sponsored", false);
|
|
195
|
+
user_pref("extensions.pocket.enabled", false);
|
|
196
|
+
user_pref("app.normandy.enabled", false);
|
|
197
|
+
user_pref("app.shield.optoutstudies.enabled", false);
|
|
198
|
+
user_pref("messaging-system.rsexperimentloader.enabled", false);
|
|
199
|
+
user_pref("browser.discovery.enabled", false);
|
|
200
|
+
|
|
201
|
+
// Determinism.
|
|
202
|
+
user_pref("toolkit.cosmeticAnimations.enabled", false);
|
|
203
|
+
user_pref("ui.prefersReducedMotion", 1);
|
|
204
|
+
// A blinking caret in a focused text field lands in a different phase on every
|
|
205
|
+
// run, so a screenshot of the find bar would never match itself.
|
|
206
|
+
user_pref("ui.caretBlinkTime", 0);
|
|
207
|
+
// Firefox flashes the find bar yellow for a moment when it opens, to draw the
|
|
208
|
+
// eye. It is transient, so a screenshot lands on it or misses it depending on
|
|
209
|
+
// timing -- which made the find bar view differ between two runs of an
|
|
210
|
+
// unchanged theme. 0 disables the flash.
|
|
211
|
+
user_pref("accessibility.typeaheadfind.flashBar", 0);
|
|
212
|
+
// Stops the find bar being pre-filled from a page selection, which would make
|
|
213
|
+
// its contents depend on what happened to be selected.
|
|
214
|
+
user_pref("accessibility.typeaheadfind.prefillwithselection", false);
|
|
215
|
+
user_pref("browser.findbar.prefillWithSelection", false);
|
|
216
|
+
user_pref("browser.search.region", "US");
|
|
217
|
+
user_pref("signon.rememberSignons", false);
|
|
218
|
+
user_pref("browser.toolbars.bookmarks.visibility", "always");
|
|
219
|
+
user_pref("browser.bookmarks.restore_default_bookmarks", false);
|
|
220
|
+
user_pref("browser.places.importBookmarksHTML", false);
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
# The Browser Toolbox is the devtools window that can inspect the browser's own
|
|
224
|
+
# UI rather than page content -- the only built-in way to hover a toolbar button
|
|
225
|
+
# and read its selector. It is off by default and needs all four of these.
|
|
226
|
+
DEVTOOLS_PREFS = """
|
|
227
|
+
user_pref("devtools.chrome.enabled", true);
|
|
228
|
+
user_pref("devtools.debugger.remote-enabled", true);
|
|
229
|
+
// Without this, attaching raises a modal that has to be clicked every time.
|
|
230
|
+
user_pref("devtools.debugger.prompt-connection", false);
|
|
231
|
+
user_pref("devtools.everOpened", true);
|
|
232
|
+
user_pref("devtools.f12.enabled", true);
|
|
233
|
+
user_pref("devtools.toolbox.host", "window");
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
# Hides artifacts of the automation harness itself -- never theme rules -- so
|
|
237
|
+
# what you see is what a real user would see. Injected as its own user sheet
|
|
238
|
+
# rather than written into the profile: an earlier version put these rules in
|
|
239
|
+
# customChrome.css, which only takes effect for themes that happen to @import
|
|
240
|
+
# it, so most themes showed the automation icons in every capture.
|
|
241
|
+
HARNESS_CSS = """/* Injected by fxcss -- harness only.
|
|
242
|
+
* Firefox marks automated sessions with a robot icon in the address bar. */
|
|
243
|
+
#remote-control-box, #remote-control-icon { display: none !important; }
|
|
244
|
+
|
|
245
|
+
/* Rollout-gated Mozilla feature button: present or absent depending on a
|
|
246
|
+
* remote config rather than on this repo. */
|
|
247
|
+
#ipprotection-button { display: none !important; }
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
# Firefox paints a red diagonal hatch across the address bar background while a
|
|
251
|
+
# session is under remote control -- its equivalent of Chrome's "controlled by
|
|
252
|
+
# automated software" banner. Left alone it appears in every capture and makes
|
|
253
|
+
# a perfectly good theme look broken.
|
|
254
|
+
#
|
|
255
|
+
# Loaded as an *agent* sheet with no !important, unlike HARNESS_CSS above, so a
|
|
256
|
+
# theme's own rules still win: agent sheets lose to user sheets for normal
|
|
257
|
+
# declarations. The robot icon must win over a theme, this must lose to one.
|
|
258
|
+
AUTOMATION_DEFAULTS_CSS = """
|
|
259
|
+
.urlbar-background { background-image: none; }
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
XULSTORE = {
|
|
263
|
+
"chrome://browser/content/browser.xhtml": {
|
|
264
|
+
"main-window": {
|
|
265
|
+
"screenX": "0", "screenY": "0",
|
|
266
|
+
"width": str(WINDOW_WIDTH), "height": str(WINDOW_HEIGHT),
|
|
267
|
+
"sizemode": "normal",
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
def _sine_wav_data_uri(seconds=6, hz=440, rate=8000):
|
|
273
|
+
"""A short sine tone as a data: URI.
|
|
274
|
+
|
|
275
|
+
Generated rather than shipped as a binary, and inlined rather than fetched,
|
|
276
|
+
so the tab-playing-audio state stays local and identical on every run.
|
|
277
|
+
"""
|
|
278
|
+
import base64
|
|
279
|
+
import math
|
|
280
|
+
import struct
|
|
281
|
+
frames = b"".join(
|
|
282
|
+
struct.pack("<h", int(9000 * math.sin(2 * math.pi * hz * i / rate)))
|
|
283
|
+
for i in range(rate * seconds))
|
|
284
|
+
header = (b"RIFF" + struct.pack("<I", 36 + len(frames)) + b"WAVEfmt "
|
|
285
|
+
+ struct.pack("<IHHIIHH", 16, 1, 1, rate, rate * 2, 2, 16)
|
|
286
|
+
+ b"data" + struct.pack("<I", len(frames)))
|
|
287
|
+
return "data:audio/wav;base64," + base64.b64encode(header + frames).decode("ascii")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
SAMPLE_PAGES = {
|
|
291
|
+
"start.html": ("Start", "<h1>Theme preview</h1><p>First tab.</p>"),
|
|
292
|
+
"docs.html": ("Documentation", "<h1>Documentation</h1><p>Second tab.</p>"),
|
|
293
|
+
"issues.html": ("Issue tracker", "<h1>Issues</h1><p>Third tab.</p>"),
|
|
294
|
+
"audio.html": ("Now playing", "<h1>Audio</h1><p>Plays a tone so the tab shows "
|
|
295
|
+
"its sound indicator.</p>"
|
|
296
|
+
"<audio src=\"__AUDIO__\" loop autoplay></audio>"),
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def build_pages(dest=None):
|
|
301
|
+
"""Local pages so a capture never depends on the network.
|
|
302
|
+
|
|
303
|
+
The directory name is derived from the page content rather than being a
|
|
304
|
+
fresh mkdtemp each run, because the file:// path is *visible in the address
|
|
305
|
+
bar*. A random path there changes the rendered URL text between two runs of
|
|
306
|
+
an unchanged theme, which reads as a real pixel difference. Content
|
|
307
|
+
addressing keeps the path stable while still invalidating when these pages
|
|
308
|
+
change.
|
|
309
|
+
"""
|
|
310
|
+
if dest is None:
|
|
311
|
+
digest = hashlib.sha256(
|
|
312
|
+
json.dumps(SAMPLE_PAGES, sort_keys=True).encode("utf-8")
|
|
313
|
+
).hexdigest()[:10]
|
|
314
|
+
dest = Path(tempfile.gettempdir()) / f"fxcss-pages-{digest}"
|
|
315
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
urls = {}
|
|
317
|
+
tone = None
|
|
318
|
+
for name, (title, body) in SAMPLE_PAGES.items():
|
|
319
|
+
if "__AUDIO__" in body:
|
|
320
|
+
tone = tone or _sine_wav_data_uri()
|
|
321
|
+
body = body.replace("__AUDIO__", tone)
|
|
322
|
+
path = dest / name
|
|
323
|
+
_write_atomic(
|
|
324
|
+
path,
|
|
325
|
+
"<!doctype html><meta charset=utf-8>"
|
|
326
|
+
f"<title>{title}</title>"
|
|
327
|
+
"<link rel=icon href=\"data:image/svg+xml,"
|
|
328
|
+
"%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E"
|
|
329
|
+
"%3Ccircle cx='8' cy='8' r='7' fill='%23315bef'/%3E%3C/svg%3E\">"
|
|
330
|
+
"<body style=\"background:#fff;color:#222;padding:36px;"
|
|
331
|
+
"font:16px -apple-system,'Segoe UI',sans-serif\">" + body)
|
|
332
|
+
urls[name] = path.resolve().as_uri()
|
|
333
|
+
return urls
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _write_atomic(path: Path, text: str):
|
|
337
|
+
"""Write via a temp file and rename, so a concurrent session never reads a
|
|
338
|
+
half-written page out of the shared directory."""
|
|
339
|
+
tmp = path.with_name(path.name + f".{os.getpid()}.tmp")
|
|
340
|
+
tmp.write_text(text, encoding="utf-8")
|
|
341
|
+
os.replace(tmp, path)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def build_profile(repo: Path, profile: Path, dark=False, native_menus=None,
|
|
345
|
+
empty_user_chrome=False, port=MARIONETTE_DEFAULT_PORT,
|
|
346
|
+
devtools=False):
|
|
347
|
+
"""Install the theme into a fresh profile the way install.sh does.
|
|
348
|
+
|
|
349
|
+
empty_user_chrome leaves userChrome.css blank so the caller owns the
|
|
350
|
+
stylesheet entirely -- used by watch mode, where replacing one sheet gives
|
|
351
|
+
exact fidelity even when a rule is deleted.
|
|
352
|
+
"""
|
|
353
|
+
profile.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
shutil.copytree(repo / "chrome", profile / "chrome", dirs_exist_ok=True)
|
|
355
|
+
|
|
356
|
+
# userChrome.css @imports customChrome.css, which the repo does not ship.
|
|
357
|
+
# Some themes @import this; create it empty so the import resolves.
|
|
358
|
+
(profile / "chrome" / "customChrome.css").write_text(
|
|
359
|
+
"/* placeholder created by fxcss */\n", encoding="utf-8")
|
|
360
|
+
if empty_user_chrome:
|
|
361
|
+
(profile / "chrome" / "userChrome.css").write_text(
|
|
362
|
+
'@import "customChrome.css";\n', encoding="utf-8")
|
|
363
|
+
|
|
364
|
+
prefs = ""
|
|
365
|
+
repo_userjs = repo / "configuration" / "user.js"
|
|
366
|
+
if repo_userjs.exists():
|
|
367
|
+
prefs += repo_userjs.read_text(encoding="utf-8") + "\n"
|
|
368
|
+
prefs += EXTRA_PREFS % {"port": port}
|
|
369
|
+
if devtools:
|
|
370
|
+
prefs += DEVTOOLS_PREFS
|
|
371
|
+
# The theme's dark rules sit behind @media (prefers-color-scheme: dark),
|
|
372
|
+
# so this pref is what switches between the two.
|
|
373
|
+
prefs += 'user_pref("ui.systemUsesDarkTheme", %d);\n' % (1 if dark else 0)
|
|
374
|
+
if native_menus is not None:
|
|
375
|
+
# On macOS Firefox uses native context menus by default, and CSS cannot
|
|
376
|
+
# style them at all. Turning this off makes them XUL menus, which the
|
|
377
|
+
# theme does style.
|
|
378
|
+
val = "true" if native_menus else "false"
|
|
379
|
+
for p in ("widget.macos.native-context-menus", "widget.gtk.native-context-menus"):
|
|
380
|
+
prefs += f'user_pref("{p}", {val});\n'
|
|
381
|
+
(profile / "user.js").write_text(prefs, encoding="utf-8")
|
|
382
|
+
(profile / "xulstore.json").write_text(json.dumps(XULSTORE), encoding="utf-8")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# --- session ---------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
LAUNCH_FLAGS = [
|
|
388
|
+
"--marionette",
|
|
389
|
+
# Firefox 137+ requires this opt-in before Marionette will hand out the
|
|
390
|
+
# chrome context that makes browser-UI screenshots possible.
|
|
391
|
+
"-remote-allow-system-access",
|
|
392
|
+
"--no-remote",
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class Session:
|
|
397
|
+
"""A running Firefox with a themed profile and a Marionette connection."""
|
|
398
|
+
|
|
399
|
+
def __init__(self, repo: Path, firefox: str, dark=False, native_menus=None,
|
|
400
|
+
empty_user_chrome=False, keep_profile=False, devtools=False):
|
|
401
|
+
self.repo, self.firefox = Path(repo), firefox
|
|
402
|
+
self.workdir = Path(tempfile.mkdtemp(prefix="fxcss-"))
|
|
403
|
+
self.profile = self.workdir / "profile"
|
|
404
|
+
self.keep_profile = keep_profile
|
|
405
|
+
self.urls = build_pages()
|
|
406
|
+
self.port = free_port()
|
|
407
|
+
build_profile(self.repo, self.profile, dark=dark, native_menus=native_menus,
|
|
408
|
+
empty_user_chrome=empty_user_chrome, port=self.port,
|
|
409
|
+
devtools=devtools)
|
|
410
|
+
self.proc = None
|
|
411
|
+
self.m = None
|
|
412
|
+
self._generation = 0
|
|
413
|
+
self._window_ready = False
|
|
414
|
+
|
|
415
|
+
def __enter__(self):
|
|
416
|
+
env = dict(os.environ)
|
|
417
|
+
env["MOZ_DISABLE_AUTO_SAFE_MODE"] = "1"
|
|
418
|
+
env["MOZ_CRASHREPORTER_DISABLE"] = "1"
|
|
419
|
+
# Chrome UI does not paint in headless mode.
|
|
420
|
+
env.pop("MOZ_HEADLESS", None)
|
|
421
|
+
cmd = [self.firefox, "--profile", str(self.profile), *LAUNCH_FLAGS,
|
|
422
|
+
"--new-window", "about:blank"]
|
|
423
|
+
self.proc = subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL,
|
|
424
|
+
stderr=subprocess.DEVNULL)
|
|
425
|
+
self.m = Marionette(port=self.port)
|
|
426
|
+
self.m.connect()
|
|
427
|
+
self.m.set_context("chrome")
|
|
428
|
+
self.m.script(RESIZE, [WINDOW_WIDTH, WINDOW_HEIGHT])
|
|
429
|
+
self.apply_harness_css()
|
|
430
|
+
return self
|
|
431
|
+
|
|
432
|
+
def __exit__(self, *exc):
|
|
433
|
+
if self.m:
|
|
434
|
+
self.m.quit()
|
|
435
|
+
if self.proc:
|
|
436
|
+
try:
|
|
437
|
+
self.proc.wait(timeout=45)
|
|
438
|
+
except subprocess.TimeoutExpired:
|
|
439
|
+
self.proc.kill()
|
|
440
|
+
if not self.keep_profile:
|
|
441
|
+
shutil.rmtree(self.workdir, ignore_errors=True)
|
|
442
|
+
|
|
443
|
+
def info(self):
|
|
444
|
+
return self.m.script(BROWSER_INFO)
|
|
445
|
+
|
|
446
|
+
def setup_window(self, pinned=True):
|
|
447
|
+
# Idempotent: callers legitimately nest (a command sets the window up,
|
|
448
|
+
# then hands the session to something that does the same). Seeding
|
|
449
|
+
# bookmarks twice used to leave the toolbar showing each one twice.
|
|
450
|
+
if self._window_ready:
|
|
451
|
+
return
|
|
452
|
+
self._window_ready = True
|
|
453
|
+
result = self.m.async_script(SEED_BOOKMARKS)
|
|
454
|
+
if result is not True:
|
|
455
|
+
print(f" note: bookmark seeding returned {result!r}", flush=True)
|
|
456
|
+
self.m.script(SETUP_TABS, [[self.urls["start.html"], self.urls["docs.html"],
|
|
457
|
+
self.urls["issues.html"]], pinned])
|
|
458
|
+
time.sleep(3.0)
|
|
459
|
+
|
|
460
|
+
def apply_harness_css(self):
|
|
461
|
+
"""Hide artifacts of the automation harness in every window.
|
|
462
|
+
|
|
463
|
+
Two sheets with deliberately different precedence: the agent sheet
|
|
464
|
+
neutralises Firefox's automation markings but yields to any theme rule,
|
|
465
|
+
while the user sheet hides harness-only widgets and must win.
|
|
466
|
+
"""
|
|
467
|
+
self.m.script(LOAD_AGENT_SHEET, [AUTOMATION_DEFAULTS_CSS])
|
|
468
|
+
return self.m.script(LOAD_HARNESS_SHEET, [HARNESS_CSS])
|
|
469
|
+
|
|
470
|
+
def apply_css(self, css_text):
|
|
471
|
+
"""Load a small ad-hoc rule set as a user sheet (for experiments)."""
|
|
472
|
+
return self.m.script(SWAP_SHEET, [css_text])
|
|
473
|
+
|
|
474
|
+
def reload_theme(self):
|
|
475
|
+
"""Re-read chrome/ from the repo and swap it into the running browser.
|
|
476
|
+
|
|
477
|
+
Each reload copies the tree to a fresh numbered directory and loads
|
|
478
|
+
userChrome.css from there by file URI. The new path gives every file --
|
|
479
|
+
the entry sheet and each @import beneath it -- a URI Firefox has not
|
|
480
|
+
seen, which is what actually defeats the style-sheet cache.
|
|
481
|
+
|
|
482
|
+
Copying rather than concatenating matters: @namespace is scoped to the
|
|
483
|
+
stylesheet that declares it, so inlining imports into one sheet would
|
|
484
|
+
let one file's namespace leak across all the others and silently change
|
|
485
|
+
which elements match.
|
|
486
|
+
"""
|
|
487
|
+
self._generation += 1
|
|
488
|
+
dest = self.profile / "chrome" / f"live-{self._generation}"
|
|
489
|
+
shutil.copytree(self.repo / "chrome", dest, dirs_exist_ok=True)
|
|
490
|
+
# Keep the CI-only overrides that customChrome.css normally supplies.
|
|
491
|
+
(dest / "customChrome.css").write_text(
|
|
492
|
+
"/* placeholder created by fxcss */\n", encoding="utf-8")
|
|
493
|
+
|
|
494
|
+
uri = (dest / "userChrome.css").resolve().as_uri()
|
|
495
|
+
self.m.script(SWAP_FILE_SHEET, [uri])
|
|
496
|
+
# Re-apply after the theme so the harness rules stay on top.
|
|
497
|
+
self.apply_harness_css()
|
|
498
|
+
|
|
499
|
+
previous = self.profile / "chrome" / f"live-{self._generation - 1}"
|
|
500
|
+
if previous.exists():
|
|
501
|
+
shutil.rmtree(previous, ignore_errors=True)
|
|
502
|
+
return uri
|
|
503
|
+
|
|
504
|
+
def set_dark(self, dark):
|
|
505
|
+
self.m.script(SET_DARK, [1 if dark else 0])
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# --- chrome-context scripts ------------------------------------------------
|
|
509
|
+
|
|
510
|
+
RESIZE = """
|
|
511
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
512
|
+
win.moveTo(0, 0);
|
|
513
|
+
win.resizeTo(arguments[0], arguments[1]);
|
|
514
|
+
return [win.outerWidth, win.outerHeight];
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
SEED_BOOKMARKS = """
|
|
518
|
+
const done = arguments[arguments.length - 1];
|
|
519
|
+
(async () => {
|
|
520
|
+
try {
|
|
521
|
+
const {PlacesUtils} = ChromeUtils.importESModule(
|
|
522
|
+
"resource://gre/modules/PlacesUtils.sys.mjs");
|
|
523
|
+
for (const [title, url] of [["GitHub", "https://github.com/"],
|
|
524
|
+
["Mozilla", "https://www.mozilla.org/"],
|
|
525
|
+
["Example", "https://example.com/"]]) {
|
|
526
|
+
await PlacesUtils.bookmarks.insert({
|
|
527
|
+
parentGuid: PlacesUtils.bookmarks.toolbarGuid,
|
|
528
|
+
type: PlacesUtils.bookmarks.TYPE_BOOKMARK, title, url});
|
|
529
|
+
}
|
|
530
|
+
done(true);
|
|
531
|
+
} catch (e) { done("error: " + e); }
|
|
532
|
+
})();
|
|
533
|
+
"""
|
|
534
|
+
|
|
535
|
+
SETUP_TABS = """
|
|
536
|
+
const [urls, pinned] = arguments;
|
|
537
|
+
const sp = Services.scriptSecurityManager.getSystemPrincipal();
|
|
538
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
539
|
+
const gb = win.gBrowser;
|
|
540
|
+
while (gb.tabs.length > 1) { gb.removeTab(gb.tabs[gb.tabs.length - 1]); }
|
|
541
|
+
gb.selectedBrowser.loadURI(Services.io.newURI(urls[0]), {triggeringPrincipal: sp});
|
|
542
|
+
for (let i = 1; i < urls.length; i++) { gb.addTab(urls[i], {triggeringPrincipal: sp}); }
|
|
543
|
+
if (pinned) { gb.pinTab(gb.tabs[0]); }
|
|
544
|
+
gb.selectedTab = gb.tabs[1];
|
|
545
|
+
return gb.tabs.length;
|
|
546
|
+
"""
|
|
547
|
+
|
|
548
|
+
SWAP_SHEET = """
|
|
549
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
550
|
+
const u = win.windowUtils;
|
|
551
|
+
const uri = "data:text/css;charset=utf-8," + encodeURIComponent(arguments[0]);
|
|
552
|
+
if (win._fxcssSheet) {
|
|
553
|
+
try { u.removeSheetUsingURIString(win._fxcssSheet, u.USER_SHEET); } catch (e) {}
|
|
554
|
+
}
|
|
555
|
+
u.loadSheetUsingURIString(uri, u.USER_SHEET);
|
|
556
|
+
win._fxcssSheet = uri;
|
|
557
|
+
return uri.length;
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
LOAD_AGENT_SHEET = """
|
|
561
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
562
|
+
const u = win.windowUtils;
|
|
563
|
+
const uri = "data:text/css;charset=utf-8," + encodeURIComponent(arguments[0]);
|
|
564
|
+
if (win._fxcssAgentSheet) {
|
|
565
|
+
try { u.removeSheetUsingURIString(win._fxcssAgentSheet, u.AGENT_SHEET); } catch (e) {}
|
|
566
|
+
}
|
|
567
|
+
u.loadSheetUsingURIString(uri, u.AGENT_SHEET);
|
|
568
|
+
win._fxcssAgentSheet = uri;
|
|
569
|
+
return true;
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
LOAD_HARNESS_SHEET = """
|
|
573
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
574
|
+
const u = win.windowUtils;
|
|
575
|
+
const uri = "data:text/css;charset=utf-8," + encodeURIComponent(arguments[0]);
|
|
576
|
+
if (win._fxcssHarnessSheet) {
|
|
577
|
+
try { u.removeSheetUsingURIString(win._fxcssHarnessSheet, u.USER_SHEET); } catch (e) {}
|
|
578
|
+
}
|
|
579
|
+
u.loadSheetUsingURIString(uri, u.USER_SHEET);
|
|
580
|
+
win._fxcssHarnessSheet = uri;
|
|
581
|
+
return true;
|
|
582
|
+
"""
|
|
583
|
+
|
|
584
|
+
SWAP_FILE_SHEET = """
|
|
585
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
586
|
+
const u = win.windowUtils;
|
|
587
|
+
const uri = arguments[0];
|
|
588
|
+
if (win._fxcssSheet) {
|
|
589
|
+
try { u.removeSheetUsingURIString(win._fxcssSheet, u.USER_SHEET); } catch (e) {}
|
|
590
|
+
}
|
|
591
|
+
u.loadSheetUsingURIString(uri, u.USER_SHEET);
|
|
592
|
+
win._fxcssSheet = uri;
|
|
593
|
+
return uri;
|
|
594
|
+
"""
|
|
595
|
+
|
|
596
|
+
SET_DARK = """
|
|
597
|
+
Services.prefs.setIntPref("ui.systemUsesDarkTheme", arguments[0]);
|
|
598
|
+
return Services.prefs.getIntPref("ui.systemUsesDarkTheme");
|
|
599
|
+
"""
|
|
600
|
+
|
|
601
|
+
FOCUS_URLBAR = """
|
|
602
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
603
|
+
win.gURLBar.focus();
|
|
604
|
+
win.gURLBar.value = arguments[0];
|
|
605
|
+
win.gURLBar.setPageProxyState("invalid");
|
|
606
|
+
win.gURLBar.selectionStart = win.gURLBar.selectionEnd = arguments[0].length;
|
|
607
|
+
return win.gURLBar.value;
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
BLUR_URLBAR = """
|
|
611
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
612
|
+
if (win.gURLBar.view.isOpen) { win.gURLBar.view.close(); }
|
|
613
|
+
win.gURLBar.value = "";
|
|
614
|
+
win.gURLBar.blur();
|
|
615
|
+
win.gBrowser.selectedBrowser.focus();
|
|
616
|
+
return true;
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
OPEN_FINDBAR = """
|
|
620
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
621
|
+
win.document.getElementById("cmd_find").doCommand();
|
|
622
|
+
return true;
|
|
623
|
+
"""
|
|
624
|
+
|
|
625
|
+
# The find bar is captured with an empty field on purpose.
|
|
626
|
+
#
|
|
627
|
+
# Firefox recolours the input to reflect the result of a search, and that state
|
|
628
|
+
# is set asynchronously and persists across close/reopen. With a term in the
|
|
629
|
+
# field, two runs of an unchanged theme could settle on different colours --
|
|
630
|
+
# reliably so in dark mode, which reuses the bar after the light pass. Both runs
|
|
631
|
+
# were internally stable, so waiting longer never converged them.
|
|
632
|
+
#
|
|
633
|
+
# An empty bar still shows everything a theme styles here: the field, the
|
|
634
|
+
# previous/next buttons, the checkboxes and the bar's own background. Trading a
|
|
635
|
+
# little realism for a view that always matches itself is the right way round
|
|
636
|
+
# for a tool whose whole job is comparing renders.
|
|
637
|
+
SELECT_TAB = """
|
|
638
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
639
|
+
const gb = win.gBrowser;
|
|
640
|
+
const i = Math.min(arguments[0], gb.tabs.length - 1);
|
|
641
|
+
gb.selectedTab = gb.tabs[i];
|
|
642
|
+
return i;
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
RESET_FINDBAR = """
|
|
646
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
647
|
+
const bar = win.gFindBar || win.gBrowser.getFindBar();
|
|
648
|
+
if (bar && bar._findField) {
|
|
649
|
+
bar._findField.value = "";
|
|
650
|
+
// Deliberately no input event: dispatching one runs a search, and a search
|
|
651
|
+
// is the only thing that sets the status attribute below.
|
|
652
|
+
bar._findField.removeAttribute("status");
|
|
653
|
+
if (bar.removeAttribute) { bar.removeAttribute("status"); }
|
|
654
|
+
const box = bar.querySelector(".findbar-textbox, .findbar-container");
|
|
655
|
+
if (box) { box.removeAttribute("status"); }
|
|
656
|
+
}
|
|
657
|
+
return !!bar;
|
|
658
|
+
"""
|
|
659
|
+
|
|
660
|
+
CLOSE_FINDBAR = """
|
|
661
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
662
|
+
if (win.gFindBar) { win.gFindBar.close(); }
|
|
663
|
+
return true;
|
|
664
|
+
"""
|
|
665
|
+
|
|
666
|
+
OPEN_AUDIO_TAB = """
|
|
667
|
+
const [url] = arguments;
|
|
668
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
669
|
+
const sp = Services.scriptSecurityManager.getSystemPrincipal();
|
|
670
|
+
// 0 = allow autoplay. Without this the tab never starts playing and the sound
|
|
671
|
+
// indicator never appears.
|
|
672
|
+
Services.prefs.setIntPref("media.autoplay.default", 0);
|
|
673
|
+
Services.prefs.setIntPref("media.autoplay.blocking_policy", 0);
|
|
674
|
+
const tab = win.gBrowser.addTab(url, {triggeringPrincipal: sp});
|
|
675
|
+
win.gBrowser.selectedTab = tab;
|
|
676
|
+
return true;
|
|
677
|
+
"""
|
|
678
|
+
|
|
679
|
+
AUDIO_STATE = """
|
|
680
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
681
|
+
const tab = win.gBrowser.selectedTab;
|
|
682
|
+
return {playing: tab.hasAttribute("soundplaying"), muted: tab.hasAttribute("muted")};
|
|
683
|
+
"""
|
|
684
|
+
|
|
685
|
+
MUTE_TAB = """
|
|
686
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
687
|
+
win.gBrowser.selectedTab.toggleMuteAudio();
|
|
688
|
+
return win.gBrowser.selectedTab.hasAttribute("muted");
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
MANY_TABS = """
|
|
692
|
+
const [url, count] = arguments;
|
|
693
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
694
|
+
const sp = Services.scriptSecurityManager.getSystemPrincipal();
|
|
695
|
+
for (let i = 0; i < count; i++) {
|
|
696
|
+
win.gBrowser.addTab(url, {triggeringPrincipal: sp});
|
|
697
|
+
}
|
|
698
|
+
return win.gBrowser.tabs.length;
|
|
699
|
+
"""
|
|
700
|
+
|
|
701
|
+
CONTAINER_TABS = """
|
|
702
|
+
const [url] = arguments;
|
|
703
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
704
|
+
const sp = Services.scriptSecurityManager.getSystemPrincipal();
|
|
705
|
+
Services.prefs.setBoolPref("privacy.userContext.enabled", true);
|
|
706
|
+
const {ContextualIdentityService} = ChromeUtils.importESModule(
|
|
707
|
+
"resource://gre/modules/ContextualIdentityService.sys.mjs");
|
|
708
|
+
const ids = ContextualIdentityService.getPublicIdentities().slice(0, 3);
|
|
709
|
+
for (const identity of ids) {
|
|
710
|
+
win.gBrowser.addTab(url, {triggeringPrincipal: sp, userContextId: identity.userContextId});
|
|
711
|
+
}
|
|
712
|
+
if (ids.length) {
|
|
713
|
+
win.gBrowser.selectedTab = win.gBrowser.tabs[win.gBrowser.tabs.length - 1];
|
|
714
|
+
}
|
|
715
|
+
return ids.map(i => i.userContextId);
|
|
716
|
+
"""
|
|
717
|
+
|
|
718
|
+
OPEN_PRIVATE = """
|
|
719
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
720
|
+
win.OpenBrowserWindow({private: true});
|
|
721
|
+
return true;
|
|
722
|
+
"""
|
|
723
|
+
|
|
724
|
+
PRIVATE_READY = """
|
|
725
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
726
|
+
win.moveTo(0, 0);
|
|
727
|
+
win.resizeTo(arguments[0], arguments[1]);
|
|
728
|
+
return win.document.documentElement.getAttribute("privatebrowsingmode");
|
|
729
|
+
"""
|
|
730
|
+
|
|
731
|
+
CLOSE_WINDOW = """
|
|
732
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
733
|
+
win.close();
|
|
734
|
+
return true;
|
|
735
|
+
"""
|
|
736
|
+
|
|
737
|
+
NAVIGATE = """
|
|
738
|
+
const [url] = arguments;
|
|
739
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
740
|
+
const sp = Services.scriptSecurityManager.getSystemPrincipal();
|
|
741
|
+
win.gBrowser.selectedBrowser.loadURI(Services.io.newURI(url), {triggeringPrincipal: sp});
|
|
742
|
+
return true;
|
|
743
|
+
"""
|
|
744
|
+
|
|
745
|
+
PAGE_TITLE = """
|
|
746
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
747
|
+
return {title: win.gBrowser.selectedTab.label,
|
|
748
|
+
busy: win.gBrowser.selectedTab.hasAttribute("busy")};
|
|
749
|
+
"""
|
|
750
|
+
|
|
751
|
+
BROWSER_INFO = """
|
|
752
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
753
|
+
const pref = (n) => { try { return Services.prefs.getBoolPref(n); } catch (e) { return null; } };
|
|
754
|
+
return {
|
|
755
|
+
version: Services.appinfo.version,
|
|
756
|
+
buildID: Services.appinfo.appBuildID,
|
|
757
|
+
os: Services.appinfo.OS,
|
|
758
|
+
dpr: win.devicePixelRatio,
|
|
759
|
+
outer: [win.outerWidth, win.outerHeight],
|
|
760
|
+
legacyStylesheets: pref("toolkit.legacyUserProfileCustomizations.stylesheets"),
|
|
761
|
+
nativeContextMenus: {
|
|
762
|
+
macos: pref("widget.macos.native-context-menus"),
|
|
763
|
+
gtk: pref("widget.gtk.native-context-menus"),
|
|
764
|
+
windows: pref("widget.windows.native-context-menus"),
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
"""
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
# --- views -----------------------------------------------------------------
|
|
771
|
+
|
|
772
|
+
def capture_views(session: Session, outdir: Path, modes=("light", "dark")):
|
|
773
|
+
"""Capture the standard set of views. Returns the browser info dict."""
|
|
774
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
775
|
+
session.setup_window()
|
|
776
|
+
info = session.info()
|
|
777
|
+
print(f" firefox {info['version']} ({info['os']}), dpr={info['dpr']}, "
|
|
778
|
+
f"window={info['outer']}, legacyStylesheets={info['legacyStylesheets']}",
|
|
779
|
+
flush=True)
|
|
780
|
+
if not info["legacyStylesheets"]:
|
|
781
|
+
raise RuntimeError(
|
|
782
|
+
"toolkit.legacyUserProfileCustomizations.stylesheets is false; "
|
|
783
|
+
"userChrome.css would not be applied and this would be a preview "
|
|
784
|
+
"of unthemed Firefox")
|
|
785
|
+
|
|
786
|
+
m = session.m
|
|
787
|
+
for mode in modes:
|
|
788
|
+
session.set_dark(mode == "dark")
|
|
789
|
+
if mode == "dark":
|
|
790
|
+
time.sleep(2.0)
|
|
791
|
+
|
|
792
|
+
_shot(m, outdir, f"{mode}-01-window")
|
|
793
|
+
|
|
794
|
+
m.script(FOCUS_URLBAR, ["firefox css theme"])
|
|
795
|
+
time.sleep(1.0)
|
|
796
|
+
_shot(m, outdir, f"{mode}-02-urlbar")
|
|
797
|
+
m.script(BLUR_URLBAR)
|
|
798
|
+
time.sleep(0.8)
|
|
799
|
+
|
|
800
|
+
# Find bars belong to a tab, and one that has already been used keeps
|
|
801
|
+
# state from that use. Light mode ran on the previous tab, so give dark
|
|
802
|
+
# its own never-opened find bar rather than reusing a dirty one.
|
|
803
|
+
m.script(SELECT_TAB, [1 if mode == "light" else 2])
|
|
804
|
+
time.sleep(1.0)
|
|
805
|
+
m.script(OPEN_FINDBAR)
|
|
806
|
+
time.sleep(1.2)
|
|
807
|
+
m.script(RESET_FINDBAR)
|
|
808
|
+
time.sleep(0.8)
|
|
809
|
+
_shot(m, outdir, f"{mode}-03-findbar", before=RESET_FINDBAR)
|
|
810
|
+
m.script(CLOSE_FINDBAR)
|
|
811
|
+
time.sleep(0.6)
|
|
812
|
+
|
|
813
|
+
# Extra chrome states, captured once rather than per colour scheme: each is
|
|
814
|
+
# about a distinct piece of UI appearing, not about light versus dark.
|
|
815
|
+
session.set_dark(False)
|
|
816
|
+
time.sleep(1.5)
|
|
817
|
+
|
|
818
|
+
# A tab playing audio, then the same tab muted -- the speaker and mute
|
|
819
|
+
# indicators are separate pieces of tab styling and themes get them wrong
|
|
820
|
+
# independently.
|
|
821
|
+
m.script(OPEN_AUDIO_TAB, [session.urls["audio.html"]])
|
|
822
|
+
time.sleep(4.0)
|
|
823
|
+
state = m.script(AUDIO_STATE)
|
|
824
|
+
if not state.get("playing"):
|
|
825
|
+
print(" note: audio tab is not reporting sound; capturing anyway", flush=True)
|
|
826
|
+
_shot(m, outdir, "extra-04-audio")
|
|
827
|
+
m.script(MUTE_TAB)
|
|
828
|
+
time.sleep(1.2)
|
|
829
|
+
_shot(m, outdir, "extra-05-muted")
|
|
830
|
+
|
|
831
|
+
# Container tabs: each carries an identity colour along the tab and an
|
|
832
|
+
# identity label in the address bar, both of which themes style and neither
|
|
833
|
+
# of which appears in an ordinary window.
|
|
834
|
+
containers = m.script(CONTAINER_TABS, [session.urls["docs.html"]])
|
|
835
|
+
time.sleep(3.0)
|
|
836
|
+
if containers:
|
|
837
|
+
_shot(m, outdir, "extra-06-containers")
|
|
838
|
+
else:
|
|
839
|
+
print(" note: no container identities available; skipping that view", flush=True)
|
|
840
|
+
|
|
841
|
+
# Enough tabs to overflow the strip, which brings out the scroll controls
|
|
842
|
+
# and the shrunken tab layout.
|
|
843
|
+
m.script(MANY_TABS, [session.urls["docs.html"], 18])
|
|
844
|
+
time.sleep(3.0)
|
|
845
|
+
_shot(m, outdir, "extra-07-many-tabs")
|
|
846
|
+
|
|
847
|
+
# A private window is a separate window with its own styling; plenty of
|
|
848
|
+
# themes style it and never look at it again.
|
|
849
|
+
#
|
|
850
|
+
# Marionette screenshots the window it is *switched to*, not the most
|
|
851
|
+
# recently opened one, so opening a window is not enough -- without the
|
|
852
|
+
# switch this captured the original window again and looked like the view
|
|
853
|
+
# was simply duplicated.
|
|
854
|
+
before = set(m.command("WebDriver:GetWindowHandles"))
|
|
855
|
+
m.script(OPEN_PRIVATE)
|
|
856
|
+
time.sleep(3.5)
|
|
857
|
+
opened = [h for h in m.command("WebDriver:GetWindowHandles") if h not in before]
|
|
858
|
+
if opened:
|
|
859
|
+
m.command("WebDriver:SwitchToWindow", {"handle": opened[0]})
|
|
860
|
+
# Harness sheets are loaded per window, so a newly opened one starts
|
|
861
|
+
# without them and would show the automation icons.
|
|
862
|
+
session.apply_harness_css()
|
|
863
|
+
mode = m.script(PRIVATE_READY, [WINDOW_WIDTH, WINDOW_HEIGHT])
|
|
864
|
+
# Load a known local page rather than leaving about:privatebrowsing up.
|
|
865
|
+
# That page is tall enough to need a scrollbar in some runs and not
|
|
866
|
+
# others, and a 2px scrollbar appearing is a real pixel difference. The
|
|
867
|
+
# private chrome is what this view is for; the content is incidental.
|
|
868
|
+
m.script(NAVIGATE, [session.urls["start.html"]])
|
|
869
|
+
time.sleep(3.0)
|
|
870
|
+
if mode:
|
|
871
|
+
_shot(m, outdir, "extra-08-private")
|
|
872
|
+
else:
|
|
873
|
+
print(" note: new window is not private; skipping that view", flush=True)
|
|
874
|
+
m.script(CLOSE_WINDOW)
|
|
875
|
+
time.sleep(1.5)
|
|
876
|
+
remaining = m.command("WebDriver:GetWindowHandles")
|
|
877
|
+
if remaining:
|
|
878
|
+
m.command("WebDriver:SwitchToWindow", {"handle": remaining[0]})
|
|
879
|
+
else:
|
|
880
|
+
print(" note: private window did not open; skipping that view", flush=True)
|
|
881
|
+
|
|
882
|
+
(outdir / "render-info.json").write_text(json.dumps(info, indent=2), encoding="utf-8")
|
|
883
|
+
return info
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _shot(m, outdir: Path, name: str, tries=8, delay=0.5, before=None):
|
|
887
|
+
"""Capture once the window has stopped changing.
|
|
888
|
+
|
|
889
|
+
Some UI state arrives asynchronously -- the find bar recolours its field
|
|
890
|
+
once a search reports back, for instance -- so a fixed sleep races it and
|
|
891
|
+
the same theme can render two different screenshots. Waiting for two
|
|
892
|
+
consecutive identical captures removes that whole class of flake without
|
|
893
|
+
having to know which widget is late.
|
|
894
|
+
|
|
895
|
+
Compares encoded PNG bytes rather than pixels so this stays dependency-free.
|
|
896
|
+
|
|
897
|
+
`before` is a chrome script re-run ahead of every capture attempt, for state
|
|
898
|
+
a widget may set again asynchronously after being cleared once. Two
|
|
899
|
+
consecutive identical captures then mean it stayed cleared, rather than
|
|
900
|
+
merely having been cleared at some earlier point.
|
|
901
|
+
"""
|
|
902
|
+
if before:
|
|
903
|
+
m.script(before)
|
|
904
|
+
previous = m.screenshot()
|
|
905
|
+
png = previous
|
|
906
|
+
for attempt in range(tries):
|
|
907
|
+
time.sleep(delay)
|
|
908
|
+
if before:
|
|
909
|
+
m.script(before)
|
|
910
|
+
png = m.screenshot()
|
|
911
|
+
if png == previous:
|
|
912
|
+
break
|
|
913
|
+
previous = png
|
|
914
|
+
else:
|
|
915
|
+
print(f" warning: {name} never settled after {tries} attempts; "
|
|
916
|
+
f"this view may compare as changed when nothing did", flush=True)
|
|
917
|
+
|
|
918
|
+
if len(png) < 2000:
|
|
919
|
+
raise RuntimeError(f"screenshot {name} is implausibly small ({len(png)} bytes)")
|
|
920
|
+
(outdir / f"{name}.png").write_bytes(png)
|
|
921
|
+
print(f" captured {name}.png ({len(png) // 1024} KB)", flush=True)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def find_firefox(explicit=None):
|
|
925
|
+
"""Locate a Firefox binary, preferring an explicit path.
|
|
926
|
+
|
|
927
|
+
Always returns an absolute path: Firefox resolves its own application
|
|
928
|
+
directory from argv[0], and a relative path can leave it unable to find its
|
|
929
|
+
resources, which surfaces much later as a Marionette connection timeout.
|
|
930
|
+
"""
|
|
931
|
+
if explicit:
|
|
932
|
+
return str(Path(explicit).expanduser().resolve())
|
|
933
|
+
env = os.environ.get("FIREFOX_BIN")
|
|
934
|
+
if env:
|
|
935
|
+
return str(Path(env).expanduser().resolve())
|
|
936
|
+
candidates = [
|
|
937
|
+
"/Applications/Firefox.app/Contents/MacOS/firefox",
|
|
938
|
+
str(Path.home() / "Applications/Firefox.app/Contents/MacOS/firefox"),
|
|
939
|
+
r"C:\Program Files\Mozilla Firefox\firefox.exe",
|
|
940
|
+
r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe",
|
|
941
|
+
"/usr/bin/firefox", "/usr/local/bin/firefox", "/snap/bin/firefox",
|
|
942
|
+
]
|
|
943
|
+
for c in candidates:
|
|
944
|
+
if Path(c).exists():
|
|
945
|
+
return c
|
|
946
|
+
found = shutil.which("firefox")
|
|
947
|
+
if found:
|
|
948
|
+
return found
|
|
949
|
+
raise SystemExit(
|
|
950
|
+
"Could not find Firefox. Pass --firefox /path/to/firefox or set FIREFOX_BIN.")
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def slugify_url(url):
|
|
954
|
+
"""A short, filesystem-safe name for a URL."""
|
|
955
|
+
trimmed = re.sub(r"^https?://(www\.)?", "", url)
|
|
956
|
+
trimmed = re.sub(r"[?#].*$", "", trimmed).strip("/")
|
|
957
|
+
slug = re.sub(r"[^a-zA-Z0-9]+", "-", trimmed).strip("-").lower()
|
|
958
|
+
return (slug or "page")[:48]
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def capture_live(session, outdir: Path, urls, modes=("light", "dark"), settle=6.0):
|
|
962
|
+
"""Screenshot the theme against real websites.
|
|
963
|
+
|
|
964
|
+
Written into a `live/` subdirectory, which is deliberate: `compare` only
|
|
965
|
+
looks at PNGs at the top level, so these never take part in the pass/fail
|
|
966
|
+
comparison. They cannot -- someone else's page can change its content, its
|
|
967
|
+
title or its favicon between two runs, and a theme pull request would get
|
|
968
|
+
blamed for it. These are for looking at, not for diffing.
|
|
969
|
+
"""
|
|
970
|
+
live_dir = Path(outdir) / "live"
|
|
971
|
+
live_dir.mkdir(parents=True, exist_ok=True)
|
|
972
|
+
m = session.m
|
|
973
|
+
session.setup_window()
|
|
974
|
+
|
|
975
|
+
captured = []
|
|
976
|
+
for url in urls:
|
|
977
|
+
slug = slugify_url(url)
|
|
978
|
+
m.script(NAVIGATE, [url])
|
|
979
|
+
|
|
980
|
+
# Wait for the tab to stop reporting itself busy, then let the page
|
|
981
|
+
# settle. _shot additionally waits for two identical frames, which
|
|
982
|
+
# covers late-loading images without needing to understand the page.
|
|
983
|
+
deadline = time.time() + 45
|
|
984
|
+
while time.time() < deadline:
|
|
985
|
+
time.sleep(1.0)
|
|
986
|
+
if not m.script(PAGE_TITLE).get("busy"):
|
|
987
|
+
break
|
|
988
|
+
time.sleep(settle)
|
|
989
|
+
|
|
990
|
+
info = m.script(PAGE_TITLE)
|
|
991
|
+
print(f" {url}\n loaded: {info['title'][:64]!r}", flush=True)
|
|
992
|
+
for mode in modes:
|
|
993
|
+
session.set_dark(mode == "dark")
|
|
994
|
+
time.sleep(2.0)
|
|
995
|
+
_shot(m, live_dir, f"{slug}-{mode}")
|
|
996
|
+
captured.append(live_dir / f"{slug}-{mode}.png")
|
|
997
|
+
session.set_dark(False)
|
|
998
|
+
time.sleep(1.0)
|
|
999
|
+
return captured
|