diff options
| author | saturneric <[email protected]> | 2026-10-24 13:53:03 +0200 |
|---|---|---|
| committer | saturneric <[email protected]> | 2026-10-24 13:53:03 +0200 |
| commit | 199c222e7f4fd04172cdd1ef953c73cc12a03d6d (patch) | |
| tree | 0a67676949760b00e341ef830967a0eb51dc92b5 | |
| parent | docs(appendix): require Qt 6.5 to build from source (diff) | |
| download | manual-199c222e7f4fd04172cdd1ef953c73cc12a03d6d.tar.gz manual-199c222e7f4fd04172cdd1ef953c73cc12a03d6d.zip | |
fix(screenshots): validate captured window dimensions
- verify rendered windows match the requested logical size
- increase reference and Linux virtual display resolutions
- update screenshot defaults, fixtures, and documentation
- add unit tests for geometry size validation
- clarify Windows artifact extraction and freshness checks
| -rw-r--r-- | scripts/screenshots/README.md | 76 | ||||
| -rw-r--r-- | scripts/screenshots/manifest.py | 2 | ||||
| -rw-r--r-- | scripts/screenshots/render.py | 22 | ||||
| -rw-r--r-- | scripts/screenshots/screenshots.yaml | 8 | ||||
| -rw-r--r-- | scripts/screenshots/tests/fakegf.py | 6 | ||||
| -rw-r--r-- | scripts/screenshots/tests/test_units.py | 32 |
6 files changed, 97 insertions, 49 deletions
diff --git a/scripts/screenshots/README.md b/scripts/screenshots/README.md index a74e327..88a5dc6 100644 --- a/scripts/screenshots/README.md +++ b/scripts/screenshots/README.md @@ -35,34 +35,34 @@ Pass it with `--binary`, or set `GF_SCREENSHOT_BINARY`. ## Files -| File | Purpose | -|---|---| -| `screenshots.yaml` | the manifest: defaults, global sources, entries | -| `screenshots.lock.json` | committed record per entry: url, hashes, commit, env | -| `.screenshots-cache/` | ignored: previews, baselines, transactions, `last-run.json` | -| `check.py` | status report and inventory | -| `run.py` | select, render, post-process, diff, upload, apply, recover | -| `render.py` | runs the binary in a throwaway environment (also a debug CLI) | -| `postprocess.py` | frame, shadow, padding, highlights, PNG and lossless WebP | -| `diff.py` | compares with the local baseline | -| `upload.py` | EasyImages upload | -| `txn.py` | the durable upload/apply transaction | -| `apply.py` | doc definition and lock writes | -| `manifest.py`, `docrefs.py`, `envhash.py`, `fonts.py`, `gitutil.py`, `common.py` | shared pieces | +| File | Purpose | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `screenshots.yaml` | the manifest: defaults, global sources, entries | +| `screenshots.lock.json` | committed record per entry: url, hashes, commit, env | +| `.screenshots-cache/` | ignored: previews, baselines, transactions, `last-run.json` | +| `check.py` | status report and inventory | +| `run.py` | select, render, post-process, diff, upload, apply, recover | +| `render.py` | runs the binary in a throwaway environment (also a debug CLI) | +| `postprocess.py` | frame, shadow, padding, highlights, PNG and lossless WebP | +| `diff.py` | compares with the local baseline | +| `upload.py` | EasyImages upload | +| `txn.py` | the durable upload/apply transaction | +| `apply.py` | doc definition and lock writes | +| `manifest.py`, `docrefs.py`, `envhash.py`, `fonts.py`, `gitutil.py`, `common.py` | shared pieces | ## Manifest ```yaml defaults: - platform: windows11 # the only reference platform + platform: windows11 # the only reference platform locale: en_US - theme: light # light | dark - size: 1100x680 # logical size, or "auto" for a dialog's natural size + theme: light # light | dark + size: 1280x960 # logical size, or "auto" for a dialog's natural size fonts: ["Noto Sans", "Noto Sans CJK SC"] clock: "2026-01-15T10:00:00Z" timeout_ms: 30000 - frame: {radius: 8, shadow: 12, padding: 24} # logical px, scaled by DPR 2 - diff: {tolerance: 16, threshold: 0.001} + frame: { radius: 8, shadow: 12, padding: 24 } # logical px, scaled by DPR 2 + diff: { tolerance: 16, threshold: 0.001 } preview_platforms: [linux-xvfb] global_sources: [src/screenshot/, resource/screenshot/, scripts/screenshot/] entries: @@ -71,11 +71,11 @@ entries: ref: img-1 scenario: main-window sources: [src/ui/main_window/] - highlight: InfoBoardWidget # or {name: X, style: box|arrow}, or a list + highlight: InfoBoardWidget # or {name: X, style: box|arrow}, or a list - id: drag-to-applications doc: src/content/docs/overview/getting-started.mdx ref: img-1 - status: manual-only # or animated; never rendered + status: manual-only # or animated; never rendered ``` `sources` and `global_sources` are paths in the GpgFrontend repository. Any @@ -87,19 +87,19 @@ entry key can override a default. python check.py --gpgfrontend ../../../GpgFrontend [--binary <exe>] [--inventory] [--json] ``` -| Status | Meaning | -|---|---| -| `missing` | the ref has no definition, an empty or `TODO` URL, or the entry has no lock record | -| `unmanaged` | an image ref (reference, inline or `<img>`) that is not in the manifest | -| `orphan` | a manifest entry whose ref is no longer on the page | -| `unknown-baseline` | the lock commit is not in the repository, or not an ancestor of HEAD | -| `outdated` | the entry's sources or the global sources changed since the lock commit | -| `inputs-changed` | settings, pipeline scripts, requirements or the optimizer changed | -| `env-changed` | the probed environment hash differs from the lock | -| `env-unchecked` | no binary, so the environment was not probed (or the probe failed; see details) | -| `dirty-sources` | uncommitted changes in the relevant sources | -| `platform-mismatch` | the probed platform is not the entry's platform | -| `ok` | none of the above | +| Status | Meaning | +| ------------------- | ---------------------------------------------------------------------------------- | +| `missing` | the ref has no definition, an empty or `TODO` URL, or the entry has no lock record | +| `unmanaged` | an image ref (reference, inline or `<img>`) that is not in the manifest | +| `orphan` | a manifest entry whose ref is no longer on the page | +| `unknown-baseline` | the lock commit is not in the repository, or not an ancestor of HEAD | +| `outdated` | the entry's sources or the global sources changed since the lock commit | +| `inputs-changed` | settings, pipeline scripts, requirements or the optimizer changed | +| `env-changed` | the probed environment hash differs from the lock | +| `env-unchecked` | no binary, so the environment was not probed (or the probe failed; see details) | +| `dirty-sources` | uncommitted changes in the relevant sources | +| `platform-mismatch` | the probed platform is not the entry's platform | +| `ok` | none of the above | Without a binary, every entry is `env-unchecked` and never `ok`. Each distinct (locale, theme, fonts, clock, size) is probed once per run. @@ -212,7 +212,7 @@ throwaway directory, `<work root>/run`, emptied before every render: `TEMP`, the Key Databases settings page shows that path, and a per-user temp folder would put your account name into the manual. - Linux: `HOME`, `XDG_*` and `TMPDIR` point into it, under - `xvfb-run -s "-screen 0 2560x1600x24"`. The default work root is + `xvfb-run -s "-screen 0 3200x2000x24"`. The default work root is `/tmp/gf-shot-<uid>`. Keep `--work-root` short: gpg-agent's socket lives deep inside it and Unix sockets are limited to 108 bytes. @@ -245,7 +245,10 @@ risk small. ## Windows 11 checklist (reference renders) 1. Dispatch `build.yml` with `screenshot: true`, then download the - `gpgfrontend-screenshot-windows-2022-portable` artifact and extract it. + `screenshot-windows-2022-portable` artifact. Extract the portable zip in + the same folder as its `BUILD-INFO.json`, so the freshness check finds the + commit. CI only builds this binary and checks nothing: the steps below, + on this machine, are the first time it runs. 2. Install Noto Sans and Noto Sans CJK SC if they are not installed. 3. Run a dry run, in light and dark (set `theme` in the manifest, or add entries): @@ -256,6 +259,7 @@ risk small. ``` Any gate failure is reported by name. + 4. Review the previews in `.screenshots-cache\preview\`. 5. If anything fails, send `.screenshots-cache\last-run.json` and the env records in `.screenshots-cache\preview\*.json`. They contain no `del` links. diff --git a/scripts/screenshots/manifest.py b/scripts/screenshots/manifest.py index 3850f1b..2fbe6cc 100644 --- a/scripts/screenshots/manifest.py +++ b/scripts/screenshots/manifest.py @@ -29,7 +29,7 @@ BUILTIN_DEFAULTS: dict[str, Any] = { "platform": "windows11", "locale": "en_US", "theme": "light", - "size": "1100x680", + "size": "1280x960", "fonts": ["Noto Sans", "Noto Sans CJK SC"], "clock": "2026-01-15T10:00:00Z", "timeout_ms": 30000, diff --git a/scripts/screenshots/render.py b/scripts/screenshots/render.py index 52c7abf..b4c67ab 100644 --- a/scripts/screenshots/render.py +++ b/scripts/screenshots/render.py @@ -26,7 +26,7 @@ from common import (DEFAULT_CACHE, DEFAULT_MANIFEST, PipelineError, UsageError, load_json, split_ids) from manifest import Entry, Manifest, load_manifest -XVFB_SCREEN = "-screen 0 2560x1600x24" +XVFB_SCREEN = "-screen 0 3200x2000x24" EXIT_REASONS = { 2: "usage-or-precondition", 3: "timeout", @@ -357,7 +357,7 @@ class Renderer: return RunOutcome(False, code, "environment-gate", ",".join(map(str, env_record["gate_failures"])), env) if probe: return RunOutcome(True, code, env=env, geometry=None) - problem = check_geometry(record, out) + problem = check_geometry(record, out, entry.size) if problem: return RunOutcome(False, code, "geometry", problem, env) return RunOutcome(True, code, env=env, geometry=record) @@ -384,7 +384,14 @@ class Renderer: return self._run(entry, probe=False, dest_dir=dest_dir, stem=entry.id) -def check_geometry(geometry: dict[str, Any], png: Path) -> str: +def check_geometry(geometry: dict[str, Any], png: Path, + size: Optional[tuple[int, int]] = None) -> str: + """Whether a render record is well formed and matches its PNG. + + With @p size (logical, as the entry asks for it), the captured window + must also be exactly that size, so a window resized after the binary's + own check is not taken for a good render. + """ if geometry.get("schema") != 1: return f"unsupported geometry schema {geometry.get('schema')!r}" if geometry.get("error"): @@ -401,6 +408,15 @@ def check_geometry(geometry: dict[str, Any], png: Path) -> str: return f"image_size {geometry.get('image_size')} does not match the PNG {actual}" if not isinstance(geometry.get("widgets"), dict) or not isinstance(geometry.get("missing", []), list): return "widgets/missing malformed" + if size is not None: + expected = [size[0] * 2, size[1] * 2] + window = next((layer for layer in geometry.get("layers") or [] + if isinstance(layer, dict) and layer.get("name") == "window"), None) + rect = window.get("rect") if window else None + if not isinstance(rect, list) or len(rect) != 4: + return "no window layer to check the size against" + if list(rect[2:]) != expected: + return f"size-mismatch: window captured at {rect[2]}x{rect[3]}, expected {expected[0]}x{expected[1]}" return "" diff --git a/scripts/screenshots/screenshots.yaml b/scripts/screenshots/screenshots.yaml index 2ffd312..4fba9e1 100644 --- a/scripts/screenshots/screenshots.yaml +++ b/scripts/screenshots/screenshots.yaml @@ -8,7 +8,7 @@ defaults: platform: windows11 locale: en_US theme: light - size: 1100x680 + size: 1280x960 fonts: ["Noto Sans", "Noto Sans CJK SC"] clock: "2026-01-15T10:00:00Z" timeout_ms: 30000 @@ -26,7 +26,7 @@ entries: doc: src/content/docs/guides/understand-interface.mdx ref: img-1 # the "Windows" tab of the window tour scenario: main-window - size: 1100x680 + size: 1280x960 sources: - src/ui/main_window/ - src/ui/widgets/ @@ -45,7 +45,7 @@ entries: doc: src/content/docs/guides/text-operations.md ref: img-1 # page header image; img-3/img-5 are animated GIFs scenario: text-decrypt-verify-result - size: 1100x680 + size: 1280x960 sources: - src/ui/main_window/ - src/ui/widgets/ @@ -55,7 +55,7 @@ entries: doc: src/content/docs/guides/key-server-operations.md ref: img-1 # "click Keys in the menu bar, then Import Key" scenario: main-window-menu-popup - size: 1100x680 + size: 1280x960 sources: - src/ui/main_window/ highlight: {name: importKeyFromKeyServer, style: arrow} diff --git a/scripts/screenshots/tests/fakegf.py b/scripts/screenshots/tests/fakegf.py index 982a2e7..52f711c 100644 --- a/scripts/screenshots/tests/fakegf.py +++ b/scripts/screenshots/tests/fakegf.py @@ -29,10 +29,10 @@ from PIL import Image, ImageDraw # noqa: E402 import envhash # noqa: E402 SCENARIOS = { - "main-window": ("1100x680", "window"), + "main-window": ("1280x960", "window"), "key-generate-dialog": ("auto", "dialog"), - "text-decrypt-verify-result": ("1100x680", "window"), - "main-window-menu-popup": ("1100x680", "composite"), + "text-decrypt-verify-result": ("1280x960", "window"), + "main-window-menu-popup": ("1280x960", "composite"), "demo": ("320x200", "window"), } diff --git a/scripts/screenshots/tests/test_units.py b/scripts/screenshots/tests/test_units.py index 48c5c18..cbbb720 100644 --- a/scripts/screenshots/tests/test_units.py +++ b/scripts/screenshots/tests/test_units.py @@ -15,7 +15,7 @@ import postprocess import upload from common import UsageError from manifest import Highlight, load_manifest, parse_manifest -from render import RenderConfig, Renderer +from render import RenderConfig, Renderer, check_geometry def geometry(w: int, h: int, **extra) -> dict: @@ -92,7 +92,7 @@ class ManifestTest(unittest.TestCase): x, y = m.entries self.assertIsNone(x.size) self.assertEqual(x.size_str, "auto") - self.assertEqual(y.size, (1100, 680)) + self.assertEqual(y.size, (1280, 960)) self.assertEqual(y.fonts, ("Noto Sans", "Noto Sans CJK SC")) self.assertEqual(y.highlight, (Highlight("a"), Highlight("b", "arrow"))) self.assertEqual(y.diff, {"tolerance": 16, "threshold": 0.001}) @@ -175,6 +175,34 @@ class RendererTest(World): self.assertEqual(listed["key-generate-dialog"], {"size": "auto", "capture_mode": "dialog"}) +class CheckGeometryTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp = short_tempdir("gfgeo") + self.addCleanup(shutil.rmtree, self.tmp, True) + + def record(self, image: tuple[int, int], window: list[int]) -> tuple[dict, Path]: + png = self.tmp / "r.png" + Image.new("RGBA", image, (240, 240, 240, 255)).save(png) + return geometry(*image, layers=[{"name": "window", "rect": window}]), png + + def test_window_at_the_requested_size_passes(self) -> None: + g, png = self.record((2200, 1360), [0, 0, 2200, 1360]) + self.assertEqual(check_geometry(g, png, (1100, 680)), "") + + def test_window_resized_late_is_caught(self) -> None: + g, png = self.record((2482, 1712), [0, 0, 2482, 1712]) + self.assertIn("size-mismatch", check_geometry(g, png, (1100, 680))) + + def test_composite_checks_the_window_layer_not_the_canvas(self) -> None: + g, png = self.record((2400, 1360), [0, 0, 2200, 1360]) + g["capture_mode"] = "composite" + self.assertEqual(check_geometry(g, png, (1100, 680)), "") + + def test_auto_size_skips_the_check(self) -> None: + g, png = self.record((300, 200), [0, 0, 300, 200]) + self.assertEqual(check_geometry(g, png, None), "") + + class UploadParseTest(unittest.TestCase): def test_response_validation(self) -> None: ok = upload.parse_response('{"result":"success","code":200,"url":"https://i.example/x.webp","del":"https://i/d"}') |
