[PATCH 7/7] test: add framebuffer screenshot testing via QMP screendump

Ahmad Fatoum
2026-04-13T07:46:09Z

Add screendump() and parse_ppm() helpers to capture and parse QEMU
framebuffer screenshots using the QMP screendump command. Use them in a
new test that draws a solid color with fbtest and verifies the pixels
match. The test auto-skips when no framebuffer is available (e.g. without
--graphics).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Ahmad Fatoum <a.fatoum@barebox.org>
---
test/py/helper.py | 53 ++++++++++++++++++++++++++++++
test/py/test_fbtest.py | 74 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 127 insertions(+)
create mode 100644 test/py/test_fbtest.py

diff --git a/test/py/helper.py b/test/py/helper.py
index ab615280048f..2e3bc489d88a 100644
--- a/test/py/helper.py
+++ b/test/py/helper.py
@@ -6,6 +6,7 @@ import os
import re
import shlex
import subprocess
+import tempfile

def parse_config(lines):
@@ -200,6 +201,58 @@ def skip_disabled(config, *options):
pytest.skip("skipping test due to disabled " + (",".join(undefined)) + " dependency")

+def parse_ppm(path):
+ """Parse a PPM P6 (binary) image file.
+
+ Returns (width, height, data) where data is bytes of RGB pixel values.
+ Pixel (x, y) starts at offset (y * width + x) * 3.
+ """
+ with open(path, 'rb') as f:
+ magic = f.readline().strip()
+ assert magic == b'P6', f"Expected P6, got {magic}"
+
+ line = f.readline()
+ while line.startswith(b'#'):
+ line = f.readline()
+
+ width, height = map(int, line.split())
+ maxval = int(f.readline().strip())
+ assert maxval == 255
+
+ data = f.read()
+ expected = width * height * 3
+ assert len(data) == expected, \
+ f"Expected {expected} bytes, got {len(data)}"
+
+ return width, height, data
+
+
+def screendump(qemu, path=None):
+ """Capture a QEMU framebuffer screenshot via QMP screendump.
+
+ Args:
+ qemu: A labgrid QEMUDriver instance.
+ path: Optional host path for the PPM file. If None, a temp file is used.
+
+ Returns:
+ (width, height, data) tuple from parse_ppm().
+ """
+ if qemu is None:
+ pytest.skip("screendump requires a QEMU target")
+
+ cleanup = path is None
+ if path is None:
+ fd, path = tempfile.mkstemp(suffix='.ppm')
+ os.close(fd)
+
+ try:
+ qemu.monitor_command('screendump', {'filename': path})
+ return parse_ppm(path)
+ finally:
+ if cleanup:
+ os.unlink(path)
+
+
def ensure_debian_iso(env, destdir):
"""
Extract Debian kernel and initrd from ISO into destdir.
diff --git a/test/py/test_fbtest.py b/test/py/test_fbtest.py
new file mode 100644
index 000000000000..58b70d7dc4ab
--- /dev/null
+++ b/test/py/test_fbtest.py
@@ -0,0 +1,74 @@
+# SPDX-License-Identifier: GPL-2.0-only
+
+import hashlib
+import pytest
+from .helper import skip_disabled, screendump
+
+
+@pytest.fixture(autouse=True)
+def check_fbtest_in_qemu(barebox, env, barebox_config):
+ skip_disabled(barebox_config, "CONFIG_CMD_FBTEST")
+
+ if 'qemu' not in env.get_target_features():
+ pytest.skip("fbtest tests only possible with QEMU")
+
+ _, _, ret = barebox.run("test -e /dev/fb0")
+ if ret != 0:
+ pytest.skip("no framebuffer device available")
+
+
+def assert_solid_color(data, width, height, color):
+ """Verify that sampled pixels in the lower half match the expected color."""
+ sample_points = [
+ (width // 2, height * 3 // 4),
+ (width // 4, height * 3 // 4),
+ (width * 3 // 4, height * 3 // 4),
+ (width // 2, height - 2),
+ ]
+
+ r_exp = (color >> 16) & 0xff
+ g_exp = (color >> 8) & 0xff
+ b_exp = color & 0xff
+
+ for x, y in sample_points:
+ off = (y * width + x) * 3
+ r, g, b = data[off], data[off + 1], data[off + 2]
+ assert abs(r - r_exp) < 10, f"pixel ({x},{y}): R={r}, expected {r_exp}"
+ assert abs(g - g_exp) < 10, f"pixel ({x},{y}): G={g}, expected {g_exp}"
+ assert abs(b - b_exp) < 10, f"pixel ({x},{y}): B={b}, expected {b_exp}"
+
+
+def test_fb_solid_color(barebox, barebox_config, strategy):
+ color = 0xff0000
+ barebox.run_check(f"fbtest -p solid -c {color:06x}")
+
+ width, height, data = screendump(strategy.qemu)
+ assert_solid_color(data, width, height, color)
+
+
+def screendump_hash(qemu):
+ """Capture a screenshot and return a hash of the pixel data."""
+ _, _, data = screendump(qemu)
+ return hashlib.sha256(data).hexdigest()
+
+
+def test_fb_patterns_distinct_and_stable(barebox, barebox_config, strategy):
+ patterns = ["solid", "geometry", "bars", "gradient"]
+
+ # Render each pattern twice and collect hashes
+ hashes = {p: [] for p in patterns}
+
+ for run in range(2):
+ for pattern in patterns:
+ barebox.run_check(f"fbtest -p {pattern} -c ffffff")
+ hashes[pattern].append(screendump_hash(strategy.qemu))
+
+ # Same pattern must produce the same output across runs
+ for pattern in patterns:
+ assert hashes[pattern][0] == hashes[pattern][1], \
+ f"pattern '{pattern}' produced different output across runs"
+
+ # Different patterns must produce different output
+ unique = set(hashes[p][0] for p in patterns)
+ assert len(unique) == len(patterns), \
+ f"expected {len(patterns)} distinct patterns, got {len(unique)}"
--
2.47.3