fxcss 0.22.0__tar.gz → 0.22.1__tar.gz

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.
Files changed (43) hide show
  1. {fxcss-0.22.0 → fxcss-0.22.1}/PKG-INFO +15 -4
  2. {fxcss-0.22.0 → fxcss-0.22.1}/README.md +14 -3
  3. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/__init__.py +1 -1
  4. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/capture.py +5 -1
  5. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/check.py +215 -26
  6. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/compare.py +9 -5
  7. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/complete.py +24 -2
  8. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/core.py +37 -8
  9. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/scaffold.py +19 -2
  10. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/PKG-INFO +15 -4
  11. {fxcss-0.22.0 → fxcss-0.22.1}/tests/test_capture_coverage.py +9 -2
  12. {fxcss-0.22.0 → fxcss-0.22.1}/tests/test_checks.py +124 -1
  13. {fxcss-0.22.0 → fxcss-0.22.1}/tests/test_units.py +142 -15
  14. {fxcss-0.22.0 → fxcss-0.22.1}/tests/test_workflows.py +52 -0
  15. {fxcss-0.22.0 → fxcss-0.22.1}/LICENSE +0 -0
  16. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/__main__.py +0 -0
  17. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/adopt.py +0 -0
  18. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/audit.py +0 -0
  19. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/catalogue.py +0 -0
  20. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/cli.py +0 -0
  21. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/fetch.py +0 -0
  22. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/install.py +0 -0
  23. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/omni.py +0 -0
  24. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/probe.py +0 -0
  25. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/sheets.py +0 -0
  26. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/firefox-watch.yml +0 -0
  27. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/pr-preview-cleanup.yml +0 -0
  28. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/pr-preview-publish.yml +0 -0
  29. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/pr-preview.yml +0 -0
  30. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/readme-previews.yml +0 -0
  31. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/showcase.yml +0 -0
  32. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/starter/chrome/userChrome.css +0 -0
  33. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/starter/custom/accent-red.css +0 -0
  34. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/tweaks.py +0 -0
  35. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/SOURCES.txt +0 -0
  36. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/dependency_links.txt +0 -0
  37. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/entry_points.txt +0 -0
  38. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/requires.txt +0 -0
  39. {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/top_level.txt +0 -0
  40. {fxcss-0.22.0 → fxcss-0.22.1}/pyproject.toml +0 -0
  41. {fxcss-0.22.0 → fxcss-0.22.1}/setup.cfg +0 -0
  42. {fxcss-0.22.0 → fxcss-0.22.1}/tests/test_patch_duplicates.py +0 -0
  43. {fxcss-0.22.0 → fxcss-0.22.1}/tests/test_profile_safety.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fxcss
3
- Version: 0.22.0
3
+ Version: 0.22.1
4
4
  Summary: Live-reload, inspect and screenshot-test Firefox userChrome.css themes
5
5
  Author: AdamXweb
6
6
  License: MIT
@@ -655,6 +655,17 @@ configuration file, it uses installed Stable, captures all optional
655
655
  stylesheets and fails on actionable selector findings. Visual comparison is
656
656
  enabled when you supply a baseline.
657
657
 
658
+ The terminal shows the current phase and each accounted-for view, including
659
+ views Firefox explicitly does not support, while the full details stay in the
660
+ run logs. The report links directly to each captured screenshot and distinguishes
661
+ a check with no visual baseline from one with advisory image changes. When
662
+ comparing captures, it shows the baseline and current Firefox environments so
663
+ you can spot version, OS, display-scale or window-size differences. If you
664
+ interrupt a run, `check` still writes a report pointing to completed captures;
665
+ views left unfinished are marked separately from failures. Rerun the
666
+ command to finish. If interruption occurs during a baseline update, inspect
667
+ the baseline directory before running another update.
668
+
658
669
  Save settings in `.fxcss.json` at the theme's root to use the same checks
659
670
  locally and in CI:
660
671
 
@@ -704,9 +715,9 @@ operating systems separate. `--firefox beta` overrides the list for one run.
704
715
  | `--config FILE` | Read a different JSON settings file. Command-line options override saved values. |
705
716
 
706
717
  Exit codes are **0** for completed checks within the configured policy,
707
- **1** for findings, and **2** for configuration, browser, capture or comparison
708
- errors. A configured baseline that is missing is an error, not an unchanged
709
- result. Invalid settings fail before Firefox is started.
718
+ **1** for findings, and **2** for interruption or configuration, browser,
719
+ capture or comparison errors. A configured baseline that is missing is an
720
+ error, not an unchanged result. Invalid settings fail before Firefox is started.
710
721
 
711
722
  For a custom GitHub workflow with Firefox and a display already available,
712
723
  run `fxcss check` and upload `.fxcss/checks/` even when the check fails. Keep the
@@ -632,6 +632,17 @@ configuration file, it uses installed Stable, captures all optional
632
632
  stylesheets and fails on actionable selector findings. Visual comparison is
633
633
  enabled when you supply a baseline.
634
634
 
635
+ The terminal shows the current phase and each accounted-for view, including
636
+ views Firefox explicitly does not support, while the full details stay in the
637
+ run logs. The report links directly to each captured screenshot and distinguishes
638
+ a check with no visual baseline from one with advisory image changes. When
639
+ comparing captures, it shows the baseline and current Firefox environments so
640
+ you can spot version, OS, display-scale or window-size differences. If you
641
+ interrupt a run, `check` still writes a report pointing to completed captures;
642
+ views left unfinished are marked separately from failures. Rerun the
643
+ command to finish. If interruption occurs during a baseline update, inspect
644
+ the baseline directory before running another update.
645
+
635
646
  Save settings in `.fxcss.json` at the theme's root to use the same checks
636
647
  locally and in CI:
637
648
 
@@ -681,9 +692,9 @@ operating systems separate. `--firefox beta` overrides the list for one run.
681
692
  | `--config FILE` | Read a different JSON settings file. Command-line options override saved values. |
682
693
 
683
694
  Exit codes are **0** for completed checks within the configured policy,
684
- **1** for findings, and **2** for configuration, browser, capture or comparison
685
- errors. A configured baseline that is missing is an error, not an unchanged
686
- result. Invalid settings fail before Firefox is started.
695
+ **1** for findings, and **2** for interruption or configuration, browser,
696
+ capture or comparison errors. A configured baseline that is missing is an
697
+ error, not an unchanged result. Invalid settings fail before Firefox is started.
687
698
 
688
699
  For a custom GitHub workflow with Firefox and a display already available,
689
700
  run `fxcss check` and upload `.fxcss/checks/` even when the check fails. Keep the
@@ -1,3 +1,3 @@
1
1
  """fxcss — a testing toolkit for Firefox userChrome.css themes."""
2
2
 
3
- __version__ = "0.22.0"
3
+ __version__ = "0.22.1"
@@ -18,7 +18,8 @@ def expected_views(variants=(), modes=("light", "dark")):
18
18
  + [f"variant-{slug}" for slug in sorted(variants)])
19
19
 
20
20
 
21
- def write_coverage(directory, info, expected, unsupported=None, failed=None):
21
+ def write_coverage(directory, info, expected, unsupported=None, failed=None,
22
+ interrupted=False):
22
23
  unsupported, failed = unsupported or {}, failed or {}
23
24
  views = {}
24
25
  for name in expected:
@@ -28,6 +29,9 @@ def write_coverage(directory, info, expected, unsupported=None, failed=None):
28
29
  views[name] = {"status": "captured"}
29
30
  elif name in unsupported:
30
31
  views[name] = {"status": "unsupported", "reason": unsupported[name]}
32
+ elif interrupted:
33
+ views[name] = {"status": "not_completed",
34
+ "reason": "capture stopped before this view completed"}
31
35
  else:
32
36
  views[name] = {"status": "failed", "reason": "expected view was not captured"}
33
37
  report = {"format": 1, "browser": info, "views": views}
@@ -6,10 +6,11 @@ import hashlib
6
6
  import json
7
7
  import math
8
8
  import shutil
9
+ import sys
9
10
  import tempfile
10
11
  from pathlib import Path
11
12
 
12
- from . import capture, core, __version__
13
+ from . import capture, compare, core, __version__
13
14
  from .fetch import VARIANT_DIRS
14
15
 
15
16
 
@@ -110,6 +111,93 @@ def comparison_status(summary, threshold):
110
111
  return 0
111
112
 
112
113
 
114
+ class CaptureProgress:
115
+ """Keep the full capture log while showing completed views in the terminal."""
116
+
117
+ def __init__(self, log, terminal, total):
118
+ self.log, self.terminal, self.total = log, terminal, total
119
+ self.count = 0
120
+ self.pending = ""
121
+
122
+ def write(self, data):
123
+ written = self.log.write(data)
124
+ self.pending += data
125
+ while "\n" in self.pending:
126
+ line, self.pending = self.pending.split("\n", 1)
127
+ if line.startswith(" captured "):
128
+ self.count += 1
129
+ name = line.removeprefix(" captured ").split(".png", 1)[0]
130
+ print(f" view {self.count}/{self.total}: {name} captured",
131
+ file=self.terminal, flush=True)
132
+ elif line.startswith(" unsupported "):
133
+ self.count += 1
134
+ name, _, reason = line.removeprefix(" unsupported ").partition(": ")
135
+ print(f" view {self.count}/{self.total}: {name} unsupported ({reason})",
136
+ file=self.terminal, flush=True)
137
+ elif line.startswith((" note:", " warning:")):
138
+ print(line, file=self.terminal, flush=True)
139
+ return written
140
+
141
+ def flush(self):
142
+ self.log.flush()
143
+
144
+
145
+ def read_coverage(path):
146
+ if not path.is_file():
147
+ return None
148
+ try:
149
+ report = json.loads(path.read_text(encoding="utf-8"))
150
+ except (OSError, ValueError):
151
+ return None
152
+ return report if isinstance(report, dict) and isinstance(report.get("views"), dict) else None
153
+
154
+
155
+ def last_capture_error(path):
156
+ if not path.is_file():
157
+ return None
158
+ for line in reversed(path.read_text(encoding="utf-8", errors="replace").splitlines()):
159
+ if line.strip().startswith("error:"):
160
+ return line.strip().removeprefix("error:").strip()
161
+ return None
162
+
163
+
164
+ def result_label(result):
165
+ if result.get("interrupted"):
166
+ return "interrupted"
167
+ status = result["exit_code"]
168
+ if status:
169
+ return ("findings", "error")[status - 1]
170
+ if result.get("comparison", {}).get("any_change"):
171
+ return "passed; visual changes to review"
172
+ if result.get("comparison_note") == "no baseline configured":
173
+ return "passed; visual comparison not run"
174
+ return "passed"
175
+
176
+
177
+ def environment_label(info):
178
+ """Describe a capture with the fields that explain visual differences."""
179
+ if not isinstance(info, dict):
180
+ return "unknown environment"
181
+ label = f"Firefox {info.get('version', 'unknown')} on {info.get('os', 'unknown')}"
182
+ details = []
183
+ if info.get("dpr") is not None:
184
+ details.append(f"DPR {info['dpr']}")
185
+ outer = info.get("outer")
186
+ if isinstance(outer, (list, tuple)) and len(outer) == 2:
187
+ details.append(f"window {outer[0]}×{outer[1]}")
188
+ return label + (f" ({', '.join(details)})" if details else "")
189
+
190
+
191
+ def environment_differences(baseline, current):
192
+ if not isinstance(baseline, dict) or not isinstance(current, dict):
193
+ return []
194
+ fields = (("version", "Firefox version"), ("os", "OS"),
195
+ ("dpr", "DPR"), ("outer", "window size"))
196
+ return [label for key, label in fields
197
+ if baseline.get(key) is not None and current.get(key) is not None
198
+ and baseline[key] != current[key]]
199
+
200
+
113
201
  BASELINE_MARKER = ".fxcss-baseline.json"
114
202
 
115
203
 
@@ -155,52 +243,119 @@ def update_baseline(baseline, captures):
155
243
 
156
244
 
157
245
  def write_report(directory, settings, results, status, baseline_updated):
246
+ interrupted = any(result.get("interrupted") for result in results)
158
247
  report = {
159
248
  "fxcss": __version__, "exit_code": status,
160
249
  "baseline_updated": baseline_updated,
250
+ "interrupted": interrupted,
161
251
  "settings": {k: str(v) if isinstance(v, Path) else v for k, v in settings.items()},
162
252
  "browsers": results,
163
253
  }
164
254
  (directory / "summary.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
165
- lines = ["# Theme check", "", f"Result: **{('passed', 'findings', 'error')[status]}**", ""]
255
+ if interrupted:
256
+ overall = "interrupted"
257
+ elif status == 0 and any(r.get("comparison", {}).get("any_change") for r in results):
258
+ overall = "passed; visual changes to review"
259
+ elif status == 0 and baseline_updated:
260
+ overall = "passed; baseline updated"
261
+ elif status == 0 and settings["baseline"] is None:
262
+ overall = "passed; visual comparison not run"
263
+ else:
264
+ overall = ("passed", "findings", "error")[status]
265
+ lines = ["# Theme check", "", f"Result: **{overall}**", ""]
166
266
  for result in results:
167
267
  label = result["firefox"].replace("|", "\\|").replace("<", "&lt;")
168
- lines += [f"## {label}", "", f"Result: **{('passed', 'findings', 'error')[result['exit_code']]}**", ""]
169
- info = result.get("render_info", {})
170
- if info:
171
- lines += [f"Firefox {info.get('version', 'unknown')} on {info.get('os', 'unknown')}", ""]
268
+ lines += [f"## {label}", "", f"Result: **{result_label(result)}**", ""]
172
269
  key = result["key"]
270
+ audit_code = result.get("audit_exit_code")
271
+ audit_state = "not completed" if audit_code is None else ("passed", "findings", "error")[audit_code]
272
+ coverage = result.get("coverage", {}).get("views", {})
273
+ info = result.get("render_info") or result.get("coverage", {}).get("browser", {})
274
+ baseline_info = result.get("baseline_info")
275
+ if baseline_info is not None:
276
+ lines += [f"Baseline: {environment_label(baseline_info)} ",
277
+ f"Current: {environment_label(info)}", ""]
278
+ differences = environment_differences(baseline_info, info or {})
279
+ if differences:
280
+ lines += ["Capture environments differ in " + ", ".join(differences)
281
+ + "; consider this when reviewing visual changes.", ""]
282
+ elif info:
283
+ lines += [environment_label(info), ""]
284
+ captured_count = sum(isinstance(v, dict) and v.get("status") == "captured"
285
+ for v in coverage.values())
286
+ unfinished_count = sum(isinstance(v, dict) and v.get("status") == "not_completed"
287
+ for v in coverage.values())
288
+ partial_counts = [f"{captured_count} captured"]
289
+ if unfinished_count:
290
+ partial_counts.append(f"{unfinished_count} unfinished")
291
+ capture_state = "complete" if result.get("captured") else (
292
+ f"partial ({', '.join(partial_counts)})"
293
+ if captured_count or unfinished_count else "not completed")
294
+ lines += [f"Selector audit: **{audit_state}**", "",
295
+ f"Screenshot capture: **{capture_state}**", ""]
296
+ if result.get("error"):
297
+ lines += ["Error: " + result["error"].replace("<", "&lt;"), ""]
298
+ if unfinished_count:
299
+ lines += [f"{unfinished_count} views did not finish before the interruption.", ""]
173
300
  coverage_path = directory / key / "shots" / capture.REPORT
174
301
  if coverage_path.is_file():
175
302
  lines += [f"- [Screenshot coverage]({key}/shots/{capture.REPORT})"]
176
- for name, view in result.get("coverage", {}).get("views", {}).items():
177
- if view["status"] == "unsupported":
178
- lines += [f"- Unsupported view `{name}`: {view['reason']}"]
303
+ for name, view in coverage.items():
304
+ if isinstance(view, dict) and view.get("status") in ("unsupported", "failed"):
305
+ lines += [f"- {view['status'].capitalize()} view `{name}`: "
306
+ f"{view.get('reason', 'no reason recorded')}"]
179
307
  for name in ("audit.txt", "capture.txt", "comparison.txt"):
180
308
  if (directory / key / name).exists():
181
309
  lines.append(f"- [{name}]({key}/{name})")
182
310
  if (directory / key / "fix.diff").exists():
183
311
  lines.append(f"- [Suggested selector patch]({key}/fix.diff)")
184
- lines += [f"- [Browser captures]({key}/shots/)", ""] if result.get("captured") else []
312
+ image_paths = {path.stem: path for path in (directory / key / "shots").glob("*.png")}
313
+ images = [image_paths.pop(name) for name in coverage if name in image_paths]
314
+ images += sorted(image_paths.values())
315
+ if images:
316
+ partial = ", partial run" if not result.get("captured") else ""
317
+ lines += [f"Captured views ({len(images)}{partial}):", ""]
318
+ for path in images:
319
+ title, mode = compare.title_for(path.stem)
320
+ label = f"{title} ({mode})" if mode in ("light", "dark") else title
321
+ lines += [f"- [{label} (`{path.stem}`)]({key}/shots/{path.name})"]
322
+ lines.append("")
185
323
  if "comparison" in result:
186
324
  summary = result["comparison"]
187
- lines += [f"Compared {len(summary['views'])} shared views; "
188
- f"{len(summary['changed_views'])} changed.", ""]
325
+ advisory = (summary["any_change"] and result["exit_code"] == 0)
326
+ note = " (advisory; no configured limit was exceeded)" if advisory else ""
327
+ lines += [f"Visual comparison: **{len(summary['changed_views'])} changed "
328
+ f"of {len(summary['views'])} shared views{note}**.", ""]
189
329
  for heading, field in (("Added", "only_in_head"), ("Missing", "only_in_base")):
190
330
  if summary[field]:
191
331
  lines += [f"{heading} views: " + ", ".join(f"`{v}`" for v in summary[field]), ""]
192
332
  for view in summary["changed_views"]:
193
- lines += [f"- [{view['view']}: {view['percent']:.3f}% changed]"
333
+ title = view.get("title") or view["view"]
334
+ lines += [f"- [{title} (`{view['view']}`): {view['percent']:.3f}% changed]"
194
335
  f"({key}/diff/{view['image']})"]
195
336
  lines.append("")
196
337
  else:
197
- lines += ["Visual comparison: " + result.get("comparison_note", "not completed") + ".", ""]
198
- if result.get("error"):
199
- lines += ["Error: " + result["error"].replace("<", "&lt;"), ""]
338
+ note = result.get("comparison_note", "not completed")
339
+ if note in ("no baseline configured", "baseline update requested"):
340
+ note = f"not run ({note})"
341
+ lines += ["Visual comparison: **" + note + "**.", ""]
200
342
  if baseline_updated:
201
343
  lines += ["Baseline updated from this run's captures.", ""]
202
344
  elif settings["baseline"]:
203
- lines += ["The baseline was left unchanged. Use `--update-baseline` to accept a successful capture run.", ""]
345
+ if any(r.get("error") == "check interrupted during baseline update" for r in results):
346
+ lines += ["The baseline update was interrupted. Inspect the baseline directory "
347
+ "before running another update.", ""]
348
+ elif interrupted:
349
+ lines += ["The baseline was left unchanged. Run `fxcss check` again to finish the check.", ""]
350
+ elif status == 2 and any("capture-coverage.json" in r.get("error", "")
351
+ and str(settings["baseline"]) in r.get("error", "")
352
+ for r in results):
353
+ lines += ["The baseline was left unchanged. Create it with "
354
+ "`fxcss check --update-baseline` after reviewing these captures.", ""]
355
+ elif status:
356
+ lines += ["The baseline was left unchanged. Resolve the reported issue before updating it.", ""]
357
+ else:
358
+ lines += ["The baseline was left unchanged. Use `--update-baseline` to accept a successful capture run.", ""]
204
359
  (directory / "report.md").write_text("\n".join(lines), encoding="utf-8")
205
360
 
206
361
 
@@ -211,6 +366,8 @@ def run(args):
211
366
  settings["out"].mkdir(parents=True, exist_ok=True)
212
367
  directory = Path(tempfile.mkdtemp(prefix="run-", dir=settings["out"]))
213
368
  results, captures = [], {}
369
+ expected = capture.expected_views(core.parse_variant_spec(
370
+ settings["variants"], core.find_variant_sheets(theme)))
214
371
  for browser in settings["firefox"]:
215
372
  key = browser_key(browser)
216
373
  dest = directory / key
@@ -218,12 +375,15 @@ def run(args):
218
375
  result = {"firefox": browser, "key": key, "exit_code": 0}
219
376
  results.append(result)
220
377
  print(f"Checking {browser}…", flush=True)
378
+ phase = "browser setup"
221
379
  try:
222
380
  selected = browser
223
381
  if "/" in browser or "\\" in browser or browser.startswith("~"):
224
382
  selected = str((theme / Path(browser).expanduser()).resolve())
225
383
  binary = core.find_firefox(selected)
226
384
  result["binary"] = str(binary)
385
+ phase = "selector audit"
386
+ print(" selector audit: running", flush=True)
227
387
  with (dest / "audit.txt").open("w", encoding="utf-8") as log:
228
388
  with contextlib.redirect_stdout(log), contextlib.redirect_stderr(log):
229
389
  result["audit_exit_code"] = cli.cmd_audit(argparse.Namespace(
@@ -231,20 +391,33 @@ def run(args):
231
391
  no_colour=True, patch=dest / "fix.diff", strict=settings["strict"],
232
392
  strict_vars=settings["strict_vars"]))
233
393
  result["exit_code"] = result["audit_exit_code"]
394
+ print(f" selector audit: {('passed', 'findings', 'error')[result['audit_exit_code']]}",
395
+ flush=True)
396
+ phase = "screenshot capture"
397
+ print(f" screenshot capture: {len(expected)} views requested", flush=True)
234
398
  with (dest / "capture.txt").open("w", encoding="utf-8") as log:
235
- with contextlib.redirect_stdout(log), contextlib.redirect_stderr(log):
399
+ progress = CaptureProgress(log, sys.stdout, len(expected))
400
+ with contextlib.redirect_stdout(progress), contextlib.redirect_stderr(log):
236
401
  code = cli.cmd_shot(argparse.Namespace(
237
402
  theme=theme, firefox=str(binary), out=dest / "shots", url=[],
238
403
  only_live=False, variants=settings["variants"], toolbar=settings["toolbar"]))
404
+ coverage_path = dest / "shots" / capture.REPORT
405
+ available_coverage = read_coverage(coverage_path)
406
+ if available_coverage:
407
+ result["coverage"] = available_coverage
239
408
  if code:
240
- if (dest / "shots" / capture.REPORT).is_file():
241
- capture.validate_coverage(dest / "shots", capture.expected_views(
242
- core.parse_variant_spec(settings["variants"], core.find_variant_sheets(theme))))
243
- raise RuntimeError("capture failed; see capture.txt")
244
- result["coverage"] = capture.validate_coverage(dest / "shots", capture.expected_views(
245
- core.parse_variant_spec(settings["variants"], core.find_variant_sheets(theme))))
409
+ cause = last_capture_error(dest / "capture.txt")
410
+ raise RuntimeError("capture failed: " + (cause or "see capture.txt"))
411
+ result["coverage"] = capture.validate_coverage(dest / "shots", expected)
246
412
  compare._captures(dest / "shots")
247
413
  result["captured"] = True
414
+ view_states = [view["status"] for view in result["coverage"]["views"].values()]
415
+ captured_count = view_states.count("captured")
416
+ unsupported_count = view_states.count("unsupported")
417
+ summary = f"{len(view_states)}/{len(expected)} views accounted for; {captured_count} captured"
418
+ if unsupported_count:
419
+ summary += f", {unsupported_count} unsupported"
420
+ print(f" screenshot capture: complete ({summary})", flush=True)
248
421
  info = dest / "shots/render-info.json"
249
422
  if info.exists():
250
423
  result["render_info"] = json.loads(info.read_text(encoding="utf-8"))
@@ -255,7 +428,9 @@ def run(args):
255
428
  elif baseline is None:
256
429
  result["comparison_note"] = "no baseline configured"
257
430
  else:
258
- capture.validate_coverage(baseline / key)
431
+ phase = "visual comparison"
432
+ print(" visual comparison: running", flush=True)
433
+ result["baseline_info"] = capture.validate_coverage(baseline / key)["browser"]
259
434
  with (dest / "comparison.txt").open("w", encoding="utf-8") as log:
260
435
  with contextlib.redirect_stdout(log), contextlib.redirect_stderr(log):
261
436
  code = compare.run(baseline / key, dest / "shots", dest / "diff", browser)
@@ -266,17 +441,31 @@ def run(args):
266
441
  result["comparison"] = summary
267
442
  result["exit_code"] = max(result["exit_code"], comparison_status(
268
443
  summary, settings["max_changed_percent"]))
444
+ print(f" visual comparison: {len(summary['changed_views'])} shared views changed",
445
+ flush=True)
446
+ except KeyboardInterrupt:
447
+ available_coverage = read_coverage(dest / "shots" / capture.REPORT)
448
+ if available_coverage:
449
+ result["coverage"] = available_coverage
450
+ result.update(exit_code=2, interrupted=True,
451
+ error=f"check interrupted during {phase}; completed files are retained")
269
452
  except (OSError, ValueError, RuntimeError, SystemExit) as exc:
270
453
  result.update(exit_code=2, error=str(exc))
271
- print(f" {browser}: {('passed', 'findings', 'error')[result['exit_code']]}", flush=True)
454
+ print(f" {browser}: {result_label(result)}", flush=True)
272
455
  if result.get("error"):
273
456
  print(f" {result['error']}", flush=True)
457
+ if result.get("interrupted"):
458
+ break
274
459
  status = max(r["exit_code"] for r in results)
275
460
  updated = False
276
461
  if args.update_baseline and status == 0:
277
462
  try:
278
463
  update_baseline(settings["baseline"], captures)
279
464
  updated = True
465
+ except KeyboardInterrupt:
466
+ status = 2
467
+ results[-1].update(exit_code=2, interrupted=True,
468
+ error="check interrupted during baseline update")
280
469
  except (OSError, ValueError, RuntimeError) as exc:
281
470
  status = 2
282
471
  results[-1].update(exit_code=2, error=f"baseline update failed: {exc}")
@@ -33,15 +33,19 @@ FRIENDLY_NAMES = {
33
33
  "01-window": "Browser window",
34
34
  "02-urlbar": "Address bar focused",
35
35
  "03-findbar": "Find bar",
36
+ "04-dialog": "Quit confirmation",
36
37
  "04-audio": "Tab playing audio",
37
38
  "05-muted": "Tab muted",
38
39
  "06-containers": "Container tabs",
39
40
  "07-many-tabs": "Tab strip overflowing",
40
41
  "08-private": "Private window",
41
42
  "09-compact": "Compact density",
42
- "10-sidebar": "Sidebar open",
43
- "11-rtl": "Right-to-left UI",
44
- "12-customize": "Customize mode",
43
+ "10-sidebar-bookmarks": "Bookmarks sidebar",
44
+ "11-sidebar-history": "History sidebar",
45
+ "12-rtl": "Right-to-left UI",
46
+ "13-customize": "Customize mode",
47
+ "14-vertical-tabs": "Vertical tabs",
48
+ "15-toolbar": "Custom toolbar",
45
49
  }
46
50
 
47
51
 
@@ -155,8 +159,8 @@ def compare_view(name, base_path, head_path, outdir):
155
159
 
156
160
  comparison = stack(
157
161
  [
158
- (("BEFORE · base branch", (90, 96, 104)), base),
159
- (("AFTER · this PR", (32, 113, 62)), head),
162
+ (("BEFORE · baseline", (90, 96, 104)), base),
163
+ (("AFTER · current capture", (32, 113, 62)), head),
160
164
  ((f"CHANGED · {pct:.2f}% of pixels", (176, 21, 92)), render_diff_panel(base, mask)),
161
165
  ],
162
166
  base.width,
@@ -24,7 +24,8 @@ PATH_OPTIONS = frozenset({
24
24
  "--baseline", "--profile", "--config",
25
25
  })
26
26
 
27
- # Options taking a comma-separated list of the theme's optional stylesheets.
27
+ # Options taking optional stylesheets; --variants/--combo also combine them
28
+ # with '+', while commas separate captures.
28
29
  SHEET_OPTIONS = frozenset({"--with", "--variants", "--combo"})
29
30
 
30
31
  SHELLS = ("bash", "zsh", "fish")
@@ -114,6 +115,24 @@ def _comma_aware(current, candidates):
114
115
  if c.startswith(tail) and c not in chosen]
115
116
 
116
117
 
118
+ def _combo_aware(current, candidates):
119
+ """Complete the last sheet in a combination, preserving earlier captures.
120
+
121
+ Sheets already in this combination are omitted, but may be reused in a
122
+ different comma-separated capture. Completed captures are not re-offered.
123
+ """
124
+ head, comma, pending = current.rpartition(",")
125
+ combo, plus, tail = pending.rpartition("+")
126
+ if not plus:
127
+ return _comma_aware(current, candidates)
128
+ chosen = set(combo.split("+"))
129
+ completed = set(head.split(","))
130
+ prefix = head + comma + combo + plus
131
+ return [prefix + c for c in candidates
132
+ if c.startswith(tail) and c not in chosen
133
+ and combo + plus + c not in completed]
134
+
135
+
117
136
  def candidates(words, cword, cwd=None):
118
137
  """Completion candidates for a command line.
119
138
 
@@ -132,7 +151,10 @@ def candidates(words, cword, cwd=None):
132
151
  if previous == "--firefox":
133
152
  return [c for c in channel_names() if c.startswith(current)]
134
153
  if previous in SHEET_OPTIONS:
135
- return _comma_aware(current, sheet_names(words, cwd))
154
+ names = sheet_names(words, cwd)
155
+ if previous in ("--variants", "--combo"):
156
+ return _combo_aware(current, names)
157
+ return _comma_aware(current, names)
136
158
 
137
159
  command = next((w for w in words[1:cword] if not w.startswith("-")
138
160
  and w in options), None)
@@ -458,15 +458,18 @@ class Session:
458
458
  self._window_ready = False
459
459
 
460
460
  def __enter__(self):
461
+ # Windows can connect Marionette before its first content process is
462
+ # ready. Give that process time, then try a fresh launch if it stalls.
463
+ attempts = 3 if sys.platform == "win32" else 2
461
464
  try:
462
- for attempt in range(2):
465
+ for attempt in range(attempts):
463
466
  self._start_browser()
464
467
  try:
465
468
  self._wait_for_initial_document()
466
469
  return self
467
470
  except BrowserStartupError as exc:
468
471
  self._stop_browser()
469
- if attempt:
472
+ if attempt == attempts - 1:
470
473
  raise
471
474
  print(f" note: {exc}; restarting the temporary Firefox process",
472
475
  flush=True)
@@ -517,11 +520,13 @@ class Session:
517
520
  proc.kill()
518
521
  proc.wait(timeout=5)
519
522
 
520
- def _wait_for_initial_document(self, timeout=5):
523
+ def _wait_for_initial_document(self, timeout=None):
521
524
  # A Windows startup can leave every browser connected but without a
522
525
  # WindowGlobal or child process. Its chrome accepts commands, yet new
523
526
  # tabs never load. Detect this before adding fixture tabs so startup
524
527
  # can retry with a fresh process and the same prepared theme profile.
528
+ if timeout is None:
529
+ timeout = 20 if sys.platform == "win32" else 5
525
530
  deadline = time.monotonic() + timeout
526
531
  while True:
527
532
  state = self.m.script(INITIAL_DOCUMENT_STATE)
@@ -548,6 +553,7 @@ class Session:
548
553
  handle = Marionette._unwrap(self.m.command("WebDriver:GetWindowHandle"))
549
554
  self.m.command("WebDriver:SwitchToWindow", {"handle": handle})
550
555
  self._wait_for_fixture_pages()
556
+ self.m.async_script(FINALIZE_FIXTURE_LABELS)
551
557
  result = self.m.async_script(SEED_BOOKMARKS)
552
558
  if result is not True:
553
559
  print(f" note: bookmark seeding returned {result!r}", flush=True)
@@ -703,6 +709,20 @@ const win = Services.wm.getMostRecentWindow("navigator:browser");
703
709
  return Array.from(win.gBrowser.tabs, tab => tab.linkedPanel);
704
710
  """
705
711
 
712
+ FINALIZE_FIXTURE_LABELS = """
713
+ const done = arguments[arguments.length - 1];
714
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
715
+ win.document.l10n.ready.then(() => {
716
+ // A title set before pseudo-localisation changes document.dir keeps the
717
+ // earlier labelendaligned value until the title changes again.
718
+ for (const tab of win.gBrowser.tabs) {
719
+ tab.removeAttribute("label");
720
+ win.gBrowser.setTabTitle(tab);
721
+ }
722
+ done(true);
723
+ });
724
+ """
725
+
706
726
  SETUP_TABS = """
707
727
  const [originalIds, pinned] = arguments;
708
728
  const win = Services.wm.getMostRecentWindow("navigator:browser");
@@ -1344,9 +1364,9 @@ def _dialog_shot(session: "Session", outdir: Path, name: str, timeout=45.0):
1344
1364
  print(f" note: {name} timed out; skipping that view", flush=True)
1345
1365
  return
1346
1366
  if result.get("skip"):
1347
- print(f" note: {result['skip']}; skipping {name}", flush=True)
1348
1367
  if result["skip"] == "no in-window modal prompts on this Firefox":
1349
1368
  return "no in-window modal prompts"
1369
+ print(f" note: {result['skip']}; skipping {name}", flush=True)
1350
1370
  return
1351
1371
  png = base64.b64decode(result["shot"].split(",", 1)[1])
1352
1372
  if len(png) < 2000:
@@ -1366,11 +1386,16 @@ def capture_views(session: Session, outdir: Path, modes=("light", "dark"),
1366
1386
  if path.stem in capture.expected_views() or capture.VARIANT.fullmatch(path.stem):
1367
1387
  path.unlink()
1368
1388
  coverage = {"info": {}, "unsupported": {}, "failed": {}}
1389
+ interrupted = False
1369
1390
  try:
1370
1391
  info = _capture_views(session, outdir, modes, variants, toolbar, coverage)
1392
+ except KeyboardInterrupt:
1393
+ interrupted = True
1394
+ raise
1371
1395
  finally:
1372
1396
  capture.write_coverage(outdir, coverage["info"], expected,
1373
- coverage["unsupported"], coverage["failed"])
1397
+ coverage["unsupported"], coverage["failed"],
1398
+ interrupted=interrupted)
1374
1399
  capture.validate_coverage(outdir, expected)
1375
1400
  return info
1376
1401
 
@@ -1424,7 +1449,9 @@ def _capture_views(session: Session, outdir: Path, modes=("light", "dark"),
1424
1449
  # _shot -- no Marionette command may run while the dialog is up.
1425
1450
  unsupported = _dialog_shot(session, outdir, f"{mode}-04-dialog")
1426
1451
  if unsupported:
1427
- coverage["unsupported"][f"{mode}-04-dialog"] = unsupported
1452
+ name = f"{mode}-04-dialog"
1453
+ coverage["unsupported"][name] = unsupported
1454
+ print(f" unsupported {name}: {unsupported}", flush=True)
1428
1455
 
1429
1456
  # Extra chrome states, captured once rather than per colour scheme: each is
1430
1457
  # about a distinct piece of UI appearing, not about light versus dark.
@@ -1559,8 +1586,10 @@ def _capture_views(session: Session, outdir: Path, modes=("light", "dark"),
1559
1586
  version = re.match(r"\d+", str(info.get("version", "")))
1560
1587
  if version and int(version[0]) < 133:
1561
1588
  coverage["unsupported"]["extra-14-vertical-tabs"] = "Firefox before 133"
1562
- print(" note: vertical tabs unavailable on this Firefox "
1563
- "(needs 133+); skipping that view", flush=True)
1589
+ print(" unsupported extra-14-vertical-tabs: Firefox before 133", flush=True)
1590
+ else:
1591
+ print(" note: vertical tabs unavailable on this Firefox "
1592
+ "(needs 133+); skipping that view", flush=True)
1564
1593
 
1565
1594
  with Session(session.repo, session.firefox) as tb:
1566
1595
  tb.setup_window()