Source code

Revision control

Copy as Markdown

Other Tools

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
"""
External-observer helper for the bug 2028944 regression test.
Launches a Firefox process and, from this (separate) process, verifies that
the accessibility (AX) API on the launched process is queryable as soon as
`NSWorkspaceDidLaunchApplicationNotification` fires. This mirrors what an
assistive technology like VoiceControl does at app launch time.
Exit codes:
0 - PASS: notification observed, AX queries succeeded.
2 - FAIL: notification did not arrive for the launched pid before timeout.
3 - FAIL: AX query returned a non-zero error.
77 - SKIP: PyObjC not available in this environment.
Based on a debug script by Eitan Isaacson (:eeejay) in bug 2028944 comment 3.
"""
import os
import subprocess
import sys
import tempfile
import threading
try:
from AppKit import NSWorkspace, NSWorkspaceDidLaunchApplicationNotification
from ApplicationServices import (
AXUIElementCopyAttributeValue,
AXUIElementCreateApplication,
)
from Foundation import NSObject
from Quartz import CFRunLoopGetCurrent, CFRunLoopRun, CFRunLoopStop
except ImportError as exc:
print(f"SKIP PyObjC unavailable: {exc}", flush=True)
sys.exit(77)
RUNLOOP_TIMEOUT_SECONDS = 20
def main(binary_path):
result = {
"got_launch": False,
"ax_ok": False,
"title_err": None,
"role_err": None,
"pid": None,
}
profile_dir = tempfile.mkdtemp(prefix="bug2028944-")
# Launch the subject Firefox with a clean environment so we don't drag
# in test-harness variables (MOZ_MARIONETTE, etc.) that would change
# its behavior. MOZ_LAUNCHED_CHILD=1 selects the relaunch codepath in
# nsAppRunner.cpp -- the same codepath that used to leave NSApp in a
# pre-init state when the workspace launch notification fired.
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": os.environ.get("HOME", profile_dir),
"MOZ_LAUNCHED_CHILD": "1",
"MOZ_DISABLE_SAFE_MODE_KEY": "1",
}
class Observer(NSObject):
def gotLaunch_(self, notification):
info = notification.userInfo()
pid = info.get("NSApplicationProcessIdentifier", 0)
if pid != child.pid:
return
result["got_launch"] = True
result["pid"] = pid
acc = AXUIElementCreateApplication(pid)
err_t, _ = AXUIElementCopyAttributeValue(acc, "AXTitle", None)
err_r, _ = AXUIElementCopyAttributeValue(acc, "AXRole", None)
result["title_err"] = int(err_t)
result["role_err"] = int(err_r)
result["ax_ok"] = err_t == 0 and err_r == 0
CFRunLoopStop(CFRunLoopGetCurrent())
observer = Observer.new()
NSWorkspace.sharedWorkspace().notificationCenter().addObserver_selector_name_object_( # noqa: E501
observer,
"gotLaunch:",
NSWorkspaceDidLaunchApplicationNotification,
None,
)
child = subprocess.Popen(
[binary_path, "--no-remote", "--profile", profile_dir],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Bound the runloop so a missed notification fails fast rather than
# hanging the test.
threading.Timer(
RUNLOOP_TIMEOUT_SECONDS, lambda: CFRunLoopStop(CFRunLoopGetCurrent())
).start()
CFRunLoopRun()
# Tear down the subject Firefox.
if child.poll() is None:
child.terminate()
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
child.kill()
if not result["got_launch"]:
print(
f"FAIL no NSWorkspaceDidLaunchApplicationNotification for pid {child.pid}",
flush=True,
)
return 2
if not result["ax_ok"]:
print(
f"FAIL AX query failed: "
f"AXTitle err={result['title_err']} "
f"AXRole err={result['role_err']}",
flush=True,
)
return 3
print(f"PASS pid={result['pid']}: AX queries succeeded", flush=True)
return 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"usage: {sys.argv[0]} <firefox-binary-path>", flush=True)
sys.exit(1)
sys.exit(main(sys.argv[1]))