Skip to main content
Glama
TestServices.swift41.6 kB
import AppKit import CoreGraphics import Foundation import PeekabooFoundation import UniformTypeIdentifiers @testable import PeekabooCLI @testable import PeekabooCore enum TestStubError: Error { case unimplemented(StaticString) } @MainActor func stubUnimplemented(_ function: StaticString = #function) -> Never { fatalError("Test stub method not implemented: \(function)") } // MARK: - Stub Services @MainActor final class StubScreenCaptureService: ScreenCaptureServiceProtocol { var permissionGranted: Bool var defaultCaptureResult: CaptureResult? var captureScreenHandler: ((Int?, CaptureScalePreference) async throws -> CaptureResult)? var captureWindowHandler: ((String, Int?, CaptureScalePreference) async throws -> CaptureResult)? var captureFrontmostHandler: ((CaptureScalePreference) async throws -> CaptureResult)? var captureAreaHandler: ((CGRect, CaptureScalePreference) async throws -> CaptureResult)? init(permissionGranted: Bool = true) { self.permissionGranted = permissionGranted } func captureScreen( displayIndex: Int?, visualizerMode _: CaptureVisualizerMode, scale: CaptureScalePreference ) async throws -> CaptureResult { if let handler = self.captureScreenHandler { return try await handler(displayIndex, scale) } return try await self.makeDefaultCaptureResult(function: #function) } func captureWindow( appIdentifier: String, windowIndex: Int?, visualizerMode _: CaptureVisualizerMode, scale: CaptureScalePreference ) async throws -> CaptureResult { if let handler = self.captureWindowHandler { return try await handler(appIdentifier, windowIndex, scale) } return try await self.makeDefaultCaptureResult(function: #function) } func captureFrontmost( visualizerMode _: CaptureVisualizerMode, scale: CaptureScalePreference ) async throws -> CaptureResult { if let handler = self.captureFrontmostHandler { return try await handler(scale) } return try await self.makeDefaultCaptureResult(function: #function) } func captureArea( _ rect: CGRect, visualizerMode _: CaptureVisualizerMode, scale: CaptureScalePreference ) async throws -> CaptureResult { if let handler = self.captureAreaHandler { return try await handler(rect, scale) } return try await self.makeDefaultCaptureResult(function: #function) } func hasScreenRecordingPermission() async -> Bool { self.permissionGranted } private func makeDefaultCaptureResult(function: StaticString) async throws -> CaptureResult { if let result = self.defaultCaptureResult { return result } // Provide a harmless stub image so unexpected capture calls don't crash the test run. return CaptureResult( imageData: Data(), metadata: CaptureMetadata(size: CGSize(width: 1, height: 1), mode: .screen) ) } } @MainActor final class StubAutomationService: UIAutomationServiceProtocol { struct ClickCall: Sendable { let target: ClickTarget let clickType: ClickType let sessionId: String? } struct TypeTextCall: Sendable { let text: String let target: String? let clearExisting: Bool let typingDelay: Int let sessionId: String? } struct TypeActionsCall: Sendable { let actions: [TypeAction] let cadence: TypingCadence let sessionId: String? } struct ScrollCall: Sendable { let request: ScrollRequest } struct SwipeCall: Sendable { let from: CGPoint let to: CGPoint let duration: Int let steps: Int let profile: MouseMovementProfile } struct DragCall: Sendable { let from: CGPoint let to: CGPoint let duration: Int let steps: Int let modifiers: String? let profile: MouseMovementProfile } struct MoveMouseCall: Sendable { let destination: CGPoint let duration: Int let steps: Int let profile: MouseMovementProfile } struct HotkeyCall: Sendable { let keys: String let holdDuration: Int } struct WaitForElementCall: Sendable { let target: ClickTarget let timeout: TimeInterval let sessionId: String? } private enum WaitTargetKey: Hashable { case elementId(String) case query(String) case coordinates(x: Double, y: Double) } var clickCalls: [ClickCall] = [] var typeTextCalls: [TypeTextCall] = [] var typeActionsCalls: [TypeActionsCall] = [] var scrollCalls: [ScrollCall] = [] var swipeCalls: [SwipeCall] = [] var dragCalls: [DragCall] = [] var moveMouseCalls: [MoveMouseCall] = [] var hotkeyCalls: [HotkeyCall] = [] var waitForElementCalls: [WaitForElementCall] = [] var detectElementsCalls: [(imageData: Data, sessionId: String?, windowContext: WindowContext?)] = [] var nextTypeActionsResult: TypeResult? var typeActionsResultProvider: (([TypeAction], TypingCadence, String?) -> TypeResult)? var waitForElementProvider: ((ClickTarget, TimeInterval, String?) -> WaitForElementResult)? private var waitForElementResults: [WaitTargetKey: WaitForElementResult] = [:] var detectElementsHandler: ((Data, String?, WindowContext?) async throws -> ElementDetectionResult)? var nextDetectionResult: ElementDetectionResult? func setWaitForElementResult(_ result: WaitForElementResult, for target: ClickTarget) { self.waitForElementResults[self.key(for: target)] = result } func detectElements( in imageData: Data, sessionId: String?, windowContext: WindowContext? ) async throws -> ElementDetectionResult { self.detectElementsCalls.append((imageData, sessionId, windowContext)) if let handler = self.detectElementsHandler { return try await handler(imageData, sessionId, windowContext) } if let nextDetectionResult { return nextDetectionResult } throw TestStubError.unimplemented(#function) } func click(target: ClickTarget, clickType: ClickType, sessionId: String?) async throws { self.clickCalls.append(ClickCall(target: target, clickType: clickType, sessionId: sessionId)) } func type( text: String, target: String?, clearExisting: Bool, typingDelay: Int, sessionId: String? ) async throws { self.typeTextCalls.append( TypeTextCall( text: text, target: target, clearExisting: clearExisting, typingDelay: typingDelay, sessionId: sessionId )) } func typeActions( _ actions: [TypeAction], cadence: TypingCadence, sessionId: String? ) async throws -> TypeResult { self.typeActionsCalls.append( TypeActionsCall(actions: actions, cadence: cadence, sessionId: sessionId) ) if let provider = self.typeActionsResultProvider { return provider(actions, cadence, sessionId) } if let nextResult = self.nextTypeActionsResult { return nextResult } let totals = actions.reduce(into: (characters: 0, keyPresses: 0)) { partial, action in switch action { case let .text(text): partial.characters += text.count partial.keyPresses += text.count case .key: partial.keyPresses += 1 case .clear: partial.keyPresses += 2 } } return TypeResult(totalCharacters: totals.characters, keyPresses: totals.keyPresses) } func scroll(_ request: ScrollRequest) async throws { self.scrollCalls.append( ScrollCall(request: request) ) } func hotkey(keys: String, holdDuration: Int) async throws { self.hotkeyCalls.append(HotkeyCall(keys: keys, holdDuration: holdDuration)) } func swipe(from: CGPoint, to: CGPoint, duration: Int, steps: Int, profile: MouseMovementProfile) async throws { self.swipeCalls.append( SwipeCall(from: from, to: to, duration: duration, steps: steps, profile: profile) ) } var accessibilityPermissionGranted = true func hasAccessibilityPermission() async -> Bool { self.accessibilityPermissionGranted } func waitForElement( target: ClickTarget, timeout: TimeInterval, sessionId: String? ) async throws -> WaitForElementResult { self.waitForElementCalls.append( WaitForElementCall(target: target, timeout: timeout, sessionId: sessionId) ) if let provider = self.waitForElementProvider { return provider(target, timeout, sessionId) } if let stored = self.waitForElementResults[self.key(for: target)] { return stored } return WaitForElementResult(found: false, element: nil, waitTime: 0) } // swiftlint:disable:next function_parameter_count func drag( from: CGPoint, to: CGPoint, duration: Int, steps: Int, modifiers: String?, profile: MouseMovementProfile ) async throws { self.dragCalls.append( DragCall(from: from, to: to, duration: duration, steps: steps, modifiers: modifiers, profile: profile) ) } func moveMouse(to: CGPoint, duration: Int, steps: Int, profile: MouseMovementProfile) async throws { self.moveMouseCalls.append( MoveMouseCall(destination: to, duration: duration, steps: steps, profile: profile) ) } func getFocusedElement() -> UIFocusInfo? { nil } func findElement( matching criteria: UIElementSearchCriteria, in appName: String? ) async throws -> DetectedElement { throw TestStubError.unimplemented(#function) } private func key(for target: ClickTarget) -> WaitTargetKey { switch target { case let .elementId(identifier): .elementId(identifier) case let .query(query): .query(query) case let .coordinates(point): .coordinates(x: point.x, y: point.y) } } } @MainActor final class StubApplicationService: ApplicationServiceProtocol { var applications: [ServiceApplicationInfo] var windowsByApp: [String: [ServiceWindowInfo]] var launchResults: [String: ServiceApplicationInfo] var launchCalls: [String] = [] var activateCalls: [String] = [] var quitCalls: [(identifier: String, force: Bool)] = [] var quitShouldSucceed = true var hideCalls: [String] = [] var unhideCalls: [String] = [] var hideOtherCalls: [String] = [] var showAllCallCount = 0 init(applications: [ServiceApplicationInfo], windowsByApp: [String: [ServiceWindowInfo]] = [:]) { self.applications = applications self.windowsByApp = windowsByApp self.launchResults = [:] } func listApplications() async throws -> UnifiedToolOutput<ServiceApplicationListData> { let data = ServiceApplicationListData(applications: self.applications) let summary = UnifiedToolOutput<ServiceApplicationListData>.Summary( brief: "Stub application list", status: .success, counts: ["applications": self.applications.count] ) return UnifiedToolOutput( data: data, summary: summary, metadata: .init(duration: 0) ) } func findApplication(identifier: String) async throws -> ServiceApplicationInfo { if let match = self.applications.first(where: { $0.name == identifier || $0.bundleIdentifier == identifier }) { return match } throw PeekabooError.appNotFound(identifier) } func listWindows( for appIdentifier: String, timeout: Float? ) async throws -> UnifiedToolOutput<ServiceWindowListData> { let windows = self.windowsByApp[appIdentifier] ?? [] let targetApp = self.applications.first(where: { $0.name == appIdentifier }) let data = ServiceWindowListData(windows: windows, targetApplication: targetApp) let summary = UnifiedToolOutput<ServiceWindowListData>.Summary( brief: "Stub window list", status: .success, counts: ["windows": windows.count] ) return UnifiedToolOutput( data: data, summary: summary, metadata: .init(duration: 0) ) } func getFrontmostApplication() async throws -> ServiceApplicationInfo { guard let first = self.applications.first else { throw PeekabooError.appNotFound("frontmost") } return first } func isApplicationRunning(identifier: String) async -> Bool { self.applications.contains { $0.name == identifier || $0.bundleIdentifier == identifier } } func launchApplication(identifier: String) async throws -> ServiceApplicationInfo { self.launchCalls.append(identifier) if let result = self.launchResults[identifier] { return result } if let existing = self.applications .first(where: { $0.name == identifier || $0.bundleIdentifier == identifier }) { return existing } return ServiceApplicationInfo( processIdentifier: Int32.random(in: 1000...2000), bundleIdentifier: "launched.\(identifier)", name: identifier ) } func activateApplication(identifier: String) async throws { self.activateCalls.append(identifier) } func quitApplication(identifier: String, force: Bool) async throws -> Bool { self.quitCalls.append((identifier: identifier, force: force)) return self.quitShouldSucceed } func hideApplication(identifier: String) async throws { self.hideCalls.append(identifier) } func unhideApplication(identifier: String) async throws { self.unhideCalls.append(identifier) } func hideOtherApplications(identifier: String) async throws { self.hideOtherCalls.append(identifier) } func showAllApplications() async throws { self.showAllCallCount += 1 } } final class StubSessionManager: SessionManagerProtocol, @unchecked Sendable { private(set) var detectionResults: [String: ElementDetectionResult] = [:] private(set) var sessionInfos: [String: SessionInfo] = [:] private(set) var storedElements: [String: [String: PeekabooCore.UIElement]] = [:] var mostRecentSessionId: String? struct ScreenshotRecord: Sendable { let path: String let applicationName: String? let windowTitle: String? let windowBounds: CGRect? } private(set) var storedScreenshots: [String: [ScreenshotRecord]] = [:] func createSession() async throws -> String { let sessionId = UUID().uuidString let now = Date() self.sessionInfos[sessionId] = SessionInfo( id: sessionId, processId: 0, createdAt: now, lastAccessedAt: now, sizeInBytes: 0, screenshotCount: 0, isActive: true ) self.mostRecentSessionId = sessionId return sessionId } func storeDetectionResult(sessionId: String, result: ElementDetectionResult) async throws { self.detectionResults[sessionId] = result self.mostRecentSessionId = sessionId let existingInfo = self.sessionInfos[sessionId] let createdAt = existingInfo?.createdAt ?? Date() self.sessionInfos[sessionId] = SessionInfo( id: sessionId, processId: existingInfo?.processId ?? 0, createdAt: createdAt, lastAccessedAt: Date(), sizeInBytes: existingInfo?.sizeInBytes ?? 0, screenshotCount: (existingInfo?.screenshotCount ?? 0) + 1, isActive: true ) self.storedElements[sessionId] = result.elements.all .reduce(into: [String: PeekabooCore.UIElement]()) { partial, element in partial[element.id] = PeekabooCore.UIElement( id: element.id, elementId: element.id, role: element.type.rawValue, title: element.label, label: element.label, value: element.value, description: nil, help: nil, roleDescription: nil, identifier: element.attributes["identifier"], frame: element.bounds, isActionable: true, parentId: nil, children: [], keyboardShortcut: nil ) } } func getDetectionResult(sessionId: String) async throws -> ElementDetectionResult? { self.detectionResults[sessionId] } func getMostRecentSession() async -> String? { self.mostRecentSessionId } func listSessions() async throws -> [SessionInfo] { Array(self.sessionInfos.values) } func cleanSession(sessionId: String) async throws { self.detectionResults.removeValue(forKey: sessionId) self.sessionInfos.removeValue(forKey: sessionId) self.storedElements.removeValue(forKey: sessionId) if self.mostRecentSessionId == sessionId { self.mostRecentSessionId = nil } } func cleanSessionsOlderThan(days: Int) async throws -> Int { let threshold = Date().addingTimeInterval(TimeInterval(-days * 24 * 60 * 60)) let ids: [String] = self.sessionInfos.values .filter { $0.lastAccessedAt < threshold } .reduce(into: []) { partialResult, info in partialResult.append(info.id) } for id in ids { try await self.cleanSession(sessionId: id) } return ids.count } func cleanAllSessions() async throws -> Int { let count = self.sessionInfos.count self.detectionResults.removeAll() self.sessionInfos.removeAll() self.storedElements.removeAll() self.mostRecentSessionId = nil return count } func getSessionStoragePath() -> String { "/tmp/peekaboo-sessions" } func storeScreenshot( sessionId: String, screenshotPath: String, applicationName: String?, windowTitle: String?, windowBounds: CGRect? ) async throws { let existingInfo = self.sessionInfos[sessionId] let createdAt = existingInfo?.createdAt ?? Date() let screenshotCount = (existingInfo?.screenshotCount ?? 0) + 1 self.sessionInfos[sessionId] = SessionInfo( id: sessionId, processId: existingInfo?.processId ?? 0, createdAt: createdAt, lastAccessedAt: Date(), sizeInBytes: existingInfo?.sizeInBytes ?? 0, screenshotCount: screenshotCount, isActive: existingInfo?.isActive ?? true ) var records = self.storedScreenshots[sessionId] ?? [] records.append( ScreenshotRecord( path: screenshotPath, applicationName: applicationName, windowTitle: windowTitle, windowBounds: windowBounds ) ) self.storedScreenshots[sessionId] = records } func getElement(sessionId: String, elementId: String) async throws -> PeekabooCore.UIElement? { self.storedElements[sessionId]?[elementId] } func findElements(sessionId: String, matching query: String) async throws -> [PeekabooCore.UIElement] { self.storedElements[sessionId]?.values.filter { $0.label?.localizedCaseInsensitiveContains(query) == true || $0.title?.localizedCaseInsensitiveContains(query) == true } ?? [] } func getUIAutomationSession(sessionId: String) async throws -> UIAutomationSession? { nil } } final class StubFileService: FileServiceProtocol { func cleanAllSessions(dryRun: Bool) async throws -> CleanResult { CleanResult(sessionsRemoved: 0, bytesFreed: 0, sessionDetails: [], dryRun: dryRun) } func cleanOldSessions(hours: Int, dryRun: Bool) async throws -> CleanResult { CleanResult(sessionsRemoved: 0, bytesFreed: 0, sessionDetails: [], dryRun: dryRun) } func cleanSpecificSession(sessionId: String, dryRun: Bool) async throws -> CleanResult { CleanResult(sessionsRemoved: 0, bytesFreed: 0, sessionDetails: [], dryRun: dryRun) } func getSessionCacheDirectory() -> URL { URL(fileURLWithPath: "/tmp/peekaboo-sessions") } func calculateDirectorySize(_ directory: URL) async throws -> Int64 { 0 } func listSessions() async throws -> [FileSessionInfo] { [] } } @available(macOS 14.0, *) final class StubProcessService: ProcessServiceProtocol, @unchecked Sendable { struct LoadScriptCall { let path: String } struct ExecuteScriptCall { let script: PeekabooScript let failFast: Bool let verbose: Bool } struct ExecuteStepCall { let step: ScriptStep let sessionId: String? } var loadScriptCalls: [LoadScriptCall] = [] var executeScriptCalls: [ExecuteScriptCall] = [] var executeStepCalls: [ExecuteStepCall] = [] var scriptsByPath: [String: PeekabooScript] = [:] var loadScriptProvider: ((String) async throws -> PeekabooScript)? var executeScriptProvider: ((PeekabooScript, Bool, Bool) async throws -> [StepResult])? var executeStepProvider: ((ScriptStep, String?) async throws -> StepExecutionResult)? var nextScript: PeekabooScript? var nextExecuteScriptResults: [StepResult]? var nextStepResult: StepExecutionResult? func loadScript(from path: String) async throws -> PeekabooScript { self.loadScriptCalls.append(LoadScriptCall(path: path)) if let provider = self.loadScriptProvider { return try await provider(path) } if let script = self.scriptsByPath[path] ?? self.scriptsByPath["*"] { return script } if let script = self.nextScript { return script } throw TestStubError.unimplemented(#function) } func executeScript( _ script: PeekabooScript, failFast: Bool, verbose: Bool ) async throws -> [StepResult] { self.executeScriptCalls.append(ExecuteScriptCall(script: script, failFast: failFast, verbose: verbose)) if let provider = self.executeScriptProvider { return try await provider(script, failFast, verbose) } if let results = self.nextExecuteScriptResults { return results } return [] } func executeStep( _ step: ScriptStep, sessionId: String? ) async throws -> StepExecutionResult { self.executeStepCalls.append(ExecuteStepCall(step: step, sessionId: sessionId)) if let provider = self.executeStepProvider { return try await provider(step, sessionId) } if let result = self.nextStepResult { return result } throw TestStubError.unimplemented(#function) } } @MainActor final class StubDockService: DockServiceProtocol { var items: [DockItem] var autoHidden: Bool init(items: [DockItem] = [], autoHidden: Bool = false) { self.items = items self.autoHidden = autoHidden } func listDockItems(includeAll: Bool) async throws -> [DockItem] { self.items } func launchFromDock(appName: String) async throws { throw TestStubError.unimplemented(#function) } func addToDock(path: String, persistent: Bool) async throws { throw TestStubError.unimplemented(#function) } func removeFromDock(appName: String) async throws { throw TestStubError.unimplemented(#function) } func rightClickDockItem(appName: String, menuItem: String?) async throws { throw TestStubError.unimplemented(#function) } func hideDock() async throws { throw TestStubError.unimplemented(#function) } func showDock() async throws { throw TestStubError.unimplemented(#function) } func isDockAutoHidden() async -> Bool { self.autoHidden } func findDockItem(name: String) async throws -> DockItem { guard let match = self.items.first(where: { $0.title == name }) else { throw PeekabooError.elementNotFound(name) } return match } } @MainActor final class StubScreenService: ScreenServiceProtocol { var screens: [ScreenInfo] init(screens: [ScreenInfo] = []) { self.screens = screens } func listScreens() -> [ScreenInfo] { self.screens } func screenContainingWindow(bounds: CGRect) -> ScreenInfo? { self.screens.first } func screen(at index: Int) -> ScreenInfo? { guard index >= 0, index < self.screens.count else { return nil } return self.screens[index] } var primaryScreen: ScreenInfo? { self.screens.first } } @MainActor final class StubClipboardService: ClipboardServiceProtocol { var current: ClipboardReadResult? var slots: [String: ClipboardReadResult] = [:] func get(prefer _: UTType?) throws -> ClipboardReadResult? { self.current } func set(_ request: ClipboardWriteRequest) throws -> ClipboardReadResult { guard let primary = request.representations.first else { throw ClipboardServiceError.writeFailed("No representations provided") } let result = ClipboardReadResult( utiIdentifier: primary.utiIdentifier, data: primary.data, textPreview: request.alsoText ) self.current = result return result } func clear() { self.current = nil } func save(slot: String) throws { guard let current else { throw ClipboardServiceError.empty } self.slots[slot] = current } func restore(slot: String) throws -> ClipboardReadResult { guard let saved = self.slots[slot] else { throw ClipboardServiceError.slotNotFound(slot) } self.current = saved return saved } } @MainActor final class StubMenuService: MenuServiceProtocol { var menusByApp: [String: MenuStructure] var frontmostMenus: MenuStructure? var menuExtras: [MenuExtraInfo] var clickPathCalls: [(app: String, path: String)] = [] var clickItemCalls: [(app: String, item: String)] = [] var clickExtraCalls: [String] = [] var listMenusRequests: [String] = [] init( menusByApp: [String: MenuStructure], frontmostMenus: MenuStructure? = nil, menuExtras: [MenuExtraInfo] = [] ) { self.menusByApp = menusByApp self.frontmostMenus = frontmostMenus self.menuExtras = menuExtras } func listMenus(for appIdentifier: String) async throws -> MenuStructure { self.listMenusRequests.append(appIdentifier) guard let structure = self.menusByApp[appIdentifier] else { throw PeekabooError.menuNotFound(appIdentifier) } return structure } func listFrontmostMenus() async throws -> MenuStructure { guard let menus = self.frontmostMenus else { throw PeekabooError.menuNotFound("frontmost") } return menus } func clickMenuItem(app: String, itemPath: String) async throws { guard self.menusByApp[app] != nil else { throw PeekabooError.menuNotFound(app) } self.clickPathCalls.append((app, itemPath)) } func clickMenuItemByName(app: String, itemName: String) async throws { guard self.menusByApp[app] != nil else { throw PeekabooError.menuNotFound(app) } self.clickItemCalls.append((app, itemName)) } func clickMenuExtra(title: String) async throws { guard self.menuExtras.contains(where: { $0.title == title }) else { throw PeekabooError.menuNotFound(title) } self.clickExtraCalls.append(title) } func listMenuExtras() async throws -> [MenuExtraInfo] { self.menuExtras } func listMenuBarItems(includeRaw: Bool) async throws -> [MenuBarItemInfo] { [] } func clickMenuBarItem(named name: String) async throws -> PeekabooCore.ClickResult { throw TestStubError.unimplemented(#function) } func clickMenuBarItem(at index: Int) async throws -> PeekabooCore.ClickResult { throw TestStubError.unimplemented(#function) } } @MainActor final class StubDialogService: DialogServiceProtocol { var dialogElements: DialogElements? var clickButtonResult: DialogActionResult? var handleFileDialogResult: DialogActionResult? var dismissResult: DialogActionResult? var enterTextResult: DialogActionResult? private(set) var recordedButtonClicks: [(button: String, window: String?)] = [] init(elements: DialogElements? = nil) { self.dialogElements = elements } func findActiveDialog(windowTitle: String?, appName: String?) async throws -> DialogInfo { guard let elements = self.dialogElements else { throw PeekabooError.elementNotFound(windowTitle ?? "dialog") } return elements.dialogInfo } func clickButton(buttonText: String, windowTitle: String?, appName: String?) async throws -> DialogActionResult { self.recordedButtonClicks.append((buttonText, windowTitle)) if let result = self.clickButtonResult { return result } throw PeekabooError.elementNotFound(buttonText) } func enterText( text: String, fieldIdentifier: String?, clearExisting: Bool, windowTitle: String?, appName: String? ) async throws -> DialogActionResult { if let result = self.enterTextResult { return result } throw PeekabooError.elementNotFound(fieldIdentifier ?? "field") } func handleFileDialog( path: String?, filename: String?, actionButton: String, appName: String? ) async throws -> DialogActionResult { if let result = self.handleFileDialogResult { return result } throw PeekabooError.elementNotFound(actionButton) } func dismissDialog(force: Bool, windowTitle: String?, appName: String?) async throws -> DialogActionResult { if let result = self.dismissResult { return result } throw PeekabooError.elementNotFound(windowTitle ?? "dialog") } func listDialogElements(windowTitle: String?, appName: String?) async throws -> DialogElements { guard let elements = self.dialogElements else { throw PeekabooError.elementNotFound(windowTitle ?? "dialog") } return elements } } @MainActor final class StubWindowService: WindowManagementServiceProtocol { var windowsByApp: [String: [ServiceWindowInfo]] var focusCalls: [WindowTarget] = [] init(windowsByApp: [String: [ServiceWindowInfo]]) { self.windowsByApp = windowsByApp } func closeWindow(target: WindowTarget) async throws { throw TestStubError.unimplemented(#function) } func minimizeWindow(target: WindowTarget) async throws { throw TestStubError.unimplemented(#function) } func maximizeWindow(target: WindowTarget) async throws { throw TestStubError.unimplemented(#function) } @MainActor func moveWindow(target: WindowTarget, to position: CGPoint) async throws { try self.updateWindow(target: target) { info in let newBounds = CGRect(origin: position, size: info.bounds.size) return info.withBounds(newBounds) } } @MainActor func resizeWindow(target: WindowTarget, to size: CGSize) async throws { try self.updateWindow(target: target) { info in let newBounds = CGRect(origin: info.bounds.origin, size: size) return info.withBounds(newBounds) } } @MainActor func setWindowBounds(target: WindowTarget, bounds: CGRect) async throws { try self.updateWindow(target: target) { info in info.withBounds(bounds) } } @MainActor func focusWindow(target: WindowTarget) async throws { self.focusCalls.append(target) } @MainActor func listWindows(target: WindowTarget) async throws -> [ServiceWindowInfo] { switch target { case let .application(app): return self.windowsByApp[app] ?? [] case let .applicationAndTitle(app, title): return self.windowsByApp[app]?.filter { $0.title.contains(title) } ?? [] case .frontmost: return self.windowsByApp.values.first ?? [] case let .windowId(id): return self.windowsByApp.values.flatMap(\.self).filter { $0.windowID == id } case let .title(title): return self.windowsByApp.values.flatMap(\.self).filter { $0.title.contains(title) } case let .index(app, index): guard let windows = self.windowsByApp[app], index < windows.count else { return [] } return [windows[index]] } } func getFocusedWindow() async throws -> ServiceWindowInfo? { nil } @MainActor private func updateWindow( target: WindowTarget, transform: (ServiceWindowInfo) -> ServiceWindowInfo ) throws { let selection = try self.resolveWindowLocation(target: target) var windows = self.windowsByApp[selection.app] ?? [] guard selection.index < windows.count else { throw PeekabooError.windowNotFound(criteria: selection.app) } let updated = transform(windows[selection.index]) windows[selection.index] = updated self.windowsByApp[selection.app] = windows } @MainActor private func resolveWindowLocation(target: WindowTarget) throws -> (app: String, index: Int) { switch target { case let .application(app): guard let windows = self.windowsByApp[app], !windows.isEmpty else { throw PeekabooError.windowNotFound(criteria: app) } return (app, 0) case let .applicationAndTitle(app, title): guard let windows = self.windowsByApp[app], let index = windows.firstIndex(where: { $0.title.localizedCaseInsensitiveContains(title) }) else { throw PeekabooError.windowNotFound(criteria: "title contains \(title)") } return (app, index) case .frontmost: if let entry = self.windowsByApp.first(where: { !$0.value.isEmpty }) { return (entry.key, 0) } throw PeekabooError.windowNotFound(criteria: "frontmost") case let .windowId(id): for (app, windows) in self.windowsByApp { if let index = windows.firstIndex(where: { $0.windowID == id }) { return (app, index) } } throw PeekabooError.windowNotFound(criteria: "windowId \(id)") case let .title(title): for (app, windows) in self.windowsByApp { if let index = windows.firstIndex(where: { $0.title.localizedCaseInsensitiveContains(title) }) { return (app, index) } } throw PeekabooError.windowNotFound(criteria: "title contains \(title)") case let .index(app, index): guard let windows = self.windowsByApp[app], index < windows.count else { throw PeekabooError.windowNotFound(criteria: "index \(index) in \(app)") } return (app, index) } } } extension ServiceWindowInfo { fileprivate func withBounds(_ bounds: CGRect) -> ServiceWindowInfo { ServiceWindowInfo( windowID: self.windowID, title: self.title, bounds: bounds, isMinimized: self.isMinimized, isMainWindow: self.isMainWindow, windowLevel: self.windowLevel, alpha: self.alpha, index: self.index, spaceID: self.spaceID, spaceName: self.spaceName, screenIndex: self.screenIndex, screenName: self.screenName ) } } @MainActor final class StubSpaceService: SpaceCommandSpaceService { let spaces: [SpaceInfo] let windowSpaces: [Int: [SpaceInfo]] var switchCalls: [CGSSpaceID] = [] var moveWindowCalls: [(windowID: CGWindowID, spaceID: CGSSpaceID?)] = [] var moveToCurrentCalls: [CGWindowID] = [] init(spaces: [SpaceInfo], windowSpaces: [Int: [SpaceInfo]] = [:]) { self.spaces = spaces self.windowSpaces = windowSpaces } func getAllSpaces() async -> [SpaceInfo] { self.spaces } func getSpacesForWindow(windowID: CGWindowID) async -> [SpaceInfo] { self.windowSpaces[Int(windowID)] ?? [] } func moveWindowToCurrentSpace(windowID: CGWindowID) async throws { self.moveToCurrentCalls.append(windowID) } func moveWindowToSpace(windowID: CGWindowID, spaceID: CGSSpaceID) async throws { self.moveWindowCalls.append((windowID, spaceID)) } func switchToSpace(_ spaceID: CGSSpaceID) async throws { self.switchCalls.append(spaceID) } } // MARK: - Aggregator @MainActor enum TestServicesFactory { static func makePeekabooServices( applications: any ApplicationServiceProtocol = StubApplicationService(applications: []), windows: any WindowManagementServiceProtocol = StubWindowService(windowsByApp: [:]), menu: any MenuServiceProtocol = StubMenuService(menusByApp: [:]), dialogs: any DialogServiceProtocol = StubDialogService(), dock: any DockServiceProtocol = StubDockService(), sessions: any SessionManagerProtocol = StubSessionManager(), files: any FileServiceProtocol = StubFileService(), clipboard: any ClipboardServiceProtocol = StubClipboardService(), process: any ProcessServiceProtocol = StubProcessService(), screens: [ScreenInfo] = [], automation: any UIAutomationServiceProtocol = StubAutomationService(), screenCapture: any ScreenCaptureServiceProtocol = StubScreenCaptureService() ) -> PeekabooServices { let screenService = StubScreenService(screens: screens) let services = PeekabooServices( logging: LoggingService(), screenCapture: screenCapture, applications: applications, automation: automation, windows: windows, menu: menu, dock: dock, dialogs: dialogs, sessions: sessions, files: files, clipboard: clipboard, process: process, permissions: PermissionsService(), audioInput: AudioInputService(aiService: PeekabooAIService()), agent: nil, configuration: ConfigurationManager.shared, screens: screenService ) return services } @MainActor struct AutomationTestContext { let services: PeekabooServices let automation: StubAutomationService let sessions: StubSessionManager } static func makeAutomationTestContext( automation: StubAutomationService = StubAutomationService(), sessions: StubSessionManager = StubSessionManager(), applications: any ApplicationServiceProtocol = StubApplicationService(applications: []), windows: any WindowManagementServiceProtocol = StubWindowService(windowsByApp: [:]), menu: any MenuServiceProtocol = StubMenuService(menusByApp: [:]), dialogs: any DialogServiceProtocol = StubDialogService(), dock: any DockServiceProtocol = StubDockService(), files: any FileServiceProtocol = StubFileService(), clipboard: any ClipboardServiceProtocol = StubClipboardService(), process: any ProcessServiceProtocol = StubProcessService(), screens: [ScreenInfo] = [], screenCapture: any ScreenCaptureServiceProtocol = StubScreenCaptureService() ) -> AutomationTestContext { let services = self.makePeekabooServices( applications: applications, windows: windows, menu: menu, dialogs: dialogs, dock: dock, sessions: sessions, files: files, clipboard: clipboard, process: process, screens: screens, automation: automation, screenCapture: screenCapture ) return AutomationTestContext(services: services, automation: automation, sessions: sessions) } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/steipete/Peekaboo'

If you have feedback or need assistance with the MCP directory API, please join our Discord server