From 1e56ffcff1c55b29683e19992da5110828dcadf0 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Fri, 23 Oct 2026 10:10:35 -0400 Subject: [PATCH] fix(screenshot): a blank frame is not a stable UI --- dbuild/screenshot.py | 27 +++++++++++--- tests/test_screenshot.py | 80 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/dbuild/screenshot.py b/dbuild/screenshot.py index c9139dd..077ab8c 100644 --- a/dbuild/screenshot.py +++ b/dbuild/screenshot.py @@ -32,6 +32,20 @@ def secure_origin(url: str) -> str: return f"{u.scheme}://{u.netloc}" +def frame_is_blank(png: bytes) -> bool: + """True when the frame is one flat color (verify's blank test). Without + scikit-image nothing counts as blank, as before.""" + try: + from io import BytesIO + + from skimage import io as skio + + from dbuild.verify import is_blank + except ImportError: + return False + return is_blank(skio.imread(BytesIO(png))) + + 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*. @@ -101,12 +115,15 @@ def capture(url: str, output: str, timeout: int = 30, min_wait: int = 0, secure_ ) stable = False while time.time() - start_time < max_stability_wait: - current_screen = driver.get_screenshot_as_base64() + current_screen = driver.get_screenshot_as_png() elapsed = time.time() - start_time - if last_screen and current_screen == last_screen and elapsed >= min_wait: - print(f"UI stabilized after {elapsed:.2f}s", file=sys.stderr) - stable = True - break + # An SPA paints a blank page before its first render; two blank + # frames match, but that is not the UI settling. + if (last_screen and current_screen == last_screen + and elapsed >= min_wait and not frame_is_blank(current_screen)): + print(f"UI stabilized after {elapsed:.2f}s", file=sys.stderr) + stable = True + break last_screen = current_screen time.sleep(0.5) diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py index 336ef9c..51153f0 100644 --- a/tests/test_screenshot.py +++ b/tests/test_screenshot.py @@ -24,3 +24,83 @@ def test_secure_context_read_from_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 + + +def _png(pattern: bool) -> bytes: + import io + + np = pytest.importorskip("numpy") + Image = pytest.importorskip("PIL.Image") + a = np.full((32, 32, 3), 128, dtype=np.uint8) + if pattern: + a[::4] = 0 + b = io.BytesIO() + Image.fromarray(a).save(b, "PNG") + return b.getvalue() + + +def test_frame_is_blank(): + pytest.importorskip("skimage") + from dbuild.screenshot import frame_is_blank + + assert frame_is_blank(_png(False)) + assert not frame_is_blank(_png(True)) + + +class _FakeDriver: + def __init__(self, frames): + self.frames = frames + self.shots = 0 + self.saved = None + + def set_page_load_timeout(self, t): + pass + + def execute_cdp_cmd(self, *a): + pass + + def get(self, url): + pass + + def execute_script(self, s): + return "complete" + + def get_screenshot_as_png(self): + f = self.frames[min(self.shots, len(self.frames) - 1)] + self.shots += 1 + return f + + def save_screenshot(self, path): + self.saved = self.frames[min(self.shots - 1, len(self.frames) - 1)] + + def quit(self): + pass + + +def _capture(monkeypatch, frames): + import dbuild.screenshot as s + + clock = [0.0] + monkeypatch.setattr(s.time, "time", lambda: clock[0]) + monkeypatch.setattr(s.time, "sleep", lambda n: clock.__setitem__(0, clock[0] + n)) + d = _FakeDriver(frames) + monkeypatch.setattr(s.webdriver, "Chrome", lambda **kw: d) + monkeypatch.setattr(s, "Service", lambda **kw: None) + assert s.capture("http://10.88.0.4:8080/", "/nonexistent.png") + return d + + +def test_blank_frames_are_not_stable(monkeypatch, capsys): + # nocodb on CI: the SPA's blank first paint matched itself and was shot. + pytest.importorskip("skimage") + blank, ui = _png(False), _png(True) + d = _capture(monkeypatch, [blank, blank, blank, ui, ui]) + assert d.saved == ui + assert "UI stabilized" in capsys.readouterr().err + + +def test_page_that_stays_blank_times_out(monkeypatch, capsys): + pytest.importorskip("skimage") + d = _capture(monkeypatch, [_png(False)]) + assert d.saved == _png(False) + assert "did not stabilize" in capsys.readouterr().err