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 diff --git a/README.md b/README.md index 6b7dbe0..3588e6f 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` @@ -185,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; 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 +`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/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..0bfc1e1 --- /dev/null +++ b/apple/README.md @@ -0,0 +1,199 @@ +# Drive iOS native host (XCTest integration source) + +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 + +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 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 +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() 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..8a58a43 100644 --- a/docs/api-migrations.md +++ b/docs/api-migrations.md @@ -1,5 +1,83 @@ # 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 +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 @@ -118,3 +196,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. 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 e1de745..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 -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 ic = Unix.in_channel_of_descr sock in - let oc = Unix.out_channel_of_descr (Unix.dup sock) in +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 _ -> ()); - close_out_noerr oc; - close_in_noerr ic + 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 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 @@ -74,6 +117,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). *) @@ -91,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 @@ -131,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 @@ -193,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 @@ -227,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") @@ -261,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 719b863..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 () = @@ -388,6 +435,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 +487,9 @@ 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", [