Capture screenshots in the compositor instead of grim
This commit is contained in:
1 parent
4b70cb40e0
commit
eda453dabd
15 files changed
+2770
-171
No files matched your search
@@ -1,7 +1,10 @@
|
||||
"""Opt-in rendered regression test; opens only a temporary nested compositor.
|
||||
FERESE_TEST_FLOATING=1 FERESE_TEST_BINARY=target/debug/ferese python3 scripts/tests/test_floating_isolated.py
|
||||
Requires cc, pkg-config, wayland-scanner, wayland-protocols, grim, and Pillow.
|
||||
Requires cc, pkg-config, wayland-scanner, wayland-protocols, and Pillow.
|
||||
The compositor and feresectl must be built; set FERESE_TEST_BINARY to point at
|
||||
the compositor and FERESE_TEST_CTL at feresectl when they are not siblings.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
@@ -42,6 +45,9 @@ class FloatingSizeTest(unittest.TestCase):
|
||||
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"),
|
||||
WAYLAND_DISPLAY=display, FERESE_ENABLE_SCREENCOPY="1")
|
||||
binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")
|
||||
ctl = repo / os.environ.get("FERESE_TEST_CTL", str(binary.parent / "feresectl"))
|
||||
if not ctl.is_file():
|
||||
self.fail(f"missing {ctl}; build it with cargo build -p feresectl")
|
||||
with (root / "compositor.log").open("w") as log:
|
||||
process = subprocess.Popen([str(binary), "--backend", "nested", "--", str(root / "client")],
|
||||
env=env, stdout=log, stderr=subprocess.STDOUT)
|
||||
@@ -56,8 +62,23 @@ class FloatingSizeTest(unittest.TestCase):
|
||||
self.assertTrue(sockets, "nested Wayland socket did not appear")
|
||||
childenv = dict(env, WAYLAND_DISPLAY=str(sockets[0]))
|
||||
time.sleep(2)
|
||||
listed = subprocess.run([str(ctl), "get-outputs"], env=childenv,
|
||||
check=True, capture_output=True, timeout=10, text=True)
|
||||
enabled = [o for o in json.loads(listed.stdout) if o.get("enabled")]
|
||||
if not enabled:
|
||||
self.fail("compositor reported no enabled output to capture")
|
||||
first = enabled[0]
|
||||
if any(first.get(k) is None for k in ("x", "y", "width", "height")):
|
||||
self.fail(f"enabled output {first.get('name')!r} has no logical geometry")
|
||||
geometry = "%d,%d %dx%d" % (first["x"], first["y"], first["width"], first["height"])
|
||||
screenshot = root / "frame.png"
|
||||
subprocess.run(["grim", "-s", "1", str(screenshot)], env=childenv, check=True, timeout=15)
|
||||
with screenshot.open("wb") as png:
|
||||
captured = subprocess.run([str(ctl), "screenshot", "-g", geometry],
|
||||
env=childenv, stdout=png, stderr=subprocess.PIPE, timeout=15)
|
||||
if captured.returncode != 0:
|
||||
self.fail(f"capture of {geometry} failed: {captured.stderr.decode()}")
|
||||
if screenshot.stat().st_size == 0:
|
||||
self.fail(f"capture of {geometry} produced an empty file")
|
||||
with Image.open(screenshot) as frame:
|
||||
rgb = frame.convert("RGB")
|
||||
red, green, blue = rgb.split()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Opt-in lock protocol smoke test. Opens a temporary nested compositor.
|
||||
Run: FERESE_TEST_LOCK=1 python3 scripts/tests/test_lock_isolated.py
|
||||
Requires release binaries, bwrap and grim. Host PAM is never modified.
|
||||
Requires release binaries and bwrap. Host PAM is never modified.
|
||||
"""
|
||||
import os
|
||||
import unittest
|
||||
@@ -23,6 +23,8 @@ class NativeLockTest(unittest.TestCase):
|
||||
env=dict(os.environ,XDG_RUNTIME_DIR=str(runtime),XDG_CONFIG_HOME=str(root/'config'),WAYLAND_DISPLAY=host_display,FERESE_ENABLE_SCREENCOPY='1')
|
||||
log=(root/'compositor.log').open('w')
|
||||
compositor=subprocess.Popen(['target/release/ferese','--backend','nested'],env=env,stdout=log,stderr=subprocess.STDOUT)
|
||||
ctl=pathlib.Path('target/release/feresectl')
|
||||
assert ctl.is_file(), f'missing {ctl}; build it with cargo build --release -p feresectl'
|
||||
locker=None
|
||||
locker_log=(root/'locker.log').open('w')
|
||||
try:
|
||||
@@ -32,19 +34,25 @@ class NativeLockTest(unittest.TestCase):
|
||||
if sockets:break
|
||||
time.sleep(.1)
|
||||
childenv=dict(env,WAYLAND_DISPLAY=str(sockets[0]),FERESE_LOCK_READY='1')
|
||||
def capture(destination, timeout=10):
|
||||
with open(destination,'wb') as png:
|
||||
return subprocess.run([str(ctl),'screenshot'],env=childenv,stdout=png,stderr=subprocess.PIPE,timeout=timeout)
|
||||
time.sleep(.6)
|
||||
subprocess.run(['grim',str(root/'unlocked.png')],env=childenv,check=True,timeout=10,capture_output=True)
|
||||
result=capture(root/'unlocked.png')
|
||||
assert result.returncode == 0, f'Capture failed while unlocked: {result.stderr.decode()}'
|
||||
assert (root/'unlocked.png').stat().st_size > 0, 'Capture produced an empty file'
|
||||
print('Native capture succeeded while unlocked',flush=True)
|
||||
locker=subprocess.Popen(['bwrap','--bind','/','/','--dev-bind','/dev','/dev','--ro-bind',str(pam),'/etc/pam.d','--unshare-user','--','target/release/ferese-lock'],env=childenv,stdout=subprocess.DEVNULL,stderr=locker_log,start_new_session=True)
|
||||
assert locker.wait(timeout=20) == 0, (root/'locker.log').read_text()
|
||||
print('Native locker received compositor confirmation',flush=True)
|
||||
result=subprocess.run(['grim',str(root/'locked.png')],env=childenv,timeout=10,capture_output=True)
|
||||
result=capture(root/'locked.png')
|
||||
assert result.returncode != 0, 'Capture unexpectedly succeeded during lock'
|
||||
print('Screencopy denied while native locker is running',flush=True)
|
||||
print('Capture denied while native locker is running',flush=True)
|
||||
os.killpg(locker.pid, signal.SIGKILL)
|
||||
time.sleep(.3)
|
||||
result=subprocess.run(['grim',str(root/'after-crash.png')],env=childenv,timeout=10,capture_output=True)
|
||||
result=capture(root/'after-crash.png')
|
||||
assert result.returncode != 0, 'Capture unexpectedly succeeded after locker crash'
|
||||
print('Screencopy remains denied after locker crash',flush=True)
|
||||
print('Capture remains denied after locker crash',flush=True)
|
||||
finally:
|
||||
if locker is not None:
|
||||
try: os.killpg(locker.pid, signal.SIGKILL)
|
||||
|
||||
@@ -10,87 +10,156 @@ import unittest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-screenshot'
|
||||
|
||||
OUTPUTS = [
|
||||
{'name': 'eDP-1', 'enabled': True, 'focused': False,
|
||||
'x': 0, 'y': 0, 'width': 1920, 'height': 1080},
|
||||
{'name': 'HDMI-A-1', 'enabled': True, 'focused': True,
|
||||
'x': 1920, 'y': 0, 'width': 2560, 'height': 1440},
|
||||
]
|
||||
FOCUSED_GEOMETRY = '1920,0 2560x1440'
|
||||
|
||||
class ScreenshotTest(unittest.TestCase):
|
||||
def test_active_output_all_outputs_and_editor_lock(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
bins = root / 'bin'
|
||||
bins.mkdir()
|
||||
env = dict(os.environ, XDG_RUNTIME_DIR=directory, TEST_ROOT=directory,
|
||||
PATH=f'{bins}:{os.environ["PATH"]}')
|
||||
(root / 'outputs.json').write_text(json.dumps([
|
||||
{'name': 'eDP-1', 'enabled': True, 'focused': False},
|
||||
{'name': 'HDMI-A-1', 'enabled': True, 'focused': True},
|
||||
]))
|
||||
scripts = {
|
||||
'grim': '''#!/bin/bash
|
||||
printf '%s\\n' "$@" >> "$TEST_ROOT/captures"
|
||||
printf image > "${@: -1}"
|
||||
''',
|
||||
'feresectl': '#!/bin/bash\ncat "$TEST_ROOT/outputs.json"\n',
|
||||
'notify-send': '#!/bin/bash\nexit 0\n',
|
||||
'wl-copy': '#!/bin/bash\nexit 0\n',
|
||||
'xdg-user-dir': '#!/bin/bash\nprintf "%s" "$TEST_ROOT/Pictures"\n',
|
||||
'satty': '''#!/bin/bash
|
||||
FARESECTL = '''#!/bin/bash
|
||||
case "$1" in
|
||||
get-outputs) cat "$TEST_ROOT/outputs.json" ;;
|
||||
screenshot)
|
||||
printf '%s\\n' "$@" >> "$TEST_ROOT/captures"
|
||||
printf image
|
||||
;;
|
||||
*) printf 'unexpected feresectl command: %s\\n' "$1" >&2; exit 1 ;;
|
||||
esac
|
||||
'''
|
||||
|
||||
SATTY = '''#!/bin/bash
|
||||
if [[ -e /proc/$$/fd/9 ]]; then touch "$TEST_ROOT/inherited-lock"; fi
|
||||
printf '%s\\n' "$@" > "$TEST_ROOT/satty-args"
|
||||
printf 'editor\\n' >> "$TEST_ROOT/editors"
|
||||
touch "$TEST_ROOT/ready"
|
||||
while [[ ! -e "$TEST_ROOT/release" ]]; do sleep 0.02; done
|
||||
''',
|
||||
}
|
||||
for name, contents in scripts.items():
|
||||
path = bins / name
|
||||
path.write_text(contents)
|
||||
path.chmod(0o755)
|
||||
def run(mode):
|
||||
return subprocess.run(['bash', str(SCRIPT), mode], env=env,
|
||||
capture_output=True, timeout=3)
|
||||
first = subprocess.Popen(['bash', str(SCRIPT), '--full'], env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while not (root / 'ready').exists():
|
||||
if time.monotonic() > deadline or first.poll() is not None:
|
||||
self.fail('First screenshot did not open its editor')
|
||||
time.sleep(0.02)
|
||||
second = run('--full')
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
self.assertEqual((root / 'editors').read_text().splitlines(), ['editor'])
|
||||
self.assertFalse((root / 'inherited-lock').exists())
|
||||
(root / 'release').touch()
|
||||
_, stderr = first.communicate(timeout=3)
|
||||
self.assertEqual(first.returncode, 0, stderr)
|
||||
third = run('--full')
|
||||
self.assertEqual(third.returncode, 0, third.stderr)
|
||||
self.assertEqual((root / 'editors').read_text().splitlines(), ['editor', 'editor'])
|
||||
self.assertFalse(list((root / 'Pictures/Screenshots').glob('*.png')))
|
||||
self.assertFalse(list(root.glob('ferese-screenshot-*.png')))
|
||||
captures = (root / 'captures').read_text().splitlines()
|
||||
self.assertEqual(captures[0:2], ['-o', 'HDMI-A-1'])
|
||||
self.assertEqual(captures[3:5], ['-o', 'HDMI-A-1'])
|
||||
all_monitors = run('--all')
|
||||
self.assertEqual(all_monitors.returncode, 0, all_monitors.stderr)
|
||||
captures = (root / 'captures').read_text().splitlines()
|
||||
self.assertEqual(len(captures), 7) # --all passes only the filename
|
||||
for outputs in [[], [{'enabled': True, 'name': 'A'}, {'enabled': True, 'name': 'B'}]]:
|
||||
(root / 'outputs.json').write_text(json.dumps(outputs))
|
||||
unavailable = run('--full')
|
||||
self.assertNotEqual(unavailable.returncode, 0)
|
||||
self.assertIn(b'Could not determine the active monitor', unavailable.stderr)
|
||||
self.assertEqual((root / 'captures').read_text().splitlines(), captures)
|
||||
(root / 'outputs.json').write_text(json.dumps([
|
||||
{'name': 'eDP-1', 'enabled': False, 'focused': True},
|
||||
{'name': 'HDMI-A-1', 'enabled': True, 'focused': False},
|
||||
]))
|
||||
single = run('--full')
|
||||
self.assertEqual(single.returncode, 0, single.stderr)
|
||||
self.assertEqual((root / 'captures').read_text().splitlines()[-3:-1], ['-o', 'HDMI-A-1'])
|
||||
finally:
|
||||
(root / 'release').touch()
|
||||
if first.poll() is None:
|
||||
first.terminate()
|
||||
first.communicate(timeout=3)
|
||||
'''
|
||||
|
||||
|
||||
def fakes():
|
||||
return {
|
||||
'feresectl': FARESECTL,
|
||||
'notify-send': '#!/bin/bash\nexit 0\n',
|
||||
'wl-copy': '#!/bin/bash\nexit 0\n',
|
||||
'xdg-user-dir': '#!/bin/bash\nprintf "%s" "$TEST_ROOT/Pictures"\n',
|
||||
'satty': SATTY,
|
||||
}
|
||||
|
||||
|
||||
class ScreenshotTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
self.bins = self.root / 'bin'
|
||||
self.bins.mkdir()
|
||||
self.env = dict(os.environ, XDG_RUNTIME_DIR=self.directory.name,
|
||||
TEST_ROOT=str(self.root),
|
||||
PATH=f'{self.bins}:{os.environ["PATH"]}')
|
||||
self.write_outputs(OUTPUTS)
|
||||
for name, contents in fakes().items():
|
||||
path = self.bins / name
|
||||
path.write_text(contents)
|
||||
path.chmod(0o755)
|
||||
|
||||
def write_outputs(self, outputs):
|
||||
(self.root / 'outputs.json').write_text(json.dumps(outputs))
|
||||
|
||||
def captures(self):
|
||||
log = self.root / 'captures'
|
||||
if not log.exists():
|
||||
return []
|
||||
return log.read_text().splitlines()
|
||||
|
||||
def run_script(self, *args, timeout=3):
|
||||
return subprocess.run(['bash', str(SCRIPT), *args], env=self.env,
|
||||
capture_output=True, timeout=timeout)
|
||||
|
||||
def test_active_output_all_outputs_and_editor_lock(self):
|
||||
first = subprocess.Popen(['bash', str(SCRIPT), '--full'], env=self.env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while not (self.root / 'ready').exists():
|
||||
if time.monotonic() > deadline or first.poll() is not None:
|
||||
self.fail('First screenshot did not open its editor')
|
||||
time.sleep(0.02)
|
||||
second = self.run_script('--full')
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
self.assertEqual((self.root / 'editors').read_text().splitlines(), ['editor'])
|
||||
editor_args = (self.root / 'satty-args').read_text().splitlines()
|
||||
self.assertEqual(editor_args[editor_args.index('--app-id') + 1],
|
||||
'dev.ferese.Screenshot')
|
||||
self.assertEqual(editor_args[editor_args.index('--resize') + 1],
|
||||
'1500x920')
|
||||
self.assertIn('--no-window-decoration', editor_args)
|
||||
self.assertNotIn('--fullscreen', editor_args)
|
||||
self.assertFalse((self.root / 'inherited-lock').exists())
|
||||
(self.root / 'release').touch()
|
||||
_, stderr = first.communicate(timeout=3)
|
||||
self.assertEqual(first.returncode, 0, stderr)
|
||||
third = self.run_script('--full')
|
||||
self.assertEqual(third.returncode, 0, third.stderr)
|
||||
self.assertEqual((self.root / 'editors').read_text().splitlines(), ['editor', 'editor'])
|
||||
self.assertFalse(list((self.root / 'Pictures/Screenshots').glob('*.png')))
|
||||
self.assertFalse(list(self.root.glob('ferese-screenshot-*.png')))
|
||||
# Each --full capture asked for the focused output by geometry, not
|
||||
# by name, because native capture has no output-name selection.
|
||||
self.assertEqual(self.captures()[0:3], ['screenshot', '-g', FOCUSED_GEOMETRY])
|
||||
self.assertEqual(self.captures()[3:6], ['screenshot', '-g', FOCUSED_GEOMETRY])
|
||||
all_monitors = self.run_script('--all')
|
||||
self.assertEqual(all_monitors.returncode, 0, all_monitors.stderr)
|
||||
self.assertEqual(len(self.captures()), 7) # --all passes only "screenshot"
|
||||
self.assertEqual(self.captures()[6], 'screenshot')
|
||||
captured = self.captures()
|
||||
for outputs in [[], [dict(OUTPUTS[0], focused=False), dict(OUTPUTS[1], focused=False)]]:
|
||||
self.write_outputs(outputs)
|
||||
unavailable = self.run_script('--full')
|
||||
self.assertNotEqual(unavailable.returncode, 0)
|
||||
self.assertIn(b'Could not determine the active monitor', unavailable.stderr)
|
||||
self.assertEqual(self.captures(), captured)
|
||||
# An enabled output with no geometry is ambiguous, not a reason to
|
||||
# capture everything.
|
||||
self.write_outputs([dict(OUTPUTS[1], focused=True, x=None)])
|
||||
unknown = self.run_script('--full')
|
||||
self.assertNotEqual(unknown.returncode, 0)
|
||||
self.assertIn(b'Could not determine the active monitor', unknown.stderr)
|
||||
self.assertEqual(self.captures(), captured)
|
||||
# Disabled outputs are ignored, so the sole remaining one is used.
|
||||
self.write_outputs([dict(OUTPUTS[0], enabled=False, focused=True),
|
||||
dict(OUTPUTS[1], focused=False)])
|
||||
single = self.run_script('--full')
|
||||
self.assertEqual(single.returncode, 0, single.stderr)
|
||||
self.assertEqual(self.captures()[-2:], ['-g', FOCUSED_GEOMETRY])
|
||||
finally:
|
||||
(self.root / 'release').touch()
|
||||
if first.poll() is None:
|
||||
first.terminate()
|
||||
first.communicate(timeout=3)
|
||||
|
||||
def test_area_selection_is_passed_through(self):
|
||||
self.bins.joinpath('slurp').write_text(
|
||||
'#!/bin/bash\nprintf "10,-20 300x200"\n')
|
||||
self.bins.joinpath('slurp').chmod(0o755)
|
||||
(self.root / 'release').touch()
|
||||
area = self.run_script()
|
||||
self.assertEqual(area.returncode, 0, area.stderr)
|
||||
self.assertEqual(self.captures(), ['screenshot', '-g', '10,-20 300x200'])
|
||||
|
||||
def test_every_mode_captures_through_the_native_command(self):
|
||||
(self.root / 'release').touch()
|
||||
cases = [(['--all'], ['screenshot']),
|
||||
(['--full'], ['screenshot', '-g', FOCUSED_GEOMETRY])]
|
||||
for args, expected in cases:
|
||||
with self.subTest(args=args):
|
||||
before = len(self.captures())
|
||||
result = self.run_script(*args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
# The fake only records these when the compositor command runs,
|
||||
# so a capture taken any other way leaves the log short.
|
||||
self.assertEqual(self.captures()[before:], expected)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in new issue
Block a user