diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift new file mode 100644 index 000000000..401eeb30e --- /dev/null +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -0,0 +1,194 @@ +import ClawdisKit +import Foundation +import Testing +import UIKit +@testable import Clawdis + +private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T { + let defaults = UserDefaults.standard + var snapshot: [String: Any?] = [:] + for key in updates.keys { + snapshot[key] = defaults.object(forKey: key) + } + for (key, value) in updates { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + defer { + for (key, value) in snapshot { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + return try body() +} + +@Suite(.serialized) struct NodeAppModelInvokeTests { + @Test @MainActor func decodeParamsFailsWithoutJSON() { + #expect(throws: Error.self) { + _ = try NodeAppModel._test_decodeParams(ClawdisCanvasNavigateParams.self, from: nil) + } + } + + @Test @MainActor func encodePayloadEmitsJSON() throws { + struct Payload: Codable, Equatable { + var value: String + } + let json = try NodeAppModel._test_encodePayload(Payload(value: "ok")) + #expect(json.contains("\"value\"")) + } + + @Test @MainActor func handleInvokeRejectsBackgroundCommands() async { + let appModel = NodeAppModel() + appModel.setScenePhase(.background) + + let req = BridgeInvokeRequest(id: "bg", command: ClawdisCanvasCommand.present.rawValue) + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .backgroundUnavailable) + } + + @Test @MainActor func handleInvokeRejectsCameraWhenDisabled() async { + let appModel = NodeAppModel() + let req = BridgeInvokeRequest(id: "cam", command: ClawdisCameraCommand.snap.rawValue) + + let defaults = UserDefaults.standard + let key = "camera.enabled" + let previous = defaults.object(forKey: key) + defaults.set(false, forKey: key) + defer { + if let previous { + defaults.set(previous, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .unavailable) + #expect(res.error?.message.contains("CAMERA_DISABLED") == true) + } + + @Test @MainActor func handleInvokeRejectsInvalidScreenFormat() async { + let appModel = NodeAppModel() + let params = ClawdisScreenRecordParams(format: "gif") + let data = try? JSONEncoder().encode(params) + let json = data.flatMap { String(data: $0, encoding: .utf8) } + + let req = BridgeInvokeRequest( + id: "screen", + command: ClawdisScreenCommand.record.rawValue, + paramsJSON: json) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.message.contains("screen format must be mp4") == true) + } + + @Test @MainActor func handleInvokeCanvasCommandsUpdateScreen() async throws { + let appModel = NodeAppModel() + appModel.screen.navigate(to: "http://example.com") + + let present = BridgeInvokeRequest(id: "present", command: ClawdisCanvasCommand.present.rawValue) + let presentRes = await appModel._test_handleInvoke(present) + #expect(presentRes.ok == true) + #expect(appModel.screen.urlString.isEmpty) + + let navigateParams = ClawdisCanvasNavigateParams(url: "http://localhost:18789/") + let navData = try JSONEncoder().encode(navigateParams) + let navJSON = String(decoding: navData, as: UTF8.self) + let navigate = BridgeInvokeRequest( + id: "nav", + command: ClawdisCanvasCommand.navigate.rawValue, + paramsJSON: navJSON) + let navRes = await appModel._test_handleInvoke(navigate) + #expect(navRes.ok == true) + #expect(appModel.screen.urlString == "http://localhost:18789/") + + let evalParams = ClawdisCanvasEvalParams(javaScript: "1+1") + let evalData = try JSONEncoder().encode(evalParams) + let evalJSON = String(decoding: evalData, as: UTF8.self) + let eval = BridgeInvokeRequest( + id: "eval", + command: ClawdisCanvasCommand.evalJS.rawValue, + paramsJSON: evalJSON) + let evalRes = await appModel._test_handleInvoke(eval) + #expect(evalRes.ok == true) + let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8)) + let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] + #expect(payload?["result"] as? String == "2") + } + + @Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws { + let appModel = NodeAppModel() + + let reset = BridgeInvokeRequest(id: "reset", command: ClawdisCanvasA2UICommand.reset.rawValue) + let resetRes = await appModel._test_handleInvoke(reset) + #expect(resetRes.ok == false) + #expect(resetRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true) + + let jsonl = "{\"beginRendering\":{}}" + let pushParams = ClawdisCanvasA2UIPushJSONLParams(jsonl: jsonl) + let pushData = try JSONEncoder().encode(pushParams) + let pushJSON = String(decoding: pushData, as: UTF8.self) + let push = BridgeInvokeRequest( + id: "push", + command: ClawdisCanvasA2UICommand.pushJSONL.rawValue, + paramsJSON: pushJSON) + let pushRes = await appModel._test_handleInvoke(push) + #expect(pushRes.ok == false) + #expect(pushRes.error?.message.contains("A2UI_HOST_NOT_CONFIGURED") == true) + } + + @Test @MainActor func handleInvokeUnknownCommandReturnsInvalidRequest() async { + let appModel = NodeAppModel() + let req = BridgeInvokeRequest(id: "unknown", command: "nope") + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .invalidRequest) + } + + @Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async { + let appModel = NodeAppModel() + let url = URL(string: "clawdis://agent?message=hello")! + await appModel.handleDeepLink(url: url) + #expect(appModel.screen.errorText?.contains("Bridge not connected") == true) + } + + @Test @MainActor func handleDeepLinkRejectsOversizedMessage() async { + let appModel = NodeAppModel() + let msg = String(repeating: "a", count: 20001) + let url = URL(string: "clawdis://agent?message=\(msg)")! + await appModel.handleDeepLink(url: url) + #expect(appModel.screen.errorText?.contains("Deep link too large") == true) + } + + @Test @MainActor func sendVoiceTranscriptThrowsWhenBridgeOffline() async { + let appModel = NodeAppModel() + await #expect(throws: Error.self) { + try await appModel.sendVoiceTranscript(text: "hello", sessionKey: "main") + } + } + + @Test @MainActor func canvasA2UIActionDispatchesStatus() async { + let appModel = NodeAppModel() + let body: [String: Any] = [ + "userAction": [ + "name": "tap", + "id": "action-1", + "surfaceId": "main", + "sourceComponentId": "button-1", + "context": ["value": "ok"], + ], + ] + await appModel._test_handleCanvasA2UIAction(body: body) + #expect(appModel.screen.urlString.isEmpty) + } +} diff --git a/apps/ios/Tests/ScreenRecordServiceTests.swift b/apps/ios/Tests/ScreenRecordServiceTests.swift new file mode 100644 index 000000000..f046b8356 --- /dev/null +++ b/apps/ios/Tests/ScreenRecordServiceTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import Clawdis + +@Suite(.serialized) struct ScreenRecordServiceTests { + @Test func clampDefaultsAndBounds() { + #expect(ScreenRecordService._test_clampDurationMs(nil) == 10000) + #expect(ScreenRecordService._test_clampDurationMs(0) == 250) + #expect(ScreenRecordService._test_clampDurationMs(60001) == 60000) + + #expect(ScreenRecordService._test_clampFps(nil) == 10) + #expect(ScreenRecordService._test_clampFps(0) == 1) + #expect(ScreenRecordService._test_clampFps(120) == 30) + #expect(ScreenRecordService._test_clampFps(.infinity) == 10) + } + + @Test @MainActor func recordRejectsInvalidScreenIndex() async { + let recorder = ScreenRecordService() + do { + _ = try await recorder.record( + screenIndex: 1, + durationMs: 250, + fps: 5, + includeAudio: false, + outPath: nil) + Issue.record("Expected invalid screen index to throw") + } catch let error as ScreenRecordService.ScreenRecordError { + #expect(error.localizedDescription.contains("Invalid screen index") == true) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/apps/ios/Tests/VoiceWakeManagerStateTests.swift b/apps/ios/Tests/VoiceWakeManagerStateTests.swift new file mode 100644 index 000000000..a20deb59c --- /dev/null +++ b/apps/ios/Tests/VoiceWakeManagerStateTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +import SwabbleKit +@testable import Clawdis + +@Suite(.serialized) struct VoiceWakeManagerStateTests { + @Test @MainActor func suspendAndResumeCycleUpdatesState() async { + let manager = VoiceWakeManager() + manager.isEnabled = true + manager.isListening = true + manager.statusText = "Listening" + + let suspended = manager.suspendForExternalAudioCapture() + #expect(suspended == true) + #expect(manager.isListening == false) + #expect(manager.statusText == "Paused") + + manager.resumeAfterExternalAudioCapture(wasSuspended: true) + try? await Task.sleep(nanoseconds: 900_000_000) + #expect(manager.statusText.contains("Voice Wake") == true) + } + + @Test @MainActor func handleRecognitionCallbackRestartsOnError() async { + let manager = VoiceWakeManager() + manager.isEnabled = true + manager.isListening = true + + manager._test_handleRecognitionCallback(transcript: nil, segments: [], errorText: "boom") + #expect(manager.statusText.contains("Recognizer error") == true) + #expect(manager.isListening == false) + + try? await Task.sleep(nanoseconds: 900_000_000) + #expect(manager.statusText.contains("Voice Wake") == true) + } + + @Test @MainActor func handleRecognitionCallbackDispatchesCommand() async { + let manager = VoiceWakeManager() + manager.triggerWords = ["clawd"] + manager.isEnabled = true + + actor CaptureBox { + var value: String? + func set(_ next: String) { self.value = next } + } + let capture = CaptureBox() + manager.configure { cmd in + await capture.set(cmd) + } + + let transcript = "clawd hello" + let clawdRange = transcript.range(of: "clawd")! + let helloRange = transcript.range(of: "hello")! + let segments = [ + WakeWordSegment(text: "clawd", start: 0.0, duration: 0.2, range: clawdRange), + WakeWordSegment(text: "hello", start: 0.8, duration: 0.2, range: helloRange), + ] + + manager._test_handleRecognitionCallback(transcript: transcript, segments: segments, errorText: nil) + #expect(manager.lastTriggeredCommand == "hello") + #expect(manager.statusText == "Triggered") + + try? await Task.sleep(nanoseconds: 300_000_000) + #expect(await capture.value == "hello") + } +}