Skip to main content
Glama
MenuExtractionTests.swift20.5 kB
import Foundation import Testing @testable import PeekabooCLI #if !PEEKABOO_SKIP_AUTOMATION private enum MenuHarnessConfig { @preconcurrency nonisolated static func runLocalHarnessEnabled() -> Bool { guard let raw = ProcessInfo.processInfo.environment["RUN_LOCAL_TESTS"]?.lowercased() else { return false } return raw == "true" || raw == "1" || raw == "yes" } } // Generic response structure for tests struct MenuTestResponse: Codable { let success: Bool let data: MenuExtractionData? let error: String? } struct MenuExtractionData: Codable { let app: String? let menu_structure: [[String: Any]]? let apps: [[String: Any]]? enum CodingKeys: String, CodingKey { case app case menu_structure case apps } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.app = try container.decodeIfPresent(String.self, forKey: .app) // Decode as generic JSON if let menuStructure = try? container.decode([[String: AnyCodable]].self, forKey: .menu_structure) { self.menu_structure = menuStructure.map { dict in dict.mapValues { $0.value } } } else { self.menu_structure = nil } if let appsArray = try? container.decode([[String: AnyCodable]].self, forKey: .apps) { self.apps = appsArray.map { dict in dict.mapValues { $0.value } } } else { self.apps = nil } } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(self.app, forKey: .app) // For encoding, we'd need to convert back to AnyCodable } } // Helper for decoding arbitrary JSON struct AnyCodable: Codable { let value: Any init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let bool = try? container.decode(Bool.self) { self.value = bool } else if let int = try? container.decode(Int.self) { self.value = int } else if let double = try? container.decode(Double.self) { self.value = double } else if let string = try? container.decode(String.self) { self.value = string } else if let array = try? container.decode([AnyCodable].self) { var values: [Any] = [] values.reserveCapacity(array.count) for element in array { values.append(element.value) } self.value = values } else if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict.mapValues { $0.value } } else { self.value = NSNull() } } func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() // Simplified encoding if let bool = value as? Bool { try container.encode(bool) } else if let int = value as? Int { try container.encode(int) } else if let double = value as? Double { try container.encode(double) } else if let string = value as? String { try container.encode(string) } else { try container.encodeNil() } } } @Suite( "Menu Extraction Tests", .serialized, .tags(.automation), .enabled(if: CLITestEnvironment.runAutomationActions), .disabled("Requires local testing with RUN_LOCAL_TESTS") ) struct MenuExtractionTests { @Test("Extract menu structure without clicking") func menuExtraction() async throws { // This test requires a running application #if !os(Linux) guard ProcessInfo.processInfo.environment["RUN_LOCAL_TESTS"] != nil else { return } // Test with Calculator app let output = try await runPeekabooCommand(["menu", "list", "--app", "Calculator", "--json-output"]) let data = try #require(output.data(using: .utf8)) let json = try JSONDecoder().decode(MenuTestResponse.self, from: data) #expect(json.success == true) // Verify we got menu data if let menuData = json.data { #expect(menuData.app == "Calculator") // Check for menu structure if let menuStructure = menuData.menu_structure { #expect(!menuStructure.isEmpty) // Verify common Calculator menus exist let menuTitles = menuStructure.compactMap { $0["title"] as? String } #expect(menuTitles.contains("Calculator")) #expect(menuTitles.contains("Edit")) #expect(menuTitles.contains("View")) #expect(menuTitles.contains("Window")) #expect(menuTitles.contains("Help")) // Check View menu has items if let viewMenu = menuStructure.first(where: { $0["title"] as? String == "View" }) { #expect(viewMenu["enabled"] as? Bool == true) if let items = viewMenu["items"] as? [[String: Any]] { let itemTitles = items.compactMap { $0["title"] as? String } // Calculator should have these view options #expect(itemTitles.contains("Basic")) #expect(itemTitles.contains("Scientific")) #expect(itemTitles.contains("Programmer")) } } } } #endif } @Test("Menu extraction includes keyboard shortcuts") func menuKeyboardShortcuts() async throws { #if !os(Linux) guard ProcessInfo.processInfo.environment["RUN_LOCAL_TESTS"] != nil else { return } // Test with TextEdit which has well-known shortcuts let output = try await runPeekabooCommand(["menu", "list", "--app", "TextEdit", "--json-output"]) let data = try #require(output.data(using: .utf8)) let json = try JSONDecoder().decode(MenuTestResponse.self, from: data) #expect(json.success == true) if let menuStructure = json.data?.menu_structure { // Find File menu if let fileMenu = menuStructure.first(where: { $0["title"] as? String == "File" }), let items = fileMenu["items"] as? [[String: Any]] { if let newItem = items.first(where: { $0["title"] as? String == "New" }) { #expect(newItem["shortcut"] as? String == "⌘N") } if let saveItem = items.first(where: { ($0["title"] as? String)?.contains("Save") == true }) { let shortcut = saveItem["shortcut"] as? String #expect(shortcut == "⌘S" || shortcut == nil) } } } #endif } @Test("Menu list-all extracts frontmost app menus") func menuListAll() async throws { #if !os(Linux) guard ProcessInfo.processInfo.environment["RUN_LOCAL_TESTS"] != nil else { return } let output = try await runPeekabooCommand(["menu", "list-all", "--json-output"]) let data = try #require(output.data(using: .utf8)) let json = try JSONDecoder().decode(MenuTestResponse.self, from: data) #expect(json.success == true) if let apps = json.data?.apps { #expect(!apps.isEmpty) if let firstApp = apps.first { #expect(firstApp["app_name"] != nil) #expect(firstApp["bundle_id"] != nil) #expect(firstApp["pid"] != nil) if let menus = firstApp["menus"] as? [[String: Any]] { #expect(!menus.isEmpty) let menuTitles = menus.compactMap { $0["title"] as? String } #expect(menuTitles.contains("Apple")) } } } #endif } @Test("Menu extraction handles nested submenus") func nestedSubmenus() async throws { #if !os(Linux) guard ProcessInfo.processInfo.environment["RUN_LOCAL_TESTS"] != nil else { return } // Finder has nested menus like View > Sort By > Name let output = try await runPeekabooCommand(["menu", "list", "--app", "Finder", "--json-output"]) let data = try #require(output.data(using: .utf8)) let json = try JSONDecoder().decode(MenuTestResponse.self, from: data) #expect(json.success == true) if let menuData = json.data { if let menuStructure = menuData.menu_structure { // Find View menu if let viewMenu = menuStructure.first(where: { $0["title"] as? String == "View" }), let items = viewMenu["items"] as? [[String: Any]] { // Look for submenu items var hasSubmenu = false for item in items { if let subItems = item["items"] as? [[String: Any]], !subItems.isEmpty { hasSubmenu = true break } } #expect(hasSubmenu, "Finder View menu should have submenus") } } } #endif } @Test("Menu extraction properly handles disabled items") func disabledMenuItems() async throws { #if !os(Linux) guard ProcessInfo.processInfo.environment["RUN_LOCAL_TESTS"] != nil else { return } let output = try await runPeekabooCommand(["menu", "list", "--app", "Finder", "--json-output"]) let data = try #require(output.data(using: .utf8)) let json = try JSONDecoder().decode(MenuTestResponse.self, from: data) #expect(json.success == true) if let menuData = json.data { if let menuStructure = menuData.menu_structure { var foundDisabledItem = false for menu in menuStructure { if let items = menu["items"] as? [[String: Any]] { for item in items { if let enabled = item["enabled"] as? Bool, !enabled { foundDisabledItem = true break } } } if foundDisabledItem { break } } #expect(foundDisabledItem, "Should find at least one disabled menu item") } } #endif } } @Suite( "Menu & Dialog Local Harness", .serialized, .tags(.automation), .enabled(if: MenuHarnessConfig.runLocalHarnessEnabled()) ) struct MenuDialogLocalHarnessTests { private static let repositoryRoot: URL = { var url = URL(fileURLWithPath: #filePath) for _ in 0..<5 { url.deleteLastPathComponent() } return url }() @Test( "TextEdit menu list and click succeed via Poltergeist", .timeLimit(.minutes(2)) ) func textEditMenuFlow() async throws { try self.ensureAppLaunched("TextEdit") try await self.ensureUntitledTextEditDocument() let listResponse: CodableJSONResponse<MenuListData> = try self.runJSONCommand( ["menu", "list", "--app", "TextEdit", "--json-output"] ) #expect(listResponse.success == true) #expect( listResponse.data.menu_structure.contains { $0.title == "File" }, "File menu should be present for TextEdit" ) let clickResponse = try self.runMenuClick(appName: "TextEdit", path: "File > New") #expect(clickResponse.success == true) #expect(clickResponse.data.menu_path == "File > New") } @Test( "Calculator menu list + Scientific toggle", .timeLimit(.minutes(2)) ) func calculatorMenuFlow() throws { try self.ensureAppLaunched("Calculator") let listResponse: CodableJSONResponse<MenuListData> = try self.runJSONCommand( ["menu", "list", "--app", "Calculator", "--json-output"] ) #expect(listResponse.success == true) #expect( listResponse.data.menu_structure.contains { $0.title == "View" }, "View menu should exist for Calculator" ) let clickResponse = try self.runMenuClick(appName: "Calculator", path: "View > Scientific") #expect(clickResponse.success == true) #expect(clickResponse.data.menu_path == "View > Scientific") } @Test( "Menu click survives window churn", .timeLimit(.minutes(2)) ) func textEditMenuAfterWindowChurn() async throws { try self.ensureAppLaunched("TextEdit") try await self.ensureUntitledTextEditDocument() for iteration in 0..<3 { _ = try ExternalCommandRunner.runPolterPeekaboo( [ "window", "set-bounds", "--app", "TextEdit", "--x", "\(120 + iteration * 40)", "--y", "\(100 + iteration * 30)", "--width", "720", "--height", "520", "--json-output", ] ) _ = try ExternalCommandRunner.runPolterPeekaboo( [ "window", "list", "--app", "TextEdit", "--json-output", ] ) } let clickResponse = try self.runMenuClick(appName: "TextEdit", path: "File > New") #expect(clickResponse.success == true) } @Test( "TextEdit Save dialog lists buttons via polter", .timeLimit(.minutes(2)) ) func textEditDialogListViaPolter() async throws { try self.ensureAppLaunched("TextEdit") try await self.ensureUntitledTextEditDocument() try await self.triggerSavePanel() let dialogResponse: CodableJSONResponse<DialogListPayload> = try self.runJSONCommand( [ "dialog", "list", "--app", "TextEdit", "--window", "Save", "--json-output", ] ) #expect(dialogResponse.success == true) #expect(dialogResponse.data.buttons.contains("Save")) #expect(dialogResponse.data.buttons.contains(where: { $0.contains("Cancel") })) try self.assertCLIBinaryFresh() _ = try ExternalCommandRunner.runPolterPeekaboo( [ "dialog", "click", "--app", "TextEdit", "--button", "Cancel", "--json-output", ] ) } @Test( "Menu stress loop runs for 45 seconds without stale window errors", .timeLimit(.minutes(3)) ) func menuStressLoop() async throws { try await self.runMenuStressLoop( appName: "TextEdit", menuPath: "File > New", verification: { response in #expect( response.data.menu_path == "File > New", "TextEdit stress iteration must land on File > New" ) } ) try await self.runMenuStressLoop( appName: "Calculator", menuPath: "View > Scientific", verification: { response in #expect( response.data.menu_path == "View > Scientific", "Calculator stress iteration must toggle Scientific mode" ) } ) } // MARK: - Helpers private func ensureAppLaunched(_ appName: String) throws { _ = try ExternalCommandRunner.runPolterPeekaboo( [ "app", "launch", "--name", appName, "--wait-until-ready", ] ) _ = try ExternalCommandRunner.runPolterPeekaboo( [ "window", "focus", "--app", appName, "--json-output", ] ) } private func runMenuClick( appName: String, path: String ) throws -> CodableJSONResponse<MenuClickResult> { try self.runJSONCommand( [ "menu", "click", "--app", appName, "--path", path, "--json-output", ] ) } private func runJSONCommand<T: Decodable>(_ arguments: [String]) throws -> T { let result = try ExternalCommandRunner.runPolterPeekaboo(arguments) return try ExternalCommandRunner.decodeJSONResponse(from: result, as: T.self) } private func ensureUntitledTextEditDocument() async throws { let response = try self.runMenuClick(appName: "TextEdit", path: "File > New") #expect(response.success == true) try await Task.sleep(nanoseconds: 500_000_000) } private func triggerSavePanel() async throws { _ = try ExternalCommandRunner.runPolterPeekaboo( [ "hotkey", "--keys", "cmd,s", ] ) try await Task.sleep(nanoseconds: 1_000_000_000) } private func runMenuStressLoop( appName: String, menuPath: String, duration: TimeInterval = 45, verification: (CodableJSONResponse<MenuClickResult>) -> Void ) async throws { try self.ensureAppLaunched(appName) let start = Date() var iteration = 0 while Date().timeIntervalSince(start) < duration { iteration += 1 let listResponse: CodableJSONResponse<MenuListData> = try self.runJSONCommand( [ "menu", "list", "--app", appName, "--json-output", ] ) #expect(listResponse.success == true, "Iteration \(iteration) failed to list menus for \(appName)") let clickResponse = try self.runMenuClick(appName: appName, path: menuPath) #expect(clickResponse.success == true, "Iteration \(iteration) failed to click \(menuPath)") verification(clickResponse) if iteration.isMultiple(of: 3) { _ = try ExternalCommandRunner.runPolterPeekaboo( [ "window", "set-bounds", "--app", appName, "--x", "\(150 + iteration * 5)", "--y", "\(180 + iteration * 3)", "--width", "700", "--height", "500", "--json-output", ] ) } if iteration.isMultiple(of: 2) { _ = try ExternalCommandRunner.runPolterPeekaboo( [ "window", "list", "--app", appName, "--json-output", ] ) } try await Task.sleep(nanoseconds: 200_000_000) // keep loop responsive (<60s cap) } } private func assertCLIBinaryFresh(maxAge: TimeInterval = 600) throws { let binaryURL = Self.repositoryRoot.appendingPathComponent("peekaboo") let attributes = try FileManager.default.attributesOfItem(atPath: binaryURL.path) guard let modifiedDate = attributes[.modificationDate] as? Date else { return } let age = Date().timeIntervalSince(modifiedDate) let freshnessMessage = "Peekaboo binary at \(binaryURL.path) is older than \(Int(maxAge)) seconds. " + "Run `polter peekaboo -- version` or rebuild via Poltergeist to refresh it." #expect(age < maxAge, Comment(rawValue: freshnessMessage)) } private struct DialogListPayload: Codable { struct TextField: Codable { let title: String let value: String let placeholder: String } let title: String let role: String let buttons: [String] let textFields: [TextField] let textElements: [String] } } // MARK: - Test Helpers private func runPeekabooCommand(_ args: [String]) async throws -> String { let result = try await InProcessCommandRunner.runShared(args) return result.combinedOutput } #endif

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