diff options
| author | saturneric <[email protected]> | 2026-10-24 18:08:11 +0200 |
|---|---|---|
| committer | saturneric <[email protected]> | 2026-10-24 18:08:11 +0200 |
| commit | 287c8c8cfc1035db4c6eff2d3a6c7cfc9121c0a7 (patch) | |
| tree | d5bb90503fb616d3c6ba5fb5340cc9b0fb852781 | |
| parent | fix(screenshots): validate captured window dimensions (diff) | |
| download | manual-287c8c8cfc1035db4c6eff2d3a6c7cfc9121c0a7.tar.gz manual-287c8c8cfc1035db4c6eff2d3a6c7cfc9121c0a7.zip | |
feat(screenshots): expand automated capture coverage
- add local HKP mock server with request isolation and tests
- support widget-based screenshot cropping after highlights
- expand the manifest with Phase 2 documentation captures
- validate crop configuration and manifest target uniqueness
- document the new crop option and manual-only entries
| -rw-r--r-- | scripts/screenshots/README.md | 1 | ||||
| -rw-r--r-- | scripts/screenshots/hkpmock.py | 137 | ||||
| -rw-r--r-- | scripts/screenshots/manifest.py | 8 | ||||
| -rw-r--r-- | scripts/screenshots/postprocess.py | 9 | ||||
| -rw-r--r-- | scripts/screenshots/render.py | 17 | ||||
| -rw-r--r-- | scripts/screenshots/screenshots.yaml | 466 | ||||
| -rw-r--r-- | scripts/screenshots/tests/test_hkpmock.py | 66 | ||||
| -rw-r--r-- | scripts/screenshots/tests/test_units.py | 19 |
8 files changed, 719 insertions, 4 deletions
diff --git a/scripts/screenshots/README.md b/scripts/screenshots/README.md index 88a5dc6..f108769 100644 --- a/scripts/screenshots/README.md +++ b/scripts/screenshots/README.md @@ -72,6 +72,7 @@ entries: scenario: main-window sources: [src/ui/main_window/] highlight: InfoBoardWidget # or {name: X, style: box|arrow}, or a list + crop: ownerBox # optional: cut the image down to this named widget - id: drag-to-applications doc: src/content/docs/overview/getting-started.mdx ref: img-1 diff --git a/scripts/screenshots/hkpmock.py b/scripts/screenshots/hkpmock.py new file mode 100644 index 0000000..d381eac --- /dev/null +++ b/scripts/screenshots/hkpmock.py @@ -0,0 +1,137 @@ +"""A local stand-in for a key server, for the key server screenshots. + +The application is told to reach ``http://keys.example.org`` through an HTTP +proxy; this is that proxy. It answers the HKP requests GpgFrontend makes +(``/pks/lookup`` with ``op=index`` and ``op=get``, ``/pks/add``) from the +demo keys in ``resource/screenshot/keyserver``, and refuses every other host, +so nothing leaves the machine. It listens on the loopback interface only. +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, quote, urlsplit + +HOST = "keys.example.org" +PROXY_ENV = "GF_SCREENSHOT_KEYSERVER_PROXY" + + +@dataclass +class DemoKey: + fingerprint: str + algo: str + length: str + created: str + expires: str + uids: list[str] = field(default_factory=list) + armored: bytes = b"" + + def matches(self, search: str) -> bool: + if search.lower().startswith("0x"): + handle = search[2:].upper() + return bool(handle) and self.fingerprint.endswith(handle) + needle = search.lower() + return any(needle in uid.lower() for uid in self.uids) + + +def load_keys(directory: Path) -> list[DemoKey]: + """The demo keys, from gpg's --with-colons listing and their exports.""" + keys: list[DemoKey] = [] + listing = (directory / "listing.colons").read_text(encoding="utf-8") + current: DemoKey | None = None + for line in listing.splitlines(): + parts = line.split(":") + if parts[0] == "pub": + current = DemoKey("", parts[3], parts[2], parts[5], parts[6]) + keys.append(current) + elif parts[0] == "fpr" and current is not None and not current.fingerprint: + current.fingerprint = parts[9] + elif parts[0] == "uid" and current is not None: + current.uids.append(parts[9]) + for key in keys: + key.armored = (directory / f"{key.fingerprint}.asc").read_bytes() + return keys + + +def index_answer(keys: list[DemoKey], search: str) -> bytes: + """A machine-readable HKP index (draft-shaw-openpgp-hkp, section 5.2).""" + found = [k for k in keys if k.matches(search)] + lines = [f"info:1:{len(found)}"] + for key in found: + lines.append(f"pub:{key.fingerprint}:{key.algo}:{key.length}:{key.created}:{key.expires}:") + for uid in key.uids: + lines.append(f"uid:{quote(uid, safe=' @<>().')}:{key.created}::") + return ("\n".join(lines) + "\n").encode("utf-8") + + +class MockKeyServer: + """The proxy, in a background thread for as long as the renderer runs.""" + + def __init__(self, directory: Path) -> None: + self.keys = load_keys(directory) + self.requests: list[str] = [] + server = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args: object) -> None: # quiet + pass + + def _target(self) -> tuple[str, str, dict[str, list[str]]]: + url = urlsplit(self.path) + host = (url.hostname or self.headers.get("Host", "").split(":")[0]).lower() + return host, url.path, parse_qs(url.query) + + def _reply(self, code: int, body: bytes, kind: str = "text/plain") -> None: + self.send_response(code) + self.send_header("Content-Type", kind) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + host, path, query = self._target() + server.requests.append(f"GET {host}{path}") + if host != HOST or path != "/pks/lookup": + self._reply(403, b"only the demo key server is served here\n") + return + op = query.get("op", [""])[0] + search = query.get("search", [""])[0] + if op == "index": + self._reply(200, index_answer(server.keys, search)) + return + if op == "get": + found = [k for k in server.keys if k.matches(search)] + if found: + self._reply(200, found[0].armored, "application/pgp-keys") + return + self._reply(404, b"no such key\n") + + def do_POST(self) -> None: + host, path, _ = self._target() + server.requests.append(f"POST {host}{path}") + self.rfile.read(int(self.headers.get("Content-Length") or 0)) + if host == HOST and path == "/pks/add": + self._reply(200, b"Key added\n") + return + self._reply(403, b"only the demo key server is served here\n") + + def do_CONNECT(self) -> None: + server.requests.append(f"CONNECT {self.path}") + self._reply(403, b"no tunnels\n") + + self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self._httpd.daemon_threads = True + self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) + self._thread.start() + + @property + def address(self) -> str: + host, port = self._httpd.server_address[:2] + return f"{host}:{port}" + + def close(self) -> None: + self._httpd.shutdown() + self._httpd.server_close() diff --git a/scripts/screenshots/manifest.py b/scripts/screenshots/manifest.py index 2fbe6cc..db3abd0 100644 --- a/scripts/screenshots/manifest.py +++ b/scripts/screenshots/manifest.py @@ -40,7 +40,7 @@ BUILTIN_DEFAULTS: dict[str, Any] = { _ENTRY_KEYS = { "id", "doc", "ref", "scenario", "locale", "platform", "size", "theme", - "fonts", "clock", "timeout_ms", "sources", "highlight", "status", + "fonts", "clock", "timeout_ms", "sources", "highlight", "crop", "status", "frame", "diff", } _DEFAULT_KEYS = set(BUILTIN_DEFAULTS) @@ -76,6 +76,7 @@ class Entry: timeout_ms: int sources: tuple[str, ...] highlight: tuple[Highlight, ...] + crop: str | None # a named widget the image is cut down to, or the whole capture status: str | None frame: dict[str, Any] = field(hash=False, compare=False) diff: dict[str, Any] = field(hash=False, compare=False) @@ -107,6 +108,7 @@ class Entry: "timeout_ms": self.timeout_ms, "sources": list(self.sources), "highlight": [h.as_dict() for h in self.highlight], + "crop": self.crop, "status": self.status, "frame": dict(self.frame), "diff": dict(self.diff), @@ -241,6 +243,9 @@ def _resolve_entry(index: int, raw: Any, defaults: dict[str, Any]) -> Entry: if not isinstance(timeout_ms, int) or timeout_ms <= 0: raise _fail(where, "timeout_ms must be a positive integer") doc = raw["doc"].replace("\\", "/").strip() + crop = raw.get("crop") + if crop is not None and (not isinstance(crop, str) or not crop.strip()): + raise _fail(where, f"crop must be a widget name, got {crop!r}") return Entry( id=entry_id, doc=doc, @@ -255,6 +260,7 @@ def _resolve_entry(index: int, raw: Any, defaults: dict[str, Any]) -> Entry: timeout_ms=timeout_ms, sources=_str_list(where + ".sources", raw.get("sources")), highlight=_highlights(where, raw.get("highlight")), + crop=crop.strip() if crop else None, status=status, frame=_merge_section(where + ".frame", defaults["frame"], raw.get("frame"), _FRAME_KEYS), diff=_merge_section(where + ".diff", defaults["diff"], raw.get("diff"), _DIFF_KEYS), diff --git a/scripts/screenshots/postprocess.py b/scripts/screenshots/postprocess.py index d50d341..67a4433 100644 --- a/scripts/screenshots/postprocess.py +++ b/scripts/screenshots/postprocess.py @@ -144,6 +144,14 @@ def apply_highlights(image: Image.Image, geometry: dict[str, Any], highlights: t return Image.alpha_composite(image, overlay) +def apply_crop(image: Image.Image, geometry: dict[str, Any], crop: str | None) -> Image.Image: + """Cut the capture down to one named widget, after highlights are drawn.""" + if crop is None: + return image + x, y, w, h = _rect_for(geometry, crop) + return image.crop((x, y, x + w, y + h)) + + def apply_frame(image: Image.Image, frame: dict[str, Any], dpr: int) -> Image.Image: radius = int(frame["radius"]) * dpr blur = int(frame["shadow"]) * dpr @@ -200,6 +208,7 @@ def process(raw_png: Path, geometry: dict[str, Any], entry: Entry, out_dir: Path if list(image.size) != list(geometry.get("image_size") or []): raise PostprocessError("size-mismatch", f"PNG {image.size} vs geometry {geometry.get('image_size')}") image = apply_highlights(image, geometry, entry.highlight, dpr) + image = apply_crop(image, geometry, entry.crop) image = apply_frame(image, entry.frame, dpr) png = Path(out_dir) / f"{stem}.png" webp = Path(out_dir) / f"{stem}.webp" diff --git a/scripts/screenshots/render.py b/scripts/screenshots/render.py index b4c67ab..dc17cd2 100644 --- a/scripts/screenshots/render.py +++ b/scripts/screenshots/render.py @@ -20,6 +20,7 @@ from typing import Any, Optional from PIL import Image import envhash +import hkpmock import fonts import gitutil from common import (DEFAULT_CACHE, DEFAULT_MANIFEST, PipelineError, UsageError, canonical_json, @@ -188,6 +189,19 @@ class Renderer: raise UsageError(f"{self.xvfb[0]} not found; it is needed for Linux previews") self._font_cache: dict[str, dict] = {} self._probe_cache: dict[tuple, RunOutcome] = {} + self._key_server: Optional[hkpmock.MockKeyServer] = None + + def key_server(self) -> Optional[hkpmock.MockKeyServer]: + """The local mock key server, started on first use; None without demo keys.""" + directory = self.fixtures().parent / "keyserver" + if self._key_server is None and (directory / "listing.colons").is_file(): + self._key_server = hkpmock.MockKeyServer(directory) + return self._key_server + + def close(self) -> None: + if self._key_server is not None: + self._key_server.close() + self._key_server = None # Paths ----------------------------------------------------------------- @@ -237,6 +251,9 @@ class Renderer: session = fresh_session_base() if session is not None: env[SESSION_STORAGE_ENV] = str(session) + key_server = self.key_server() + if key_server is not None: + env[hkpmock.PROXY_ENV] = key_server.address return env # Command lines ----------------------------------------------------------- diff --git a/scripts/screenshots/screenshots.yaml b/scripts/screenshots/screenshots.yaml index 4fba9e1..bf15eb3 100644 --- a/scripts/screenshots/screenshots.yaml +++ b/scripts/screenshots/screenshots.yaml @@ -2,7 +2,7 @@ # paths in `sources` and `global_sources` are relative to the GpgFrontend repo. # Frame sizes are logical pixels and are scaled by the capture DPR (2). # -# Phase 1: four entries, dry run only. Nothing here has been applied yet. +# Phase 1 entries first, then Phase 2 batches. Nothing has been applied yet. defaults: platform: windows11 @@ -59,3 +59,467 @@ entries: sources: - src/ui/main_window/ highlight: {name: importKeyFromKeyServer, style: arrow} + + # Phase 2, batch 1: Key Details, owner trust and revocation. + - id: key-details-overview + doc: src/content/docs/guides/view-keypair-info.md + ref: img-1 # the Key Details window + scenario: key-details-keypair + size: auto + sources: [src/ui/dialog/keypair_details/] + + - id: key-details-owner + doc: src/content/docs/guides/view-keypair-info.md + ref: img-2 # "Owner" + scenario: key-details-keypair + size: auto + crop: ownerBox + sources: [src/ui/dialog/keypair_details/] + + - id: key-details-primary-key + doc: src/content/docs/guides/view-keypair-info.md + ref: img-3 # "Primary Key" + scenario: key-details-keypair + size: auto + crop: primaryKeyBox + sources: [src/ui/dialog/keypair_details/] + + - id: key-details-algorithm-detail + doc: src/content/docs/guides/view-keypair-info.md + ref: img-4 # "Algorithm Detail" + scenario: key-details-keypair + size: auto + crop: primaryKeyBox + highlight: algorithmDetail + sources: [src/ui/dialog/keypair_details/] + + - id: key-details-fingerprint + doc: src/content/docs/guides/view-keypair-info.md + ref: img-5 # "Fingerprint" + scenario: key-details-keypair + size: auto + crop: fingerprintBox + sources: [src/ui/dialog/keypair_details/] + + - id: key-details-uids + doc: src/content/docs/guides/view-keypair-info.md + ref: img-6 # "UID Info" + scenario: key-details-uids + size: auto + sources: [src/ui/dialog/keypair_details/] + + - id: key-details-uid-signatures + doc: src/content/docs/guides/view-keypair-info.md + ref: img-7 # "Signatures on a UID" + scenario: key-details-uids + size: auto + crop: uidSignatureBox + sources: [src/ui/dialog/keypair_details/] + + - id: key-details-keychain + doc: src/content/docs/guides/view-keypair-info.md + ref: img-8 # "Subkey Info" + scenario: key-details-keychain + size: auto + sources: [src/ui/dialog/keypair_details/] + + - id: key-details-operations-public + doc: src/content/docs/guides/view-keypair-info.md + ref: img-9 # "On a Public Key" + scenario: key-details-operations-public + size: auto + sources: [src/ui/dialog/keypair_details/] + + - id: key-details-operations-private + doc: src/content/docs/guides/view-keypair-info.md + ref: img-10 # "On a Private Key" + scenario: key-details-operations + size: auto + sources: [src/ui/dialog/keypair_details/] + + - id: owner-trust-column + doc: src/content/docs/guides/openpgp-trust-manage.md + ref: img-1 # "Owner Trust": the Trust column in KeyPair Management + scenario: key-management-window + size: 1200x760 + highlight: trustColumn + sources: [src/ui/main_window/, src/core/model/GpgKeyTableModel.cpp] + + - id: owner-trust-context-menu + doc: src/content/docs/guides/openpgp-trust-manage.md + ref: img-2 # "How to set Owner Trust" + scenario: key-context-menu + highlight: {name: setOwnerTrustLevel, style: arrow} + sources: [src/ui/main_window/] + + - id: uid-signatures-highlighted + doc: src/content/docs/guides/openpgp-trust-manage.md + ref: img-3 # "Signing UIDs" + scenario: key-details-uids + size: auto + highlight: uidSignatureBox + sources: [src/ui/dialog/keypair_details/] + + - id: revoke-certificate-button + doc: src/content/docs/advanced/key-revocation.mdx + ref: img-1 # "Generating a Key Revocation Certificate" + scenario: key-details-operations + size: auto + highlight: revokeCertificateButton + sources: [src/ui/dialog/keypair_details/] + + - id: revocation-options + doc: src/content/docs/advanced/key-revocation.mdx + ref: img-2 # the reason code and text + scenario: revocation-options-dialog + size: auto + sources: [src/ui/dialog/RevocationOptionsDialog.cpp, src/ui/function/GenerateRevocationCert.cpp] + + - id: revoked-key-details + doc: src/content/docs/advanced/key-revocation.mdx + ref: img-3 # a revoked key in Key Details + scenario: key-details-revoked + size: auto + sources: [src/ui/dialog/keypair_details/, src/ui/main_window/] + + # Phase 2, batch 2: key management, categories, groups, import and export. + - id: key-management + doc: src/content/docs/guides/key-management.mdx + ref: img-1 # the KeyPair Management window + scenario: key-management-window + size: 1200x760 + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: key-categories + doc: src/content/docs/guides/key-categories.mdx + ref: img-1 # "Where to Find Them" + scenario: key-management-window + size: 1200x760 + highlight: KeyCategoryList + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: import-key-toolbar + doc: src/content/docs/guides/import-export-key-pair.md + ref: img-1 # Import on the main toolbar + scenario: main-window + highlight: importKeyButton + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: import-key-key-management + doc: src/content/docs/guides/import-export-key-pair.md + ref: img-2 # Import Key in KeyPair Management + scenario: key-management-window + size: 1200x760 + highlight: importKeyButton + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: export-public-key + doc: src/content/docs/guides/import-export-key-pair.md + ref: img-3 # "Save to a File" + scenario: key-details-operations + size: auto + highlight: exportPublicKeyButton + sources: [src/ui/dialog/keypair_details/] + + - id: export-checked-keys + doc: src/content/docs/guides/import-export-key-pair.md + ref: img-4 # "Export Several at Once" + scenario: key-management-checked + size: 1200x760 + highlight: [checkColumn, exportKeyButton] + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: export-private-key + doc: src/content/docs/guides/import-export-key-pair.md + ref: img-5 # "Export a Private Key" + scenario: key-details-operations + size: auto + highlight: exportPrivateKeyButton + sources: [src/ui/dialog/keypair_details/] + + - id: key-group-checked-keys + doc: src/content/docs/guides/key-group.md + ref: img-1 # tick the keys, then the new-group button + scenario: main-window-checked-keys + highlight: [checkColumn, keyGroupButton] + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: key-group-create + doc: src/content/docs/guides/key-group.md + ref: img-2 # the New Key Group dialog + scenario: key-group-create-dialog + size: auto + sources: [src/ui/dialog/KeyGroupCreationDialog.cpp, src/ui/widgets/KeyList.cpp] + + - id: key-group-manage + doc: src/content/docs/guides/key-group.md + ref: img-3 # "Edit a group" + scenario: key-group-manage-dialog + size: auto + sources: [src/ui/dialog/KeyGroupManageDialog.cpp] + + # Phase 2, batch 3: main window operations, files and symmetric encryption. + # The former animations are stills of the moment that matters; encrypting + # is shown before the click, since its output differs on every run. + - id: index-main-window + doc: src/content/docs/index.mdx + ref: img-1 # "See GpgFrontend in Action" + scenario: text-decrypt-verify-result + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: text-encrypt + doc: src/content/docs/guides/text-operations.md + ref: img-2 # "Encrypt Text" + scenario: text-encrypt-ready + highlight: [checkColumn, encryptButton] + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: text-decrypt + doc: src/content/docs/guides/text-operations.md + ref: img-3 # "Decrypt Text" + scenario: text-decrypt-result + highlight: InfoBoardWidget + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: text-sign + doc: src/content/docs/guides/text-operations.md + ref: img-4 # "Sign Text" + scenario: text-sign-result + highlight: InfoBoardWidget + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: text-verify + doc: src/content/docs/guides/text-operations.md + ref: img-5 # "Verify a Signature" + scenario: text-verify-result + highlight: InfoBoardWidget + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: settings-sym-encrypt + doc: src/content/docs/guides/symmetric-encrypt-decrypt.mdx + ref: img-1 # Appearance > Toolbar > Sym. Encrypt + scenario: settings-sym-encrypt + size: 900x660 + highlight: symmetricEncrCheckBox + sources: [src/ui/dialog/settings/] + + - id: sym-encrypt-button + doc: src/content/docs/guides/symmetric-encrypt-decrypt.mdx + ref: img-2 # the Sym. Encrypt button + scenario: sym-encrypt-button + highlight: symEncryptButton + sources: [src/ui/main_window/] + + - id: file-panel-open + doc: src/content/docs/guides/file-operations.md + ref: img-1 # a file picked in the File Panel + scenario: file-panel + highlight: [openButton, fileTree] + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: file-panel-workspace-menu + doc: src/content/docs/guides/file-operations.md + ref: img-2 # Workspace > New File Panel + scenario: workspace-menu + highlight: newFilePanel + sources: [src/ui/main_window/] + + - id: file-encrypt + doc: src/content/docs/guides/file-operations.md + ref: img-3 # encrypting a file + scenario: file-panel + highlight: [checkColumn, encryptButton] + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: file-decrypt-verify + doc: src/content/docs/guides/file-operations.md + ref: img-4 # decrypting and verifying a file + scenario: file-decrypt-verify-result + highlight: InfoBoardWidget + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: file-batch-mode-button + doc: src/content/docs/guides/file-operations.md + ref: img-5 # the batch mode button + scenario: file-batch + crop: fileToolRow + highlight: batchModeButton + sources: [src/ui/widgets/] + + - id: file-batch-mode + doc: src/content/docs/guides/file-operations.md + ref: img-6 # several files at once + scenario: file-batch + highlight: [fileTree, encryptButton] + sources: [src/ui/main_window/, src/ui/widgets/] + + - id: settings-binary-mode + doc: src/content/docs/guides/file-operations.md + ref: img-7 # Settings > General > Use Binary Mode + scenario: settings-binary-mode + size: 900x660 + highlight: asciiModeCheckBox + sources: [src/ui/dialog/settings/] + + - id: file-options-ascii-armor + doc: src/content/docs/guides/file-operations.md + ref: img-8 # options menu > Use ASCII Armor + scenario: file-options-menu + crop: optionsArea + highlight: useAsciiArmor + sources: [src/ui/widgets/] + + - id: sym-encrypt-menu + doc: src/content/docs/guides/symmetric-encrypt-decrypt.mdx + ref: img-3 # running it: the encrypt button's Sym. Encrypt entry + scenario: sym-encrypt-menu + highlight: symEncrypt + sources: [src/ui/main_window/] + + # Phase 2, batch 4: subkeys, key packages, the key server and build info. + # The key server is render.py's local mock (hkpmock.py), reached through a + # proxy as http://keys.example.org; nothing leaves the machine. + - id: subkey-new-subkey + doc: src/content/docs/guides/generate-use-subkey.md + ref: img-1 # Keychain tab > New Subkey + scenario: key-details-keychain + size: auto + highlight: newSubkeyButton + sources: [src/ui/dialog/keypair_details/] + + - id: subkey-generate + doc: src/content/docs/guides/generate-use-subkey.md + ref: img-2 # the Generate New Subkey window + scenario: subkey-generate-dialog + size: auto + sources: [src/ui/dialog/key_generate/] + + - id: subkey-export + doc: src/content/docs/guides/generate-use-subkey.md + ref: img-3 # "Export a Subkey" + scenario: keychain-subkey + size: auto + highlight: exportSubkeyButton + sources: [src/ui/dialog/keypair_details/] + + - id: subkey-switch-database + doc: src/content/docs/guides/generate-use-subkey.md + ref: img-4 # "Switch Key Database" + scenario: key-database-menu + highlight: keyDatabaseWork + sources: [src/ui/widgets/] + + - id: subkey-import-file + doc: src/content/docs/guides/generate-use-subkey.md + ref: img-5 # "Import the Subkey" + scenario: import-menu + highlight: importFromFile + sources: [src/ui/main_window/] + + - id: subkey-imported + doc: src/content/docs/guides/generate-use-subkey.md + ref: img-6 # "Verify Imported Subkey" + scenario: work-keychain-subkey + size: auto + sources: [src/ui/dialog/keypair_details/] + + - id: subkey-hash-symbol + doc: src/content/docs/guides/generate-use-subkey.md + ref: img-7 # "Meaning of '#' Symbol" + scenario: work-key-list + highlight: aliceType + sources: [src/ui/widgets/, src/core/model/GpgKeyTableModel.cpp] + + - id: subkey-primary-not-exists + doc: src/content/docs/guides/generate-use-subkey.md + ref: img-8 # "Primary Key Not Exists" + scenario: work-keypair + size: auto + highlight: primaryKeyExistence + sources: [src/ui/dialog/keypair_details/] + + - id: subkey-encrypt + doc: src/content/docs/guides/generate-use-subkey.md + ref: img-9 # "Use Subkey to Encrypt" + scenario: work-encrypt-ready + highlight: encryptButton + sources: [src/ui/main_window/] + + - id: key-package-export-menu + doc: src/content/docs/advanced/key-package.mdx + ref: img-1 # Export Key > Export As Key Package + scenario: key-package-export-menu + size: 1200x760 + highlight: exportKeyPackage + sources: [src/ui/main_window/] + + - id: key-package-export-dialog + doc: src/content/docs/advanced/key-package.mdx + ref: img-2 # the Export As Key Package dialog + scenario: key-package-export-dialog + size: auto + sources: [src/ui/dialog/import_export/ExportKeyPackageDialog.cpp] + + - id: key-package-import-menu + doc: src/content/docs/advanced/key-package.mdx + ref: img-3 # Import Key > Key Package + scenario: key-package-import-menu + size: 1200x760 + highlight: importKeyPackage + sources: [src/ui/main_window/] + + - id: key-server-search + doc: src/content/docs/guides/key-server-operations.md + ref: img-2 # search results + scenario: key-server-search + size: 820x600 + highlight: [searchEdit, searchResults] + sources: [src/ui/main_window/] + + - id: key-server-import + doc: src/content/docs/guides/key-server-operations.md + ref: img-3 # "After You Import" + scenario: key-server-import + size: auto + sources: [src/ui/dialog/import_export/KeyImportDetailDialog.cpp] + + - id: key-server-operations + doc: src/content/docs/guides/key-server-operations.md + ref: img-4 # "Work With One Key" + scenario: key-server-operations + size: auto + highlight: publishKey + sources: [src/ui/dialog/keypair_details/] + + - id: build-information + doc: src/content/docs/appendix/code-binary-verify.md + ref: img-4 # About > Build Information; changes with every build + scenario: about-build-info + size: auto + sources: [src/ui/dialog/help/] + + # Made by hand: other operating systems and their own windows. + - id: interface-macos + doc: src/content/docs/guides/understand-interface.mdx + ref: img-2 + status: manual-only + - id: interface-linux + doc: src/content/docs/guides/understand-interface.mdx + ref: img-3 + status: manual-only + - id: windows-signature + doc: src/content/docs/appendix/code-binary-verify.md + ref: img-1 + status: manual-only + - id: macos-notarization + doc: src/content/docs/appendix/code-binary-verify.md + ref: img-2 + status: manual-only + - id: linux-signature + doc: src/content/docs/appendix/code-binary-verify.md + ref: img-3 + status: manual-only + - id: drag-to-applications + doc: src/content/docs/overview/getting-started.mdx + ref: img-1 + status: manual-only diff --git a/scripts/screenshots/tests/test_hkpmock.py b/scripts/screenshots/tests/test_hkpmock.py new file mode 100644 index 0000000..67b52d5 --- /dev/null +++ b/scripts/screenshots/tests/test_hkpmock.py @@ -0,0 +1,66 @@ +"""The local mock key server: what it answers, and that it answers nothing else.""" + +from __future__ import annotations + +import shutil +import unittest +import urllib.error +import urllib.request + +from helpers import short_tempdir, write + +import hkpmock + +LISTING = """\ +tru::1:1743508800:0:3:1:5 +pub:-:255:22:F32819327EBEC29F:1735732800:::-:::scSC::::::23::0: +fpr:::::::::3818A814A0DE75F628AF39C9F32819327EBEC29F: +uid:-::::1735732800::ABC::Grace Example <[email protected]>::::::::::0: +sub:-:255:18:AAAA:1735732800::::::e::::::23: +fpr:::::::::1111111111111111111111111111111111111111: +""" + + +class MockKeyServerTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp = short_tempdir("gfhkp") + self.addCleanup(shutil.rmtree, self.tmp, True) + write(self.tmp / "listing.colons", LISTING) + write(self.tmp / "3818A814A0DE75F628AF39C9F32819327EBEC29F.asc", "ARMORED\n") + self.server = hkpmock.MockKeyServer(self.tmp) + self.addCleanup(self.server.close) + proxy = urllib.request.ProxyHandler({"http": "http://" + self.server.address}) + self.opener = urllib.request.build_opener(proxy) + + def get(self, url: str) -> tuple[int, bytes]: + try: + with self.opener.open(url, timeout=5) as reply: + return reply.status, reply.read() + except urllib.error.HTTPError as exc: + return exc.code, exc.read() + + def test_index_lists_matching_keys_with_their_primary_fingerprint(self) -> None: + code, body = self.get("http://keys.example.org/pks/lookup?search=grace%40example.org&op=index&options=mr") + self.assertEqual(code, 200) + self.assertEqual(body.decode().splitlines(), [ + "info:1:1", + "pub:3818A814A0DE75F628AF39C9F32819327EBEC29F:22:255:1735732800::", + "uid:Grace Example <[email protected]>:1735732800::", + ]) + code, body = self.get("http://keys.example.org/pks/lookup?search=nobody%40example.org&op=index") + self.assertEqual(body.decode().splitlines(), ["info:1:0"]) + + def test_get_by_key_id_or_fingerprint(self) -> None: + for handle in ("0xF32819327EBEC29F", "0x3818A814A0DE75F628AF39C9F32819327EBEC29F"): + self.assertEqual(self.get(f"http://keys.example.org/pks/lookup?search={handle}&op=get"), + (200, b"ARMORED\n")) + self.assertEqual(self.get("http://keys.example.org/pks/lookup?search=0xDEAD&op=get")[0], 404) + + def test_other_hosts_are_refused_and_nothing_is_forwarded(self) -> None: + self.assertEqual(self.get("http://example.com/pks/lookup?search=a&op=index")[0], 403) + self.assertEqual(self.get("http://keys.example.org/other")[0], 403) + self.assertEqual(self.server.requests[-2:], ["GET example.com/pks/lookup", "GET keys.example.org/other"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/screenshots/tests/test_units.py b/scripts/screenshots/tests/test_units.py index cbbb720..f28062e 100644 --- a/scripts/screenshots/tests/test_units.py +++ b/scripts/screenshots/tests/test_units.py @@ -57,6 +57,17 @@ class PostprocessTest(unittest.TestCase): self.tmp, "r") self.assertEqual(len({plain.pixels_sha256, boxed.pixels_sha256, arrow.pixels_sha256}), 3) + def test_crop_cuts_to_the_named_widget(self) -> None: + from dataclasses import replace + entry = replace(self.entry_with(Highlight("box")), crop="box") + out = postprocess.process(self.raw, geometry(200, 120), entry, self.tmp, "c") + pad = self.entry.frame["padding"] * 2 + with Image.open(out.png) as img: + self.assertEqual(img.size, (60 + 2 * pad, 30 + 2 * pad)) + with self.assertRaises(postprocess.PostprocessError) as ctx: + postprocess.process(self.raw, geometry(200, 120), replace(self.entry, crop="nowhere"), self.tmp, "x") + self.assertEqual(ctx.exception.reason, "highlight-unknown") + def test_unknown_or_missing_highlight_fails(self) -> None: for name, reason in (("gone", "highlight-missing"), ("nowhere", "highlight-unknown")): with self.assertRaises(postprocess.PostprocessError) as ctx: @@ -113,8 +124,12 @@ class ManifestTest(unittest.TestCase): def test_real_manifest_loads(self) -> None: m = load_manifest(Path(__file__).resolve().parents[1] / "screenshots.yaml") - self.assertEqual([e.id for e in m.entries], ["main-window", "key-generate-dialog", - "text-decrypt-verify-result", "main-window-menu-popup"]) + ids = [e.id for e in m.entries] + self.assertEqual(ids[:4], ["main-window", "key-generate-dialog", + "text-decrypt-verify-result", "main-window-menu-popup"]) + self.assertEqual(len(ids), len(set(ids))) + targets = [(e.doc, e.ref) for e in m.entries] + self.assertEqual(len(targets), len(set(targets)), "two entries replace the same image") class RendererTest(World): |
