Files
cthulhu/tests/native_notifications.py

175 lines
6.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Private Xvfb/AT-SPI proof for the Go notification-list helper."""
import json
import os
from pathlib import Path
import select
import subprocess
import sys
sys.dont_write_bytecode = True
from native_x11 import wait_for
def inside():
import pyatspi
binary = os.environ.get("CTHULHU_BINARY", str(Path(__file__).resolve().parents[1] / "cthulhu"))
process = subprocess.Popen(
[binary, "--notification-list-ui"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, text=True,
)
def send(value):
process.stdin.write(json.dumps(value) + "\n")
process.stdin.flush()
def request():
assert select.select([process.stdout], [], [], 5)[0], "no notification-list request"
return json.loads(process.stdout.readline())
def walk(node):
yield node
for child in node:
if child is not None:
yield from walk(child)
def find_window():
for application in pyatspi.Registry.getDesktop(0):
for node in application:
if node and node.name == "2 notifications":
return node
return None
def press(node):
assert node.queryComponent().grabFocus(), ("cannot focus", node.name)
subprocess.run(["xdotool", "key", "--clearmodifiers", "space"], check=True)
def refresh_controls():
controls.clear()
for node in walk(window):
if node.name:
controls.setdefault(node.name, node)
def entry(identifier, message, received, can_control=False, actions=None):
return {"id": identifier, "message": message, "received": received,
"canControl": can_control, "actions": actions or []}
initial = {
"title": "2 notifications",
"selected": "1",
"entries": [
entry("1", "Live message", "0 seconds ago", True, [
{"key": "default", "label": "View"},
{"key": "reply", "label": "Reply"},
]),
entry("0", "Older message", "1 minute ago"),
],
}
try:
send(initial)
window = wait_for(find_window, "notification accessible window missing")
nodes = list(walk(window))
controls = {}
for node in nodes:
if node.name:
controls.setdefault(node.name, node)
table = next(node for node in nodes if node.getRole() == pyatspi.ROLE_TABLE)
assert table.name == "2 notifications", table.name
names = {node.name for node in nodes}
for name in ("Live message", "Older message", "0 seconds ago", "1 minute ago",
"Copy", "Dismiss Notification", "Clear", "Close", "View", "Reply"):
assert name in names, ("missing accessible name", name)
interactive_roles = {
pyatspi.ROLE_PUSH_BUTTON, pyatspi.ROLE_TABLE, pyatspi.ROLE_TABLE_CELL,
}
unnamed = sorted(node.getRoleName() for node in nodes
if node.getRole() in interactive_roles and not node.name)
assert not unnamed, ("unnamed interactive controls", unnamed)
assert "Dismiss Notification" in controls
assert controls["Dismiss Notification"].getState().contains(pyatspi.STATE_SENSITIVE)
xid = subprocess.check_output(
["xdotool", "search", "--onlyvisible", "--name", "^2 notifications$"], text=True,
).splitlines()[0]
subprocess.run(["xdotool", "windowfocus", xid], check=True)
assert table.queryComponent().grabFocus(), "cannot focus notification table"
subprocess.run(["xdotool", "key", "--clearmodifiers", "Down"], check=True)
selected = request()
assert selected == {"selection": "0", "row": 1}, selected
send(dict(initial, selected="0"))
wait_for(lambda: "Notification is no longer available" in {
node.name for node in walk(window)
}, "unavailable action status missing")
assert not controls["Dismiss Notification"].getState().contains(pyatspi.STATE_SENSITIVE)
assert "View" not in {node.name for node in walk(window)}
assert "Reply" not in {node.name for node in walk(window)}
send(dict(initial, selected="1"))
wait_for(lambda: controls["Dismiss Notification"].getState().contains(pyatspi.STATE_SENSITIVE),
"live selection did not restore controls")
refresh_controls()
press(controls["Reply"])
invoked = request()
assert invoked == {"action": "reply", "selection": "1", "row": 0}, invoked
send(dict(initial, selected="1"))
refresh_controls()
press(controls["Copy"])
copied = request()
assert copied == {"copy": True}, copied
press(controls["Dismiss Notification"])
dismissed = request()
assert dismissed == {"dismiss": True, "selection": "1", "row": 0}, dismissed
send({"title": "1 notification", "selected": "0",
"entries": [entry("0", "Older message", "1 minute ago")]})
refresh_controls()
press(controls["Clear"])
cleared = request()
assert cleared == {"clear": True}, cleared
send({"close": True})
assert process.wait(timeout=5) == 0
assert process.stdout.read() == "", "clear submitted extra requests"
print("PASS: notification list accessibility, selection refresh, actions, copy, dismiss, clear")
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
def main():
if "--inside" in sys.argv:
inside()
return
read_fd, write_fd = os.pipe()
server = subprocess.Popen(
["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1024x768x24", "-nolisten", "tcp"],
pass_fds=(write_fd,), stderr=subprocess.DEVNULL,
)
os.close(write_fd)
try:
with os.fdopen(read_fd) as reader:
display = reader.readline().strip()
assert display, "Xvfb failed"
environment = dict(
os.environ, DISPLAY=":" + display, GDK_BACKEND="x11",
GTK_MODULES="gail:atk-bridge", GIO_USE_VFS="local",
)
for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"):
environment.pop(key, None)
subprocess.run(
["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"],
env=environment, check=True, timeout=60,
)
finally:
server.terminate()
server.wait(timeout=5)
if __name__ == "__main__":
main()