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/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