Bring in goodies from testing.
# Conflicts: # docs/testing/wine-proton.md
This commit is contained in:
commit
3026bc55c2
54 files changed
+2615
-918
No files matched your search
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user