From 9e6ad79cee4219e5fe8604eab8cbc25d01da32bf Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 6 Nov 2026 04:31:16 +0000 Subject: [PATCH 1/6] Add loopback TCP attach for native test hosts --- README.md | 25 ++++++++++++++++++++++++- bin/main.ml | 24 +++++++++++++++++++++--- docs/api-migrations.md | 30 ++++++++++++++++++++++++++++++ src/live.ml | 20 +++++++++++++++++--- test/test_drive.ml | 13 +++++++++++++ 5 files changed, 105 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6b7dbe0..713d0c6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Three ways to drive: loads, e.g. `libmeng_editor.so` built via `ocamlopt -output-complete-obj`). Patches stream back as wire JSON and are replayed into the same `Model`. -- **Live** (`Drive.Live`): attach over Unix socket or WebSocket. Use +- **Live** (`Drive.Live`): attach over Unix socket, loopback TCP or WebSocket. Use `--input semantic` (default) for injected LUI events, or `--input native` for capability-checked, acknowledged host input. Native input never falls back to semantic injection. @@ -92,6 +92,29 @@ Because the scenario drives the *live* process, the real UI visibly responds, but semantic events do not exercise native hit testing, gesture recognition, or focus handling. +## Loopback TCP attach (`--tcp`) + +Native test hosts can expose the same newline-delimited JSON protocol on a +loopback TCP listener. This also works across an explicit device port forward: + +```sh +adb -s "$DEVICE" forward tcp:49377 tcp:49377 +drive --tcp 127.0.0.1:49377 --input native scenario.drive +adb -s "$DEVICE" forward --remove tcp:49377 +``` + +Start a compatible test host on the device first; port forwarding alone does +not provide native input. `--tcp` accepts only `127.0.0.1`, `localhost` +(mapped directly to IPv4 loopback), or `[::1]`, with a port from 1 to 65535. +No DNS lookup or external interface is allowed. It is mutually exclusive with +`--lib`, `--socket` and `--ws-listen`. There is no reconnect/retry: a timed-out +action may already have run. Close the host and start a new test session. + +Embedding callers use `Drive.Live.connect_tcp ~host:"127.0.0.1" ~port:49377`, +then `Drive.Live.driver ~timeout:5.0` and `Drive.Live.close`, just as for Unix +socket attach. No native capability is implied by the transport: the host's +v1 handshake remains mandatory. + ## Coordinate taps (`tap x y`) In semantic mode, selector events target a node id directly; `tap x y` diff --git a/bin/main.ml b/bin/main.ml index 9b3af3c..82793b1 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -9,7 +9,7 @@ let usage = "usage: drive (--lib [--os macos|ios|android|linux|windows|generic] \\\n\ \ [--host swiftui|web|kotlin|generic] \\\n\ - \ [--env NAME=VALUE]... | --socket | --ws-listen ) \\\n\ + \ [--env NAME=VALUE]... | --socket | --tcp | --ws-listen ) \\\n\ \ " let main () = @@ -18,6 +18,7 @@ let main () = Sys.set_signal Sys.sigpipe Sys.Signal_ignore; let lib = ref "" in let socket = ref "" in + let tcp = ref "" in let ws_listen = ref 0 in let os = ref "generic" in let host = ref "generic" in @@ -33,6 +34,7 @@ let main () = [ ("--lib", Arg.Set_string lib, "shared library exporting lui_ocaml_* ABI"); ("--socket", Arg.Set_string socket, "attach to a live app's MENG_DRIVE_SOCKET"); + ("--tcp", Arg.Set_string tcp, "HOST:PORT attach to a loopback native host (e.g. 127.0.0.1:49377)"); ( "--ws-listen", Arg.Set_int ws_listen, "PORT accept a WebSocket host attach (web: page dials out to drive)" ); @@ -45,13 +47,13 @@ let main () = ("--env", Arg.String (fun kv -> env := kv :: !env), "NAME=VALUE for the target process env"); ] anon usage; - let targets = List.filter Fun.id [ !lib <> ""; !socket <> ""; !ws_listen <> 0 ] in + let targets = List.filter Fun.id [ !lib <> ""; !socket <> ""; !tcp <> ""; !ws_listen <> 0 ] in if List.length targets <> 1 || !scenario = "" then begin prerr_endline usage; exit 2 end; if !input = Drive.Input.Native && !lib <> "" then - failwith "drive: native input requires --socket or --ws-listen; FFI only supports semantic injection"; + failwith "drive: native input requires --socket, --tcp or --ws-listen; FFI only supports semantic injection"; if not (Float.is_finite !timeout) || !timeout <= 0.0 then failwith "drive: --action-timeout must be a finite positive number"; List.iter @@ -97,6 +99,22 @@ let main () = exit 2 in (Drive.Live.driver ~timeout:!timeout live, fun () -> Drive.Live.close live) + else if !tcp <> "" then + let host, port = + match String.rindex_opt !tcp ':' with + | Some colon -> + let host = String.sub !tcp 0 colon in + let host = if host = "[::1]" then "::1" else host in + let port = + String.sub !tcp (colon + 1) (String.length !tcp - colon - 1) + in + (match int_of_string_opt port with + | Some port -> host, port + | None -> failwith "drive: --tcp expects a loopback HOST:PORT") + | None -> failwith "drive: --tcp expects a loopback HOST:PORT" + in + let live = Drive.Live.connect_tcp ~host ~port in + (Drive.Live.driver ~timeout:!timeout live, fun () -> Drive.Live.close live) else if !ws_listen <> 0 then let live = try Drive.Live.listen_ws ~port:!ws_listen diff --git a/docs/api-migrations.md b/docs/api-migrations.md index bc54440..9018ba0 100644 --- a/docs/api-migrations.md +++ b/docs/api-migrations.md @@ -1,5 +1,35 @@ # API and wire migrations +## Loopback TCP native host attach + +`Live.connect_tcp ~host ~port` and `drive --tcp HOST:PORT` add an optional +newline-delimited TCP transport for native test hosts and device port forwards. +The wire protocol and capability requirements remain v1; existing Unix socket, +WebSocket and semantic/FFI integrations do not change. + +Only `127.0.0.1`, `localhost` (without DNS, mapped to IPv4 loopback), and `::1` +are accepted by the library. Use `[::1]:PORT` at the CLI. Ports must be in +1..65535. Other hosts are rejected before connecting. Hosts must independently +bind loopback and opt in to test control; do not ship an auto-starting listener. + +```ocaml +let live = Drive.Live.connect_tcp ~host:"127.0.0.1" ~port:49377 in +Fun.protect ~finally:(fun () -> Drive.Live.close live) (fun () -> + let driver = Drive.Live.driver ~timeout:5.0 live in + Drive.Scenario.run ~input:Drive.Input.Native driver source) +``` + +```sh +# With a compatible Android test host already running: +adb -s "$DEVICE" forward tcp:49377 tcp:49377 +drive --tcp 127.0.0.1:49377 --input native smoke.drive +adb -s "$DEVICE" forward --remove tcp:49377 +``` + +TCP is mutually exclusive with the other CLI transports. It does not launch +an app, simulator, emulator, test IME or native input adapter, and never changes +the default semantic mode. Unsupported native actions still fail explicitly. + ## Explicit input modes and native host protocol v1 The default remains `Drive.Input.Semantic`. Existing in-process, FFI, and diff --git a/src/live.ml b/src/live.ml index e1de745..b277ed3 100644 --- a/src/live.ml +++ b/src/live.ml @@ -45,9 +45,9 @@ let enqueue t line = Queue.add line t.queue; Mutex.unlock t.mu -let connect ~socket_path = - let sock = Unix.socket Unix.PF_UNIX Unix.SOCK_STREAM 0 in - Unix.connect sock (Unix.ADDR_UNIX socket_path); +let connect_stream ~domain address = + let sock = Unix.socket domain Unix.SOCK_STREAM 0 in + (try Unix.connect sock address with exn -> Unix.close sock; raise exn); let ic = Unix.in_channel_of_descr sock in let oc = Unix.out_channel_of_descr (Unix.dup sock) in let close () = @@ -74,6 +74,20 @@ let connect ~socket_path = ()); t +let connect ~socket_path = + connect_stream ~domain:Unix.PF_UNIX (Unix.ADDR_UNIX socket_path) + +let connect_tcp ~host ~port = + if port < 1 || port > 65535 then + invalid_arg "drive: TCP port must be between 1 and 65535"; + let domain, address = + match host with + | "127.0.0.1" | "localhost" -> Unix.PF_INET, Unix.inet_addr_loopback + | "::1" -> Unix.PF_INET6, Unix.inet6_addr_loopback + | _ -> invalid_arg "drive: TCP attach must use 127.0.0.1, localhost or [::1]" + in + connect_stream ~domain (Unix.ADDR_INET (address, port)) + (* Browser hosts cannot accept connections, so for web attach the page dials out: `drive --ws-listen ` accepts a WebSocket and speaks the same line protocol (each text message = one line). *) diff --git a/test/test_drive.ml b/test/test_drive.ml index 719b863..297a714 100644 --- a/test/test_drive.ml +++ b/test/test_drive.ml @@ -388,6 +388,18 @@ let test_ws_frame_codec () = Unix.close r; Unix.close w +let test_tcp_loopback_admission () = + List.iter + (fun (host, port) -> + match Live.connect_tcp ~host ~port with + | live -> + Live.close live; + Alcotest.fail "unsafe TCP endpoint accepted" + | exception Invalid_argument _ -> ()) + [ ("0.0.0.0", 49377); ("192.0.2.1", 49377); ("::", 49377); + ("example.com", 49377); ("127.0.0.1.example.com", 49377); + ("127.0.0.1", 0); ("localhost", -1); ("::1", 65536) ] + let () = Alcotest.run "drive" [ @@ -428,6 +440,7 @@ let () = Alcotest.test_case "tap miss / no frames" `Quick test_live_tap_miss; Alcotest.test_case "native completion contract" `Quick test_native_completion_contract; + Alcotest.test_case "TCP attach is loopback-only" `Quick test_tcp_loopback_admission; ] ); ( "ws", [ From 6b117f538a2ce0ad36234eee938e3c16aa0911b0 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 6 Nov 2026 04:43:20 +0000 Subject: [PATCH 2/6] Add source-only iOS XCTest native input runner --- apple/.gitignore | 2 + apple/DriveTCP.swift | 207 +++++++++++++++ apple/DriveUITests.example.swift | 28 +++ apple/DriveWire.swift | 150 +++++++++++ apple/DriveXCTestRunner.swift | 415 +++++++++++++++++++++++++++++++ apple/README.md | 198 +++++++++++++++ apple/generate-project.py | 135 ++++++++++ 7 files changed, 1135 insertions(+) create mode 100644 apple/.gitignore create mode 100644 apple/DriveTCP.swift create mode 100644 apple/DriveUITests.example.swift create mode 100644 apple/DriveWire.swift create mode 100644 apple/DriveXCTestRunner.swift create mode 100644 apple/README.md create mode 100644 apple/generate-project.py diff --git a/apple/.gitignore b/apple/.gitignore new file mode 100644 index 0000000..0141686 --- /dev/null +++ b/apple/.gitignore @@ -0,0 +1,2 @@ +.generated/ +__pycache__/ diff --git a/apple/DriveTCP.swift b/apple/DriveTCP.swift new file mode 100644 index 0000000..8784c7b --- /dev/null +++ b/apple/DriveTCP.swift @@ -0,0 +1,207 @@ +#if os(iOS) +import Foundation +import Network + +// All mutable state crossing the Network callback queue is protected by condition. +final class DriveTCPChannel: @unchecked Sendable { + private let connection: NWConnection + private let queue = DispatchQueue(label: "dev.lui.drive.tcp") + private let condition = NSCondition() + private var buffer = Data() + private var closed: DriveFailure? + private var deadlineToken = UUID() + private let maximumLine: Int + + init(connection: NWConnection, maximumLine: Int = 1_048_576) { + self.connection = connection + self.maximumLine = maximumLine + connection.stateUpdateHandler = { [weak self] state in + switch state { + case let .failed(error): self?.close(.transport(String(describing: error))) + case .cancelled: self?.close(.transport("connection cancelled")) + default: break + } + } + connection.start(queue: queue) + receive() + } + + convenience init(loopbackPort: UInt16) { + self.init(connection: NWConnection( + host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: loopbackPort)!, using: .tcp + ), maximumLine: 33_554_432) + } + + deinit { connection.cancel() } + + func close(_ reason: DriveFailure = .transport("disconnected")) { + condition.lock() + if closed == nil { closed = reason } + buffer.removeAll(keepingCapacity: false) + condition.broadcast() + condition.unlock() + connection.cancel() + } + + func check(deadline: TimeInterval) throws { + condition.lock() + let failure = closed + condition.unlock() + if let failure { throw failure } + if ProcessInfo.processInfo.systemUptime >= deadline { + close(.deadline) + throw DriveFailure.deadline + } + } + + func arm(deadline: TimeInterval) { + let token = UUID() + condition.lock() + deadlineToken = token + condition.unlock() + queue.asyncAfter(deadline: .now() + max(0, deadline - ProcessInfo.processInfo.systemUptime)) { [weak self] in + guard let self else { return } + self.condition.lock() + let expired = self.deadlineToken == token && self.closed == nil + if expired { + self.closed = .deadline + self.buffer.removeAll(keepingCapacity: false) + self.condition.broadcast() + } + self.condition.unlock() + if expired { self.connection.cancel() } + } + } + + func disarm() { + condition.lock() + deadlineToken = UUID() + condition.unlock() + } + + func readLine(until deadline: TimeInterval) throws -> Data? { + condition.lock() + defer { condition.unlock() } + while true { + if let closed { throw closed } + if let index = buffer.firstIndex(of: 10) { + let line = Data(buffer.prefix(upTo: index)) + buffer.removeSubrange(...index) + guard !line.isEmpty else { throw DriveFailure.protocolViolation("empty JSON line") } + return line + } + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return nil } + _ = condition.wait(until: Date(timeIntervalSinceNow: remaining)) + } + } + + private final class Receipt: @unchecked Sendable { + let condition = NSCondition() + var complete = false + var error: String? + + func finish(_ error: NWError?) { + condition.lock() + self.error = error.map { String(describing: $0) } + complete = true + condition.broadcast() + condition.unlock() + } + } + + func send(_ value: DriveJSON, deadline: TimeInterval) throws { + try check(deadline: deadline) + var data = try value.data() + data.append(10) + let receipt = Receipt() + connection.send(content: data, completion: .contentProcessed { receipt.finish($0) }) + receipt.condition.lock() + while !receipt.complete { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + if remaining <= 0 { break } + _ = receipt.condition.wait(until: Date(timeIntervalSinceNow: remaining)) + } + let complete = receipt.complete + let error = receipt.error + receipt.condition.unlock() + guard complete else { close(.deadline); throw DriveFailure.deadline } + if let error { close(.transport(error)); throw DriveFailure.transport(error) } + try check(deadline: deadline) + } + + private func receive() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, done, error in + guard let self else { return } + self.condition.lock() + if let data, self.closed == nil { self.buffer.append(data) } + let overflow = self.buffer.count > self.maximumLine + self.condition.broadcast() + self.condition.unlock() + if overflow { self.close(.protocolViolation("JSON line buffer limit exceeded")) } + else if let error { self.close(.transport(String(describing: error))) } + else if done { self.close() } + else { self.receive() } + } + } +} + +final class DriveTCPServer: @unchecked Sendable { + private let listener: NWListener + private let condition = NSCondition() + private var channel: DriveTCPChannel? + private var accepted = false + private var failure: String? + + init(port: UInt16) throws { + guard port >= 1024 else { throw DriveFailure.rejected("runner port must be 1024...65535") } + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: port)!) + listener = try NWListener(using: parameters) + listener.newConnectionHandler = { [weak self] connection in + guard let self, case let .hostPort(host, _) = connection.endpoint, + host == .ipv4(.loopback) else { connection.cancel(); return } + self.condition.lock() + if self.accepted { self.condition.unlock(); connection.cancel(); return } + self.accepted = true + self.channel = DriveTCPChannel(connection: connection) + self.condition.broadcast() + self.condition.unlock() + } + listener.stateUpdateHandler = { [weak self] state in + guard let self, case let .failed(error) = state else { return } + self.condition.lock() + self.failure = String(describing: error) + self.condition.broadcast() + self.condition.unlock() + } + listener.start(queue: DispatchQueue(label: "dev.lui.drive.listener")) + } + + func accept(deadline: TimeInterval) throws -> DriveTCPChannel { + condition.lock() + defer { condition.unlock() } + while channel == nil && failure == nil { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { throw DriveFailure.deadline } + _ = condition.wait(until: Date(timeIntervalSinceNow: remaining)) + } + if let failure { throw DriveFailure.transport(failure) } + guard let channel else { throw DriveFailure.transport("listener closed") } + return channel + } + + func stop() { + listener.cancel() + condition.lock() + accepted = true + failure = "listener stopped" + let channel = channel + condition.broadcast() + condition.unlock() + channel?.close() + } + + deinit { listener.cancel() } +} +#endif diff --git a/apple/DriveUITests.example.swift b/apple/DriveUITests.example.swift new file mode 100644 index 0000000..e11f3a1 --- /dev/null +++ b/apple/DriveUITests.example.swift @@ -0,0 +1,28 @@ +#if os(iOS) +import XCTest + +@MainActor +final class DriveUITests: XCTestCase { + func testDriveSession() throws { + let environment = ProcessInfo.processInfo.environment + guard environment["DRIVE_XCTEST"] == "1" else { throw XCTSkip("Set DRIVE_XCTEST=1 to run the Drive host") } + guard let bundleID = environment["DRIVE_APP_BUNDLE_ID"], !bundleID.isEmpty, + let probePort = UInt16(environment["LUI_DRIVE_PROBE_PORT"] ?? "49378"), + let runnerPort = UInt16(environment["DRIVE_RUNNER_PORT"] ?? "49379"), + let actionTimeout = Double(environment["DRIVE_ACTION_TIMEOUT"] ?? "4"), + let sessionTimeout = Double(environment["DRIVE_SESSION_TIMEOUT"] ?? "300"), + probePort != runnerPort else { + throw DriveFailure.rejected("invalid Drive UI-test environment") + } + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: bundleID) + app.launchEnvironment["LUI_DRIVE_PROBE"] = "1" + app.launchEnvironment["LUI_DRIVE_PROBE_PORT"] = String(probePort) + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + defer { app.terminate() } + let runner = try DriveXCTestRunner(app: app, probePort: probePort, actionTimeout: actionTimeout) + try runner.run(port: runnerPort, sessionTimeout: sessionTimeout) + } +} +#endif diff --git a/apple/DriveWire.swift b/apple/DriveWire.swift new file mode 100644 index 0000000..3bbd933 --- /dev/null +++ b/apple/DriveWire.swift @@ -0,0 +1,150 @@ +import Foundation +import CoreFoundation + +enum DriveFailure: Error, CustomStringConvertible { + case protocolViolation(String) + case rejected(String) + case transport(String) + case deadline + + var description: String { + switch self { + case let .protocolViolation(message), let .rejected(message), let .transport(message): message + case .deadline: "Drive deadline exceeded; transport is poisoned" + } + } +} + +enum DriveJSON: Codable, Equatable { + case object([String: DriveJSON]), array([DriveJSON]), string(String) + case bool(Bool), int(Int), double(Double), null + + init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer() + if value.decodeNil() { self = .null } + else if let decoded = try? value.decode(Bool.self) { self = .bool(decoded) } + else if let decoded = try? value.decode(Int.self) { self = .int(decoded) } + else if let decoded = try? value.decode(Double.self) { self = .double(decoded) } + else if let decoded = try? value.decode(String.self) { self = .string(decoded) } + else if let decoded = try? value.decode([DriveJSON].self) { self = .array(decoded) } + else { self = .object(try value.decode([String: DriveJSON].self)) } + } + + func encode(to encoder: Encoder) throws { + var value = encoder.singleValueContainer() + switch self { + case let .object(v): try value.encode(v) + case let .array(v): try value.encode(v) + case let .string(v): try value.encode(v) + case let .bool(v): try value.encode(v) + case let .int(v): try value.encode(v) + case let .double(v): try value.encode(v) + case .null: try value.encodeNil() + } + } + + subscript(_ key: String) -> DriveJSON? { + guard case let .object(value) = self else { return nil } + return value[key] + } + + var integer: Int? { if case let .int(v) = self { v } else { nil } } + var string: String? { if case let .string(v) = self { v } else { nil } } + var number: Double? { + switch self { + case let .double(v): v + case let .int(v): Double(v) + default: nil + } + } + + func data() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(self) + } +} + +struct DriveRequest { + static let capabilities = [ + "click", "double-click", "long-click", "tap", "type", "key", + "scroll", "drag", "screenshot", "settle", + ] + let request: Int + let action: String + let fields: DriveJSON + + init(line: Data, previousRequest: Int) throws { + let value = try JSONDecoder().decode(DriveJSON.self, from: line) + guard let raw = try JSONSerialization.jsonObject(with: line) as? [String: Any] else { + throw DriveFailure.protocolViolation("expected a JSON object") + } + guard case let .object(fields) = value, + value["drive"]?.string == "action", value["version"]?.integer == 1, + let request = value["request"]?.integer, request > 0, + request <= 9_007_199_254_740_991, request == previousRequest + 1, + let action = value["action"]?.string, Self.capabilities.contains(action) + else { throw DriveFailure.protocolViolation("invalid Drive v1 action, capability, or sequential request ID") } + let required: Set + switch action { + case "click", "double-click", "long-click": required = ["id"] + case "type": required = ["id", "text"] + case "key": required = ["combo"] + case "tap": required = ["x", "y"] + case "scroll": required = ["id", "dx", "dy"] + case "drag": required = ["id", "target"] + default: required = [] + } + guard Set(fields.keys) == required.union(["drive", "version", "request", "action"]) else { + throw DriveFailure.protocolViolation("missing or unexpected action fields") + } + for key in ["version", "request", "id", "target"] where raw[key] != nil { + guard let number = raw[key] as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + !["f", "d"].contains(String(cString: number.objCType)) else { + throw DriveFailure.protocolViolation("expected integer \(key)") + } + } + for key in required { + switch key { + case "id", "target": + guard let id = value[key]?.integer, id >= 0 else { + throw DriveFailure.protocolViolation("invalid node ID") + } + case "text", "combo": + guard value[key]?.string != nil else { throw DriveFailure.protocolViolation("expected string") } + default: + guard let number = value[key]?.number, number.isFinite else { + throw DriveFailure.protocolViolation("expected finite viewport points") + } + } + } + self.request = request + self.action = action + self.fields = value + } +} + +struct DriveProbeSnapshot: Decodable { + struct Identifier: Decodable { + let identifier: String + let explicit: Bool + } + let generation: Int + let commitSequence: Int + let committed: Bool + let ops: [DriveJSON] + let nodeIDs: [Int] + let roots: [Int] + let frames: [[Double]] + let nativeIdentifiers: [String: Identifier] + let viewport: [Double] + let coordinateSpace: String +} + +struct DriveProbeReply: Decodable { + let probe: String + let version: Int + let request: Int + let snapshot: DriveProbeSnapshot +} diff --git a/apple/DriveXCTestRunner.swift b/apple/DriveXCTestRunner.swift new file mode 100644 index 0000000..e6ca686 --- /dev/null +++ b/apple/DriveXCTestRunner.swift @@ -0,0 +1,415 @@ +#if os(iOS) +import Foundation +import XCTest + +/// Compile into an XCTest UI-testing target only, never the application target. +@MainActor +public final class DriveXCTestRunner { + private let app: XCUIApplication + private let probe: DriveTCPChannel + private let actionTimeout: TimeInterval + private var probeRequest = 0 + private var publishedIDs: [Int] = [] + private var publishedSignature: Data? + + public init(app: XCUIApplication, probePort: UInt16 = 49378, actionTimeout: TimeInterval = 4) throws { + guard probePort >= 1024, actionTimeout.isFinite, (0.5...120).contains(actionTimeout) else { + throw DriveFailure.rejected("invalid probe port or action timeout (0.5...120 seconds)") + } + self.app = app + self.actionTimeout = actionTimeout + probe = DriveTCPChannel(loopbackPort: probePort) + } + + /// Blocks this UI test until Drive disconnects or the bounded session expires. + public func run(port: UInt16 = 49379, sessionTimeout: TimeInterval = 300) throws { + guard sessionTimeout.isFinite, (1...3600).contains(sessionTimeout) else { + throw DriveFailure.rejected("session timeout must be 1...3600 seconds") + } + let end = now + sessionTimeout + let server = try DriveTCPServer(port: port) + defer { server.stop(); probe.close() } + let client = try server.accept(deadline: end) + var previousRequest = 0 + let initialDeadline = min(end, now + actionTimeout) + client.arm(deadline: initialDeadline) + try client.send(.object([ + "drive": .string("hello"), "version": .int(1), + "capabilities": .array(DriveRequest.capabilities.map { .string($0) }), + ]), deadline: initialDeadline) + let initial = try quietSnapshot(client: client, deadline: initialDeadline) + // Live.driver becomes ready on ops, so initial frames must precede it. + try publish(initial, client: client, deadline: initialDeadline, initial: true) + client.disarm() + while now < end { + let line: Data? + do { line = try client.readLine(until: min(end, now + 0.05)) } + catch let failure as DriveFailure { + if case .transport = failure { return } + throw failure + } + if let line { + let request = try DriveRequest(line: line, previousRequest: previousRequest) + previousRequest = request.request + let deadline = min(end, now + actionTimeout) + client.arm(deadline: deadline) + var rejection: String? + do { try perform(request, client: client, deadline: deadline) } + catch let failure as DriveFailure { + if case let .rejected(message) = failure { rejection = message } + else { throw failure } + } + let latest = try quietSnapshot(client: client, deadline: deadline) + if rejection == nil, request.action == "type", + let id = request.fields["id"]?.integer, + retainedText(id, snapshot: latest.snapshot) != request.fields["text"]?.string { + rejection = "native text replacement did not produce the requested retained text" + } + var result: [String: DriveJSON] = [ + "drive": .string("result"), "version": .int(1), + "request": .int(request.request), "ok": .bool(rejection == nil), + ] + if let rejection { result["error"] = .string(rejection) } + if rejection == nil && request.action == "screenshot" { + try client.check(deadline: deadline) + result["png"] = .string(app.screenshot().pngRepresentation.base64EncodedString()) + } + // A fresh capture after screenshot also catches layout changes during capture. + let final: Capture + if request.action == "screenshot" { + final = try quietSnapshot(client: client, deadline: deadline) + } else { + final = latest + } + try publish(final, client: client, deadline: deadline) + try client.send(.object(result), deadline: deadline) + client.disarm() + } else { + let deadline = min(end, now + actionTimeout) + client.arm(deadline: deadline) + let latest = try capture(client: client, deadline: deadline) + if latest.signature != publishedSignature { + try publish(latest, client: client, deadline: deadline) + } + client.disarm() + } + } + client.close(.deadline) + throw DriveFailure.deadline + } + + private var now: TimeInterval { ProcessInfo.processInfo.systemUptime } + + private struct Capture { + let snapshot: DriveProbeSnapshot + let frames: DriveJSON + let signature: Data + } + + private func snapshot(client: DriveTCPChannel, deadline: TimeInterval) throws -> (DriveProbeSnapshot, Data) { + try client.check(deadline: deadline) + probeRequest += 1 + try probe.send(.object([ + "probe": .string("snapshot"), "version": .int(1), "request": .int(probeRequest), + ]), deadline: deadline) + guard let data = try probe.readLine(until: deadline) else { + client.close(.deadline); probe.close(.deadline); throw DriveFailure.deadline + } + try client.check(deadline: deadline) + let reply = try JSONDecoder().decode(DriveProbeReply.self, from: data) + let value = reply.snapshot + guard reply.probe == "snapshot", reply.version == 1, reply.request == probeRequest, + value.coordinateSpace == "window-top-left-points", + value.viewport.count == 4, value.viewport.allSatisfy(\.isFinite), + value.viewport[0] == 0, value.viewport[1] == 0, + value.viewport[2] > 0, value.viewport[3] > 0, + Set(value.nodeIDs).count == value.nodeIDs.count, + value.frames.allSatisfy({ $0.count == 5 && $0.allSatisfy(\.isFinite) }) else { + throw DriveFailure.protocolViolation("invalid or mismatched LUI probe snapshot") + } + return (value, data) + } + + private func window(_ snapshot: DriveProbeSnapshot) throws -> XCUIElement { + guard app.state == .runningForeground else { throw DriveFailure.rejected("app is not foreground") } + let windows = app.windows.allElementsBoundByIndex.filter { $0.exists && !$0.frame.isEmpty } + guard windows.count == 1 else { + throw DriveFailure.rejected("Drive iOS requires exactly one visible application window") + } + let window = windows[0] + let frame = window.frame + guard abs(frame.width - snapshot.viewport[2]) <= 1, + abs(frame.height - snapshot.viewport[3]) <= 1 else { + throw DriveFailure.rejected("native window and probe viewport disagree; rotation/multiple scenes unsupported") + } + return window + } + + private func capture(client: DriveTCPChannel, deadline: TimeInterval) throws -> Capture { + let (snapshot, data) = try snapshot(client: client, deadline: deadline) + let window = try window(snapshot) + let origin = window.frame.origin + let ids = Set(snapshot.nodeIDs) + var frames: [Int: [DriveJSON]] = [:] + for frame in snapshot.frames { + guard frame[0] >= 0, frame[0] < Double(Int.max), frame[0].rounded() == frame[0] else { + throw DriveFailure.protocolViolation("invalid probe frame ID") + } + let id = Int(frame[0]) + guard ids.contains(id), frame[3] >= 0, frame[4] >= 0 else { + throw DriveFailure.protocolViolation("orphan or negative probe frame") + } + frames[id] = [.int(id)] + frame.dropFirst().map { .double($0) } + } + var nativeState: [DriveJSON] = [] + for key in snapshot.nativeIdentifiers.keys.sorted() { + try client.check(deadline: deadline) + guard let id = Int(key), ids.contains(id), let info = snapshot.nativeIdentifiers[key] else { + throw DriveFailure.protocolViolation("orphan native identifier") + } + // Retained SwiftUI geometry is not used for native target actions. + frames.removeValue(forKey: id) + let query = nativeQuery(identifier: info.identifier) + let count = info.identifier.isEmpty ? 0 : query.count + var state: [DriveJSON] = [.int(id), .int(count)] + if count == 1 { + let element = query.element(boundBy: 0) + let rect = element.frame.offsetBy(dx: -origin.x, dy: -origin.y) + let valid = element.exists && rect.minX.isFinite && rect.minY.isFinite && + rect.width.isFinite && rect.height.isFinite && !rect.isEmpty + if valid { + let entry: [DriveJSON] = [.int(id), .double(rect.minX), .double(rect.minY), + .double(rect.width), .double(rect.height)] + frames[id] = entry + state += [.array(entry), .bool(element.isEnabled), .bool(element.isHittable)] + } + } + nativeState.append(.array(state)) + } + try client.check(deadline: deadline) + let wireFrames = DriveJSON.object([ + "frames": .array(frames.keys.sorted().compactMap { frames[$0].map { .array($0) } }), + ]) + // Probe request IDs are excluded from stability; the actual tree and native state are included. + let raw = try JSONDecoder().decode(DriveJSON.self, from: data) + let signature = try DriveJSON.array([ + raw["snapshot"] ?? .null, .array(nativeState), wireFrames, + ]).data() + return Capture(snapshot: snapshot, frames: wireFrames, signature: signature) + } + + private func quietSnapshot(client: DriveTCPChannel, deadline: TimeInterval) throws -> Capture { + var previous: Data? + var quietSince = now + while true { + try client.check(deadline: deadline) + let current = try capture(client: client, deadline: deadline) + if current.signature != previous || !current.snapshot.committed { quietSince = now } + previous = current.signature + if current.snapshot.committed && now - quietSince >= 0.1 { return current } + // Polling cadence, not an unconditional settle or a network-idle claim. + RunLoop.current.run(until: Date(timeIntervalSinceNow: min(0.02, max(0, deadline - now)))) + } + } + + private func publish(_ current: Capture, client: DriveTCPChannel, deadline: TimeInterval, initial: Bool = false) throws { + let drops = publishedIDs.map { DriveJSON.object(["op": .string("drop-node"), "id": .int($0)]) } + let batch = DriveJSON.object([ + "generation": .int(current.snapshot.generation), "ops": .array(drops + current.snapshot.ops), + ]) + if initial { try client.send(current.frames, deadline: deadline) } + try client.send(batch, deadline: deadline) + if !initial { try client.send(current.frames, deadline: deadline) } + publishedIDs = current.snapshot.nodeIDs + publishedSignature = current.signature + } + + private func target(_ id: Int, snapshot: DriveProbeSnapshot) throws -> XCUIElement { + guard snapshot.committed else { + throw DriveFailure.rejected("LUI has a pending native commit; settle before resolving the target") + } + guard snapshot.nodeIDs.contains(id), let info = snapshot.nativeIdentifiers[String(id)], + info.explicit, !info.identifier.isEmpty else { + throw DriveFailure.rejected("node \(id) requires an explicit stable accessibility-identifier and a native accessible control") + } + guard snapshot.nativeIdentifiers.values.filter({ $0.identifier == info.identifier }).count == 1 else { + throw DriveFailure.rejected("effective accessibility-identifier is not unique in LUI") + } + let query = nativeQuery(identifier: info.identifier) + guard query.count == 1 else { + throw DriveFailure.rejected("node \(id) has no unique native accessibility element (hidden/custom controls are unsupported)") + } + let element = query.element(boundBy: 0) + guard element.identifier == info.identifier, element.exists, element.isEnabled, element.isHittable else { + throw DriveFailure.rejected("node \(id) is not enabled and hittable") + } + return element + } + + private func nativeQuery(identifier: String) -> XCUIElementQuery { + app.descendants(matching: .any).matching(NSPredicate(format: "identifier == %@", identifier)) + } + + private func coordinate(_ point: CGPoint, window: XCUIElement) throws -> XCUICoordinate { + let size = window.frame.size + guard point.x.isFinite, point.y.isFinite, point.x >= 0, point.y >= 0, + point.x < size.width, point.y < size.height else { + throw DriveFailure.rejected("coordinate is outside the logical app viewport") + } + return window.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0)) + .withOffset(CGVector(dx: point.x, dy: point.y)) + } + + private func perform(_ request: DriveRequest, client: DriveTCPChannel, deadline: TimeInterval) throws { + let (snapshot, _) = try snapshot(client: client, deadline: deadline) + let window = try window(snapshot) + try client.check(deadline: deadline) + switch request.action { + case "click", "double-click", "long-click", "type": + guard let id = request.fields["id"]?.integer else { throw DriveFailure.protocolViolation("missing id") } + let element = try target(id, snapshot: snapshot) + try client.check(deadline: deadline) + switch request.action { + case "click": element.tap() + case "double-click": element.doubleTap() + case "long-click": element.press(forDuration: 0.8) + default: + try replaceText(element, id: id, text: request.fields["text"]?.string ?? "", + snapshot: snapshot, client: client, deadline: deadline) + } + case "tap": + guard let x = request.fields["x"]?.number, let y = request.fields["y"]?.number else { + throw DriveFailure.protocolViolation("missing coordinates") + } + let point = try coordinate(CGPoint(x: x, y: y), window: window) + try client.check(deadline: deadline) + point.tap() + case "drag": + guard let id = request.fields["id"]?.integer, let destination = request.fields["target"]?.integer else { + throw DriveFailure.protocolViolation("missing drag targets") + } + let source = try target(id, snapshot: snapshot) + let target = try target(destination, snapshot: snapshot) + let from = source.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + let to = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + try validateVisibleCenter(source, window: window) + try validateVisibleCenter(target, window: window) + try client.check(deadline: deadline) + from.press(forDuration: 0.1, thenDragTo: to, withVelocity: .slow, thenHoldForDuration: 0.1) + case "scroll": + guard let id = request.fields["id"]?.integer, let dx = request.fields["dx"]?.number, + let dy = request.fields["dy"]?.number else { throw DriveFailure.protocolViolation("missing scroll fields") } + try scroll(id, dx: dx, dy: dy, client: client, deadline: deadline) + case "key": + let combo = request.fields["combo"]?.string ?? "" + let text: String + switch combo { + case "Enter", "Return": text = XCUIKeyboardKey.return.rawValue + case "Backspace": text = XCUIKeyboardKey.delete.rawValue + case "Space": text = " " + default: + throw DriveFailure.rejected("unsupported iOS key combo; supported: Enter, Return, Backspace, Space (software keyboard only)") + } + guard app.keyboards.count == 1 else { throw DriveFailure.rejected("software keyboard is not active") } + try client.check(deadline: deadline) + app.typeText(text) + case "screenshot", "settle": break + default: throw DriveFailure.protocolViolation("unsupported capability") + } + try client.check(deadline: deadline) + } + + private func validateVisibleCenter(_ element: XCUIElement, window: XCUIElement) throws { + guard window.frame.contains(CGPoint(x: element.frame.midX, y: element.frame.midY)) else { + throw DriveFailure.rejected("drag endpoint center is outside the app viewport") + } + } + + private func replaceText(_ element: XCUIElement, id: Int, text: String, snapshot: DriveProbeSnapshot, + client: DriveTCPChannel, deadline: TimeInterval) throws { + guard [.textField, .secureTextField, .textView, .searchField].contains(element.elementType) else { + throw DriveFailure.rejected("type requires a native editable text field, search field, or text view") + } + let current = retainedText(id, snapshot: snapshot) + guard let current else { throw DriveFailure.rejected("text replacement requires a retained text property") } + if snapshot.ops.contains(where: { + $0["id"]?.integer == id && $0["property"]?.string == "read-only" && $0["value"] == .bool(true) + }) { + throw DriveFailure.rejected("text replacement does not support read-only controls") + } + try client.check(deadline: deadline) + element.tap() + try client.check(deadline: deadline) + guard app.keyboards.count == 1 else { throw DriveFailure.rejected("software keyboard is not active") } + if !current.isEmpty { + element.press(forDuration: 1.1) + try client.check(deadline: deadline) + let items = app.menuItems.matching(identifier: "Select All") + let buttons = app.buttons.matching(identifier: "Select All") + let selectAll: XCUIElement + if items.count == 1 { selectAll = items.element(boundBy: 0) } + else if buttons.count == 1 { selectAll = buttons.element(boundBy: 0) } + else { throw DriveFailure.rejected("native Select All edit menu unavailable; use an English edit menu and a selectable field") } + guard selectAll.isEnabled, selectAll.isHittable else { + throw DriveFailure.rejected("native Select All is not hittable") + } + try client.check(deadline: deadline) + selectAll.tap() + try client.check(deadline: deadline) + let (fresh, _) = try self.snapshot(client: client, deadline: deadline) + let input = try target(id, snapshot: fresh) + try client.check(deadline: deadline) + input.typeText(XCUIKeyboardKey.delete.rawValue) + } + if !text.isEmpty { + let (fresh, _) = try self.snapshot(client: client, deadline: deadline) + let input = try target(id, snapshot: fresh) + try client.check(deadline: deadline) + input.typeText(text) + } + } + + private func retainedText(_ id: Int, snapshot: DriveProbeSnapshot) -> String? { + snapshot.ops.first { $0["id"]?.integer == id && $0["property"]?.string == "text" }?["value"]?.string + } + + private func scroll(_ id: Int, dx: Double, dy: Double, + client: DriveTCPChannel, deadline: TimeInterval) throws { + guard abs(dx) <= 10_000, abs(dy) <= 10_000 else { + throw DriveFailure.rejected("scroll exceeds the bounded 10000-point gesture budget") + } + if dx == 0 && dy == 0 { + let (snapshot, _) = try snapshot(client: client, deadline: deadline) + _ = try target(id, snapshot: snapshot) + return + } + var remaining = CGVector(dx: dx, dy: dy) + var strokes = 0 + while abs(remaining.dx) > 0.01 || abs(remaining.dy) > 0.01 { + strokes += 1 + guard strokes <= 64 else { throw DriveFailure.rejected("scroll exceeds 64 native strokes") } + let (snapshot, _) = try snapshot(client: client, deadline: deadline) + let window = try window(snapshot) + let element = try target(id, snapshot: snapshot) + let rect = element.frame.intersection(window.frame).insetBy(dx: 8, dy: 8) + guard !rect.isNull, rect.width >= 16, rect.height >= 16 else { + throw DriveFailure.rejected("scroll control has no visible gesture area") + } + let scale = min(1, rect.width * 0.7 / max(abs(remaining.dx), 0.01), + rect.height * 0.7 / max(abs(remaining.dy), 0.01)) + let step = CGVector(dx: remaining.dx * scale, dy: remaining.dy * scale) + let origin = window.frame.origin + // Positive content motion is opposite the finger gesture. + let from = try coordinate(CGPoint(x: rect.midX + step.dx / 2 - origin.x, + y: rect.midY + step.dy / 2 - origin.y), window: window) + let to = try coordinate(CGPoint(x: rect.midX - step.dx / 2 - origin.x, + y: rect.midY - step.dy / 2 - origin.y), window: window) + try client.check(deadline: deadline) + from.press(forDuration: 0.05, thenDragTo: to, withVelocity: .slow, thenHoldForDuration: 0.1) + try client.check(deadline: deadline) + remaining.dx -= step.dx + remaining.dy -= step.dy + } + } +} +#endif diff --git a/apple/README.md b/apple/README.md new file mode 100644 index 0000000..54ed24a --- /dev/null +++ b/apple/README.md @@ -0,0 +1,198 @@ +# Drive iOS native host (source-only) + +This adapter is implementation source, **not yet Apple-SDK compiled or +Simulator-validated**. Linux core checks do not validate XCTest, UIKit, Network, +Swift concurrency annotations, keyboard behavior, or native coordinate transforms. +Do not treat this directory as a validated platform release until the integrating +application builds it with Xcode and exercises it through its UI-testing target. + +## Two processes, no semantic fallback + +1. A DEBUG iOS app links `LUIAppleBackend` and explicitly retains an opt-in + `LUIDriveSnapshotProbe`. The probe only reads the retained LUI tree and geometry. +2. An **XCTest UI-testing target** compiles `DriveWire.swift`, `DriveTCP.swift`, + and `DriveXCTestRunner.swift`. Copy/adapt `DriveUITests.example.swift` into that + target for an entrypoint. Do not add any of these files to the application, + a production framework, or a unit-test target hosted inside the application. +3. Drive connects to the runner's `127.0.0.1:49379` newline-JSON v1 listener. + The runner obtains read-only snapshots from the app on `127.0.0.1:49378`. + Both sockets bind IPv4 loopback explicitly and reject other peers. There is no + wildcard bind, Bonjour advertisement, remote hostname, or authentication token. + Other local processes can connect: use an isolated development machine/device, + not this opt-in debug probe as a security boundary against local processes. + +All input is public XCTest input: `XCUIElement.tap`, `doubleTap`, +`press(forDuration:)`, `typeText`, and `XCUICoordinate` press/drag APIs. No +`Press`/`TextChanged` dispatch, `sendActions`, accessibility `setValue`, private +HID/touch construction, or synthetic fallback is present. App launch and teardown +belong to the UI test, never the production runtime. + +## App hookup + +Use the matching LUI Apple backend change providing `debugDriveSnapshot()` and +`LUIDriveSnapshotProbe`. See `platform/apple/DriveProbe.md` in LUI for an exact +Navigation demo host hookup without changing OCaml view code. + +Retain the probe after the app's single foreground key window is available and +call `stop()` during host teardown. Wrap all probe references in `#if DEBUG`. +Release builds contain neither the probe nor the debug snapshot API. Adding the +source alone opens no listener, including in DEBUG builds. The factory returns +`nil` unless the app's environment explicitly contains `LUI_DRIVE_PROBE=1`. + +The sample UI test passes that app launch environment; do not enable it in the +shipping app's launch defaults. Frame reporting/collection are enabled only for +the active probe and restored on `stop()` or probe deinitialization. Existing +frame callbacks are never replaced. + +## Xcode Test action / Test Plan environment + +### Standalone compile gate (no UI execution) + +Python 3 and a selected Xcode 26+ installation with an iOS Simulator SDK are the +only prerequisites. From the Drive repository root: + +```sh +python3 apple/generate-project.py --app-bundle-id dev.lui.components +xcodebuild \ + -project apple/.generated/DriveApple.xcodeproj \ + -scheme DriveApple -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath apple/.generated/DerivedData \ + CODE_SIGNING_ALLOWED=NO build-for-testing +``` + +The generator uses only Python's standard library, references the four local +Swift files directly, creates a shared scheme, and writes only ignored generated +project/build artifacts. The project has **one UI-testing target and no app +target/dependencies**; Target Application is intentionally None because the +entrypoint uses `XCUIApplication(bundleIdentifier:)`. The app need not be built +or installed for this compile gate. The command does not run UI tests or launch +a Simulator. **The generator was exercised on Linux; this Xcode command has not +been executed or verified yet.** + +For an actual UI run, separately build and install the matching DEBUG app on the +selected Simulator first (the standalone project does not install the app under +test). Then use the generated scheme's Test action, or adapt the sample into +the application's own UI-testing scheme, which can own app build/install. The +generated Test action enables `DRIVE_XCTEST=1` and the supplied bundle ID; the +other environment values below can be edited in Xcode. Never run this long-lived +Drive session test as part of an ordinary unattended full UI suite. + +### Runner environment + +Configure these on the **UI-test runner's Test action or Test Plan**, not merely +as arbitrary shell variables passed to `xcodebuild`: + +| Variable | Default | Purpose | +|---|---|---| +| `DRIVE_XCTEST` | absent | Must equal `1`; otherwise the example skips | +| `DRIVE_APP_BUNDLE_ID` | none | Bundle ID of the already-built DEBUG app | +| `LUI_DRIVE_PROBE_PORT` | `49378` | Explicit app/runner loopback probe port | +| `DRIVE_RUNNER_PORT` | `49379` | Runner loopback listener; must differ from probe | +| `DRIVE_ACTION_TIMEOUT` | `4` | Runner deadline in seconds, `0.5...120` | +| `DRIVE_SESSION_TIMEOUT` | `300` | Includes waiting for Drive to attach, `1...3600` | + +The example launches the app with `-AppleLanguages (en) -AppleLocale en_US` so +native **Select All** lookup is deterministic. Custom launch arguments and app +login/fixture setup remain the integrating test's responsibility. Run only this +test (no parallel test workers) on the selected Simulator, then attach Drive: + +```sh +# On the Mac host; --tcp is supplied by the integrating Drive CLI change. +drive --tcp 127.0.0.1:49379 --input native --action-timeout 5 scenario.drive +``` + +For a slower native keyboard operation, increase both timeouts, with Drive's +deadline longer than the runner's. Port collisions fail rather than binding a +different interface. Project generation creates no simulator or app under test; +Xcode build-for-testing creates the UI-test bundle/runner. Physical devices need explicit secure port +forwarding for a Mac-hosted Drive client; automatic device transport is not +implemented. Multiple scenes/windows and rotation during an action are rejected +when native/probe viewport sizes disagree; use a fixed-orientation single-window +test fixture. + +## Supported actions and limits + +The runner advertises exactly `click`, `double-click`, `long-click`, `tap`, +`type`, `key`, `scroll`, `drag`, `screenshot`, and `settle`. + +- Target actions require an explicit, nonempty, stable LUI + `accessibility-identifier`, unique among effective LUI identifiers and native + accessibility elements. The runner matches the **native identifier property**, + never a label fallback, and freshly checks existence, enabled, and hittable. + Untagged controls, inaccessible custom canvas controls, hidden/offscreen + controls, and identifier propagation creating multiple native matches fail + explicitly. Extension authors must expose the same identifier on the actual + native control. There is no coordinate fallback for target actions and no + automatic scrolling to a target. +- Click/double-click/long-click use element-native hit points. Long-click holds + for `0.8s`. Drag uses center-to-center public coordinate gestures; both native + centers must fit in the current viewport. This is a pointer gesture, not an + assertion that the app implements a drop operation; fixtures need unobscured + endpoint centers. +- Coordinate tap uses top-left **window-local logical points** and the native + window's coordinate transform, checks bounds, and does not scale by screenshot + pixels. The app's native window size must match the probe viewport. +- `type` means replacement, including empty and Unicode strings, on a native + editable text/search/secure field or text view with retained `text`. It focuses + the field, uses the native **Select All** edit menu for nonempty text, sends + native Delete, then native `typeText`. The resulting retained `text` must match + before success. Read-only controls, missing Select All, suppressed software + keyboards, custom input methods unsupported by XCTest, and nonselectable fields + fail; values are never assigned programmatically. A rejection after a native + menu/gesture may leave that native UI visible; it is not rolled back. +- `key` supports only `Enter`/`Return`, `Backspace`, and `Space` with an active + software keyboard. Desktop modifiers/chords, Tab, arrows, forward Delete, + Escape, and hardware-keyboard-only input are explicitly rejected. Use `type` + for arbitrary text; unsupported combinations are not advertised as desktop + keyboard parity merely because the action capability is named `key`. +- Scroll positive `dx`/`dy` means content right/down, so fingers move left/up. + Deltas are logical gesture-travel points, not guaranteed exact content offset: + platform scrolling, clamping, and app behavior still apply. Large moves are + split into at most 64 native strokes, with a 10000-point per-axis budget, and + reacquire the current native target/frame before every stroke. +- Screenshot uses `XCUIApplication.screenshot().pngRepresentation` and returns + base64 `png`. Image pixels and logical frame points intentionally differ. + +## Completion, snapshots, and failure + +After native XCTest calls return, the runner polls the probe and native +accessibility frames until the retained generation/commit state, tree, geometry, +and native frame/enabled/hittable observations are unchanged for at least 100ms. +It refuses pending retained commits and has a hard action deadline. The 20ms +poll interval is a sampling cadence, not an unconditional settle delay. + +**Limit:** public XCTest does not expose exhaustive finite-animation state. +This is bounded sampled tree/native-frame quiet plus XCTest call completion, +not universal animation completion, network idle, or async application-task +completion. Assert/wait for business state explicitly in the Drive scenario. + +The runner is a single serial writer. On initial attachment it sends +`hello -> full frames -> initial ops` (ops mark Drive readiness). After each +action it sends `full replacement ops -> full frames -> correlated result`. +Replacement first drops **all** previously published IDs, then reconstructs the +current tree, including extension fingerprints and scalar wire values; vanished +nodes and removed properties cannot linger. Probe-only metadata is not forwarded +to Drive. Every frame publication replaces the complete frame table. Action +lookup never reads a cached target frame. + +Malformed/unknown messages, versions, fields, capabilities, and duplicate, +out-of-order, or nonpositive request IDs close the session. Valid requests must +start at 1 and increase by exactly 1. JSON input buffers, session duration, +partial probe requests, and action duration are bounded. Action rejections return +`ok:false,error` only after publishing current state. A deadline or disconnect +poisons/closes transport, discards buffered requests, and prevents subsequent +native operations and success replies. There is no cross-request held pointer: +each gesture is one atomic XCTest call. **An already-running XCTest call cannot +be forcibly interrupted via public APIs**; its completion/release can outlive +the transport deadline. The adapter does not claim otherwise. + +## Integration validation still required + +Build the LUI Apple package in DEBUG and Release (the latter must not export the +probe), then compile the three runner files and sample in a real iOS UI-testing +target. With approval, exercise tagged controls, Unicode/empty replacement, +native scroll/drag, screenshots, removed properties/nodes, initial attach +ordering, malformed/duplicate IDs, timeout/disconnect, obscured/disabled targets, +and non-1x Simulator scaling. UI-driven testing/recordings are a separate testing +agent task; none was run for this source-only handoff. diff --git a/apple/generate-project.py b/apple/generate-project.py new file mode 100644 index 0000000..f75f37e --- /dev/null +++ b/apple/generate-project.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Generate a standalone, dependency-free XCTest UI-testing project; do not run it.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import xml.etree.ElementTree as ET + + +def identifier(name): + return hashlib.sha256(name.encode()).hexdigest()[:24].upper() + + +def openstep(value, depth=0): + indent = "\t" * depth + if isinstance(value, dict): + lines = ["{"] + for key, child in value.items(): + lines.append(f"{indent}\t{json.dumps(key)} = {openstep(child, depth + 1)};") + return "\n".join(lines) + f"\n{indent}}}" + if isinstance(value, list): + return "(" + ", ".join(openstep(child, depth) for child in value) + ")" + if isinstance(value, int): + return str(value) + return json.dumps(value, ensure_ascii=False) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--app-bundle-id", required=True) + parser.add_argument("--output", type=Path, default=Path(__file__).resolve().parent / ".generated") + args = parser.parse_args() + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]+", args.app_bundle_id): + parser.error("--app-bundle-id must be an application bundle identifier") + source = Path(__file__).resolve().parent + project = args.output.resolve() / "DriveApple.xcodeproj" + scheme_directory = project / "xcshareddata" / "xcschemes" + scheme_directory.mkdir(parents=True, exist_ok=True) + objects = {} + + def add(object_name, **fields): + key = identifier(object_name) + objects[key] = fields + return key + + build_files = [] + references = [] + for name in ["DriveWire.swift", "DriveTCP.swift", "DriveXCTestRunner.swift", "DriveUITests.example.swift"]: + path = source / name + if not path.is_file(): + parser.error(f"missing source: {path}") + reference = add(name, isa="PBXFileReference", lastKnownFileType="sourcecode.swift", + path=str(path), sourceTree="") + references.append(reference) + build_files.append(add(f"build-{name}", isa="PBXBuildFile", fileRef=reference)) + product = add("product", isa="PBXFileReference", explicitFileType="wrapper.cfbundle", + includeInIndex=0, path="DriveAppleTests.xctest", sourceTree="BUILT_PRODUCTS_DIR") + products = add("products", isa="PBXGroup", children=[product], name="Products", sourceTree="") + group = add("root-group", isa="PBXGroup", children=references + [products], sourceTree="") + sources = add("sources", isa="PBXSourcesBuildPhase", buildActionMask=2147483647, + files=build_files, runOnlyForDeploymentPostprocessing=0) + frameworks = add("frameworks", isa="PBXFrameworksBuildPhase", buildActionMask=2147483647, + files=[], runOnlyForDeploymentPostprocessing=0) + project_configs = [] + target_configs = [] + for configuration in ["Debug", "Release"]: + project_configs.append(add(f"project-{configuration}", isa="XCBuildConfiguration", + name=configuration, buildSettings={ + "SDKROOT": "iphoneos", "IPHONEOS_DEPLOYMENT_TARGET": "17.0", + "SWIFT_VERSION": "6.0", "CLANG_ENABLE_MODULES": "YES", + })) + settings = { + "PRODUCT_NAME": "$(TARGET_NAME)", "PRODUCT_BUNDLE_IDENTIFIER": "dev.lui.drive.xctest", + "GENERATE_INFOPLIST_FILE": "YES", "TARGETED_DEVICE_FAMILY": "1,2", + "SUPPORTED_PLATFORMS": "iphoneos iphonesimulator", "SKIP_INSTALL": "YES", + "ENABLE_TESTING_SEARCH_PATHS": "YES", "SWIFT_STRICT_CONCURRENCY": "complete", + "FRAMEWORK_SEARCH_PATHS": ["$(inherited)", "$(PLATFORM_DIR)/Developer/Library/Frameworks"], + "LIBRARY_SEARCH_PATHS": ["$(inherited)", "$(PLATFORM_DIR)/Developer/usr/lib"], + "OTHER_LDFLAGS": ["$(inherited)", "-framework", "XCTest"], + "LD_RUNPATH_SEARCH_PATHS": ["$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks"], + "ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES": "YES", + "SWIFT_OPTIMIZATION_LEVEL": "-Onone" if configuration == "Debug" else "-O", + } + if configuration == "Debug": + settings["SWIFT_ACTIVE_COMPILATION_CONDITIONS"] = ["$(inherited)", "DEBUG"] + target_configs.append(add(f"target-{configuration}", isa="XCBuildConfiguration", + name=configuration, buildSettings=settings)) + project_config_list = add("project-configs", isa="XCConfigurationList", + buildConfigurations=project_configs, defaultConfigurationIsVisible=0, + defaultConfigurationName="Debug") + target_config_list = add("target-configs", isa="XCConfigurationList", + buildConfigurations=target_configs, defaultConfigurationIsVisible=0, + defaultConfigurationName="Debug") + target = add("target", isa="PBXNativeTarget", name="DriveAppleTests", productName="DriveAppleTests", + buildConfigurationList=target_config_list, buildPhases=[sources, frameworks], + buildRules=[], dependencies=[], productReference=product, + productType="com.apple.product-type.bundle.ui-testing") + root = add("project", isa="PBXProject", attributes={"LastUpgradeCheck": "2600"}, + buildConfigurationList=project_config_list, compatibilityVersion="Xcode 14.0", + developmentRegion="en", hasScannedForEncodings=0, knownRegions=["en", "Base"], + mainGroup=group, productRefGroup=products, projectDirPath="", projectRoot="", targets=[target]) + (project / "project.pbxproj").write_text("// !$*UTF8*$!\n" + openstep({ + "archiveVersion": 1, "classes": {}, "objectVersion": 56, "objects": objects, "rootObject": root, + }) + "\n", encoding="utf-8") + + scheme = ET.Element("Scheme", LastUpgradeVersion="2600", version="1.3") + build = ET.SubElement(scheme, "BuildAction", parallelizeBuildables="NO", buildImplicitDependencies="NO") + entries = ET.SubElement(build, "BuildActionEntries") + entry = ET.SubElement(entries, "BuildActionEntry", buildForTesting="YES", buildForRunning="NO", + buildForProfiling="NO", buildForArchiving="NO", buildForAnalyzing="YES") + reference_attributes = { + "BuildableIdentifier": "primary", "BlueprintIdentifier": target, + "BuildableName": "DriveAppleTests.xctest", "BlueprintName": "DriveAppleTests", + "ReferencedContainer": "container:DriveApple.xcodeproj", + } + ET.SubElement(entry, "BuildableReference", **reference_attributes) + action = ET.SubElement(scheme, "TestAction", buildConfiguration="Debug", + selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB", + selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB", + shouldUseLaunchSchemeArgsEnv="NO") + testables = ET.SubElement(action, "Testables") + testable = ET.SubElement(testables, "TestableReference", skipped="NO", parallelizable="NO") + ET.SubElement(testable, "BuildableReference", **reference_attributes) + environment = ET.SubElement(action, "EnvironmentVariables") + for key, value in {"DRIVE_XCTEST": "1", "DRIVE_APP_BUNDLE_ID": args.app_bundle_id}.items(): + ET.SubElement(environment, "EnvironmentVariable", key=key, value=value, isEnabled="YES") + ET.indent(scheme) + ET.ElementTree(scheme).write(scheme_directory / "DriveApple.xcscheme", encoding="utf-8", xml_declaration=True) + print(project) + print("Generated UI-testing-only project; no app build, Simulator launch, or UI test was executed.") + + +if __name__ == "__main__": + main() From 435c3a177108d79bb5cb942bb9d230b20121a290 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 6 Nov 2026 04:44:52 +0000 Subject: [PATCH 3/6] Compile iOS XCTest runner on Apple-scoped pull requests --- .github/workflows/apple-compile.yml | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/apple-compile.yml diff --git a/.github/workflows/apple-compile.yml b/.github/workflows/apple-compile.yml new file mode 100644 index 0000000..6b6939f --- /dev/null +++ b/.github/workflows/apple-compile.yml @@ -0,0 +1,32 @@ +name: Apple native host compile + +on: + pull_request: + paths: + - 'apple/**' + - '.github/workflows/apple-compile.yml' + +permissions: + contents: read + +concurrency: + group: apple-compile-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + ios-xctest-compile: + name: iOS XCTest build-for-testing + runs-on: macos-26 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - name: Generate standalone UI-testing project + run: python3 apple/generate-project.py --app-bundle-id dev.lui.components + - name: Compile iOS runner without executing UI tests + run: >- + xcodebuild + -project apple/.generated/DriveApple.xcodeproj + -scheme DriveApple -configuration Debug + -destination 'generic/platform=iOS Simulator' + -derivedDataPath apple/.generated/DerivedData + CODE_SIGNING_ALLOWED=NO build-for-testing From b2ecdf4b70bb7cc3f8c819dc96ec6be18a2d168b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 6 Nov 2026 04:48:10 +0000 Subject: [PATCH 4/6] Document mobile native host boundaries --- README.md | 18 +++++++++++++++++- docs/api-migrations.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 713d0c6..3cab950 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,23 @@ Native scenarios stop at the first failure. | In-process / FFI | Yes | No | | `web/lui-drive.js` | Synthetic DOM events | No | | `web/cdp-host.mjs` | No | Chromium mouse, text, keys, wheel, pointer drag, PNG | -| Apple / Android / MyGo | Existing host-specific semantic attach | Requires a v1 native adapter | +| Android | Existing host-specific semantic attach | Optional LUI debug instrumentation host, API 30+; device input unverified | +| iOS | Existing host-specific semantic attach | [Separate XCTest runner](apple/README.md) + DEBUG LUI read-only probe; Apple validation pending | +| MyGo | Existing host-specific semantic attach | Unsupported: the pinned SDK has no public live-window input dispatcher | + +Android's optional host lives in [LUI](https://github.com/logseq/lui), under +`platform/android/drive`; its sample launcher explicitly preserves/restores a +test-device IME and TCP forwarding. Text requires deliberately selecting the +test IME; the SDK never changes it automatically. Compilation and protocol +tests are not evidence of actual device input. Unsupported geometry/input fails +instead of falling back to semantic events. + +The iOS runner is a separate UI-testing target, not app code. It uses public +XCTest input and reads the app through an explicitly enabled DEBUG probe; +stable native accessibility identifiers are required. The standalone generated +project compiles the runner only, not the app under test. Follow the +[Apple guide](apple/README.md) for build/install steps and public XCTest limits. +Neither mobile adapter implies MyGo support or universal animation/network idle. CDP currently targets tagged light DOM, not iframes or shadow roots. Drag is a pointer gesture, not a promised HTML5 drag-and-drop operation; diff --git a/docs/api-migrations.md b/docs/api-migrations.md index 9018ba0..d167bad 100644 --- a/docs/api-migrations.md +++ b/docs/api-migrations.md @@ -148,3 +148,36 @@ The driver validates base64 and the PNG signature before exposing bytes. Bind all test/debug transports to loopback and use isolated browser profiles. These endpoints grant control over the attached application; they are development facilities, not production remote-control APIs. + +## Optional iOS XCTest native runner + +`apple/` adds source for a separate XCTest UI-testing target and a standard +library Python Xcode project generator. It does not change Drive protocol v1, +semantic defaults or existing transports. Compile the runner with the documented +`build-for-testing` command; separately build/install a matching DEBUG LUI app +before an actual UI run. Never link the runner into the application binary. + +The app explicitly opts into LUI's read-only snapshot probe on IPv4 loopback +port 49378. The runner exposes the native Drive endpoint on loopback port 49379: + +```sh +drive --tcp 127.0.0.1:49379 --input native --action-timeout 5 scenario.drive +``` + +Initial hello and full frames precede ops readiness. Replacement snapshots +drop previously published IDs before rebuilding nodes/properties/links; +fresh ops and frames precede every result. Selector actions require explicit, +unique native accessibility identifiers and current enabled/hittable controls. +There is no label, coordinate or semantic fallback for selector input. +Coordinates are window-local logical points; screenshots are PNG pixels. +Typing is native replacement, including empty text, using the English Select +All edit menu and software keyboard. Unsupported key combinations/editors +fail explicitly. Settling samples retained/native-frame quiet, not exhaustive +animation, network or application-task completion. + +Deadline/disconnect poisons and closes transport and discards subsequent input. +Public XCTest cannot forcibly interrupt an atomic gesture already in flight; +that call may finish after the transport deadline. No retry or late success is +issued. Physical-device forwarding, multiple scenes, and arbitrary custom +canvas targeting are not supplied. See [`apple/README.md`](../apple/README.md) +for compile validation, app hookup, exact capabilities and platform limits. From 4485c2a69d9fd91a6452187ece9250591e8497f2 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 6 Nov 2026 04:49:43 +0000 Subject: [PATCH 5/6] Bound native transport writes and surface fatal poll errors --- docs/api-migrations.md | 48 +++++++++++++ src/drive_target.c | 19 +++++ src/live.ml | 153 ++++++++++++++++++++++++++++------------- src/ws.ml | 39 +++++------ test/test_drive.ml | 49 +++++++++++++ 5 files changed, 240 insertions(+), 68 deletions(-) diff --git a/docs/api-migrations.md b/docs/api-migrations.md index 9018ba0..49f3ef2 100644 --- a/docs/api-migrations.md +++ b/docs/api-migrations.md @@ -1,5 +1,53 @@ # API and wire migrations +## Live transport deadlines and fatal polling errors + +Native actions now use **one deadline for sending and completion**, including +write serialization, socket backpressure and WebSocket framing. Previously the +completion timeout started after a potentially blocking write. Built-in Unix, +TCP and WebSocket transports use per-call nonblocking socket sends without +changing the concurrent reader's socket mode. There is no background action +sender that can keep submitting input after the caller reports a timeout. + +A native write/completion timeout or transport failure poisons and closes the +connection. Late results cannot revive it; start a fresh session rather than +retrying an action that may already have run. `Live.close` is now idempotent, +including its custom close callback, so existing `Fun.protect` cleanup remains +safe after automatic closure. These deadlines do not cover TCP connect or the +WebSocket HTTP upgrade. + +`Live.poll` now raises `Failure` for a recorded fatal protocol error, not only +for disconnect. This includes polling inside assertions and the final scenario +poll: an invalid/version-incompatible session can no longer pass merely because +no further native action is sent. Embedding callers must treat this as a fatal +session error and close the transport. + +### Custom transports + +Existing `Live.create ~send_line ~close ()` calls remain source-compatible. +Arbitrary application callbacks cannot be preempted safely: `send_line` must +return promptly and must not leave an asynchronous sender running after failure. +A late callback return is rejected, but its blocking time cannot be bounded by +Drive. Custom transports that may block should provide the new optional writer: + +```ocaml +let live = + Drive.Live.create ~send_line:send_semantic ~close:close_transport + ~send_line_until:(fun ~deadline json -> + write_json_before ~deadline json) + () +``` + +`deadline` is an absolute `Unix.gettimeofday ()` timestamp, shared with the +completion wait. The callback receives the same serialized JSON string without +a newline as `send_line`; it owns transport framing and must honor the deadline +for lock acquisition and every partial write. Raise `Failure` (for example, +`Failure "host write timed out"`) or `Unix.Unix_error` on failure/expiration. +It must stop sending before returning/raising and must not enqueue work that can emit late +input. Drive then poisons/closes the session and returns an action/request error. +The optional writer is used for native actions; legacy semantic sends continue +to use `send_line`. The default action timeout remains 5 seconds. + ## Loopback TCP native host attach `Live.connect_tcp ~host ~port` and `drive --tcp HOST:PORT` add an optional diff --git a/src/drive_target.c b/src/drive_target.c index 3d72b03..78a5d6e 100644 --- a/src/drive_target.c +++ b/src/drive_target.c @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -17,6 +18,24 @@ #include #include #include +#include + +/* Per-call nonblocking writes leave the buffered reader's socket mode intact. */ +CAMLprim value drive_send_nonblocking(value socket, value data, value offset, + value length) { + int fd = Int_val(socket); + int flags = MSG_DONTWAIT; +#ifdef MSG_NOSIGNAL + flags |= MSG_NOSIGNAL; +#elif defined(SO_NOSIGPIPE) + int enabled = 1; + if (setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)) == -1) + caml_uerror("setsockopt", Nothing); +#endif + ssize_t n = send(fd, String_val(data) + Long_val(offset), Long_val(length), flags); + if (n == -1) caml_uerror("send", Nothing); + return Val_long(n); +} typedef void (*patch_cb)(const char *); diff --git a/src/live.ml b/src/live.ml index b277ed3..39c9787 100644 --- a/src/live.ml +++ b/src/live.ml @@ -10,6 +10,7 @@ open Lui_protocol type t = { send_line : string -> unit; + send_line_until : (deadline:float -> string -> unit) option; queue : string Queue.t; mu : Mutex.t; tree : Model.t; @@ -21,14 +22,15 @@ type t = { mutable pending : int option; mutable reply : Yojson.Safe.t option; mutable disconnected : bool; + mutable closed : bool; close_transport : unit -> unit; } -let create ?(close = fun () -> ()) ~send_line () = - { send_line; queue = Queue.create (); mu = Mutex.create (); +let create ?(close = fun () -> ()) ?send_line_until ~send_line () = + { send_line; send_line_until; queue = Queue.create (); mu = Mutex.create (); tree = Model.create (); frames = Hashtbl.create 64; capabilities = None; protocol_error = None; ready = false; - next_request = 0; pending = None; reply = None; disconnected = false; + next_request = 0; pending = None; reply = None; disconnected = false; closed = false; close_transport = close } let mark_disconnected t = @@ -37,31 +39,72 @@ let mark_disconnected t = Mutex.unlock t.mu let close t = - mark_disconnected t; - t.close_transport () + Mutex.lock t.mu; + let closing = not t.closed in + t.closed <- true; + t.disconnected <- true; + Mutex.unlock t.mu; + if closing then t.close_transport () let enqueue t line = Mutex.lock t.mu; Queue.add line t.queue; Mutex.unlock t.mu +external send_nonblocking : Unix.file_descr -> string -> int -> int -> int + = "drive_send_nonblocking" + +let socket_io sock ic = + let closed = Atomic.make false in + let write_mu = Mutex.create () in + let check deadline = + if Atomic.get closed then failwith "drive: live host disconnected"; + if Unix.gettimeofday () >= deadline then failwith "host write timed out" + in + let write_until ~deadline parts = + let rec lock () = + check deadline; + if not (Mutex.try_lock write_mu) then begin + Unix.sleepf (min 0.005 (max 0.0 (deadline -. Unix.gettimeofday ()))); + lock () + end + in + lock (); + Fun.protect ~finally:(fun () -> Mutex.unlock write_mu) (fun () -> + List.iter (fun data -> + let rec write offset = + check deadline; + if offset < String.length data then + match send_nonblocking sock data offset (String.length data - offset) with + | 0 -> failwith "drive: live host disconnected" + | n -> write (offset + n) + | exception Unix.Unix_error (Unix.EINTR, _, _) -> write offset + | exception Unix.Unix_error ((Unix.EAGAIN | Unix.EWOULDBLOCK), _, _) -> + let remaining = deadline -. Unix.gettimeofday () in + let wait = if Float.is_finite remaining then max 0.0 remaining else -1.0 in + (try ignore (Unix.select [] [sock] [] wait) + with Unix.Unix_error (Unix.EINTR, _, _) -> ()); + write offset + in + write 0) parts) + in + let close () = + Atomic.set closed true; + (try Unix.shutdown sock Unix.SHUTDOWN_ALL with Unix.Unix_error _ -> ()); + Mutex.lock write_mu; + Fun.protect ~finally:(fun () -> Mutex.unlock write_mu) (fun () -> close_in_noerr ic) + in + write_until, close + let connect_stream ~domain address = let sock = Unix.socket domain Unix.SOCK_STREAM 0 in (try Unix.connect sock address with exn -> Unix.close sock; raise exn); let ic = Unix.in_channel_of_descr sock in - let oc = Unix.out_channel_of_descr (Unix.dup sock) in - let close () = - (try Unix.shutdown sock Unix.SHUTDOWN_ALL with Unix.Unix_error _ -> ()); - close_out_noerr oc; - close_in_noerr ic - in + let write_until, close = socket_io sock ic in + let send_line_until ~deadline s = write_until ~deadline [s; "\n"] in let t = - create ~close - ~send_line: - (fun s -> - output_string oc s; - output_char oc '\n'; - flush oc) () + create ~close ~send_line_until + ~send_line:(send_line_until ~deadline:infinity) () in ignore (Thread.create @@ -105,19 +148,22 @@ let listen_ws ~port = Unix.setsockopt conn Unix.TCP_NODELAY true; let ic = Unix.in_channel_of_descr conn and oc = Unix.out_channel_of_descr (Unix.dup conn) in - if not (Ws.accept_handshake ic oc) then - failwith "drive: bad websocket handshake"; - let write_mu = Mutex.create () in - let write ?(opcode = 1) s = - Mutex.lock write_mu; - Fun.protect ~finally:(fun () -> Mutex.unlock write_mu) - (fun () -> Ws.write_frame oc ~opcode s) + let accepted = + try Fun.protect ~finally:(fun () -> close_out_noerr oc) + (fun () -> Ws.accept_handshake ic oc) + with exn -> close_in_noerr ic; raise exn + in + if not accepted then begin + close_in_noerr ic; + failwith "drive: bad websocket handshake" + end; + let write_until, close = socket_io conn ic in + let write ?(opcode = 1) ~deadline s = + write_until ~deadline [Ws.frame_header ~opcode (String.length s); s] in let t = - create ~close:(fun () -> - (try Unix.shutdown conn Unix.SHUTDOWN_ALL with Unix.Unix_error _ -> ()); - close_out_noerr oc; - close_in_noerr ic) ~send_line:(fun s -> write s) () + create ~close ~send_line_until:(fun ~deadline s -> write ~deadline s) + ~send_line:(fun s -> write ~deadline:infinity s) () in ignore (Thread.create @@ -145,7 +191,7 @@ let listen_ws ~port = end; loop () | `Ping payload -> - write ~opcode:10 payload; + write ~opcode:10 ~deadline:infinity payload; loop () | `Pong | `Skip -> loop () | `Close | `Eof -> mark_disconnected t @@ -207,24 +253,30 @@ let drain t = let poll t = ignore (drain t); + Option.iter failwith t.protocol_error; if t.disconnected then failwith "drive: live host disconnected" let send t json = t.send_line (Yojson.Safe.to_string json) -let await t ~timeout predicate = - let deadline = Unix.gettimeofday () +. timeout in +let await_until t ~deadline predicate = let rec loop () = poll t; - match t.protocol_error with - | Some msg -> Error msg - | None -> - if predicate () then Ok () - else if Unix.gettimeofday () >= deadline then Error "host completion timed out" - else (Unix.sleepf 0.005; loop ()) + if Unix.gettimeofday () >= deadline then Error "host completion timed out" + else if predicate () then Ok () + else (Unix.sleepf (min 0.005 (max 0.0 (deadline -. Unix.gettimeofday ()))); loop ()) in loop () +let await t ~timeout predicate = + await_until t ~deadline:(Unix.gettimeofday () +. timeout) predicate + +let abort t msg = + t.protocol_error <- Some msg; + (try close t with _ -> ()); + Error msg + let native_request t ~timeout action = + let deadline = Unix.gettimeofday () +. timeout in let name, fields = Input.request action in let context request msg = Printf.sprintf "native %s request %d: %s" name request msg @@ -241,13 +293,16 @@ let native_request t ~timeout action = t.pending <- Some request; t.reply <- None; Fun.protect ~finally:(fun () -> t.pending <- None; t.reply <- None) (fun () -> - send t (`Assoc + let line = Yojson.Safe.to_string (`Assoc ([ ("drive", `String "action"); ("version", `Int 1); - ("request", `Int request); ("action", `String name) ] @ fields)); - match await t ~timeout (fun () -> Option.is_some t.reply) with + ("request", `Int request); ("action", `String name) ] @ fields)) in + if Unix.gettimeofday () >= deadline then failwith "host write timed out"; + (match t.send_line_until with + | Some write -> write ~deadline line + | None -> t.send_line line); + match await_until t ~deadline (fun () -> Option.is_some t.reply) with | Error msg -> - t.protocol_error <- Some (context request msg); - Error (context request msg) + abort t (context request msg) | Ok () -> match t.reply with | None -> Error (context request "missing result") @@ -275,13 +330,15 @@ let native t ~timeout action = let fail msg = let name, _ = Input.request action in let msg = Printf.sprintf "native %s request %d: %s" name t.next_request msg in - t.protocol_error <- Some msg; - Error msg + abort t msg in - try native_request t ~timeout action with - | Failure msg | Sys_error msg | Yojson.Json_error msg -> fail msg - | Yojson.Safe.Util.Type_error (msg, _) -> fail ("invalid host message: " ^ msg) - | Unix.Unix_error (e, _, _) -> fail (Unix.error_message e) + match t.protocol_error with + | Some msg -> Error msg + | None -> + try native_request t ~timeout action with + | Failure msg | Sys_error msg | Yojson.Json_error msg -> fail msg + | Yojson.Safe.Util.Type_error (msg, _) -> fail ("invalid host message: " ^ msg) + | Unix.Unix_error (e, _, _) -> fail (Unix.error_message e) let send_nav t hash = send t (`Assoc [ ("event", `String "nav"); ("hash", `String hash) ]) diff --git a/src/ws.ml b/src/ws.ml index 9166efa..75bc4a7 100644 --- a/src/ws.ml +++ b/src/ws.ml @@ -203,26 +203,25 @@ let read_frame ic = | 0xA -> `Pong | _ -> `Skip) +let frame_header ?(opcode = 1) len = + if len < 126 then + String.init 2 (fun i -> if i = 0 then Char.chr (0x80 lor opcode) else Char.chr len) + else if len <= 0xffff then + String.init 4 (fun i -> + match i with + | 0 -> Char.chr (0x80 lor opcode) + | 1 -> '\x7e' + | 2 -> Char.chr (len lsr 8) + | _ -> Char.chr (len land 0xff)) + else begin + let b = Bytes.create 10 in + Bytes.set b 0 (Char.chr (0x80 lor opcode)); + Bytes.set b 1 '\x7f'; + Bytes.set_int64_be b 2 (Int64.of_int len); + Bytes.unsafe_to_string b + end + let write_frame oc ?(opcode = 1) s = - let len = String.length s in - let hdr = - if len < 126 then - String.init 2 (fun i -> if i = 0 then Char.chr (0x80 lor opcode) else Char.chr len) - else if len <= 0xffff then - String.init 4 (fun i -> - match i with - | 0 -> Char.chr (0x80 lor opcode) - | 1 -> '\x7e' - | 2 -> Char.chr (len lsr 8) - | _ -> Char.chr (len land 0xff)) - else begin - let b = Bytes.create 10 in - Bytes.set b 0 (Char.chr (0x80 lor opcode)); - Bytes.set b 1 '\x7f'; - Bytes.set_int64_be b 2 (Int64.of_int len); - Bytes.unsafe_to_string b - end - in - output_string oc hdr; + output_string oc (frame_header ~opcode (String.length s)); output_string oc s; flush oc diff --git a/test/test_drive.ml b/test/test_drive.ml index 297a714..3c14aee 100644 --- a/test/test_drive.ml +++ b/test/test_drive.ml @@ -271,6 +271,53 @@ let test_native_completion_contract () = "legacy host cannot inject native input", None, "ok", false; "incompatible version", Some {|{"drive":"hello","version":2,"capabilities":["click"]}|}, "ok", false ]) +let test_fatal_protocol_polling () = + List.iter + (fun source -> + let t = Live.create ~send_line:(fun _ -> ()) () in + Live.ingest_line t {|{"drive":"hello","version":1,"capabilities":["click"]}|}; + Live.ingest_line t {|{"frames":[[1,0,0,10,10]]}|}; + Live.ingest_line t {|{"ops":[{"op":"create-node","id":1,"kind":"button"}]}|}; + let d = Live.driver t in + Live.enqueue t {|{"drive":"unexpected","version":1}|}; + let failed = + try Scenario.run ~input:Input.Native d source <> [] + with Failure _ -> true + in + Alcotest.(check bool) "fatal protocol error cannot pass" true failed) + [ "expect id:1"; "" ] + +let test_native_deadline_includes_send () = + let host = ref None in + let closes = ref 0 in + let sends = ref 0 in + let timeout = 0.01 in + let t = Live.create ~close:(fun () -> incr closes) + ~send_line:(fun line -> + incr sends; + let request = Yojson.Safe.Util.member "request" (Yojson.Safe.from_string line) in + let deadline = Unix.gettimeofday () +. timeout in + let rec exhaust_budget () = + let remaining = deadline -. Unix.gettimeofday () in + if remaining > 0.0 then (Unix.sleepf remaining; exhaust_budget ()) + in + exhaust_budget (); + Option.iter (fun t -> Live.enqueue t (Yojson.Safe.to_string (`Assoc [ + "drive", `String "result"; "version", `Int 1; + "request", request; "ok", `Bool true ]))) !host) () + in + host := Some t; + Live.ingest_line t {|{"drive":"hello","version":1,"capabilities":["click"]}|}; + Alcotest.(check bool) "late completion cannot succeed" true + (Result.is_error (Live.native t ~timeout (Input.Click 1))); + Alcotest.(check int) "deadline closes transport" 1 !closes; + let sent = !sends in + Alcotest.(check bool) "deadline poisons session" true + (Result.is_error (Live.native t ~timeout (Input.Click 1))); + Alcotest.(check int) "no later input" sent !sends; + Live.close t; + Alcotest.(check int) "cleanup does not close twice" 1 !closes + (* ---------- scenario DSL ---------- *) let test_scenario_pass () = @@ -440,6 +487,8 @@ let () = Alcotest.test_case "tap miss / no frames" `Quick test_live_tap_miss; Alcotest.test_case "native completion contract" `Quick test_native_completion_contract; + Alcotest.test_case "fatal protocol errors fail polling" `Quick test_fatal_protocol_polling; + Alcotest.test_case "native deadline includes sending" `Quick test_native_deadline_includes_send; Alcotest.test_case "TCP attach is loopback-only" `Quick test_tcp_loopback_admission; ] ); ( "ws", From 2aeaf981684b167d65547cfcd2ac1c588b10ec2e Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 6 Nov 2026 04:55:47 +0000 Subject: [PATCH 6/6] Clarify iOS compile-only validation boundary --- README.md | 2 +- apple/README.md | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3cab950..3588e6f 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ Native scenarios stop at the first failure. | `web/lui-drive.js` | Synthetic DOM events | No | | `web/cdp-host.mjs` | No | Chromium mouse, text, keys, wheel, pointer drag, PNG | | Android | Existing host-specific semantic attach | Optional LUI debug instrumentation host, API 30+; device input unverified | -| iOS | Existing host-specific semantic attach | [Separate XCTest runner](apple/README.md) + DEBUG LUI read-only probe; Apple validation pending | +| iOS | Existing host-specific semantic attach | [Separate XCTest runner](apple/README.md) + DEBUG LUI read-only probe; runner compiles, device input unverified | | MyGo | Existing host-specific semantic attach | Unsupported: the pinned SDK has no public live-window input dispatcher | Android's optional host lives in [LUI](https://github.com/logseq/lui), under diff --git a/apple/README.md b/apple/README.md index 54ed24a..0bfc1e1 100644 --- a/apple/README.md +++ b/apple/README.md @@ -1,10 +1,11 @@ -# Drive iOS native host (source-only) +# Drive iOS native host (XCTest integration source) -This adapter is implementation source, **not yet Apple-SDK compiled or -Simulator-validated**. Linux core checks do not validate XCTest, UIKit, Network, -Swift concurrency annotations, keyboard behavior, or native coordinate transforms. -Do not treat this directory as a validated platform release until the integrating -application builds it with Xcode and exercises it through its UI-testing target. +The Apple compile-only workflow builds the standalone XCTest target with the +iOS Simulator SDK; **native Simulator/device input is not yet validated**. +Linux core checks do not validate XCTest, UIKit, Network, Swift concurrency, +keyboard behavior, or native coordinate transforms. The integrating application +must still build/install its own DEBUG probe-enabled app and exercise this runner +through a UI-testing target before treating it as device-tested support. ## Two processes, no semantic fallback @@ -67,8 +68,8 @@ project/build artifacts. The project has **one UI-testing target and no app target/dependencies**; Target Application is intentionally None because the entrypoint uses `XCUIApplication(bundleIdentifier:)`. The app need not be built or installed for this compile gate. The command does not run UI tests or launch -a Simulator. **The generator was exercised on Linux; this Xcode command has not -been executed or verified yet.** +a Simulator. The same command is exercised by the Apple compile-only workflow; +successful compilation is not evidence of app launch or native interaction. For an actual UI run, separately build and install the matching DEBUG app on the selected Simulator first (the standalone project does not install the app under