online.git: 6 commits - macos/coda

0 views
Skip to first unread message

"Jan Holesovsky (via cogerrit)"

unread,
2:09 AM (15 hours ago) 2:09 AM
to collaboraon...@googlegroups.com
macos/coda/coda/NativeUIServer.swift | 340 +++++++++++++++++++++++++
macos/coda/coda/WebDriverHTTPServerBase.swift | 172 ++++++++++++
macos/coda/coda/WebDriverManager.swift | 74 +++--
macos/coda/coda/WebDriverServer.swift | 158 -----------
macos/coda/wdio-test/lib/coda-macos.service.ts | 20 +
macos/coda/wdio-test/wdio.conf.ts | 12
6 files changed, 588 insertions(+), 188 deletions(-)

New commits:
commit 642b073e4fcf4bdc2825e28cbbb6ec42cbe20654
Author: Jan Holesovsky <ke...@collabora.com>
AuthorDate: Tue May 12 13:12:36 2026 +0200
Commit: Jan Holesovsky <ke...@collabora.com>
CommitDate: Mon May 25 06:08:52 2026 +0000

macOS tests: Implement GET /source for NativeUIServer

The harness spec calls browser.native.getPageSource() which the Qt
AT-SPI driver answers with a dump of the accessibility tree.

Add the equivalent endpoint: GET /session/{id}/source walks
NSApp.windows via NSAccessibility and returns an indented text
dump of the role/identifier/title of each element. Useful both
for getPageSource() assertions and for debugging selector mismatches.

Signed-off-by: Jan Holesovsky <ke...@collabora.com>
Change-Id: I67c2419911b09a0011e71e8b246417b12fedb0c4
Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/2474
Reviewed-by: Tor Lillqvist <t...@collabora.com>
Tested-by: Jenkins CPCI <rel...@collaboraoffice.com>

diff --git a/macos/coda/coda/NativeUIServer.swift b/macos/coda/coda/NativeUIServer.swift
index ef6dc9f9e45b..55fcb5da73cf 100644
--- a/macos/coda/coda/NativeUIServer.swift
+++ b/macos/coda/coda/NativeUIServer.swift
@@ -121,6 +121,15 @@ final class NativeUIServer: WebDriverHTTPServerBase {
return
}

+ // GET /session/{id}/source
+ if request.method == "GET" && subpath == ["source"] {
+ DispatchQueue.main.async { [weak self] in
+ self?.sendW3C(connection: connection,
+ value: NSAccessibilityServer.dumpTree())
+ }
+ return
+ }
+
sendW3CError(connection: connection, error: "unknown command",
message: "\(request.method) \(request.path) not implemented")
}
@@ -277,6 +286,28 @@ private enum NSAccessibilityServer {
}
return false
}
+
+ /// Return an indented text dump of the app's accessibility tree.
+ static func dumpTree() -> String {
+ var out = ""
+ for window in NSApp.windows {
+ dump(window, depth: 0, into: &out)
+ }
+ return out
+ }
+
+ private static func dump(_ obj: NSObject, depth: Int, into out: inout String) {
+ let indent = String(repeating: " ", count: depth)
+ let ax = obj as? NSAccessibilityProtocol
+ let role = ax?.accessibilityRole()?.rawValue ?? "<no-role>"
+ let id = ax?.accessibilityIdentifier() ?? ""
+ let title = ax?.accessibilityTitle() ?? ""
+ out += "\(indent)\(role) id=\"\(id)\" title=\"\(title)\"\n"
+ let children = (ax?.accessibilityChildren() as? [NSObject]) ?? []
+ for child in children {
+ dump(child, depth: depth + 1, into: &out)
+ }
+ }
}

/**
commit 47cc978e535fe765a8f31e09410e1a4a41938db4
Author: Jan Holesovsky <ke...@collabora.com>
AuthorDate: Tue May 12 12:39:38 2026 +0200
Commit: Jan Holesovsky <ke...@collabora.com>
CommitDate: Mon May 25 06:08:52 2026 +0000

macOS tests: Use =YES form for -ApplePersistenceIgnoreState in wdio launch

When -ApplePersistenceIgnoreState and YES are passed as two separate
arguments to `open -a app --args ...`, NSUserDefaults does not
consume them early enough, leaving NSDocumentController to interpret
"YES" as a bare file name to open. Use -ApplePersistenceIgnoreState=YES
(single argument with =) so there is no bare argument to misinterpret.

Signed-off-by: Jan Holesovsky <ke...@collabora.com>
Change-Id: Idde3950a746f7460d51b48570629d33283cb19c8
Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/2473
Reviewed-by: Tor Lillqvist <t...@collabora.com>

diff --git a/macos/coda/wdio-test/lib/coda-macos.service.ts b/macos/coda/wdio-test/lib/coda-macos.service.ts
index 831ba0875efd..3d3be17962da 100644
--- a/macos/coda/wdio-test/lib/coda-macos.service.ts
+++ b/macos/coda/wdio-test/lib/coda-macos.service.ts
@@ -83,7 +83,7 @@ export class CodaMacOSServiceLauncher {
'--uitesting',
`--testDriverPort=${webDriverPort}`,
`--nativeUIPort=${nativeUIPort}`,
- '-ApplePersistenceIgnoreState', 'YES',
+ '-ApplePersistenceIgnoreState=YES',
], {
stdio: ['ignore', 'pipe', 'pipe'],
});
commit b0d96acdd2f48a3be1862dbae6e9707d7d825727
Author: Jan Holesovsky <ke...@collabora.com>
AuthorDate: Tue May 12 10:39:59 2026 +0200
Commit: Jan Holesovsky <ke...@collabora.com>
CommitDate: Mon May 25 06:08:52 2026 +0000

macOS tests: Point the wdio 'native' driver at the NativeUIServer

Add a NATIVE_UI_PORT (default 4568) alongside WEBDRIVER_PORT. The
multiremote 'native' capability now connects to that port instead
of sharing the WebDriver port.

Pass --nativeUIPort=<port> when launching the app and wait for
/status on both servers before running tests.

Signed-off-by: Jan Holesovsky <ke...@collabora.com>
Change-Id: I03b47eb3ae05ac8437ec16519f20e425ba172c40
Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/2472
Reviewed-by: Tor Lillqvist <t...@collabora.com>

diff --git a/macos/coda/wdio-test/lib/coda-macos.service.ts b/macos/coda/wdio-test/lib/coda-macos.service.ts
index 3242268ac260..831ba0875efd 100644
--- a/macos/coda/wdio-test/lib/coda-macos.service.ts
+++ b/macos/coda/wdio-test/lib/coda-macos.service.ts
@@ -20,6 +20,7 @@ const sleep = promisify(setTimeout);
interface CodaMacOSServiceOptions {
appPath: string;
webDriverPort: number | string;
+ nativeUIPort: number | string;
fixturesDir: string;
}

@@ -59,7 +60,7 @@ export class CodaMacOSServiceLauncher {
}

async onPrepare(): Promise<void> {
- const { appPath, webDriverPort, fixturesDir } = this.#options;
+ const { appPath, webDriverPort, nativeUIPort, fixturesDir } = this.#options;

// Copy fixtures to a temp directory; tests open files from there
// via the JS bridge as needed.
@@ -81,6 +82,7 @@ export class CodaMacOSServiceLauncher {
'--args',
'--uitesting',
`--testDriverPort=${webDriverPort}`,
+ `--nativeUIPort=${nativeUIPort}`,
'-ApplePersistenceIgnoreState', 'YES',
], {
stdio: ['ignore', 'pipe', 'pipe'],
@@ -94,10 +96,16 @@ export class CodaMacOSServiceLauncher {
if (msg) console.log(`[coda-macos]: ${msg}`);
});

- await waitForHttp(
- `http://localhost:${webDriverPort}/status`,
- 'WebDriverServer',
- );
+ await Promise.all([
+ waitForHttp(
+ `http://localhost:${webDriverPort}/status`,
+ 'WebDriverServer',
+ ),
+ waitForHttp(
+ `http://localhost:${nativeUIPort}/status`,
+ 'NativeUIServer',
+ ),
+ ]);

console.log('coda-macos is ready, tests will now run');
}
diff --git a/macos/coda/wdio-test/wdio.conf.ts b/macos/coda/wdio-test/wdio.conf.ts
index 8227c734af97..9b6ae3cbec1c 100644
--- a/macos/coda/wdio-test/wdio.conf.ts
+++ b/macos/coda/wdio-test/wdio.conf.ts
@@ -18,6 +18,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const WEBDRIVER_PORT = Number(process.env.WEBDRIVER_PORT || 4567);
+const NATIVE_UI_PORT = Number(process.env.NATIVE_UI_PORT || 4568);

// Locate the Xcode Debug build by querying DerivedData (or take an
// override via CODA_APP).
@@ -44,10 +45,10 @@ export const config = {

maxInstances: 1,

- // Multiremote so the shared Qt specs can use browser.webEngine.
- // The webEngine driver points directly at our WebDriverServer.
- // The native driver also points at our server; native-only tests
- // (AT-SPI) will fail but are in separate describe blocks.
+ // Multiremote, matching the Qt setup:
+ // webEngine -> WebDriverServer (drives the WKWebView)
+ // native -> NativeUIServer (drives macOS NSAccessibility)
+ // Both servers run in-process inside the app under test.
capabilities: {
webEngine: {
port: WEBDRIVER_PORT,
@@ -56,7 +57,7 @@ export const config = {
},
},
native: {
- port: WEBDRIVER_PORT,
+ port: NATIVE_UI_PORT,
capabilities: {
browserName: 'chrome',
},
@@ -75,6 +76,7 @@ export const config = {
[CodaMacOSServiceLauncher, {
appPath: CODA_APP,
webDriverPort: WEBDRIVER_PORT,
+ nativeUIPort: NATIVE_UI_PORT,
fixturesDir: FIXTURES_DIR,
}],
],
commit 315aa97128532e57c9888e6720107968c0eafbbc
Author: Jan Holesovsky <ke...@collabora.com>
AuthorDate: Tue May 12 10:38:14 2026 +0200
Commit: Jan Holesovsky <ke...@collabora.com>
CommitDate: Mon May 25 06:08:52 2026 +0000

macOS tests: Start NativeUIServer from WebDriverManager

Recognise --nativeUIPort=<port> alongside --testDriverPort=<port>
and start NativeUIServer when it is provided.

Factor the port-from-argv parsing into a small helper since we
now have two ports to read.

Signed-off-by: Jan Holesovsky <ke...@collabora.com>
Change-Id: I81f4e627e011995e5b1f87094b4e4c2e2bfe0c4a
Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/2471
Reviewed-by: Tor Lillqvist <t...@collabora.com>

diff --git a/macos/coda/coda/WebDriverManager.swift b/macos/coda/coda/WebDriverManager.swift
index 1411cf1e949b..26f41f346483 100644
--- a/macos/coda/coda/WebDriverManager.swift
+++ b/macos/coda/coda/WebDriverManager.swift
@@ -28,6 +28,7 @@ final class WebDriverManager {
static let shared = WebDriverManager()

private var server: WebDriverServer?
+ private var nativeServer: NativeUIServer?

/// Insertion-ordered handles -> weak references to webviews.
private var entries: [(handle: String, webView: Weak<WKWebView>)] = []
@@ -42,40 +43,57 @@ final class WebDriverManager {
private init() {}

/**
- * Start the embedded WebDriver server if `--testDriverPort=<port>`
- * is present in the launch arguments. Idempotent.
+ * Start the embedded WebDriver servers if requested via launch arguments:
+ * --testDriverPort=<port> starts the WebDriverServer (webEngine driver)
+ * --nativeUIPort=<port> starts the NativeUIServer (native driver)
+ * Idempotent.
*/
func startIfRequested() {
- guard server == nil else { return }
-
let args = ProcessInfo.processInfo.arguments
- let portString = args.lazy
- .compactMap { $0.hasPrefix("--testDriverPort=") ? String($0.dropFirst("--testDriverPort=".count)) : nil }
- .first
- guard let portString, let port = UInt16(portString) else { return }
-
- do {
- let s = try WebDriverServer(port: port,
- jsExecutor: { [weak self] js, completion in
- self?.execute(js: js, completion: completion)
- },
- focusHandler: { [weak self] done in
- self?.focusActiveWebView(done: done)
- },
- handlesProvider: { [weak self] in
- self?.liveHandles() ?? []
- },
- switchHandler: { [weak self] handle in
- self?.switchTo(handle: handle) ?? false
- }
- )
- s.start()
- server = s
- } catch {
- NSLog("WebDriverManager: failed to start server: %@", error.localizedDescription)
+
+ if server == nil,
+ let port = readPort(from: args, prefix: "--testDriverPort=") {
+ do {
+ let s = try WebDriverServer(port: port,
+ jsExecutor: { [weak self] js, completion in
+ self?.execute(js: js, completion: completion)
+ },
+ focusHandler: { [weak self] done in
+ self?.focusActiveWebView(done: done)
+ },
+ handlesProvider: { [weak self] in
+ self?.liveHandles() ?? []
+ },
+ switchHandler: { [weak self] handle in
+ self?.switchTo(handle: handle) ?? false
+ }
+ )
+ s.start()
+ server = s
+ } catch {
+ NSLog("WebDriverManager: failed to start WebDriverServer: %@", error.localizedDescription)
+ }
+ }
+
+ if nativeServer == nil,
+ let port = readPort(from: args, prefix: "--nativeUIPort=") {
+ do {
+ let s = try NativeUIServer(port: port)
+ s.start()
+ nativeServer = s
+ } catch {
+ NSLog("WebDriverManager: failed to start NativeUIServer: %@", error.localizedDescription)
+ }
}
}

+ private func readPort(from args: [String], prefix: String) -> UInt16? {
+ args.lazy
+ .compactMap { $0.hasPrefix(prefix) ? String($0.dropFirst(prefix.count)) : nil }
+ .first
+ .flatMap { UInt16($0) }
+ }
+
/**
* Register a WKWebView and return its handle. The manager keeps a
* weak reference, so the caller must call `unregister(handle:)`
commit 1782e28d710c80ea25175ec56dcd00b1bb7a0f78
Author: Jan Holesovsky <ke...@collabora.com>
AuthorDate: Tue May 12 10:37:35 2026 +0200
Commit: Jan Holesovsky <ke...@collabora.com>
CommitDate: Mon May 25 06:08:52 2026 +0000

macOS tests: Add NativeUIServer for accessibility-driven UI tests

Add a second W3C WebDriver-style server (NativeUIServer) that drives
the macOS native UI from inside the app process via NSAccessibility.
This is the macOS counterpart of the Qt AT-SPI driver: same protocol,
different element tree.

Endpoints (W3C subset):
GET /status
POST /session, DELETE /session/{id}
POST /session/{id}/element -- find by XPath
POST /session/{id}/element/{eid}/click
POST /session/{id}/element/{eid}/value -- set value
GET /session/{id}/element/{eid}/displayed

The XPath parser handles the subset our tests use:
//*[@accessibility-id="..."]
//*[@title="..."]
//*[@role="..."]

Element finding walks NSApp.windows recursively via
accessibilityChildren(). Found elements are kept in a weak registry
keyed by UUID and returned in the W3C element shape so tests can
later send click/setValue.

Not wired in yet - the manager wiring follows separately.

Signed-off-by: Jan Holesovsky <ke...@collabora.com>
Change-Id: I316966900d14b0ac5d1774dcc893fb4ca65da7a4
Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/2470
Reviewed-by: Tor Lillqvist <t...@collabora.com>

diff --git a/macos/coda/coda/NativeUIServer.swift b/macos/coda/coda/NativeUIServer.swift
new file mode 100644
index 000000000000..ef6dc9f9e45b
--- /dev/null
+++ b/macos/coda/coda/NativeUIServer.swift
@@ -0,0 +1,309 @@
+/*
+ * Copyright the Collabora Online contributors.
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * 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/.
+ */
+
+import AppKit
+import Foundation
+import Network
+
+/**
+ * W3C WebDriver server that drives the macOS native UI via NSAccessibility.
+ *
+ * This is the macOS counterpart of the Qt AT-SPI driver: the same
+ * protocol (WebDriver), against a different element tree. Tests use
+ * `browser.native.$('//*[@accessibility-id="..."]')` and the calls
+ * arrive here, where we walk NSApp.windows via NSAccessibility and
+ * return a matching element.
+ *
+ * Supported endpoints (W3C subset):
+ * GET /status
+ * POST /session, DELETE /session/{id}
+ * POST /session/{id}/element -- find by selector
+ * POST /session/{id}/element/{eid}/click
+ * POST /session/{id}/element/{eid}/value -- set value
+ * POST /session/{id}/element/{eid}/displayed
+ *
+ * Selectors: a tiny XPath subset of the form
+ * //*[@accessibility-id="ID"]
+ * //*[@title="Title"]
+ * //*[@role="AXButton"]
+ *
+ * [Now "close" XPaths misinterpreted as comments: */*/*/*/]
+ */
+final class NativeUIServer: WebDriverHTTPServerBase {
+
+ private struct Weak<T: AnyObject> {
+ weak var value: T?
+ }
+
+ /// Element registry: WebDriver element id -> NSAccessibility object.
+ private var elements: [String: Weak<NSObject>] = [:]
+
+ init(port: UInt16) throws {
+ try super.init(port: port, label: "NativeUIServer")
+ }
+
+ override func routeRequest(_ request: HTTPRequest, connection: NWConnection) {
+ let segments = request.path.split(separator: "/").map(String.init)
+
+ // GET /status
+ if request.method == "GET" && request.path == "/status" {
+ sendW3C(connection: connection, value: ["ready": true, "message": "coda-macos-native"])
+ return
+ }
+
+ // POST /session
+ if request.method == "POST" && segments == ["session"] {
+ let newId = UUID().uuidString.lowercased()
+ sessionIds.insert(newId)
+ sendW3C(connection: connection, value: [
+ "sessionId": newId,
+ "capabilities": [String: Any]()
+ ] as [String: Any])
+ return
+ }
+
+ // DELETE /session/{id}
+ if request.method == "DELETE" && segments.count == 2 && segments[0] == "session" {
+ sessionIds.remove(segments[1])
+ sendW3C(connection: connection, value: NSNull())
+ return
+ }
+
+ // Routes that require a valid session: /session/{id}/...
+ guard segments.count >= 3 && segments[0] == "session" else {
+ sendW3CError(connection: connection, error: "unknown command",
+ message: "\(request.method) \(request.path) not implemented")
+ return
+ }
+ guard sessionIds.contains(segments[1]) else {
+ sendW3CError(connection: connection, error: "invalid session id",
+ message: "No active session with id '\(segments[1])'")
+ return
+ }
+
+ let subpath = Array(segments.dropFirst(2))
+
+ // POST /session/{id}/element
+ if request.method == "POST" && subpath == ["element"] {
+ handleFindElement(request, connection: connection)
+ return
+ }
+
+ // POST /session/{id}/element/{eid}/click
+ if request.method == "POST" && subpath.count == 3
+ && subpath[0] == "element" && subpath[2] == "click" {
+ performOnElement(id: subpath[1], connection: connection) { obj in
+ NSAccessibilityServer.performPress(on: obj)
+ }
+ return
+ }
+
+ // POST /session/{id}/element/{eid}/value
+ if request.method == "POST" && subpath.count == 3
+ && subpath[0] == "element" && subpath[2] == "value" {
+ handleSetValue(elementId: subpath[1], request: request, connection: connection)
+ return
+ }
+
+ // GET /session/{id}/element/{eid}/displayed
+ if request.method == "GET" && subpath.count == 3
+ && subpath[0] == "element" && subpath[2] == "displayed" {
+ performOnElement(id: subpath[1], connection: connection) { obj in
+ !NSAccessibilityServer.isHidden(obj)
+ }
+ return
+ }
+
+ sendW3CError(connection: connection, error: "unknown command",
+ message: "\(request.method) \(request.path) not implemented")
+ }
+
+ // MARK: - Element finding
+
+ private func handleFindElement(_ request: HTTPRequest, connection: NWConnection) {
+ guard let json = try? JSONSerialization.jsonObject(with: request.body) as? [String: Any],
+ let using = json["using"] as? String,
+ let value = json["value"] as? String else {
+ sendW3CError(connection: connection, error: "invalid argument",
+ message: "Missing 'using' or 'value'")
+ return
+ }
+ guard using == "xpath" else {
+ sendW3CError(connection: connection, error: "invalid argument",
+ message: "Only 'xpath' is supported (got '\(using)')")
+ return
+ }
+
+ guard let predicate = SimpleXPath.parse(value) else {
+ sendW3CError(connection: connection, error: "invalid argument",
+ message: "Unsupported XPath: \(value)")
+ return
+ }
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ guard let found = NSAccessibilityServer.findElement(matching: predicate) else {
+ self.sendW3CError(connection: connection, error: "no such element",
+ message: "No element matching \(value)")
+ return
+ }
+
+ let elementId = UUID().uuidString.lowercased()
+ self.elements[elementId] = Weak(value: found)
+ // W3C element shape:
+ // {"element-6066-11e4-a52e-4f735466cecf": "<id>"}
+ self.sendW3C(connection: connection, value: [
+ "element-6066-11e4-a52e-4f735466cecf": elementId
+ ])
+ }
+ }
+
+ private func performOnElement(id: String, connection: NWConnection,
+ action: @escaping (NSObject) -> Any) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ guard let weakRef = self.elements[id], let obj = weakRef.value else {
+ self.sendW3CError(connection: connection, error: "stale element reference",
+ message: "Element '\(id)' no longer exists")
+ return
+ }
+ let result = action(obj)
+ if let bool = result as? Bool {
+ self.sendW3C(connection: connection, value: bool)
+ } else {
+ self.sendW3C(connection: connection, value: NSNull())
+ }
+ }
+ }
+
+ private func handleSetValue(elementId: String, request: HTTPRequest,
+ connection: NWConnection) {
+ let json = (try? JSONSerialization.jsonObject(with: request.body) as? [String: Any]) ?? [:]
+ // W3C body: {"text": "..."}, JSON Wire: {"value": ["a","b","c"]}
+ let text: String
+ if let t = json["text"] as? String {
+ text = t
+ } else if let v = json["value"] as? [String] {
+ text = v.joined()
+ } else {
+ sendW3CError(connection: connection, error: "invalid argument",
+ message: "Missing 'text' or 'value' in body")
+ return
+ }
+
+ performOnElement(id: elementId, connection: connection) { obj in
+ NSAccessibilityServer.setValue(text, on: obj)
+ return NSNull()
+ }
+ }
+}
+
+/**
+ * Internal NSAccessibility helpers used by NativeUIServer.
+ *
+ * The macOS accessibility API exposes element attributes via Cocoa
+ * protocol methods (accessibilityChildren, accessibilityIdentifier,
+ * accessibilityTitle, ...). This wrapper walks NSApp.windows and
+ * matches against a SimpleXPath predicate.
+ */
+private enum NSAccessibilityServer {
+
+ static func findElement(matching predicate: SimpleXPath.Predicate) -> NSObject? {
+ for window in NSApp.windows {
+ if let match = walk(window, predicate: predicate) {
+ return match
+ }
+ }
+ return nil
+ }
+
+ private static func walk(_ root: NSObject, predicate: SimpleXPath.Predicate) -> NSObject? {
+ if matches(root, predicate: predicate) {
+ return root
+ }
+ let ax = root as? NSAccessibilityProtocol
+ let children = (ax?.accessibilityChildren() as? [NSObject]) ?? []
+ for child in children {
+ if let found = walk(child, predicate: predicate) {
+ return found
+ }
+ }
+ return nil
+ }
+
+ private static func matches(_ obj: NSObject, predicate: SimpleXPath.Predicate) -> Bool {
+ guard let ax = obj as? NSAccessibilityProtocol else { return false }
+ switch predicate.attribute {
+ case "accessibility-id", "identifier":
+ return ax.accessibilityIdentifier() == predicate.value
+ case "title":
+ return (ax.accessibilityTitle() ?? "") == predicate.value
+ case "role":
+ return (ax.accessibilityRole()?.rawValue ?? "") == predicate.value
+ default:
+ return false
+ }
+ }
+
+ static func performPress(on obj: NSObject) -> Any {
+ if let button = obj as? NSButton {
+ button.performClick(nil)
+ } else if let ax = obj as? NSAccessibilityProtocol {
+ _ = ax.accessibilityPerformPress()
+ }
+ return NSNull()
+ }
+
+ static func setValue(_ text: String, on obj: NSObject) {
+ if let textField = obj as? NSTextField {
+ textField.stringValue = text
+ return
+ }
+ if let ax = obj as? NSAccessibilityProtocol {
+ ax.setAccessibilityValue(text)
+ }
+ }
+
+ static func isHidden(_ obj: NSObject) -> Bool {
+ if let view = obj as? NSView {
+ return view.isHidden || view.window == nil
+ }
+ return false
+ }
+}
+
+/**
+ * Minimal XPath parser for the subset our tests use:
+ *
+ * //*[@accessibility-id="..."]
+ * //*[@title="..."]
+ * //*[@role="..."]
+ *
+ * [Now "close" XPaths misinterpreted as comments: */*/*/]
+ */
+private enum SimpleXPath {
+ struct Predicate {
+ let attribute: String
+ let value: String
+ }
+
+ static func parse(_ xpath: String) -> Predicate? {
+ // Match: //*[@<attr>="<value>"]
+ let pattern = #"^//\*\[@([\w-]+)\s*=\s*"([^"]*)"\]$"#
+ guard let regex = try? NSRegularExpression(pattern: pattern),
+ let m = regex.firstMatch(in: xpath, range: NSRange(xpath.startIndex..., in: xpath)),
+ m.numberOfRanges == 3,
+ let attrR = Range(m.range(at: 1), in: xpath),
+ let valR = Range(m.range(at: 2), in: xpath) else {
+ return nil
+ }
+ return Predicate(attribute: String(xpath[attrR]), value: String(xpath[valR]))
+ }
+}
commit 66f018f66d17700b5435e00ace2d6a9eec8913e7
Author: Jan Holesovsky <ke...@collabora.com>
AuthorDate: Tue May 12 10:36:13 2026 +0200
Commit: Jan Holesovsky <ke...@collabora.com>
CommitDate: Mon May 25 06:08:52 2026 +0000

macOS tests: Extract HTTP server base for reuse

Pull the NWListener setup, connection handling, HTTP request
parsing, and W3C response helpers out of WebDriverServer into
a new WebDriverHTTPServerBase class. Subclasses override
routeRequest() to dispatch to W3C endpoints.

WebDriverServer becomes a subclass with the route table. Behavior
is unchanged. The split lets us add a second server (for native
UI automation) without duplicating the HTTP plumbing.

Signed-off-by: Jan Holesovsky <ke...@collabora.com>
Change-Id: I63d795a41fedf3d1df850251aefec20444ae36e4
Reviewed-on: https://gerrit.collaboraoffice.com/c/online/+/2469
Reviewed-by: Tor Lillqvist <t...@collabora.com>

diff --git a/macos/coda/coda/WebDriverHTTPServerBase.swift b/macos/coda/coda/WebDriverHTTPServerBase.swift
new file mode 100644
index 000000000000..718e46e741f7
--- /dev/null
+++ b/macos/coda/coda/WebDriverHTTPServerBase.swift
@@ -0,0 +1,172 @@
+/*
+ * Copyright the Collabora Online contributors.
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * 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/.
+ */
+
+import Foundation
+import Network
+
+/**
+ * Shared HTTP plumbing for the W3C WebDriver-style servers in the app.
+ *
+ * Subclasses implement `routeRequest(_:connection:)` to dispatch
+ * parsed HTTP requests to W3C endpoints. The base class handles
+ * the NWListener, connection lifecycle, request parsing, and W3C
+ * JSON response formatting.
+ */
+class WebDriverHTTPServerBase {
+
+ /// Parsed HTTP request as the subclass needs it.
+ struct HTTPRequest {
+ let method: String
+ let path: String
+ let body: Data
+ }
+
+ private let listener: NWListener
+ private let label: String
+
+ /// All session IDs created via POST /session.
+ var sessionIds = Set<String>()
+
+ init(port: UInt16, label: String) throws {
+ let params = NWParameters.tcp
+ params.acceptLocalOnly = true
+ self.listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
+ self.label = label
+ }
+
+ func start() {
+ listener.newConnectionHandler = { [weak self] connection in
+ self?.handleConnection(connection)
+ }
+ listener.start(queue: .global(qos: .userInitiated))
+ NSLog("%@: listening on port %d", label, listener.port?.rawValue ?? 0)
+ }
+
+ func stop() {
+ listener.cancel()
+ }
+
+ /// Subclass entry point - dispatch to a W3C endpoint handler.
+ func routeRequest(_ request: HTTPRequest, connection: NWConnection) {
+ sendW3CError(connection: connection, error: "unknown command",
+ message: "\(request.method) \(request.path) not implemented")
+ }
+
+ // MARK: - Connection handling
+
+ private func handleConnection(_ connection: NWConnection) {
+ connection.start(queue: .global(qos: .userInitiated))
+ receiveRequest(connection: connection, accumulated: Data())
+ }
+
+ private func receiveRequest(connection: NWConnection, accumulated: Data) {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
+ guard let self = self else { return }
+
+ if let error = error {
+ NSLog("%@: receive error: %@", self.label, error.localizedDescription)
+ connection.cancel()
+ return
+ }
+
+ var buffer = accumulated
+ if let data = data {
+ buffer.append(data)
+ }
+
+ if let request = self.parseHTTPRequest(buffer) {
+ self.routeRequest(request, connection: connection)
+ } else if isComplete {
+ connection.cancel()
+ } else {
+ self.receiveRequest(connection: connection, accumulated: buffer)
+ }
+ }
+ }
+
+ /**
+ * Try to parse a complete HTTP request from the accumulated data.
+ * Returns nil if more data is needed.
+ */
+ private func parseHTTPRequest(_ data: Data) -> HTTPRequest? {
+ guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else {
+ return nil
+ }
+
+ let headerData = data[data.startIndex..<headerEnd.lowerBound]
+ guard let headerString = String(data: headerData, encoding: .utf8) else {
+ return nil
+ }
+
+ let lines = headerString.components(separatedBy: "\r\n")
+ guard let requestLine = lines.first else { return nil }
+ let parts = requestLine.split(separator: " ", maxSplits: 2)
+ guard parts.count >= 2 else { return nil }
+
+ let method = String(parts[0])
+ let path = String(parts[1])
+
+ var contentLength = 0
+ for line in lines.dropFirst() {
+ if line.lowercased().hasPrefix("content-length:") {
+ let value = line.dropFirst("content-length:".count).trimmingCharacters(in: .whitespaces)
+ contentLength = Int(value) ?? 0
+ }
+ }
+
+ let bodyStart = headerEnd.upperBound
+ let available = data.count - data.distance(from: data.startIndex, to: bodyStart)
+ if available < contentLength {
+ return nil
+ }
+
+ let body = data[bodyStart..<data.index(bodyStart, offsetBy: contentLength)]
+ return HTTPRequest(method: method, path: path, body: Data(body))
+ }
+
+ // MARK: - W3C response helpers
+
+ func sendW3C(connection: NWConnection, value: Any) {
+ let wrapper: [String: Any] = ["value": value]
+ if let data = try? JSONSerialization.data(withJSONObject: wrapper),
+ let body = String(data: data, encoding: .utf8) {
+ sendResponse(connection: connection, status: "200 OK", body: body)
+ } else {
+ sendResponse(connection: connection, status: "200 OK",
+ body: #"{"value":null}"#)
+ }
+ }
+
+ func sendW3CError(connection: NWConnection, error: String, message: String) {
+ let wrapper: [String: Any] = [
+ "value": [
+ "error": error,
+ "message": message,
+ "stacktrace": ""
+ ]
+ ]
+ let status = error == "invalid session id" ? "404 Not Found" : "500 Internal Server Error"
+ if let data = try? JSONSerialization.data(withJSONObject: wrapper),
+ let body = String(data: data, encoding: .utf8) {
+ sendResponse(connection: connection, status: status, body: body)
+ }
+ }
+
+ private func sendResponse(connection: NWConnection, status: String, body: String) {
+ let bodyData = Data(body.utf8)
+ let header = "HTTP/1.1 \(status)\r\nContent-Type: application/json\r\nContent-Length: \(bodyData.count)\r\nConnection: close\r\n\r\n"
+ var response = Data(header.utf8)
+ response.append(bodyData)
+
+ connection.send(content: response, completion: .contentProcessed { _ in
+ connection.cancel()
+ })
+ }
+}
diff --git a/macos/coda/coda/WebDriverServer.swift b/macos/coda/coda/WebDriverServer.swift
index 436cc0e1ff76..640e13dc1bbd 100644
--- a/macos/coda/coda/WebDriverServer.swift
+++ b/macos/coda/coda/WebDriverServer.swift
@@ -12,28 +12,23 @@ import Foundation
import Network

/**
- * W3C WebDriver protocol server for UI testing.
+ * W3C WebDriver protocol server for the WKWebView side.
*
- * Listens on localhost and implements a subset of the W3C WebDriver
- * protocol sufficient for WebDriverIO to execute JavaScript, manage
- * sessions, and track window handles. This allows the same test
- * specs to run on macOS (this server), Linux (WebEngineDriver), and
- * Windows (EdgeDriver).
+ * Implements the subset of the W3C WebDriver protocol sufficient for
+ * WebDriverIO to execute JavaScript, manage sessions, and track window
+ * handles. This allows the same test specs to run on macOS (this
+ * server), Linux (WebEngineDriver), and Windows (EdgeDriver).
*
* Additionally supports POST /focus as a custom extension to make
* the WKWebView the macOS first responder for XCUITest typing.
*/
-final class WebDriverServer {
+final class WebDriverServer: WebDriverHTTPServerBase {

- private let listener: NWListener
private let jsExecutor: (String, @escaping (Any?, Error?) -> Void) -> Void
private let focusHandler: (@escaping () -> Void) -> Void
private let handlesProvider: () -> [String]
private let switchHandler: (String) -> Bool

- /// All session IDs created via POST /session.
- private var sessionIds = Set<String>()
-
/**
* Create a WebDriver server.
*
@@ -49,110 +44,14 @@ final class WebDriverServer {
focusHandler: @escaping (@escaping () -> Void) -> Void,
handlesProvider: @escaping () -> [String],
switchHandler: @escaping (String) -> Bool) throws {
- let params = NWParameters.tcp
- params.acceptLocalOnly = true
- self.listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
self.jsExecutor = jsExecutor
self.focusHandler = focusHandler
self.handlesProvider = handlesProvider
self.switchHandler = switchHandler
+ try super.init(port: port, label: "WebDriverServer")
}

- func start() {
- listener.newConnectionHandler = { [weak self] connection in
- self?.handleConnection(connection)
- }
- listener.start(queue: .global(qos: .userInitiated))
- NSLog("WebDriverServer: listening on port %d", listener.port?.rawValue ?? 0)
- }
-
- func stop() {
- listener.cancel()
- }
-
- // MARK: - Connection handling
-
- private func handleConnection(_ connection: NWConnection) {
- connection.start(queue: .global(qos: .userInitiated))
- receiveRequest(connection: connection, accumulated: Data())
- }
-
- private func receiveRequest(connection: NWConnection, accumulated: Data) {
- connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
- guard let self = self else { return }
-
- if let error = error {
- NSLog("WebDriverServer: receive error: %@", error.localizedDescription)
- connection.cancel()
- return
- }
-
- var buffer = accumulated
- if let data = data {
- buffer.append(data)
- }
-
- if let request = self.parseHTTPRequest(buffer) {
- self.routeRequest(request, connection: connection)
- } else if isComplete {
- connection.cancel()
- } else {
- self.receiveRequest(connection: connection, accumulated: buffer)
- }
- }
- }
-
- // MARK: - HTTP parsing
-
- private struct HTTPRequest {
- let method: String
- let path: String
- let body: Data
- }
-
- /**
- * Try to parse a complete HTTP request from the accumulated data.
- * Returns nil if more data is needed.
- */
- private func parseHTTPRequest(_ data: Data) -> HTTPRequest? {
- guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else {
- return nil
- }
-
- let headerData = data[data.startIndex..<headerEnd.lowerBound]
- guard let headerString = String(data: headerData, encoding: .utf8) else {
- return nil
- }
-
- let lines = headerString.components(separatedBy: "\r\n")
- guard let requestLine = lines.first else { return nil }
- let parts = requestLine.split(separator: " ", maxSplits: 2)
- guard parts.count >= 2 else { return nil }
-
- let method = String(parts[0])
- let path = String(parts[1])
-
- var contentLength = 0
- for line in lines.dropFirst() {
- if line.lowercased().hasPrefix("content-length:") {
- let value = line.dropFirst("content-length:".count).trimmingCharacters(in: .whitespaces)
- contentLength = Int(value) ?? 0
- }
- }
-
- let bodyStart = headerEnd.upperBound
- let available = data.count - data.distance(from: data.startIndex, to: bodyStart)
- if available < contentLength {
- return nil
- }
-
- let body = data[bodyStart..<data.index(bodyStart, offsetBy: contentLength)]
- return HTTPRequest(method: method, path: path, body: Data(body))
- }
-
- // MARK: - W3C WebDriver routing
-
- private func routeRequest(_ request: HTTPRequest, connection: NWConnection) {
+ override func routeRequest(_ request: HTTPRequest, connection: NWConnection) {
let segments = request.path.split(separator: "/").map(String.init)

// GET /status
@@ -198,7 +97,7 @@ final class WebDriverServer {

// GET /session/{id}/source
if request.method == "GET" && subpath == ["source"] {
- jsExecutor("document.documentElement.outerHTML") { [weak self] result, error in
+ jsExecutor("document.documentElement.outerHTML") { [weak self] result, _ in
if let html = result as? String {
self?.sendW3C(connection: connection, value: html)
} else {
@@ -301,43 +200,4 @@ final class WebDriverServer {
}
}
}
-
- // MARK: - W3C response helpers
-
- private func sendW3C(connection: NWConnection, value: Any) {
- let wrapper: [String: Any] = ["value": value]
- if let data = try? JSONSerialization.data(withJSONObject: wrapper),
- let body = String(data: data, encoding: .utf8) {
- sendResponse(connection: connection, status: "200 OK", body: body)
- } else {
- sendResponse(connection: connection, status: "200 OK",
- body: #"{"value":null}"#)
- }
- }
-
- private func sendW3CError(connection: NWConnection, error: String, message: String) {
- let wrapper: [String: Any] = [
- "value": [
- "error": error,
- "message": message,
- "stacktrace": ""
- ]
- ]
- let status = error == "invalid session id" ? "404 Not Found" : "500 Internal Server Error"
- if let data = try? JSONSerialization.data(withJSONObject: wrapper),
- let body = String(data: data, encoding: .utf8) {
- sendResponse(connection: connection, status: status, body: body)
- }
- }
-
- private func sendResponse(connection: NWConnection, status: String, body: String) {
- let bodyData = Data(body.utf8)
- let header = "HTTP/1.1 \(status)\r\nContent-Type: application/json\r\nContent-Length: \(bodyData.count)\r\nConnection: close\r\n\r\n"
- var response = Data(header.utf8)
- response.append(bodyData)
-
- connection.send(content: response, completion: .contentProcessed { _ in
- connection.cancel()
- })
- }
}

Reply all
Reply to author
Forward
0 new messages