#!/usr/bin/env python3 """Firefox/Thunderbird reading with a private reader, X11, profile and SSIP.""" import argparse import json import os from pathlib import Path import shutil import subprocess import sys import tempfile sys.dont_write_bytecode = True from native_echo_preview import PreviewSpeechServer from native_x11 import wait_for from native_test_session import run_private_session TITLE = "Cthulhu Mozilla Reading Probe" CONTENT = ["Reading start", "First readable paragraph.", "Reading section", "Second readable paragraph.", "Local destination link", "Final readable paragraph."] HTML = f'''{TITLE}

{CONTENT[0]}

{CONTENT[1]}

{CONTENT[2]}

{CONTENT[3]}

{CONTENT[4]}

{CONTENT[5]}

''' def write_profile(profile, application): profile.mkdir() prefs = {"browser.startup.homepage_override.mstone": "ignore", "datareporting.policy.firstRunURL": "", "browser.shell.checkDefaultBrowser": False, "browser.startup.page": 0, "browser.newtabpage.enabled": False, "datareporting.policy.dataSubmissionEnabled": False} if application == "thunderbird": (profile / "Mail" / "Local Folders").mkdir(parents=True) prefs.update({"mail.shell.checkDefaultClient": False, "mailnews.start_page.enabled": False, "mail.provider.enabled": False, "mail.rights.override": True, "mailnews.database.global.indexer.enabled": False, "mail.accountmanager.accounts": "account1", "mail.accountmanager.defaultaccount": "account1", "mail.accountmanager.localfoldersserver": "server1", "mail.account.account1.server": "server1", "mail.account.account1.identities": "id1", "mail.server.server1.type": "none", "mail.server.server1.hostname": "Local Folders", "mail.server.server1.userName": "nobody", "mail.server.server1.name": "Local Folders", "mail.server.server1.directory": str(profile / "Mail" / "Local Folders"), "mail.server.server1.directory-rel": "[ProfD]Mail/Local Folders", "mail.identity.id1.fullName": "Username", "mail.identity.id1.useremail": "username@example.invalid", "mail.identity.id1.valid": True, "mail.compose.autosave": False}) (profile / "user.js").write_text("".join( f"user_pref({json.dumps(key)}, {json.dumps(value)});\n" for key, value in prefs.items())) def walk(node): if node is not None: yield node for child in node: yield from walk(child) def run_inside(application): import pyatspi directory = Path(os.environ["CTHULHU_MOZILLA_EVIDENCE"]) executable = shutil.which(application) if not executable: raise RuntimeError(f"{application} executable missing") profile = directory / "profile" write_profile(profile, application) fixture = directory / "reading.html" fixture.write_text(HTML) args = [executable, "--no-remote", "--profile", str(profile), "--new-window", fixture.as_uri()] if application == "thunderbird": fixture = directory / "message.eml" fixture.write_text("From: Username \n" "To: Recipient \n" f"Subject: {TITLE}\nMIME-Version: 1.0\n" "Content-Type: text/html; charset=UTF-8\n\n" + HTML) args = [executable, "--no-remote", "--profile", str(profile), "-file", str(fixture)] config = directory / "settings.toml" config.write_text('''schema-version = 2 active-profile = "default" [profiles.default.speech] output-module = "main" [profiles.default.braille] enabled = false [profiles.default.sound] enabled = false [profiles.default.key-echo] enabled = false ''') wmConfig = directory / "i3.config" wmConfig.write_text(f"font pango:monospace 10\nipc-socket {directory / 'i3.sock'}\n") server = PreviewSpeechServer(directory / "speech.sock") processes, report = [], [] logPath = directory / "reader.log" def key(chord): subprocess.run(["xdotool", "key", "--clearmodifiers", chord], check=True) def find_document(): for app in pyatspi.Registry.getDesktop(0): if app.name.lower() == application: for node in walk(app): if node.getRoleName() == "document web" and node.name == TITLE: return node def check(chord, expected): offset = len(server.snapshot()) key(chord) wait_for(lambda: any(row["text"] == expected for row in server.snapshot()[offset:]), f"{application}: {chord} did not speak {expected!r}", timeout=8) # Normal presentation queues several chunks without SSIP end events. # The recorder completes the final chunk; earlier chunks can remain open. wait_for(lambda: server.snapshot()[-1]["finished"], "presentation did not settle") rows = server.snapshot()[offset:] actual = [row["text"] for row in rows if row["text"] in CONTENT] assert actual == [expected], (chord, expected, rows) assert not any("Could not" in row["text"] for row in rows), (chord, rows) report.append({"key": chord, "expected": expected, "speech": rows}) try: processes.append(subprocess.Popen(["i3", "-c", str(wmConfig)], stdout=subprocess.DEVNULL, stderr=(directory / "wm.log").open("w"))) wait_for(lambda: "0x" in subprocess.check_output( ["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"], text=True), "private WM missing") processes.append(subprocess.Popen([ os.environ["CTHULHU_BINARY"], "--config", str(config), "--no-tentacles", "--speech-socket", str(directory / "speech.sock"), "--debug-file", str(logPath)], stdout=subprocess.DEVNULL, stderr=(directory / "reader-stderr.log").open("w"))) wait_for(lambda: logPath.exists() and "remote D-Bus service ready" in logPath.read_text(), "private reader missing") processes.append(subprocess.Popen(args, env=dict(os.environ, MOZ_ACCESSIBILITY_ENABLED="1"), stdout=subprocess.DEVNULL, stderr=(directory / "application.log").open("w"))) document = wait_for(find_document, "fixture document missing", timeout=25) window = subprocess.check_output(["xdotool", "search", "--onlyvisible", "--name", TITLE], text=True).splitlines()[0] subprocess.run(["xdotool", "windowactivate", "--sync", window], check=True) assert document.queryComponent().grabFocus(), "document refused focus" wait_for(lambda: document.getState().contains(pyatspi.STATE_FOCUSED), "document not focused") wait_for(lambda: server.snapshot() and server.snapshot()[-1]["finished"], "initial focus presentation did not settle") check("ctrl+Home", CONTENT[0]) for text in CONTENT[1:]: check("Down", text) for text in reversed(CONTENT[:-1]): check("Up", text) check("h", CONTENT[2]) check("Down", CONTENT[3]) check("shift+h", CONTENT[2]) check("shift+h", CONTENT[0]) check("k", CONTENT[4]) check("ctrl+Home", CONTENT[0]) offset = len(server.snapshot()) key("KP_Add") wait_for(lambda: any(row["text"] == CONTENT[-1] and row["finished"] for row in server.snapshot()[offset:]), "Read All did not finish", timeout=12) rows = server.snapshot()[offset:] assert [row["text"] for row in rows if row["text"] in CONTENT] == CONTENT, rows assert not any("Could not" in row["text"] for row in rows), rows report.append({"key": "KP_Add", "speech": rows}) print(f"PASS: {application} forward/reverse lines, heading-to-arrow, link and Read All", flush=True) finally: for process in reversed(processes): process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5) server.close() (directory / "report.json").write_text(json.dumps(report, indent=2)) (directory / "speech.json").write_text(json.dumps(server.snapshot(), indent=2)) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("application", choices=("firefox", "thunderbird", "both"), nargs="?", default="both") parser.add_argument("--inside", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args() if not os.environ.get("CTHULHU_BINARY"): parser.error("Set CTHULHU_BINARY to a freshly built reader") if args.inside: run_inside(args.application) return applications = ("firefox", "thunderbird") if args.application == "both" else (args.application,) for application in applications: directory = Path(tempfile.mkdtemp(prefix=f"cthulhu-{application}-reading-")) print(f"{application} evidence: {directory}", flush=True) environment = dict(os.environ, XDG_SESSION_TYPE="x11", CTHULHU_MOZILLA_EVIDENCE=str(directory)) for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): environment.pop(name, None) with (directory / "session.log").open("w") as log: run_private_session(["xvfb-run", "-a", "-s", "-screen 0 1280x900x24", "dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), application, "--inside"], env=environment, stderr=log, check=True, timeout=120) if __name__ == "__main__": main()