Files
wine-access/tests/menu_integration.py

366 lines
26 KiB
Python

"""Exercise Wine menus/editors against a private D-Bus recording service.
Requires a graphical session, PyGObject, Wine, and MinGW. No speech is sent to
the running reader. The fixture window closes automatically.
"""
import argparse
import os
from pathlib import Path
import subprocess
import shutil
import sys
import time
from gi.repository import Gio, GLib
from wine_test_environment import add_test_options, run_wine_test, wine_test_directory
parser = argparse.ArgumentParser()
parser.add_argument("--build-dir", default="build")
parser.add_argument("--session", action="store_true")
parser.add_argument("--reader", choices=("cthulhu", "orca"))
parser.add_argument("--extension-endpoint", action="store_true")
parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "listbox", "delphi", "winforms", "controls", "scintilla", "goldwave"), default="menu")
parser.add_argument("--notepadpp-exe", type=Path)
parser.add_argument("--goldwave-exe", type=Path)
parser.add_argument("--32-bit", dest="is32Bit", action="store_true")
parser.add_argument("--late-helper", action="store_true")
parser.add_argument("--restart-helper", action="store_true")
add_test_options(parser)
args = parser.parse_args()
if args.is32Bit and args.fixture not in ("menu", "edit", "winforms", "list", "listbox", "delphi", "controls"):
parser.error("32-bit checks currently require --fixture menu, edit, winforms, list, listbox, delphi or controls")
if (args.late_helper or args.restart_helper) and args.fixture not in ("edit", "winforms", "scintilla"):
parser.error("helper lifecycle checks require --fixture edit, winforms or scintilla")
if args.fixture == "scintilla" and (not args.notepadpp_exe or not args.notepadpp_exe.is_file()):
parser.error("--fixture scintilla requires --notepadpp-exe pointing to a 64-bit Notepad++ executable")
if args.fixture == "goldwave" and (not args.goldwave_exe or not args.goldwave_exe.is_file()):
parser.error("--fixture goldwave requires --goldwave-exe pointing to GoldWave 5.70")
build = Path(args.build_dir).resolve()
if not args.session:
sys.exit(run_wine_test(["dbus-run-session", "--", sys.executable,
__file__, "--session", "--build-dir", str(build),
"--fixture", args.fixture,
*(["--extension-endpoint"] if args.extension_endpoint else []),
*(["--reader", args.reader] if args.reader else []),
*(["--notepadpp-exe", str(args.notepadpp_exe.resolve())] if args.notepadpp_exe else []),
*(["--goldwave-exe", str(args.goldwave_exe.resolve())] if args.goldwave_exe else []),
*(["--32-bit"] if args.is32Bit else []),
*(["--late-helper"] if args.late_helper else []),
*(["--restart-helper"] if args.restart_helper else [])], build, "menu_integration",
keep=args.keep, keepOnFailure=args.keepOnFailure))
busName = "org.gnome.Orca1.Service" if args.reader == "orca" else "org.stormux.Cthulhu1.Service"
interface = busName.rsplit(".", 1)[0] + ".WineAccess" if args.extension_endpoint else busName
object_path = "/" + busName.replace(".", "/") + ("/WineAccess" if args.extension_endpoint else "")
readerArguments = ["--reader", args.reader] if args.reader else []
if args.extension_endpoint:
readerArguments += ["--reader-path", object_path, "--reader-interface", interface]
xml = '<node><interface name="' + interface + '"><method name="PresentAccessibleEvent">'
for index, kind in enumerate(["s"] * 7 + ["i", "i", "s"]):
xml += f'<arg name="arg{index}" type="{kind}" direction="in"/>'
xml += '<arg type="b" direction="out"/></method>'
for method, inputs in (("SpeakText", "sb"), ("CancelSpeech", "")):
xml += '<method name="' + method + '">'
xml += ''.join('<arg type="' + kind + '" direction="in"/>' for kind in inputs)
xml += '<arg type="b" direction="out"/></method>'
xml += '</interface></node>'
events = []
def record(connection, sender, path, iface, method, parameters, invocation):
events.append(parameters.unpack())
invocation.return_value(GLib.Variant("(b)", (True,)))
connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)
connection.register_object(object_path, Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0], record, None, None)
connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "RequestName",
GLib.Variant("(su)", (busName, 0)), None,
Gio.DBusCallFlags.NONE, 1000, None)
with wine_test_directory(prefix="wine-access-menus-") as temporary:
env = os.environ.copy()
env.update(WINEPREFIX=str(Path(temporary) / "prefix"), WINEDEBUG=os.environ.get("WINE_ACCESS_TEST_DEBUG", "-all"),
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
ready = Path(temporary) / "ready"
env["WINE_ACCESS_FIXTURE_READY"] = "Z:" + str(ready).replace("/", "\\")
if args.fixture == "goldwave":
appDir = Path(temporary) / "goldwave"
shutil.copytree(args.goldwave_exe.parent, appDir)
env["WINE_ACCESS_GOLDWAVE_EXE"] = "Z:" + str(appDir / args.goldwave_exe.name).replace("/", "\\")
if args.fixture == "scintilla":
# Copy the application to isolate its configuration as well as its
# document. Never open or edit files in the user's running instance.
appDir = Path(temporary) / "notepadpp"
appDir.mkdir()
for name in (args.notepadpp_exe.name, "langs.model.xml", "stylers.model.xml"):
source = args.notepadpp_exe.parent / name
if source.is_file():
shutil.copy2(source, appDir / name)
(appDir / "doLocalConf.xml").touch()
document = Path(temporary) / "editor.txt"
document.write_bytes(("hello beta\r\nScintilla second line\r\ncafé 😀 tail\r\n" +
"x" * 1100000 + "\r\n" + "x" * 1022 + "😀tail").encode("utf-8"))
env["WINE_ACCESS_NOTEPADPP_EXE"] = "Z:" + str(appDir / args.notepadpp_exe.name).replace("/", "\\")
env["WINE_ACCESS_SCINTILLA_DOCUMENT"] = "Z:" + str(document).replace("/", "\\")
fixture = Path(temporary) / "menu-fixture.exe"
compiler = "i686-w64-mingw32-gcc" if args.is32Bit or args.fixture == "goldwave" else "x86_64-w64-mingw32-gcc"
sourceName = "edit" if args.fixture == "winforms" else args.fixture
if args.fixture == "delphi":
sourceName = "list"
subprocess.run([compiler, "-Wall", "-Wextra", "-mwindows",
*(["-DWINE_ACCESS_WINFORMS_FIXTURE"] if args.fixture == "winforms" else []),
*(["-DWINE_ACCESS_DELPHI_FIXTURE"] if args.fixture == "delphi" else []),
"-o", str(fixture), str(Path(__file__).with_name(sourceName + "_fixture.c")),
"-luser32", "-lcomctl32", "-loleacc", "-lole32", "-ladvapi32"], check=True)
helper = None
app = None
try:
subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45)
if args.late_helper:
app = subprocess.Popen(["wine", str(fixture)], env=env)
deadline = time.monotonic() + 10
while not ready.exists() and app.poll() is None and time.monotonic() < deadline:
time.sleep(0.01)
assert ready.exists(), "editor did not become ready before helper attachment"
helper = subprocess.Popen([str(build / "wine-access.exe"), "--no-controller", *readerArguments], env=env)
# Allow the helper to register its bridge before opening the fixture.
deadline = time.monotonic() + 1
while time.monotonic() < deadline:
while GLib.MainContext.default().iteration(False):
pass
time.sleep(0.01)
assert helper.poll() is None, "helper exited before fixture startup"
if app is None:
app = subprocess.Popen(["wine", str(fixture)], env=env)
restarted = False
deadline = time.monotonic() + 25
while app.poll() is None and time.monotonic() < deadline:
while GLib.MainContext.default().iteration(False):
pass
if args.restart_helper and not restarted and any(e[0].endswith(":text") for e in events):
helper.terminate()
helper.wait(timeout=5)
helper = subprocess.Popen([str(build / "wine-access.exe"), "--no-controller", *readerArguments], env=env)
restarted = True
time.sleep(0.01)
assert app.poll() == 0, "fixture failed or timed out"
assert helper.poll() is None, f"helper exited during the fixture: {helper.returncode}"
if args.restart_helper:
assert restarted, "fixture never triggered a helper restart"
for _ in range(20):
while GLib.MainContext.default().iteration(False):
pass
time.sleep(0.01)
focused = None
for event in events:
if event[1] == "focus":
focused = event[0]
elif event[1] == "text":
assert event[0] == focused, f"text sent without establishing focus: {event!r}"
elif event[1] == "value" and event[3] == "combo box":
assert event[0] == focused, f"combo value sent for an unfocused source: {event!r}; focus={focused}; events={events!r}"
if args.fixture == "goldwave":
for event in events:
print(event[1:9])
menus = [e for e in events if ":menu:" in e[0]]
assert any(e[2] == "File" and e[3] == "submenu" for e in menus), "GoldWave File menu missing"
assert any(e[2].startswith("Open") and e[3] == "menu item" for e in menus), "GoldWave Open command missing"
values = [e[4] for e in events if e[1] == "value" and e[3] == "combo box"]
assert len(values) >= 3 and len(set(values)) >= 2, f"GoldWave combo arrow feedback missing: {values!r}"
print("PASS: actual 32-bit GoldWave menus and file-type combo arrows without tabbing away")
sys.exit(0)
if args.fixture == "controls":
for event in events:
print(event[1:9])
for caption in ("Albums & artists β", "Details", "Default Playlist", "Second Playlist"):
assert any(e[2] == caption and e[3] == "page tab" and e[7] in (1, 2)
and e[8] == 2 for e in events), f"native/ATL tab missing: {caption}"
assert any(e[2] == "All music" and e[3] == "tree item" and "collapsed" in e[5]
for e in events), "collapsed tree selection missing"
assert any(e[2] == "All music" and "expanded" in e[5] for e in events), "tree expansion missing"
assert any(e[2] == "Artist & album β" and e[3] == "tree item" for e in events), "tree navigation missing"
assert any(e[1] == "focus" and e[2] == "Named & native button" and e[3] == "button"
for e in events), "named generic MSAA client hid button role"
for value in ("artist & album", "year && date"):
assert any(e[2] == "View & sort" and e[3] == "combo box" and e[4] == value
for e in events), f"combo label/role/value missing: {value}"
assert any(e[4] == "search & find" for e in events), "edit value changed"
if not args.is32Bit:
assert any(e[2] == "Filter & query" and e[3] == "edit" and e[4] == "search & find"
for e in events), "late native edit label/value missing"
assert any(e[3] == "list" and e[4] == "Rock & roll && blues"
for e in events), "list box lost literal ampersands"
controlEvents = [e for e in events if e[3] in ("page tab", "tree item", "button", "combo box", "edit")]
assert controlEvents and all(e[9] == "Wine & controls fixture" for e in controlEvents), "window title changed"
assert any(e[2] == "" and e[3] == "page tab" and e[7:9] == (0, 0)
for e in events), "empty tabs retained stale caption"
assert any(e[2] == "" and e[3] == "tree" for e in events), "empty tree retained stale item"
assert not any(e[2] == "Tree1" for e in events), "generic tree name leaked"
print("PASS: native/ATL tabs, tree selection/states/return, named button, combo and edit labels, empty controls")
sys.exit(0)
if args.fixture == "delphi":
for event in events:
print(event[1:9])
assert any(e[1] == "focus" and e[2] == "Required components" and
e[3] == "list" and e[4] == "Pending, Runtime & tools β" and
e[5] == "not checked" and e[7:9] == (1, 2) for e in events), "Delphi initial row/label/state missing"
rows = [e for e in events if e[3] == "list" and e[4] == "Installed, Second component"]
assert any(e[1] == "selection" and e[5] == "selected, not checked" for e in rows), "Delphi Down missing"
assert any(e[1] == "selection" and e[5] == "selected, checked" for e in rows), "Space checkbox change missing"
assert any(e[1] == "focus" and e[5] == "selected, checked" for e in rows), "Delphi return lost checkbox"
assert sum(e[1] == "selection" and e[5] == "selected, not checked" for e in rows) >= 2, "Space uncheck missing"
for caption in ("Return to control", "OK"):
assert any(e[1] == "focus" and e[2] == caption and e[3] == "button" for e in events), f"Delphi button missing: {caption}"
assert any(e[3] == "list" and e[4] == "" and e[5] == "" and e[7:9] == (0, 0) for e in events), "empty Delphi list retained state"
assert not any(e[2] == "List1" for e in events), "Delphi generic client leaked"
print("PASS: Delphi list labels, rows, keyboard navigation, checkbox toggles, return, buttons and empty state")
sys.exit(0)
if args.fixture == "listbox":
listEvents = [e for e in events if e[3] == "list"]
for event in listEvents:
print(event[1:9])
assert any(e[1] == "focus" and e[2] == "Tutorial maps" and
e[4] == "First map" and e[7:9] == (1, 3)
for e in listEvents), "initial map or label missing"
selections = [(e[4], e[7], e[8]) for e in listEvents if e[1] == "selection"]
assert selections == [("Second map β", 2, 3), ("Third map", 3, 3),
("Second map β", 2, 3), ("", 0, 0)], \
f"map navigation/empty state missing or duplicated: {selections}"
assert sum(e[1] == "focus" and e[4] == "First map"
for e in listEvents) == 2, "return to map list missing or duplicated"
assert any(e[1] == "focus" and e[2] == "OK" for e in events), "button focus missing"
assert not any("Background-only" in str(e) for e in events), "background map leaked"
assert len({e[0] for e in listEvents}) == 1, "map events changed source identity"
print("PASS: list-box arrows, Unicode, positions, focus return, background exclusion and empty state")
sys.exit(0)
if args.fixture == "list":
listEvents = [e for e in events if e[3] == "list"]
for event in listEvents:
print(event[1:9])
assert any(e[1] == "focus" and e[2] == "Required components" and
e[4] == "Pending, Runtime & tools β" and e[7:9] == (1, 2)
and e[5] == "" for e in listEvents), "unselected initial row or label missing"
assert any(e[1] == "selection" and e[4] == "Installed, Second component" and
e[7:9] == (2, 2) for e in listEvents), "Down lost second row/columns"
assert any(e[1] == "selection" and e[4] == "Pending, Runtime & tools β" and
e[7:9] == (1, 2) for e in listEvents), "Up lost first row"
assert any(e[1] == "focus" and e[4] == "Pending, Runtime & tools β" and
e[5] == "selected" for e in listEvents), "return to list lost selection"
assert any(e[4] == "Ready, Runtime & tools β" for e in listEvents), "value event lost changed row text"
assert any(e[4] == "" and e[7:9] == (0, 0) for e in listEvents), "empty list retained stale row"
assert any(e[2] == "Return to control" for e in events), "button focus missing"
assert not any(e[2] == "List1" for e in events), "generic MSAA client leaked"
print("PASS: native list focus, columns, Unicode, navigation, return, and empty list")
sys.exit(0)
if args.fixture == "notepad":
dialogEvents = [e for e in events if e[2] in ("Yes", "No", "Cancel")]
for event in dialogEvents:
print("Save dialog:", event[:7])
assert [e[2] for e in dialogEvents if e[1] == "focus"] == ["Yes", "No", "Cancel", "No"], "save dialog focus order changed"
text_events = [e for e in events if e[0].endswith(":text")]
spoken = [e[2] for e in text_events]
for expected in ("e", "l", "beta", "Notepad second line", "N selected",
"N unselected", "N deleted"):
assert expected in spoken, f"missing Notepad feedback: {expected!r}; {text_events!r}"
assert spoken.count("l") == 2, "Notepad lost repeated characters"
typed = [e[2] for e in text_events if e[1] == "text" and "kind=8" in e[5]]
assert typed == ["z", "z", "space", "new line"], f"Notepad typing echo missing or incorrect: {typed!r}"
assert any(e[3] == "edit" and e[4] == "otepad second line" for e in text_events), "Notepad menu return missing"
print("PASS: actual Wine Notepad typing echo, keyboard characters, words, lines, selection, deletion, and menu return")
sys.exit(0)
if args.fixture == "scintilla":
textEvents = [e for e in events if e[0].endswith(":text")]
for event in textEvents:
print(event[1:7])
spoken = [e[2] for e in textEvents if e[1] == "text"]
for expected in ("e", "l", "beta", "hello ", "Scintilla second line", "hello beta", "S selected",
"c selected", "c unselected", "S unselected", "S deleted", "c",
"Scintilla selected", "Scintilla deleted", "café 😀 tail", "é", "😀",
"😀 selected", "😀 unselected", "😀 deleted"):
assert expected in spoken, f"missing Scintilla feedback: {expected!r}; {textEvents!r}"
assert spoken.count("l") == 2, "Scintilla lost repeated characters"
assert any(e[1] == "focus" and e[4] == "intilla second line" for e in textEvents), "menu return lost Scintilla caret line"
assert any(e[3] == "read only edit" for e in textEvents), "Scintilla read-only role missing"
assert any(e[2] == "c" and "kind=7" in e[5] for e in textEvents), "Scintilla Backspace suffix wrong"
assert any(e[2] == "😀" and "kind=7" in e[5] for e in textEvents), "multibyte Backspace missing"
assert any(e[2] == "x" * 1022 for e in textEvents), "Scintilla truncation split a surrogate pair"
assert any(e[2] == "x" * 1023 for e in textEvents), "large Scintilla document line missing"
focusEvents = [e for e in textEvents if e[1] == "focus"]
assert [e[4] for e in focusEvents[-2:]] == ["hello beta", "hello beta"], "single-byte Scintilla charset reading failed"
assert all("offset_unit=byte" in e[5] for e in textEvents if e[1] == "text"), "Scintilla offsets mislabeled"
print("PASS: actual Notepad++ arrows, words, Unicode, selection, deletion, undo, menu return and read-only document")
sys.exit(0)
if args.fixture in ("edit", "winforms"):
text_events = [e for e in events if e[0].endswith(":text")]
for event in text_events:
print(event[2:7])
spoken = [e[2] for e in text_events]
for expected in ("l", "beta", "second line", "s selected", "e selected",
"e unselected", "s unselected", "s deleted", "e",
"alpha selected", "alpha deleted", "😀"):
assert expected in spoken, f"missing editor feedback: {expected!r}"
assert any(e[3] == "edit" and e[4] == "alpha beta" for e in text_events), "initial line missing"
assert any(e[3] == "read only edit" for e in text_events), "read-only focus missing"
assert any(e[6] == "password" for e in text_events), "password role missing"
assert not any("private-password-fixture" in str(e) for e in events), "password leaked"
assert "😀 deleted" not in spoken, "read-only control reported deletion"
assert "hello deleted" not in spoken, "replacement misreported as deletion"
assert sum(e[1] == "text" and e[2] == "l" for e in text_events) == 3, "rapid repeated letters lost"
assert any(e[3] == "edit" and e[4] == "menu return line" for e in text_events), "menu return lost caret line"
assert any(e[1] == "text" and e[2] == "e" and "kind=7" in e[5] for e in text_events), "Backspace did not report only the removed character"
assert "e deleted" not in spoken, "Backspace retained the deletion suffix"
assert sum(e[2] == "new line" and "kind=7" in e[5] for e in text_events) == 1, "CRLF Backspace feedback missing"
assert sum(e[2] == "new line deleted" and "kind=4" in e[5] for e in text_events) == 1, "CRLF Delete feedback missing"
assert any(e[4] == "x" * 1022 for e in text_events), "truncated surrogate discarded line"
assert "d" in spoken and "undo restored" in spoken, "Undo left stale text snapshot"
for line in ("readonly first", "readonly second"):
assert any(e[1] == "text" and e[2] == line for e in text_events), f"read-only arrow feedback missing: {line}"
assert "r deleted" not in spoken, "read-only document reported an unsuccessful deletion"
typed = [e[2] for e in text_events if e[1] == "text" and "kind=8" in e[5]]
assert typed == ["z", "z", "space", "\u00e9", "\U0001f600", "new line", "tab", "q", "L", "\U0001f600"], f"typing echo missing or incorrect: {typed!r}"
assert not any(e[1] == "text" and e[2] in ("!", "@", "~", "%") for e in events), "rejected, password or background typing leaked"
history_focus = next(e[0] for e in text_events
if e[1] == "focus" and e[4] == "blank")
history_actions = [e[2] for e in text_events
if e[0] == history_focus and e[1] == "text"]
assert history_actions == ["history north", "history south", "history north", "blank",
"history north", "history south", "history north", "h", "blank", "blank", "n"], \
f"command history missing, reordered, duplicated or leaked: {history_actions!r}"
if args.fixture == "winforms":
assert any(e[2] == "Email" and e[3] == "edit" and e[4] == "alpha beta"
for e in text_events), "managed email label/focus missing"
assert any(e[2] == "Password" and e[3] == "edit" and e[6] == "password"
and e[4] == "" for e in text_events), "managed password label/protection missing"
assert not any(e[2] == "Unrelated" for e in text_events), "unrelated label associated with field"
print("PASS: editor focus, typing echo, characters, words, lines, selection, deletion, Unicode, passwords, and read-only controls")
sys.exit(0)
menus = [event for event in events if ":menu:" in event[0]]
for event in menus:
print(event[2:7])
assert any(e[2] == "File" and e[3] == "submenu" for e in menus), "File menu missing"
assert any(e[2] == "Open" and e[4] == "Ctrl+O" for e in menus), "Open or shortcut missing"
assert any(e[2] == "Word wrap" and e[6] == "checked" for e in menus), "checked item missing"
assert any(e[2] == "Edit" and e[3] == "submenu" for e in menus), "Edit menu missing"
assert any(e[2] == "Cut" and e[4] == "Ctrl+X" for e in menus), "Edit command missing"
assert any(e[2] == "Context action" for e in menus), "context menu missing"
assert any(e[2] == "Favorites" and e[3] == "submenu" for e in menus), "nested submenu missing"
assert any(e[2] == "Nested action" for e in menus), "nested command missing"
saved = [e for e in menus if e[2] == "Pinned & offline"]
assert len(saved) == 2, f"duplicate suppression/menu reset failed: {len(saved)}"
assert all(e[4] == "Ctrl+P" and e[6] == "checked, unavailable" for e in saved)
assert any(e[2] == "Return to control" and e[3] == "button" for e in events), "control focus missing"
assert any(e[2] == "Application-owned accessibility" and e[3] == "client"
for e in events), "marshaled application MSAA object unavailable"
subprocess.run([str(build / "wine-access.exe"),
"--no-dialog-reader", "--no-controller"],
env=env, check=True, timeout=5)
print("PASS: native and nested menus, context menus, shortcuts, states, "
"duplicate suppression, reopening, marshaled MSAA, control focus, and disable options")
finally:
# Only this test's newly created Wine prefix is affected.
subprocess.run(["wineserver", "-k"], env=env, timeout=10, check=False)
for child in (app, helper):
if child is not None:
child.wait(timeout=10)