mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-10-18 03:23:28 +03:00
- add Auto, Enabled, and Disabled accessibility policies - propagate policy across primary and secondary window scenes - avoid manager creation when accessibility is disabled - harden native wrapper and model-source invalidation - improve macOS focus traversal and lifecycle testing - add native multi-window and concurrent-destruction coverage
761 lines
28 KiB
Swift
761 lines
28 KiB
Swift
#!/usr/bin/env swift
|
|
|
|
import ApplicationServices
|
|
import Darwin
|
|
import Foundation
|
|
|
|
struct HarnessFailure: Error, CustomStringConvertible {
|
|
let description: String
|
|
}
|
|
|
|
struct RecordedNotification {
|
|
let element: AXUIElement
|
|
let name: String
|
|
}
|
|
|
|
final class NotificationRecorder {
|
|
var events: [RecordedNotification] = []
|
|
|
|
func contains(_ name: CFString, target: AXUIElement, since index: Int) -> Bool {
|
|
count(name, target: target, since: index) > 0
|
|
}
|
|
|
|
func count(_ name: CFString, target: AXUIElement, since index: Int) -> Int {
|
|
events.dropFirst(index).filter {
|
|
$0.name == name as String && CFEqual($0.element, target)
|
|
}.count
|
|
}
|
|
}
|
|
|
|
let notificationCallback: AXObserverCallback = { _, element, notification, context in
|
|
guard let context else { return }
|
|
let recorder = Unmanaged<NotificationRecorder>.fromOpaque(context).takeUnretainedValue()
|
|
recorder.events.append(RecordedNotification(element: element, name: notification as String))
|
|
}
|
|
|
|
func progress(_ message: String) {
|
|
FileHandle.standardError.write(Data("[macOS accessibility] \(message)\n".utf8))
|
|
}
|
|
|
|
func fail(_ message: String) throws -> Never {
|
|
throw HarnessFailure(description: message)
|
|
}
|
|
|
|
func check(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
|
if !condition() {
|
|
try fail(message)
|
|
}
|
|
}
|
|
|
|
func optionalAttribute(_ element: AXUIElement, _ attribute: CFString) -> CFTypeRef? {
|
|
var value: CFTypeRef?
|
|
return AXUIElementCopyAttributeValue(element, attribute, &value) == .success ? value : nil
|
|
}
|
|
|
|
func stringAttribute(_ element: AXUIElement, _ attribute: CFString) -> String? {
|
|
optionalAttribute(element, attribute) as? String
|
|
}
|
|
|
|
func numberAttribute(_ element: AXUIElement, _ attribute: CFString) -> NSNumber? {
|
|
optionalAttribute(element, attribute) as? NSNumber
|
|
}
|
|
|
|
func elementsAttribute(_ element: AXUIElement, _ attribute: CFString) -> [AXUIElement] {
|
|
optionalAttribute(element, attribute) as? [AXUIElement] ?? []
|
|
}
|
|
|
|
func elementsAttributeResult(
|
|
_ element: AXUIElement, _ attribute: CFString
|
|
) -> ([AXUIElement], AXError) {
|
|
var value: CFTypeRef?
|
|
let error = AXUIElementCopyAttributeValue(element, attribute, &value)
|
|
return (value as? [AXUIElement] ?? [], error)
|
|
}
|
|
|
|
func pointAttribute(_ element: AXUIElement, _ attribute: CFString) -> CGPoint? {
|
|
guard let value = optionalAttribute(element, attribute),
|
|
CFGetTypeID(value) == AXValueGetTypeID()
|
|
else {
|
|
return nil
|
|
}
|
|
var point = CGPoint.zero
|
|
return AXValueGetValue(value as! AXValue, .cgPoint, &point) ? point : nil
|
|
}
|
|
|
|
func sizeAttribute(_ element: AXUIElement, _ attribute: CFString) -> CGSize? {
|
|
guard let value = optionalAttribute(element, attribute),
|
|
CFGetTypeID(value) == AXValueGetTypeID()
|
|
else {
|
|
return nil
|
|
}
|
|
var size = CGSize.zero
|
|
return AXValueGetValue(value as! AXValue, .cgSize, &size) ? size : nil
|
|
}
|
|
|
|
func rangeAttribute(_ element: AXUIElement, _ attribute: CFString) -> CFRange? {
|
|
guard let value = optionalAttribute(element, attribute),
|
|
CFGetTypeID(value) == AXValueGetTypeID()
|
|
else {
|
|
return nil
|
|
}
|
|
var range = CFRange()
|
|
return AXValueGetValue(value as! AXValue, .cfRange, &range) ? range : nil
|
|
}
|
|
|
|
func rangeValue(location: Int, length: Int) throws -> AXValue {
|
|
var range = CFRange(location: location, length: length)
|
|
guard let value = AXValueCreate(.cfRange, &range) else {
|
|
try fail("could not create AX range value")
|
|
}
|
|
return value
|
|
}
|
|
|
|
func rangeEquals(_ range: CFRange?, location: Int, length: Int) -> Bool {
|
|
range?.location == location && range?.length == length
|
|
}
|
|
|
|
func parameterizedAttribute(
|
|
_ element: AXUIElement, _ attribute: CFString, _ parameter: CFTypeRef
|
|
) throws -> CFTypeRef {
|
|
var value: CFTypeRef?
|
|
let error = AXUIElementCopyParameterizedAttributeValue(
|
|
element, attribute, parameter, &value)
|
|
guard error == .success, let value else {
|
|
try fail("querying \(attribute) failed with AX error \(error.rawValue)")
|
|
}
|
|
return value
|
|
}
|
|
|
|
func observe(
|
|
_ observer: AXObserver, _ element: AXUIElement, _ notification: CFString,
|
|
_ recorder: NotificationRecorder
|
|
) throws {
|
|
let error = AXObserverAddNotification(
|
|
observer, element, notification, Unmanaged.passUnretained(recorder).toOpaque())
|
|
if error != .success {
|
|
try fail("observing \(notification) failed with AX error \(error.rawValue)")
|
|
}
|
|
}
|
|
|
|
func checkSingleNotification(
|
|
_ recorder: NotificationRecorder, _ notification: CFString, target: AXUIElement,
|
|
since index: Int
|
|
) throws {
|
|
let deadline = Date().addingTimeInterval(0.1)
|
|
repeat {
|
|
CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.01, true)
|
|
} while Date() < deadline
|
|
let count = recorder.count(notification, target: target, since: index)
|
|
try check(
|
|
count == 1,
|
|
"expected one \(notification) notification for \(describe(target)), received \(count)")
|
|
}
|
|
|
|
func descendants(_ root: AXUIElement, limit: Int = 4_096) -> [AXUIElement] {
|
|
var result: [AXUIElement] = []
|
|
var pending = elementsAttribute(root, kAXChildrenAttribute as CFString)
|
|
while !pending.isEmpty && result.count < limit {
|
|
let element = pending.removeFirst()
|
|
result.append(element)
|
|
pending.append(contentsOf: elementsAttribute(element, kAXChildrenAttribute as CFString))
|
|
}
|
|
return result
|
|
}
|
|
|
|
func findElement(in root: AXUIElement, role: String, name: String) -> AXUIElement? {
|
|
for element in descendants(root) {
|
|
guard stringAttribute(element, kAXRoleAttribute as CFString) == role else { continue }
|
|
let names = [
|
|
stringAttribute(element, kAXTitleAttribute as CFString),
|
|
stringAttribute(element, kAXDescriptionAttribute as CFString),
|
|
stringAttribute(element, kAXHelpAttribute as CFString),
|
|
stringAttribute(element, kAXValueAttribute as CFString),
|
|
]
|
|
if names.contains(where: { $0 == name }) {
|
|
return element
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func describe(_ element: AXUIElement?) -> String {
|
|
guard let element else { return "nil" }
|
|
let attributes: [(String, CFString)] = [
|
|
("role", kAXRoleAttribute as CFString),
|
|
("title", kAXTitleAttribute as CFString),
|
|
("description", kAXDescriptionAttribute as CFString),
|
|
("identifier", kAXIdentifierAttribute as CFString),
|
|
]
|
|
return attributes.map { name, attribute in
|
|
"\(name)=\(stringAttribute(element, attribute) ?? "nil")"
|
|
}.joined(separator: " ")
|
|
}
|
|
|
|
func waitFor<T>(
|
|
_ description: String, timeout: TimeInterval, operation: () -> T?
|
|
) throws -> T {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
repeat {
|
|
if let result = operation() {
|
|
return result
|
|
}
|
|
CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.05, true)
|
|
} while Date() < deadline
|
|
try fail("timed out waiting for \(description)")
|
|
}
|
|
|
|
func actionNames(_ element: AXUIElement) throws -> [String] {
|
|
var names: CFArray?
|
|
let error = AXUIElementCopyActionNames(element, &names)
|
|
guard error == .success else {
|
|
try fail("copying action names failed with AX error \(error.rawValue)")
|
|
}
|
|
return names as? [String] ?? []
|
|
}
|
|
|
|
func perform(_ element: AXUIElement, _ action: CFString) throws {
|
|
let error = AXUIElementPerformAction(element, action)
|
|
if error != .success {
|
|
try fail("\(action) failed with AX error \(error.rawValue)")
|
|
}
|
|
}
|
|
|
|
func stressQueriesDuringAction(
|
|
staleElement: AXUIElement, actionElement: AXUIElement, timeout: TimeInterval
|
|
) throws {
|
|
AXUIElementSetMessagingTimeout(staleElement, 0.25)
|
|
let started = DispatchSemaphore(value: 0)
|
|
let completed = DispatchGroup()
|
|
let resultLock = NSLock()
|
|
var unexpectedErrors: [Int32] = []
|
|
completed.enter()
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
defer { completed.leave() }
|
|
for index in 0..<64 {
|
|
var role: CFTypeRef?
|
|
let roleError = AXUIElementCopyAttributeValue(
|
|
staleElement, kAXRoleAttribute as CFString, &role)
|
|
var children: CFTypeRef?
|
|
let childrenError = AXUIElementCopyAttributeValue(
|
|
staleElement, kAXChildrenAttribute as CFString, &children)
|
|
if index == 0 { started.signal() }
|
|
for error in [roleError, childrenError] {
|
|
if error != .success && error != .invalidUIElement && error != .cannotComplete
|
|
&& error != .noValue && error != .attributeUnsupported
|
|
{
|
|
resultLock.lock()
|
|
unexpectedErrors.append(error.rawValue)
|
|
resultLock.unlock()
|
|
}
|
|
}
|
|
usleep(1_000)
|
|
}
|
|
}
|
|
guard started.wait(timeout: .now() + 1) == .success else {
|
|
try fail("concurrent AX query worker did not start")
|
|
}
|
|
try perform(actionElement, kAXPressAction as CFString)
|
|
guard completed.wait(timeout: .now() + timeout) == .success else {
|
|
try fail("concurrent AX queries did not finish after the destructive action")
|
|
}
|
|
resultLock.lock()
|
|
let errors = unexpectedErrors
|
|
resultLock.unlock()
|
|
try check(errors.isEmpty, "concurrent AX queries returned unexpected errors: \(errors)")
|
|
}
|
|
|
|
func setAttribute(_ element: AXUIElement, _ attribute: CFString, _ value: CFTypeRef) throws {
|
|
let error = AXUIElementSetAttributeValue(element, attribute, value)
|
|
if error != .success {
|
|
try fail("setting \(attribute) failed with AX error \(error.rawValue)")
|
|
}
|
|
}
|
|
|
|
struct Arguments {
|
|
var executable = "bin/eepp-ui-accessibility"
|
|
var timeout: TimeInterval = 10
|
|
}
|
|
|
|
func parseArguments() throws -> Arguments {
|
|
var result = Arguments()
|
|
var index = 1
|
|
while index < CommandLine.arguments.count {
|
|
switch CommandLine.arguments[index] {
|
|
case "--executable":
|
|
index += 1
|
|
guard index < CommandLine.arguments.count else { try fail("missing --executable value") }
|
|
result.executable = CommandLine.arguments[index]
|
|
case "--timeout":
|
|
index += 1
|
|
guard index < CommandLine.arguments.count,
|
|
let timeout = TimeInterval(CommandLine.arguments[index]), timeout > 0
|
|
else {
|
|
try fail("invalid --timeout value")
|
|
}
|
|
result.timeout = timeout
|
|
default:
|
|
try fail("unknown argument: \(CommandLine.arguments[index])")
|
|
}
|
|
index += 1
|
|
}
|
|
return result
|
|
}
|
|
|
|
func run() throws {
|
|
try check(AXIsProcessTrusted(), "the harness requires macOS Accessibility permission")
|
|
let arguments = try parseArguments()
|
|
let executableURL = URL(fileURLWithPath: arguments.executable).standardizedFileURL
|
|
try check(
|
|
FileManager.default.isExecutableFile(atPath: executableURL.path),
|
|
"accessibility example is not executable at \(executableURL.path)")
|
|
|
|
let process = Process()
|
|
process.executableURL = executableURL
|
|
// This mode also selects QuitPolicy::OnLastWindowClosed, which is required to exercise
|
|
// primary-window removal while the secondary window remains alive.
|
|
process.arguments = ["--close-primary"]
|
|
var environment = ProcessInfo.processInfo.environment
|
|
let executableDirectory = executableURL.deletingLastPathComponent().path
|
|
if let existing = environment["DYLD_LIBRARY_PATH"], !existing.isEmpty {
|
|
environment["DYLD_LIBRARY_PATH"] = "\(executableDirectory):\(existing)"
|
|
} else {
|
|
environment["DYLD_LIBRARY_PATH"] = executableDirectory
|
|
}
|
|
process.environment = environment
|
|
try process.run()
|
|
defer {
|
|
if process.isRunning {
|
|
process.terminate()
|
|
let deadline = Date().addingTimeInterval(2)
|
|
while process.isRunning && Date() < deadline {
|
|
Thread.sleep(forTimeInterval: 0.05)
|
|
}
|
|
if process.isRunning {
|
|
Darwin.kill(process.processIdentifier, SIGKILL)
|
|
}
|
|
}
|
|
}
|
|
|
|
let application = AXUIElementCreateApplication(process.processIdentifier)
|
|
AXUIElementSetMessagingTimeout(application, 2)
|
|
progress("waiting for application windows")
|
|
var lastWindowStatus = ""
|
|
let windows: [AXUIElement] = try waitFor(
|
|
"two accessibility windows", timeout: arguments.timeout
|
|
) {
|
|
let (found, error) = elementsAttributeResult(application, kAXWindowsAttribute as CFString)
|
|
let status = "AXWindows error=\(error.rawValue) count=\(found.count)"
|
|
if status != lastWindowStatus {
|
|
progress(status)
|
|
lastWindowStatus = status
|
|
}
|
|
return found.count == 2 ? found : nil
|
|
}
|
|
try check(
|
|
stringAttribute(application, kAXRoleAttribute as CFString) == kAXApplicationRole,
|
|
"application role was not AXApplication")
|
|
let windowsByTitle = Dictionary(
|
|
uniqueKeysWithValues: windows.map {
|
|
(stringAttribute($0, kAXTitleAttribute as CFString) ?? "", $0)
|
|
})
|
|
try check(
|
|
Set(windowsByTitle.keys)
|
|
== Set(["eepp - Accessibility", "eepp - Accessibility Secondary"]),
|
|
"window titles were incorrect")
|
|
|
|
progress("checking hierarchy and actions")
|
|
guard let primary = windowsByTitle["eepp - Accessibility"] else {
|
|
try fail("could not find the primary window by title")
|
|
}
|
|
let recorder = NotificationRecorder()
|
|
var observer: AXObserver?
|
|
let observerError = AXObserverCreate(
|
|
process.processIdentifier, notificationCallback, &observer)
|
|
guard observerError == .success, let observer else {
|
|
try fail("creating AX notification observer failed with AX error \(observerError.rawValue)")
|
|
}
|
|
let observerSource = AXObserverGetRunLoopSource(observer)
|
|
CFRunLoopAddSource(CFRunLoopGetCurrent(), observerSource, CFRunLoopMode.defaultMode)
|
|
defer {
|
|
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), observerSource, CFRunLoopMode.defaultMode)
|
|
}
|
|
guard let save = findElement(in: primary, role: kAXButtonRole, name: "Save settings") else {
|
|
try fail("could not find Save settings button")
|
|
}
|
|
let saveActions = try actionNames(save)
|
|
try check(saveActions.contains(kAXPressAction), "save button did not expose AXPress")
|
|
try perform(save, kAXPressAction as CFString)
|
|
guard let metadataButton = findElement(
|
|
in: primary, role: kAXButtonRole, name: "Update accessible metadata")
|
|
else {
|
|
try fail("could not find accessible-metadata update button")
|
|
}
|
|
try observe(observer, metadataButton, kAXTitleChangedNotification as CFString, recorder)
|
|
try observe(observer, metadataButton, kAXLayoutChangedNotification as CFString, recorder)
|
|
let metadataEventStart = recorder.events.count
|
|
try perform(metadataButton, kAXPressAction as CFString)
|
|
try check(
|
|
stringAttribute(metadataButton, kAXDescriptionAttribute as CFString)
|
|
== "Updated metadata control",
|
|
"updated accessible name was not exposed as AXDescription")
|
|
try check(
|
|
stringAttribute(metadataButton, kAXHelpAttribute as CFString)
|
|
== "Updated accessibility help",
|
|
"updated accessible help was incorrect")
|
|
let _: Bool = try waitFor("metadata title notification", timeout: arguments.timeout) {
|
|
recorder.contains(
|
|
kAXTitleChangedNotification as CFString, target: metadataButton,
|
|
since: metadataEventStart) ? true : nil
|
|
}
|
|
let _: Bool = try waitFor("metadata help notification", timeout: arguments.timeout) {
|
|
recorder.contains(
|
|
kAXLayoutChangedNotification as CFString, target: metadataButton,
|
|
since: metadataEventStart) ? true : nil
|
|
}
|
|
try checkSingleNotification(
|
|
recorder, kAXTitleChangedNotification as CFString, target: metadataButton,
|
|
since: metadataEventStart)
|
|
try checkSingleNotification(
|
|
recorder, kAXLayoutChangedNotification as CFString, target: metadataButton,
|
|
since: metadataEventStart)
|
|
|
|
guard let checkbox = findElement(
|
|
in: primary, role: kAXCheckBoxRole, name: "Enable autosave")
|
|
else {
|
|
try fail("could not find autosave checkbox")
|
|
}
|
|
try check(
|
|
numberAttribute(checkbox, kAXValueAttribute as CFString)?.intValue == 0,
|
|
"checkbox initial value was not unchecked")
|
|
try observe(observer, checkbox, kAXValueChangedNotification as CFString, recorder)
|
|
let checkboxEventStart = recorder.events.count
|
|
try perform(checkbox, kAXPressAction as CFString)
|
|
try check(
|
|
numberAttribute(checkbox, kAXValueAttribute as CFString)?.intValue == 1,
|
|
"checkbox did not toggle through AXPress")
|
|
let _: Bool = try waitFor("checkbox value notification", timeout: arguments.timeout) {
|
|
recorder.contains(
|
|
kAXValueChangedNotification as CFString, target: checkbox, since: checkboxEventStart)
|
|
? true : nil
|
|
}
|
|
try checkSingleNotification(
|
|
recorder, kAXValueChangedNotification as CFString, target: checkbox,
|
|
since: checkboxEventStart)
|
|
|
|
guard let projectName = findElement(
|
|
in: primary, role: kAXTextFieldRole, name: "Project name")
|
|
else {
|
|
try fail("could not find Project name text field")
|
|
}
|
|
try check(
|
|
stringAttribute(projectName, kAXValueAttribute as CFString) == "eepp",
|
|
"text field value was incorrect")
|
|
try setAttribute(projectName, kAXValueAttribute as CFString, "A😀e\u{301}" as CFString)
|
|
try check(
|
|
stringAttribute(projectName, kAXValueAttribute as CFString) == "A😀e\u{301}",
|
|
"text field value did not update")
|
|
try observe(
|
|
observer, projectName, kAXSelectedTextChangedNotification as CFString, recorder)
|
|
let textSelectionEventStart = recorder.events.count
|
|
try setAttribute(
|
|
projectName, kAXSelectedTextRangeAttribute as CFString,
|
|
try rangeValue(location: 1, length: 4))
|
|
try check(
|
|
rangeEquals(
|
|
rangeAttribute(projectName, kAXSelectedTextRangeAttribute as CFString), location: 1,
|
|
length: 4),
|
|
"Unicode text selection did not round-trip as UTF-16")
|
|
let _: Bool = try waitFor("text selection notification", timeout: arguments.timeout) {
|
|
recorder.contains(
|
|
kAXSelectedTextChangedNotification as CFString, target: projectName,
|
|
since: textSelectionEventStart) ? true : nil
|
|
}
|
|
try checkSingleNotification(
|
|
recorder, kAXSelectedTextChangedNotification as CFString, target: projectName,
|
|
since: textSelectionEventStart)
|
|
try check(
|
|
numberAttribute(projectName, kAXNumberOfCharactersAttribute as CFString)?.intValue == 5,
|
|
"Unicode text field did not report its UTF-16 character count")
|
|
let emojiRangeValue = try parameterizedAttribute(
|
|
projectName, kAXRangeForIndexParameterizedAttribute as CFString, 1 as CFNumber)
|
|
var emojiRange = CFRange()
|
|
try check(
|
|
CFGetTypeID(emojiRangeValue) == AXValueGetTypeID()
|
|
&& AXValueGetValue(emojiRangeValue as! AXValue, .cfRange, &emojiRange)
|
|
&& rangeEquals(emojiRange, location: 1, length: 2),
|
|
"AXRangeForIndex did not preserve the surrogate pair")
|
|
|
|
guard let description = findElement(
|
|
in: primary, role: kAXTextFieldRole, name: "Description")
|
|
else {
|
|
try fail("could not find Description text editor")
|
|
}
|
|
try setAttribute(description, kAXValueAttribute as CFString, "one\n😀x" as CFString)
|
|
try setAttribute(
|
|
description, kAXSelectedTextRangeAttribute as CFString,
|
|
try rangeValue(location: 4, length: 2))
|
|
try check(
|
|
stringAttribute(description, kAXSelectedTextAttribute as CFString) == "😀",
|
|
"multiline editor selected text was incorrect")
|
|
try check(
|
|
numberAttribute(description, kAXInsertionPointLineNumberAttribute as CFString)?.intValue
|
|
== 1,
|
|
"multiline editor insertion point line was incorrect")
|
|
let selectedSubstring = try parameterizedAttribute(
|
|
description, kAXStringForRangeParameterizedAttribute as CFString,
|
|
try rangeValue(location: 4, length: 2))
|
|
try check(
|
|
(selectedSubstring as? String) == "😀",
|
|
"AXStringForRange did not use UTF-16 ranges")
|
|
|
|
guard let password = findElement(
|
|
in: primary, role: kAXTextFieldRole, name: "Account password")
|
|
else {
|
|
try fail("could not find secure text field")
|
|
}
|
|
try check(
|
|
stringAttribute(password, kAXSubroleAttribute as CFString) == kAXSecureTextFieldSubrole,
|
|
"password field did not expose AXSecureTextField")
|
|
try check(
|
|
stringAttribute(password, kAXValueAttribute as CFString) == nil,
|
|
"password field exposed its value")
|
|
|
|
guard let lightTheme = findElement(
|
|
in: primary, role: kAXRadioButtonRole, name: "Light Theme")
|
|
else {
|
|
try fail("could not find Light Theme radio button")
|
|
}
|
|
try observe(observer, lightTheme, kAXValueChangedNotification as CFString, recorder)
|
|
let radioEventStart = recorder.events.count
|
|
try perform(lightTheme, kAXPressAction as CFString)
|
|
try check(
|
|
numberAttribute(lightTheme, kAXValueAttribute as CFString)?.boolValue == true,
|
|
"radio button did not select through AXPress")
|
|
let _: Bool = try waitFor("radio value notification", timeout: arguments.timeout) {
|
|
recorder.contains(
|
|
kAXValueChangedNotification as CFString, target: lightTheme, since: radioEventStart)
|
|
? true : nil
|
|
}
|
|
try checkSingleNotification(
|
|
recorder, kAXValueChangedNotification as CFString, target: lightTheme,
|
|
since: radioEventStart)
|
|
|
|
guard let volume = findElement(in: primary, role: kAXSliderRole, name: "Volume") else {
|
|
try fail("could not find Volume slider")
|
|
}
|
|
try check(
|
|
numberAttribute(volume, kAXMinValueAttribute as CFString)?.doubleValue == 0
|
|
&& numberAttribute(volume, kAXMaxValueAttribute as CFString)?.doubleValue == 100,
|
|
"slider range was incorrect")
|
|
try observe(observer, volume, kAXValueChangedNotification as CFString, recorder)
|
|
let sliderEventStart = recorder.events.count
|
|
try setAttribute(volume, kAXValueAttribute as CFString, 55 as CFNumber)
|
|
try check(
|
|
numberAttribute(volume, kAXValueAttribute as CFString)?.doubleValue == 55,
|
|
"slider value did not update")
|
|
let _: Bool = try waitFor("slider value notification", timeout: arguments.timeout) {
|
|
recorder.contains(
|
|
kAXValueChangedNotification as CFString, target: volume, since: sliderEventStart)
|
|
? true : nil
|
|
}
|
|
try checkSingleNotification(
|
|
recorder, kAXValueChangedNotification as CFString, target: volume,
|
|
since: sliderEventStart)
|
|
|
|
guard let retries = findElement(
|
|
in: primary, role: kAXIncrementorRole, name: "Retry count")
|
|
else {
|
|
try fail("could not find Retry count incrementor")
|
|
}
|
|
try perform(retries, kAXIncrementAction as CFString)
|
|
try check(
|
|
numberAttribute(retries, kAXValueAttribute as CFString)?.doubleValue == 4,
|
|
"incrementor did not increment")
|
|
|
|
guard let progressBar = findElement(
|
|
in: primary, role: kAXProgressIndicatorRole, name: "Operation progress")
|
|
else {
|
|
try fail("could not find progress indicator")
|
|
}
|
|
try check(
|
|
numberAttribute(progressBar, kAXValueAttribute as CFString)?.doubleValue == 35,
|
|
"progress indicator value was incorrect")
|
|
|
|
try observe(
|
|
observer, application, kAXFocusedUIElementChangedNotification as CFString, recorder)
|
|
try setAttribute(save, kAXFocusedAttribute as CFString, kCFBooleanTrue)
|
|
let _: Bool = try waitFor("application focus on Save settings", timeout: arguments.timeout) {
|
|
var focused: CFTypeRef?
|
|
let error = AXUIElementCopyAttributeValue(
|
|
application, kAXFocusedUIElementAttribute as CFString, &focused)
|
|
return error == .success && focused != nil && CFEqual(focused, save) ? true : nil
|
|
}
|
|
let focusEventStart = recorder.events.count
|
|
try setAttribute(projectName, kAXFocusedAttribute as CFString, kCFBooleanTrue)
|
|
let _: Bool = try waitFor("application focus on Project name", timeout: arguments.timeout) {
|
|
var focused: CFTypeRef?
|
|
let error = AXUIElementCopyAttributeValue(
|
|
application, kAXFocusedUIElementAttribute as CFString, &focused)
|
|
return error == .success && focused != nil && CFEqual(focused, projectName) ? true : nil
|
|
}
|
|
let _: Bool = try waitFor("focused element notification", timeout: arguments.timeout) {
|
|
recorder.contains(
|
|
kAXFocusedUIElementChangedNotification as CFString, target: projectName,
|
|
since: focusEventStart) ? true : nil
|
|
}
|
|
try checkSingleNotification(
|
|
recorder, kAXFocusedUIElementChangedNotification as CFString, target: projectName,
|
|
since: focusEventStart)
|
|
|
|
progress("checking geometry and hit testing")
|
|
guard let position = pointAttribute(save, kAXPositionAttribute as CFString),
|
|
let size = sizeAttribute(save, kAXSizeAttribute as CFString)
|
|
else {
|
|
try fail("save button did not expose geometry")
|
|
}
|
|
try check(size.width > 0 && size.height > 0, "save button geometry was empty")
|
|
var hitElement: AXUIElement?
|
|
let hitError = AXUIElementCopyElementAtPosition(
|
|
application, Float(position.x + size.width / 2), Float(position.y + size.height / 2),
|
|
&hitElement)
|
|
try check(
|
|
hitError == .success && hitElement != nil && CFEqual(hitElement, save),
|
|
"AX hit testing at \(position.x + size.width / 2),\(position.y + size.height / 2) "
|
|
+ "returned \(describe(hitElement)) instead of \(describe(save))")
|
|
|
|
progress("checking model hierarchy and replacement")
|
|
guard let colors = findElement(in: primary, role: kAXListRole, name: "Colors") else {
|
|
try fail("could not find Colors list")
|
|
}
|
|
try check(
|
|
elementsAttribute(colors, kAXRowsAttribute as CFString).count == 3,
|
|
"list did not expose three rows")
|
|
guard let projects = findElement(in: primary, role: kAXTableRole, name: "Projects") else {
|
|
try fail("could not find Projects table")
|
|
}
|
|
let originalRows = elementsAttribute(projects, kAXRowsAttribute as CFString)
|
|
try check(originalRows.count == 2, "table did not expose two rows")
|
|
let staleRow = originalRows[0]
|
|
try observe(observer, projects, kAXLayoutChangedNotification as CFString, recorder)
|
|
try observe(observer, staleRow, kAXUIElementDestroyedNotification as CFString, recorder)
|
|
guard let replaceProjects = findElement(
|
|
in: primary, role: kAXButtonRole, name: "Replace project model")
|
|
else {
|
|
try fail("could not find model replacement button")
|
|
}
|
|
let modelEventStart = recorder.events.count
|
|
try perform(replaceProjects, kAXPressAction as CFString)
|
|
let replacementRows: [AXUIElement] = try waitFor(
|
|
"replacement table model", timeout: arguments.timeout
|
|
) {
|
|
let rows = elementsAttribute(projects, kAXRowsAttribute as CFString)
|
|
return rows.count == 1
|
|
&& stringAttribute(rows[0], kAXValueAttribute as CFString) == "Gamma" ? rows : nil
|
|
}
|
|
try check(replacementRows.count == 1, "replacement table rows were incorrect")
|
|
let _: Bool = try waitFor("table layout notification", timeout: arguments.timeout) {
|
|
recorder.contains(
|
|
kAXLayoutChangedNotification as CFString, target: projects, since: modelEventStart)
|
|
? true : nil
|
|
}
|
|
let _: Bool = try waitFor("stale row destruction notification", timeout: arguments.timeout) {
|
|
recorder.contains(
|
|
kAXUIElementDestroyedNotification as CFString, target: staleRow, since: modelEventStart)
|
|
? true : nil
|
|
}
|
|
try checkSingleNotification(
|
|
recorder, kAXLayoutChangedNotification as CFString, target: projects,
|
|
since: modelEventStart)
|
|
try checkSingleNotification(
|
|
recorder, kAXUIElementDestroyedNotification as CFString, target: staleRow,
|
|
since: modelEventStart)
|
|
var staleRowRole: CFTypeRef?
|
|
try check(
|
|
AXUIElementCopyAttributeValue(
|
|
staleRow, kAXRoleAttribute as CFString, &staleRowRole) != .success,
|
|
"stale model row remained queryable")
|
|
|
|
progress("checking dynamic window lifecycle")
|
|
guard let openWindow = findElement(
|
|
in: primary, role: kAXButtonRole, name: "Open accessibility window")
|
|
else {
|
|
try fail("could not find dynamic-window button")
|
|
}
|
|
try observe(observer, application, kAXWindowCreatedNotification as CFString, recorder)
|
|
let windowEventStart = recorder.events.count
|
|
try perform(openWindow, kAXPressAction as CFString)
|
|
let dynamicWindow: AXUIElement = try waitFor(
|
|
"dynamic third window", timeout: arguments.timeout
|
|
) {
|
|
elementsAttribute(application, kAXWindowsAttribute as CFString).first {
|
|
stringAttribute($0, kAXTitleAttribute as CFString) == "eepp - Accessibility Dynamic"
|
|
}
|
|
}
|
|
let _: Bool = try waitFor("window creation notification", timeout: arguments.timeout) {
|
|
recorder.contains(
|
|
kAXWindowCreatedNotification as CFString, target: dynamicWindow, since: windowEventStart)
|
|
? true : nil
|
|
}
|
|
try checkSingleNotification(
|
|
recorder, kAXWindowCreatedNotification as CFString, target: dynamicWindow,
|
|
since: windowEventStart)
|
|
let closeDynamic: AXUIElement = try waitFor(
|
|
"dynamic-window accessibility hierarchy", timeout: arguments.timeout
|
|
) {
|
|
findElement(in: dynamicWindow, role: kAXButtonRole, name: "Close dynamic window")
|
|
}
|
|
try stressQueriesDuringAction(
|
|
staleElement: dynamicWindow, actionElement: closeDynamic, timeout: arguments.timeout)
|
|
let _: Bool = try waitFor("dynamic window removal", timeout: arguments.timeout) {
|
|
elementsAttribute(application, kAXWindowsAttribute as CFString).count == 2 ? true : nil
|
|
}
|
|
|
|
progress("checking primary window removal")
|
|
guard
|
|
elementsAttribute(application, kAXWindowsAttribute as CFString).contains(where: {
|
|
stringAttribute($0, kAXTitleAttribute as CFString)
|
|
== "eepp - Accessibility Secondary"
|
|
})
|
|
else {
|
|
try fail("could not find the secondary window by title")
|
|
}
|
|
guard let closePrimaryValue = optionalAttribute(primary, kAXCloseButtonAttribute as CFString),
|
|
CFGetTypeID(closePrimaryValue) == AXUIElementGetTypeID()
|
|
else { try fail("primary window did not expose its native close button") }
|
|
let closePrimary = closePrimaryValue as! AXUIElement
|
|
try stressQueriesDuringAction(
|
|
staleElement: save, actionElement: closePrimary, timeout: arguments.timeout)
|
|
let survivingWindow: AXUIElement = try waitFor(
|
|
"primary window removal", timeout: arguments.timeout
|
|
) {
|
|
let found = elementsAttribute(application, kAXWindowsAttribute as CFString)
|
|
return found.count == 1 ? found[0] : nil
|
|
}
|
|
try check(
|
|
stringAttribute(survivingWindow, kAXTitleAttribute as CFString)
|
|
== "eepp - Accessibility Secondary",
|
|
"secondary window did not survive primary removal")
|
|
var staleRole: CFTypeRef?
|
|
let staleError = AXUIElementCopyAttributeValue(
|
|
save, kAXRoleAttribute as CFString, &staleRole)
|
|
try check(staleError != .success, "stale primary element remained queryable")
|
|
|
|
progress("closing surviving secondary window")
|
|
guard let closeSecondary = findElement(
|
|
in: survivingWindow, role: kAXButtonRole, name: "Close secondary window")
|
|
else {
|
|
try fail("surviving secondary window was not queryable")
|
|
}
|
|
try perform(closeSecondary, kAXPressAction as CFString)
|
|
process.waitUntilExit()
|
|
try check(process.terminationStatus == 0, "example exited with a failure status")
|
|
print("macOS accessibility semantic and lifecycle harness passed")
|
|
}
|
|
|
|
do {
|
|
try run()
|
|
} catch {
|
|
FileHandle.standardError.write(Data("macOS accessibility harness failed: \(error)\n".utf8))
|
|
exit(EXIT_FAILURE)
|
|
}
|