234 lines
12 KiB
Python
234 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Chromium AT-SPI proof on a private X server, bus, and browser profile."""
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
from native_test_session import run_private_session
|
|
|
|
|
|
BROWSER_PROOFS = {
|
|
"find": ("./internal/app", "^TestNativeBrowserFind$", 35, 40, "browser-find.html"),
|
|
"actions": ("./internal/app", "^TestNativeBrowserActionOwnership$", 35, 40, "browser-actions.html"),
|
|
"link-sounds": ("./internal/app", "^TestNativeBrowserCaretLinkSounds$", 40, 45, "browser-link-sounds.html"),
|
|
"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"),
|
|
"live-atomic": ("./internal/atspi", "^TestNativeBrowserAtomicLiveRegion$", 30, 35, "browser-live.html"),
|
|
"replaced-feed": ("./internal/atspi", "^TestNativeBrowserMissingFeedRefreshesWholeBranch$", 30, 35, "browser-replaced-feed.html"),
|
|
"nested-document": ("./internal/app", "^TestNativeBrowserNestedDocumentHeadings$", 40, 45),
|
|
"reveal": ("./internal/app", "^TestNativeBrowserRevealedHeadings$", 60, 65),
|
|
"flat-chrome": ("./internal/review", "^TestNativeBrowserChromeFlatReview$", 35, 40),
|
|
"generatedlinks": ("./internal/app", "^TestNativeBrowserGeneratedLinkNavigation$", 30, 35),
|
|
"replacement": ("./internal/app", "^TestNativeBrowserInlineReplacementNavigation$", 30, 35),
|
|
"tooltips": ("./internal/app", "^TestNativeBrowserTooltipNavigation$", 30, 35),
|
|
"menu": ("./internal/app", "^TestNativeBrowserMenuControls$", 30, 35),
|
|
"lists": ("./internal/app", "^TestNativeBrowserLists$", 35, 40, "browser-lists.html"),
|
|
"pointer": ("./internal/atspi", "^TestNativePointerInput$", 15, 20),
|
|
"progress": ("./internal/app", "^TestNativeBrowserProgress$", 30, 35, "browser-progress.html"),
|
|
"fragments": ("./internal/review", "^TestNativeBrowserFragmentLines$", 30, 35, "browser-fragments.html"),
|
|
"positions": ("./internal/atspi", "^TestNativeBrowserSetPositions$", 25, 30, "browser-positions.html"),
|
|
"tabs": ("./internal/app", "^TestNativeBrowserTabs$", 30, 35),
|
|
"namedlinks": ("./internal/app", "^TestNativeBrowserNamedLinks$", 25, 30, "browser-named-links.html"),
|
|
"linkedheadings": ("./internal/app", "^TestNativeLinkedHeadings$", 45, 50, "browser-linked-headings.html"),
|
|
"catalog": ("./internal/atspi", "^TestNativeBrowserCatalogCapture$", 30, 35, "browser-catalog.html"),
|
|
"lines": ("./internal/atspi", "^TestNativeBrowserLogicalLines$", 25, 30, "browser-lines.html"),
|
|
"tables": ("./internal/app", "^TestNativeBrowserTables$", 35, 40),
|
|
"dynamic": ("./internal/app", "^TestNativeBrowserDynamicNavigation$", 40, 45),
|
|
"ticking": ("./internal/app", "^TestNativeBrowserTickingNavigation$", 40, 45),
|
|
"browser": ("./internal/atspi", "^TestNativeBrowser$", 45, 50),
|
|
"live": ("./internal/atspi", "^TestNativeBrowserLiveRegion$", 30, 35),
|
|
"document": ("./internal/app", "^TestNativeBrowserDocumentCommands$", 45, 50),
|
|
"editing": ("./internal/app", "^TestNativeBrowserEditing$", 45, 50),
|
|
"editor": ("./internal/app", "^TestNativeBrowserEditorTyping$", 30, 35, "browser-editor.html"),
|
|
"selection": ("./internal/app", "^TestNativeBrowserSelection$", 35, 40),
|
|
"modes": ("./internal/app", "^TestNativeBrowserModes$", 45, 50),
|
|
"keys": ("./internal/platform/xorg", "^TestNativeBrowseKeyTransitions$", 20, 25),
|
|
}
|
|
|
|
|
|
def selected_proofs():
|
|
selected = {name.strip() for name in os.environ.get(
|
|
"CTHULHU_NATIVE_BROWSER_TESTS", ",".join(BROWSER_PROOFS)).split(",") if name.strip()}
|
|
unknown = selected - BROWSER_PROOFS.keys()
|
|
if not selected or unknown:
|
|
raise ValueError("Select browser proofs from: " + ",".join(BROWSER_PROOFS) +
|
|
("; unknown: " + ",".join(sorted(unknown)) if unknown else ""))
|
|
return selected
|
|
|
|
|
|
def stop_atspi_bus(processes):
|
|
for process in reversed(processes):
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
|
|
|
|
def wait_for_bus_name(process, busArgs, name):
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
if process.poll() is not None:
|
|
raise RuntimeError(name + " daemon exited before the probe")
|
|
# Query the bus itself: contacting the service can activate another
|
|
# daemon (or a user-manager unit) before our child owns its name.
|
|
result = subprocess.run(
|
|
["gdbus", "call", *busArgs, "--dest", "org.freedesktop.DBus",
|
|
"--object-path", "/org/freedesktop/DBus", "--method",
|
|
"org.freedesktop.DBus.NameHasOwner", name],
|
|
capture_output=True, text=True, timeout=2)
|
|
if result.returncode == 0 and result.stdout.strip() == "(true,)":
|
|
return
|
|
time.sleep(0.1)
|
|
raise RuntimeError(name + " did not become ready")
|
|
|
|
|
|
def start_atspi_bus():
|
|
# D-Bus activation of org.a11y.Bus and of the AT-SPI registry may
|
|
# delegate to systemd user units, which cannot work in this private
|
|
# session (disposable runtime dir, no user manager tied to the private
|
|
# bus) and must not be required by the test suite on non-systemd systems
|
|
# either. Launch both daemons directly; later activation requests then
|
|
# find the names already owned.
|
|
def locate(name, candidates):
|
|
found = shutil.which(name)
|
|
if found:
|
|
return found
|
|
for candidate in candidates:
|
|
if os.access(candidate, os.X_OK):
|
|
return candidate
|
|
raise RuntimeError(name + " not found")
|
|
|
|
launcher = locate("at-spi-bus-launcher",
|
|
("/usr/lib/at-spi-bus-launcher", "/usr/libexec/at-spi-bus-launcher"))
|
|
registry = locate("at-spi2-registryd",
|
|
("/usr/lib/at-spi2-registryd", "/usr/libexec/at-spi2-registryd"))
|
|
processes = []
|
|
try:
|
|
process = subprocess.Popen([launcher, "--launch-immediately"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
processes.append(process)
|
|
wait_for_bus_name(process, ["--session"], "org.a11y.Bus")
|
|
result = subprocess.run(
|
|
["gdbus", "call", "--session", "--dest", "org.a11y.Bus",
|
|
"--object-path", "/org/a11y/bus", "--method", "org.a11y.Bus.GetAddress"],
|
|
capture_output=True, text=True, timeout=2, check=True)
|
|
address = result.stdout.split("'", 2)[1]
|
|
registryEnv = os.environ.copy()
|
|
registryEnv["AT_SPI_BUS_ADDRESS"] = address
|
|
registryd = subprocess.Popen([registry], env=registryEnv,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
processes.append(registryd)
|
|
wait_for_bus_name(registryd, ["--address", address], "org.a11y.atspi.Registry")
|
|
return processes
|
|
except BaseException:
|
|
stop_atspi_bus(processes)
|
|
raise
|
|
|
|
|
|
def run_inside():
|
|
browser = os.environ.get("CTHULHU_BROWSER") or shutil.which("brave")
|
|
if not browser:
|
|
raise RuntimeError("Set CTHULHU_BROWSER to a Chromium-family executable")
|
|
selected = selected_proofs()
|
|
atspi = start_atspi_bus()
|
|
try:
|
|
for name, proof in BROWSER_PROOFS.items():
|
|
if name in selected:
|
|
print(f"Running browser proof: {name}", flush=True)
|
|
# F7 toggles a profile-wide setting. Each proof must start from
|
|
# the same browser state whether run alone or after editing.
|
|
run_browser_proof(browser, proof)
|
|
finally:
|
|
stop_atspi_bus(atspi)
|
|
|
|
|
|
def run_browser_proof(browser, proof):
|
|
with tempfile.TemporaryDirectory(prefix="cthulhu-browser-profile-") as profile:
|
|
with tempfile.TemporaryFile() as log:
|
|
process = subprocess.Popen([
|
|
browser, "--user-data-dir=" + profile, "--no-first-run",
|
|
"--no-default-browser-check", "--disable-background-networking",
|
|
"--disable-component-update", "--disable-sync", "--password-store=basic",
|
|
"--force-renderer-accessibility", "--ozone-platform=x11",
|
|
"--new-window", Path(__file__).with_name(proof[4] if len(proof) > 4 else "browser.html").resolve().as_uri(),
|
|
], stdout=log, stderr=log)
|
|
try:
|
|
deadline = time.monotonic() + 20
|
|
while time.monotonic() < deadline:
|
|
if process.poll() is not None:
|
|
raise RuntimeError("Browser exited before the probe")
|
|
window_name = "Cthulhu Linked Headings Probe" if proof[1] == "^TestNativeLinkedHeadings$" else "Cthulhu Browser Probe"
|
|
result = subprocess.run(
|
|
["xdotool", "search", "--onlyvisible", "--name", window_name],
|
|
capture_output=True, text=True)
|
|
if result.stdout.strip():
|
|
window = result.stdout.splitlines()[0]
|
|
subprocess.run(["xdotool", "windowfocus", window], check=True)
|
|
break
|
|
time.sleep(0.1)
|
|
else:
|
|
raise RuntimeError("Browser fixture window missing")
|
|
environment = dict(os.environ, CTHULHU_NATIVE_BROWSER="1")
|
|
if proof[1] == "^TestNativeLinkedHeadings$":
|
|
environment["CTHULHU_NATIVE_LINKED_HEADINGS"] = "1"
|
|
environment.setdefault("GOCACHE", "/tmp/cthulhu-cache")
|
|
package, expression, testTimeout, runTimeout = proof[:4]
|
|
subprocess.run(["go", "test", package, "-run", expression,
|
|
"-count=1", "-v", f"-timeout={testTimeout}s"],
|
|
env=environment, check=True, timeout=runTimeout,
|
|
cwd=Path(__file__).resolve().parents[1])
|
|
finally:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
|
|
|
|
def main():
|
|
selected = selected_proofs()
|
|
if len(sys.argv) == 2 and sys.argv[1] == "--inside":
|
|
run_inside()
|
|
return
|
|
with tempfile.TemporaryDirectory(prefix="cthulhu-browser-"):
|
|
read_fd, write_fd = os.pipe()
|
|
server = subprocess.Popen(
|
|
["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "800x600x24", "-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()
|
|
if not display:
|
|
raise RuntimeError("Xvfb failed to start")
|
|
environment = dict(os.environ, DISPLAY=":" + display, XDG_SESSION_TYPE="x11")
|
|
environment.pop("DBUS_SESSION_BUS_ADDRESS", None)
|
|
environment.pop("AT_SPI_BUS_ADDRESS", None)
|
|
environment.pop("WAYLAND_DISPLAY", None)
|
|
run_private_session(
|
|
["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"],
|
|
env=environment,
|
|
check=True,
|
|
# Budget each isolated browser startup, bounded Go run and
|
|
# cleanup instead of imposing one 120-second suite limit.
|
|
timeout=15 + sum(30 + BROWSER_PROOFS[name][3] for name in selected),
|
|
)
|
|
finally:
|
|
server.terminate()
|
|
server.wait(timeout=3)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|