diff --git a/dbuild/config.py b/dbuild/config.py index ae9170b..08290cb 100644 --- a/dbuild/config.py +++ b/dbuild/config.py @@ -237,6 +237,12 @@ class AppTestConfig: https: bool = field(default=False, metadata={ "desc": "Use HTTPS for health and screenshot checks", }) + secure_context: bool = field(default=False, metadata={ + "desc": "Open the screenshot page as a secure context over plain http. " + "For apps that need SharedArrayBuffer or other secure-only browser " + "APIs (sqlite in the browser, e.g. Actual); the container's " + "`http://:` is not one otherwise.", + }) compose: bool = field(default=False, metadata={ "desc": "Start the service via `compose.yaml` instead of `podman run`", }) @@ -779,6 +785,7 @@ def _parse_test_config(data: dict[str, Any], compose_data: dict[str, Any] | None ssim_threshold = cit.get("ssim_threshold") edge_threshold = cit.get("edge_threshold") https = cit.get("https", False) + secure_context = cit.get("secure_context", False) compose = cit.get("compose", False) puid = cit.get("puid", True) puid_ignore = cit.get("puid_ignore") or [] @@ -846,6 +853,7 @@ def normalize_anno(a: str) -> str: ssim_threshold=ssim_threshold, edge_threshold=edge_threshold, https=https, + secure_context=secure_context, compose=compose, puid=puid, puid_ignore=puid_ignore, diff --git a/dbuild/screenshot.py b/dbuild/screenshot.py index 2206018..c9139dd 100644 --- a/dbuild/screenshot.py +++ b/dbuild/screenshot.py @@ -10,6 +10,7 @@ import os import sys import time +from urllib.parse import urlsplit from selenium import webdriver from selenium.webdriver.chrome.options import Options @@ -22,7 +23,16 @@ WINDOW_SIZE = os.environ.get("SCREENSHOT_SIZE", "1920,1080") -def capture(url: str, output: str, timeout: int = 30, min_wait: int = 0) -> bool: +def secure_origin(url: str) -> str: + """The origin to treat as secure for url: scheme://host:port, or "" for + one that already is (https) or that cannot be parsed.""" + u = urlsplit(url) + if u.scheme != "http" or not u.netloc: + return "" + return f"{u.scheme}://{u.netloc}" + + +def capture(url: str, output: str, timeout: int = 30, min_wait: int = 0, secure_context: bool = False) -> bool: """Capture a screenshot of *url* and save to *output*. Waits for ``document.readyState == "complete"`` then monitors for UI @@ -38,6 +48,8 @@ def capture(url: str, output: str, timeout: int = 30, min_wait: int = 0) -> bool Selenium page-load timeout in seconds. min_wait: Minimum seconds to wait before declaring stable. + secure_context: + Treat the page's plain-http origin as secure (cit.secure_context). Returns ------- @@ -51,6 +63,14 @@ def capture(url: str, output: str, timeout: int = 30, min_wait: int = 0) -> bool options.add_argument("--disable-extensions") options.add_argument(f"--window-size={WINDOW_SIZE}") options.set_capability("acceptInsecureCerts", True) + # The container is reached at its own IP over plain http, which is not a + # secure context: the browser withholds SharedArrayBuffer, and apps that + # run sqlite in the browser (Actual) stop with a fatal error instead of + # their UI. An image that says so (cit.secure_context) gets just this + # origin treated as secure. + origin = secure_origin(url) if secure_context else "" + if origin: + options.add_argument(f"--unsafely-treat-insecure-origin-as-secure={origin}") if CHROME_BIN: options.binary_location = CHROME_BIN diff --git a/dbuild/test.py b/dbuild/test.py index 5504a29..8737fc2 100644 --- a/dbuild/test.py +++ b/dbuild/test.py @@ -413,6 +413,7 @@ def _test_screenshot( port: int, *, https: bool = False, + secure_context: bool = False, screenshot_path: str | None = None, screenshot_wait: int = 0, baseline: Path | None = None, @@ -444,7 +445,7 @@ def _test_screenshot( screenshot_file = tmp.name try: - if not capture(url, screenshot_file, timeout=30, min_wait=screenshot_wait): + if not capture(url, screenshot_file, timeout=30, min_wait=screenshot_wait, secure_context=secure_context): return False, "Screenshot capture failed", {} # Basic verification @@ -636,6 +637,7 @@ def _functional_checks( ip, port, https=https, + secure_context=test.secure_context, screenshot_path=test.screenshot_path, screenshot_wait=test.screenshot_wait or 0, baseline=baseline, @@ -1045,7 +1047,7 @@ def run_screenshot(cfg: Config, args: argparse.Namespace) -> int: url = f"{scheme}://{ip}:{port}{screenshot_path}" log.info(f"Capturing: {url}") - if not capture(url, output, timeout=30, min_wait=screenshot_wait): + if not capture(url, output, timeout=30, min_wait=screenshot_wait, secure_context=test.secure_context): log.error("Screenshot capture failed") return 1 diff --git a/docs/dbuild.1 b/docs/dbuild.1 index 60339e4..5702294 100644 --- a/docs/dbuild.1 +++ b/docs/dbuild.1 @@ -1,4 +1,4 @@ -.TH DBUILD 1 "2026-10-02" "dbuild 1.10.1" "User Commands" +.TH DBUILD 1 "2026-10-03" "dbuild 1.10.1" "User Commands" .SH NAME dbuild \- FreeBSD OCI container image build tool .SH SYNOPSIS diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py new file mode 100644 index 0000000..336ef9c --- /dev/null +++ b/tests/test_screenshot.py @@ -0,0 +1,26 @@ +"""The screenshot browser treats the container's plain-http origin as secure.""" + +import pytest + +pytest.importorskip("selenium") + +from dbuild.screenshot import secure_origin + + +def test_secure_origin_for_the_container_address(): + # Actual needs SharedArrayBuffer, which the browser only gives a secure + # context; http:// is not one. + assert secure_origin("http://10.88.0.26:5006/") == "http://10.88.0.26:5006" + assert secure_origin("http://10.88.0.26:5006/some/path?x=1") == "http://10.88.0.26:5006" + + +def test_secure_origin_leaves_https_and_garbage_alone(): + assert secure_origin("https://10.88.0.26:8443/") == "" + assert secure_origin("not a url") == "" + + +def test_secure_context_read_from_config(): + from dbuild.config import _parse_test_config + + assert _parse_test_config({"cit": {"port": 5006, "secure_context": True}}).secure_context is True + assert _parse_test_config({"cit": {"port": 5006}}).secure_context is False