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.
- {fxcss-0.22.0 → fxcss-0.22.1}/PKG-INFO +15 -4
- {fxcss-0.22.0 → fxcss-0.22.1}/README.md +14 -3
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/__init__.py +1 -1
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/capture.py +5 -1
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/check.py +215 -26
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/compare.py +9 -5
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/complete.py +24 -2
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/core.py +37 -8
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/scaffold.py +19 -2
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/PKG-INFO +15 -4
- {fxcss-0.22.0 → fxcss-0.22.1}/tests/test_capture_coverage.py +9 -2
- {fxcss-0.22.0 → fxcss-0.22.1}/tests/test_checks.py +124 -1
- {fxcss-0.22.0 → fxcss-0.22.1}/tests/test_units.py +142 -15
- {fxcss-0.22.0 → fxcss-0.22.1}/tests/test_workflows.py +52 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/LICENSE +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/__main__.py +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/adopt.py +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/audit.py +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/catalogue.py +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/cli.py +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/fetch.py +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/install.py +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/omni.py +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/probe.py +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/sheets.py +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/firefox-watch.yml +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/pr-preview-cleanup.yml +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/pr-preview-publish.yml +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/pr-preview.yml +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/readme-previews.yml +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/showcase.yml +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/starter/chrome/userChrome.css +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/templates/starter/custom/accent-red.css +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss/tweaks.py +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/SOURCES.txt +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/dependency_links.txt +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/entry_points.txt +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/requires.txt +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/fxcss.egg-info/top_level.txt +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/pyproject.toml +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/setup.cfg +0 -0
- {fxcss-0.22.0 → fxcss-0.22.1}/tests/test_patch_duplicates.py +0 -0
- {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.
|
|
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,
|
|
708
|
-
errors. A configured baseline that is missing is an
|
|
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,
|
|
685
|
-
errors. A configured baseline that is missing is an
|
|
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
|
|
@@ -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
|
-
|
|
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("<", "<")
|
|
168
|
-
lines += [f"## {label}", "", f"Result: **{(
|
|
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("<", "<"), ""]
|
|
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
|
|
177
|
-
if view
|
|
178
|
-
lines += [f"-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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}: {(
|
|
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": "
|
|
43
|
-
"11-
|
|
44
|
-
"12-
|
|
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 ·
|
|
159
|
-
(("AFTER ·
|
|
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
|
|
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
|
-
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
1563
|
-
|
|
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()
|