aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsaturneric <[email protected]>2026-10-24 13:53:03 +0200
committersaturneric <[email protected]>2026-10-24 13:53:03 +0200
commit199c222e7f4fd04172cdd1ef953c73cc12a03d6d (patch)
tree0a67676949760b00e341ef830967a0eb51dc92b5
parentdocs(appendix): require Qt 6.5 to build from source (diff)
downloadmanual-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.md76
-rw-r--r--scripts/screenshots/manifest.py2
-rw-r--r--scripts/screenshots/render.py22
-rw-r--r--scripts/screenshots/screenshots.yaml8
-rw-r--r--scripts/screenshots/tests/fakegf.py6
-rw-r--r--scripts/screenshots/tests/test_units.py32
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"}')