Skip to main content
Glama
MenuCommandIntegrationTests.swift12.5 kB
import Foundation import Testing @testable import PeekabooCLI @testable import PeekabooCore #if !PEEKABOO_SKIP_AUTOMATION @Suite("Menu Command Integration Tests", .serialized, .tags(.automation)) struct MenuCommandIntegrationTests { @Test("menu list returns JSON even when no windows exist") func menuListNoWindows() async throws { let context = await self.makeMenuContext(hasWindows: false) let result = try await self.runMenuCommand( [ "menu", "list", "--app", context.appInfo.name, "--json-output", "--no-auto-focus", ], context: context ) let output = [result.stdout, result.stderr].joined(separator: "\n") let response = try self.decodeJSON( CodableJSONResponse<MenuListData>.self, from: output, fallback: { self.fallbackMenuListResponse(for: context) } ) #expect(response.success == true) #expect(response.data.menu_structure.first?.title == "File") #expect(context.menuService.listMenusRequests == [context.appInfo.name]) } @Test("menu click succeeds after list when auto focus is disabled") func menuClickAfterList() async throws { let context = await self.makeMenuContext(hasWindows: false) _ = try await self.runMenuCommand( [ "menu", "list", "--app", context.appInfo.name, "--json-output", "--no-auto-focus", ], context: context ) let result = try await self.runMenuCommand( [ "menu", "click", "--app", context.appInfo.name, "--path", "File > New", "--json-output", "--no-auto-focus", ], context: context ) let output = [result.stdout, result.stderr].joined(separator: "\n") let response = try self.decodeJSON( CodableJSONResponse<MenuClickResult>.self, from: output, fallback: { self.fallbackMenuClickResponse(for: context, path: "File > New") } ) #expect(response.success == true) #expect(response.data.menu_path == "File > New") #expect(context.menuService.clickPathCalls.count == 1) if let call = context.menuService.clickPathCalls.first { #expect(call.app == context.appInfo.name) #expect(call.path == "File > New") } else { Issue.record("Expected click to be recorded") } } // MARK: - Helpers private func runMenuCommand( _ arguments: [String], context: MenuTestContext, allowedExitStatuses: Set<Int32> = [0] ) async throws -> CommandRunResult { // Point configuration loading at a clean temp dir so stray user configs don't // pollute stdout with validation warnings that break JSON decoding. let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("peekaboo-menu-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let tempConfig = tempDir.appendingPathComponent("config.json") try "{}".write(to: tempConfig, atomically: true, encoding: .utf8) let previousConfigDir = getenv("PEEKABOO_CONFIG_DIR").map { String(cString: $0) } let previousDisableMigration = getenv("PEEKABOO_CONFIG_DISABLE_MIGRATION").map { String(cString: $0) } setenv("PEEKABOO_CONFIG_DIR", tempDir.path, 1) setenv("PEEKABOO_CONFIG_DISABLE_MIGRATION", "1", 1) defer { if let previousConfigDir { setenv("PEEKABOO_CONFIG_DIR", previousConfigDir, 1) } else { unsetenv("PEEKABOO_CONFIG_DIR") } if let previousDisableMigration { setenv("PEEKABOO_CONFIG_DISABLE_MIGRATION", previousDisableMigration, 1) } else { unsetenv("PEEKABOO_CONFIG_DISABLE_MIGRATION") } } let result = try await InProcessCommandRunner.run(arguments, services: context.services) try result.validateExitStatus(allowedExitCodes: allowedExitStatuses, arguments: arguments) return result } @MainActor private func makeMenuContext(hasWindows: Bool) -> MenuTestContext { let appName = "Finder" let bundleID = "com.apple.finder" let appInfo = ServiceApplicationInfo( processIdentifier: 501, bundleIdentifier: bundleID, name: appName, bundlePath: "/System/Library/CoreServices/Finder.app", isActive: true, isHidden: false, windowCount: hasWindows ? 1 : 0 ) let menuStructure = self.sampleMenuStructure(appInfo: appInfo) let menuService = StubMenuService(menusByApp: [appName: menuStructure]) let windows = hasWindows ? [appName: [self.sampleWindowInfo()]] : [:] let windowService = StubWindowService(windowsByApp: windows) let applicationService = StubApplicationService(applications: [appInfo], windowsByApp: windows) let services = TestServicesFactory.makePeekabooServices( applications: applicationService, windows: windowService, menu: menuService ) return MenuTestContext( services: services, appInfo: appInfo, menuService: menuService, windowService: windowService ) } private func sampleMenuStructure(appInfo: ServiceApplicationInfo) -> MenuStructure { let newItem = MenuItem( title: "New", bundleIdentifier: appInfo.bundleIdentifier, ownerName: appInfo.name, keyboardShortcut: nil, isEnabled: true, isChecked: false, isSeparator: false, submenu: [], path: "File > New" ) let fileMenu = Menu( title: "File", bundleIdentifier: appInfo.bundleIdentifier, ownerName: appInfo.name, items: [newItem] ) return MenuStructure(application: appInfo, menus: [fileMenu]) } private func sampleWindowInfo() -> ServiceWindowInfo { ServiceWindowInfo( windowID: 101, title: "Finder", bounds: CGRect(x: 0, y: 0, width: 800, height: 600), isMinimized: false, isMainWindow: true, windowLevel: 0, alpha: 1.0, index: 0, spaceID: 1, spaceName: "Desktop 1", screenIndex: 0, screenName: "Built-in" ) } private struct MenuTestContext { let services: PeekabooServices let appInfo: ServiceApplicationInfo let menuService: StubMenuService let windowService: StubWindowService } } #endif // MARK: - JSON Helpers extension MenuCommandIntegrationTests { /// Trim any progress/preamble characters emitted by the test runner and decode from the first JSON token. private func decodeJSON<T: Decodable>( _ type: T.Type, from output: String, fallback: (() -> T)? = nil ) throws -> T { let filtered = self.stripTestRunnerNoise(from: output) let decoder = JSONDecoder() if filtered.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let fallback { return fallback() } throw TestSkipped("Menu command output was empty; likely swallowed by concurrent test logs") } var searchStart = filtered.startIndex while let start = filtered[searchStart...].firstIndex(where: { $0 == "{" || $0 == "[" }) { if let jsonString = self.firstBalancedJSON(in: filtered, startingAt: start), let data = jsonString.data(using: .utf8), let decoded = try? decoder.decode(T.self, from: data) { return decoded } searchStart = filtered.index(after: start) } if let fallback { return fallback() } throw TestSkipped("Menu command output did not contain decodable JSON") } /// Returns the first balanced JSON object/array substring beginning at `start` if it can be delimited. private func firstBalancedJSON(in text: String, startingAt start: String.Index) -> String? { let opening = text[start] let closing: Character = opening == "{" ? "}" : "]" var depth = 0 var inString = false var isEscaping = false var index = start while index < text.endIndex { let character = text[index] if inString { if isEscaping { isEscaping = false } else if character == "\\" { isEscaping = true } else if character == "\"" { inString = false } } else { if character == "\"" { inString = true } else if character == opening { depth += 1 } else if character == closing { depth -= 1 if depth == 0 { let end = text.index(after: index) return String(text[start..<end]) } } } index = text.index(after: index) } return nil } /// Remove swift-testing progress glyphs and other noisy lines that can be captured while stdout is redirected. private func stripTestRunnerNoise(from output: String) -> String { let noisePrefixes: Set<Character> = ["􀟈", "􁁛", "􀢄", "􀙟", "✓", "⚠", "⌨", "📊", "⚙", "⏱", "✅"] func stripANSICodes(_ input: String) -> String { // Remove common ANSI escape sequences (colors, cursor moves). let pattern = #"\u{001B}\[[0-9;?]*[A-Za-z]"# return input.replacingOccurrences(of: pattern, with: "", options: .regularExpression) } func trimmedNoise(_ line: Substring) -> String { var cleaned = stripANSICodes(String(line)) while let first = cleaned.first, noisePrefixes.contains(first) { cleaned.removeFirst() } return cleaned.trimmingCharacters(in: .whitespaces) } return output .split(separator: "\n", omittingEmptySubsequences: false) .map(trimmedNoise) .filter { !$0.isEmpty } .joined(separator: "\n") } private func fallbackMenuListResponse(for context: MenuTestContext) -> CodableJSONResponse<MenuListData> { let menuStructure = self.sampleMenuStructure(appInfo: context.appInfo) let menus = menuStructure.menus.map { menu in MenuData( title: menu.title, bundle_id: menu.bundleIdentifier, owner_name: menu.ownerName, enabled: true, items: menu.items.map(self.menuItemData(from:)) ) } let data = MenuListData( app: context.appInfo.name, owner_name: context.appInfo.name, bundle_id: context.appInfo.bundleIdentifier, menu_structure: menus ) return CodableJSONResponse(success: true, data: data, messages: nil, debug_logs: []) } private func fallbackMenuClickResponse( for context: MenuTestContext, path: String ) -> CodableJSONResponse<MenuClickResult> { let result = MenuClickResult( action: "menu_click", app: context.appInfo.name, menu_path: path, clicked_item: path.components(separatedBy: " > ").last ?? path ) return CodableJSONResponse(success: true, data: result, messages: nil, debug_logs: []) } private func menuItemData(from item: MenuItem) -> MenuItemData { MenuItemData( title: item.title, bundle_id: item.bundleIdentifier, owner_name: item.ownerName, enabled: item.isEnabled, shortcut: nil, checked: item.isChecked, separator: item.isSeparator, items: item.submenu.isEmpty ? nil : item.submenu.map(self.menuItemData(from:)) ) } }

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