Bring in goodies from testing.

# Conflicts:
#	docs/testing/wine-proton.md
This commit is contained in:
hjozwiak committed 2026-11-02 11:30:32 -05:00
commit 3026bc55c2
54 files changed
+2615 -918

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<meta charset="utf-8">
<title>Cthulhu Browser Probe</title>
<ol>
<li><article aria-labelledby="author message timestamp">
<h3><button id="author">Sample author</button> <time id="timestamp">Today at noon</time></h3>
<div id="message">A message with readable contents.</div>
<button id="reply">Reply</button><button>Forward</button>
</article></li>
</ol>
<script>document.addEventListener('keydown', e => { if (e.key === 'F9') document.getElementById('reply').focus(); });</script>
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Storm Dragon
# Authors: Storm Dragon, Angus Kola
# SPDX-License-Identifier: GPL-3.0-or-later
"""Real GTK socket/plug embedding on separate providers for private tests."""
import ctypes
import os
import sys
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Atk", "1.0")
gi.require_version("GdkX11", "3.0")
from gi.repository import Atk, GdkX11, GLib, Gtk
mode = sys.argv[1]
processName = "mate-panel" if mode == "panel" else "applet" if mode == "standalone" else mode
GLib.set_prgname(processName)
if ctypes.CDLL(None).prctl(15, processName.encode(), 0, 0, 0) != 0:
raise RuntimeError("cannot set fixture process name")
window = Gtk.Plug.new(int(sys.argv[2])) if mode == "applet" else Gtk.Window()
if mode == "applet":
window.connect("delete-event", lambda *_args: True)
if mode == "standalone":
Atk.get_root().set_name("mate-panel")
window.set_title("Embedded applet fixture " + mode)
window.set_default_size(400, 120)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
window.add(box)
socket = None
if mode in ("applet", "standalone"):
box.get_accessible().set_role(Atk.Role.PANEL)
box.get_accessible().set_name("Network applet")
first = Gtk.Button(label="Network")
first.get_accessible().set_role(Atk.Role.ICON)
first.get_accessible().set_name("")
second = Gtk.Button(label="Applet settings")
box.add(first)
box.add(second)
elif mode == "editor":
first = Gtk.Entry()
first.get_accessible().set_name("Editor entry")
box.add(first)
else:
first = Gtk.Button(label="Before applet")
second = Gtk.Button(label="After applet")
socket = Gtk.Socket()
box.add(first)
box.add(socket)
box.add(second)
barrier = Gtk.Label()
barrier.get_accessible().set_role(Atk.Role.ALERT)
box.add(barrier)
window.show_all()
pendingInput = ""
def receive_command(_source, _condition):
global pendingInput
content = os.read(sys.stdin.fileno(), 4096)
if not content:
Gtk.main_quit()
return False
pendingInput += content.decode()
while "\n" in pendingInput:
command, pendingInput = pendingInput.split("\n", 1)
if command == "first":
first.grab_focus()
elif command == "second":
second.grab_focus()
elif command == "stale":
first.get_accessible().notify_state_change(Atk.StateType.FOCUSED, True)
barrier.set_text("")
barrier.set_text("Applet barrier")
elif command == "detach":
socket.destroy()
barrier.set_text("Detached host barrier")
elif command.startswith("edit "):
first.set_text(command.removeprefix("edit "))
first.grab_focus()
elif command == "quit":
Gtk.main_quit()
return False
return True
GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, receive_command)
GLib.idle_add(lambda: print(
"ready", GdkX11.X11Window.get_xid(window.get_window()),
socket.get_id() if socket is not None else 0, flush=True) or False)
Gtk.main()
+3
View File
@@ -24,6 +24,9 @@ if providerName == "marco":
raise RuntimeError("cannot set fixture process name")
window = Gtk.Window(title="Switcher fixture " + providerName)
# Label updates must not resize the foreground window while the test compares
# its full X11 snapshot to check that the switcher has not taken ownership.
window.set_default_size(400, 100)
if providerName == "marco":
window.set_accept_focus(False)
window.set_focus_on_map(False)
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Storm Dragon
# Authors: Storm Dragon, Angus Kola
# SPDX-License-Identifier: GPL-3.0-or-later
"""Exercise real Audacious GUIs without the desktop player's session or files."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import wave
sys.dont_write_bytecode = True
from native_test_session import run_private_session
from native_x11 import wait_for
def run_inside():
root = Path(__file__).resolve().parents[1]
temporary = Path(os.environ['XDG_CONFIG_HOME'])
configPath = temporary / 'i3.conf'
configPath.write_text('font pango:monospace 10\nfocus_follows_mouse no\nipc-socket ' +
str(temporary / 'i3.sock') + '\n', encoding='utf-8')
manager = subprocess.Popen(['i3', '-c', str(configPath)], stdout=subprocess.DEVNULL)
process = None
try:
wait_for(lambda: subprocess.run(['i3-msg', '-t', 'get_version'],
capture_output=True).returncode == 0, 'private i3 missing')
tracks = []
trackDirectory = temporary / 'Example Artist' / 'Example Album'
trackDirectory.mkdir(parents=True)
for name in ('Alpha', 'Beta', 'Gamma'):
path = trackDirectory / (name + '.wav')
with wave.open(str(path), 'wb') as track:
track.setparams((1, 2, 8000, 0, 'NONE', 'not compressed'))
track.writeframes(b'\0\0' * 8000)
tracks.append(str(path))
process = subprocess.Popen(['audacious', '-2', os.environ['CTHULHU_NATIVE_AUDACIOUS'], '-e', *tracks])
def find_window():
result = subprocess.run(['xdotool', 'search', '--onlyvisible', '--name', '^Audacious \\(2\\)$'],
capture_output=True, text=True)
return result.stdout.strip().splitlines()[0] if result.stdout.strip() else None
wait_for(find_window, 'Audacious window missing')
import pyatspi
def find_playlist(node):
if node.getRoleName() in ('tree', 'table'):
table = node.queryTable()
if table.nRows == 3:
return table
for child in node:
result = find_playlist(child)
if result is not None:
return result
return None
playlist = wait_for(lambda: find_playlist(pyatspi.Registry.getDesktop(0)),
'Audacious playlist missing')
assert playlist.addRowSelection(0), 'could not select first fixture row'
if os.environ['CTHULHU_NATIVE_AUDACIOUS'] == '-G':
assert playlist.getAccessibleAt(0, 0).queryComponent().grabFocus(), 'could not focus first fixture cell'
# Selecting through AT-SPI does not initialize Audacious's GTK
# keyboard cursor. The first Down establishes its first row.
subprocess.run(['xdotool', 'key', '--clearmodifiers', 'Down'], check=True)
wait_for(lambda: playlist.getSelectedRows() == [0], 'GTK cursor did not select first row')
subprocess.run(['go', 'test', './internal/app', '-run', '^TestNativeAudacious$',
'-count=1', '-v', '-timeout=90s'], cwd=root, check=True, timeout=120)
finally:
if process is not None:
process.terminate()
process.wait(timeout=5)
manager.terminate()
manager.wait(timeout=5)
if __name__ == '__main__':
if '--diagnose' in sys.argv:
import pyatspi
def describe(node, depth=0):
if depth > 8:
return
if node.getRoleName() in ('tree', 'table', 'frame', 'table cell'):
print(node.getRoleName(), repr(node.name), node.getState().getStates(), flush=True)
if node.getRoleName() in ('tree', 'table'):
table = node.queryTable()
print('ROWS', table.getSelectedRows(), flush=True)
for row in range(table.nRows):
cell = table.getAccessibleAt(row, 0)
print('CELL', repr(cell.name), cell.getState().getStates(), flush=True)
for child in node:
describe(child, depth+1)
for app in pyatspi.Registry.getDesktop(0):
if app.name.lower() == 'audacious':
describe(app)
elif '--inside' in sys.argv:
run_inside()
else:
moduleCache = subprocess.check_output(['go', 'env', 'GOMODCACHE'], text=True).strip()
for mode in ('-Q', '-G'):
with tempfile.TemporaryDirectory(prefix='cthulhu-audacious-') as temporary:
environment = dict(os.environ, XDG_CONFIG_HOME=temporary,
XDG_CACHE_HOME=temporary, XDG_DATA_HOME=temporary, XDG_STATE_HOME=temporary,
GOMODCACHE=moduleCache, GOCACHE='/tmp/cthulhu-go-cache',
GOPROXY='off', QT_QPA_PLATFORM='xcb',
QT_LINUX_ACCESSIBILITY_ALWAYS_ON='1', NO_AT_BRIDGE='0',
CTHULHU_NATIVE_AUDACIOUS=mode)
environment['I3SOCK'] = str(Path(temporary) / 'i3.sock')
run_private_session(['xvfb-run', '-a', 'dbus-run-session', '--',
sys.executable, __file__, '--inside'], env=environment, check=True)
+1
View File
@@ -12,6 +12,7 @@ from native_test_session import run_private_session
BROWSER_PROOFS = {
"article": ("./internal/atspi", "^TestNativeBrowserArticleContext$", 30, 35, "browser-article.html"),
"emoji": ("./internal/app", "^TestNativeBrowserEmoji$", 35, 40, "browser-emoji.html"),
"structural-focus": ("./internal/app", "^TestNativeBrowserStructuralFocus$", 45, 50),
"disabled-fields": ("./internal/app", "^TestNativeBrowserDisabledFieldsRemainReadable$", 40, 70, "browser-disabled-fields.html"),
+2
View File
@@ -58,6 +58,8 @@ def fixture(version):
option = menu.addAction('Optional feature')
option.setCheckable(True)
ordinary = menu.addAction('Ordinary action')
nestedMenu = menu.addMenu('Nested actions')
nestedMenu.addAction('Nested command')
contextMenu = QtWidgets.QMenu(window)
contextOption = contextMenu.addAction('Context option')
contextOption.setCheckable(True)
+115
View File
@@ -0,0 +1,115 @@
# Copyright (c) 2026 Storm Dragon
# Authors: Storm Dragon, Angus Kola
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tentacle management action routing with fixture responses, no downloads."""
import select
from native_x11 import wait_for
def check_page(pyatspi, process, window, nodes, named, walk, focus, key,
request, send, initial, application):
tab = next(node for node in nodes
if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_PAGE_TAB)
assert tab.parent.querySelection().selectChild(tab.getIndexInParent())
origin = "https://example.org/tentacles"
repository = {"URL": origin, "ID": "example.source", "Name": "Example",
"Tentacles": [{"ID": "echo", "Name": "Example package", "Version": "1.0",
"Capabilities": ["presentation"], "Releases": [{}]}]}
snapshot = {"Entries": [], "Repositories": [repository]}
table = next(node for node in walk(window)
if node.name == "Tentacles" and node.getRole() == pyatspi.ROLE_TABLE)
def press(name):
button = named(name)
wait_for(lambda: button.getState().contains(pyatspi.STATE_SENSITIVE), name + " disabled")
focus(button)
key("space")
status_node = named(initial["Message"])
def accept(action, package_id="", url=""):
value = request()["Tentacle"]
assert value == {"Action": action, "ID": package_id, "URL": url, "Entry": None}, value
message = "Accepted " + action
if action == "settings":
# Successful settings replies intentionally leave the status alone.
# Observe a changed snapshot cell as the response barrier instead.
snapshot["Entries"][0]["Message"] = message
send(dict(initial, Tentacles=snapshot, Message=message))
if action == "settings":
wait_for(lambda: any(node.name == message for node in walk(table)),
"settings response was not rendered")
else:
wait_for(lambda: status_node.name == message, action + " response missing")
wait_for(lambda: named("Refresh status").getState().contains(pyatspi.STATE_SENSITIVE),
action + " left preferences busy")
url_entry = next(node for node in walk(window)
if node.name == "Repository URL" and
node.getState().contains(pyatspi.STATE_EDITABLE))
url_entry.queryEditableText().setTextContents(origin)
press("Add repository")
accept("repository-add", url=origin)
wait_for(lambda: table.queryTable().nRows == 1, "catalog row missing")
focus(table)
key("Home")
def confirmation():
for app in pyatspi.Registry.getDesktop(0):
for node in app:
if node and node.getRole() in (pyatspi.ROLE_DIALOG, pyatspi.ROLE_ALERT):
if any("Only install packages" in child.name for child in walk(node)):
return node
return None
def answer(label):
dialog = wait_for(confirmation, "install confirmation missing")
message = " ".join(node.name for node in walk(dialog))
assert "Example package version 1.0" in message, message
assert "send speech and braille messages" in message, message
button = next(node for node in walk(dialog)
if node.name == label and node.getRole() == pyatspi.ROLE_PUSH_BUTTON)
focus(button)
key("space")
wait_for(lambda: not confirmation(), "confirmation did not close")
press("Install")
answer("Cancel")
assert not select.select([process.stdout], [], [], 0.2)[0], "Cancel submitted installation"
press("Install")
answer("Install")
entry = {"ID": "installed-echo", "LocalID": "echo", "Name": "Example package",
"Version": "1.0", "Repository": origin, "RepositoryID": "example.source",
"Presentation": True, "Enabled": False}
status = {"Entry": entry, "State": "disabled"}
snapshot["Entries"] = [status]
accept("install", "echo", origin)
focus(table)
key("Home", "space")
entry["Enabled"], status["State"] = True, "active"
accept("enable", entry["ID"])
press("Retry")
accept("retry", entry["ID"])
press("Settings")
accept("settings", entry["ID"])
focus(table)
key("space")
entry["Enabled"], status["State"] = False, "disabled"
accept("disable", entry["ID"])
press("Remove tentacle")
snapshot["Entries"] = []
accept("remove", entry["ID"])
press("Refresh status")
accept("list")
repositories = named("Tentacle repositories")
assert repositories.querySelection().selectChild(1), "repository tab missing"
press("Refresh repository")
accept("repository-refresh", url=origin)
press("Remove repository")
snapshot["Repositories"] = []
accept("repository-remove", url=origin)
wait_for(lambda: table.queryTable().nRows == 0, "removed repository left rows")
press("Cancel")
assert process.wait(timeout=5) == 0
print("PASS: repository actions, install confirmation/Cancel, enable/disable, retry, settings, remove, refresh")