Skip to main content
Glama
AppCommandTests.swift12.4 kB
import Foundation import Testing @testable import PeekabooCLI @testable import PeekabooCore #if !PEEKABOO_SKIP_AUTOMATION @Suite("App Command Tests", .serialized, .tags(.automation), .enabled(if: CLITestEnvironment.runAutomationRead)) struct AppCommandTests { @Test("App command exists") func appCommandExists() { let config = AppCommand.commandDescription #expect(config.commandName == "app") #expect(config.abstract.contains("Control applications")) } @Test("App command has expected subcommands") func appSubcommands() { let subcommands = AppCommand.commandDescription.subcommands #expect(subcommands.count == 5) var subcommandNames: [String] = [] subcommandNames.reserveCapacity(subcommands.count) for descriptor in subcommands { let name = descriptor.commandDescription.commandName ?? "" subcommandNames.append(name) } #expect(subcommandNames.contains("launch")) #expect(subcommandNames.contains("quit")) #expect(subcommandNames.contains("hide")) #expect(subcommandNames.contains("show")) #expect(subcommandNames.contains("switch")) } @Test("App launch command help") func appLaunchHelp() async throws { let output = try await runAppCommand(["app", "launch", "--help"]) #expect(output.contains("Launch an application")) #expect(output.contains("--app")) #expect(output.contains("--bundle-id")) #expect(output.contains("--wait")) #expect(output.contains("--background")) } @Test("App quit command validation") func appQuitValidation() async throws { // Test missing app/all await #expect(throws: (any Error).self) { _ = try await runAppCommand(["app", "quit"]) } // Test conflicting options await #expect(throws: (any Error).self) { _ = try await runAppCommand(["app", "quit", "--app", "Finder", "--all"]) } } @Test("App hide command validation") func appHideValidation() async throws { // Normal hide should work let output = try await runAppCommand(["app", "hide", "--app", "Finder", "--help"]) #expect(output.contains("Hide applications")) // Test --others flag #expect(output.contains("--others")) } @Test("App show command validation") func appShowValidation() async throws { // Test missing app/all await #expect(throws: (any Error).self) { _ = try await runAppCommand(["app", "show"]) } } @Test("App switch command validation") func appSwitchValidation() async throws { // Test missing to/cycle await #expect(throws: (any Error).self) { _ = try await runAppCommand(["app", "switch"]) } } @Test("App lifecycle flow") func appLifecycleFlow() { // This tests the logical flow of app lifecycle commands let launchCmd = ["app", "launch", "--app", "TextEdit", "--wait"] let hideCmd = ["app", "hide", "--app", "TextEdit"] let showCmd = ["app", "show", "--app", "TextEdit"] let quitCmd = ["app", "quit", "--app", "TextEdit", "--json-output"] // Verify command structure is valid #expect(launchCmd.count > 3) #expect(hideCmd.count > 3) #expect(showCmd.count > 3) #expect(quitCmd.count > 3) } } // MARK: - App Command Integration Tests @Suite( "App Command Integration Tests", .serialized, .enabled(if: CLITestEnvironment.runAutomationActions) ) struct AppCommandIntegrationTests { @Test("Launch application") func launchApp() async throws { let (output, appService) = try await runAppCommandWithService([ "app", "launch", "--app", "TextEdit", "--wait", "--json-output", ]) struct LaunchResult: Codable { let action: String let app_name: String let bundle_id: String let pid: Int32 let is_ready: Bool } let response = try JSONDecoder().decode( CodableJSONResponse<LaunchResult>.self, from: Data(output.utf8) ) #expect(response.success == true) #expect(response.data.action == "launch") #expect(response.data.app_name == "TextEdit") #expect(response.data.pid > 0) let launchCalls = await appServiceState(appService) { $0.launchCalls } #expect(launchCalls.contains("TextEdit")) } @Test("Hide and show application") func hideShowApp() async throws { // First hide let (hideOutput, hideService) = try await runAppCommandWithService([ "app", "hide", "--app", "TextEdit", "--json-output", ]) let hideData = try JSONDecoder().decode(JSONResponse.self, from: Data(hideOutput.utf8)) #expect(hideData.success == true) let hideCalls = await appServiceState(hideService) { $0.hideCalls } #expect(hideCalls.contains("TextEdit")) // Then show let (showOutput, showService) = try await runAppCommandWithService([ "app", "show", "--app", "TextEdit", "--json-output", ]) let showData = try JSONDecoder().decode(JSONResponse.self, from: Data(showOutput.utf8)) #expect(showData.success == true) let unhideCalls = await appServiceState(showService) { $0.unhideCalls } #expect(unhideCalls.contains("TextEdit")) } @Test("Switch between applications") func switchApps() async throws { // Cycle to next app let (cycleOutput, cycleService) = try await runAppCommandWithService([ "app", "switch", "--cycle", "--json-output", ]) let cycleData = try JSONDecoder().decode(JSONResponse.self, from: Data(cycleOutput.utf8)) #expect(cycleData.success == true) let cycleCalls = await appServiceState(cycleService) { $0.activateCalls } #expect(!cycleCalls.isEmpty) // Switch to specific app let (switchOutput, switchService) = try await runAppCommandWithService([ "app", "switch", "--to", "Finder", "--json-output", ]) let switchData = try JSONDecoder().decode(JSONResponse.self, from: Data(switchOutput.utf8)) #expect(switchData.success == true) let switchCalls = await appServiceState(switchService) { $0.activateCalls } #expect(switchCalls.contains("Finder")) } @Test("Quit application with save") func quitWithSave() async throws { let (output, appService) = try await runAppCommandWithService([ "app", "quit", "--app", "TextEdit", "--json-output", ]) struct AppQuitInfo: Codable { let app_name: String let bundle_id: String let pid: Int32 let terminated: Bool } struct QuitResult: Codable { let action: String let force: Bool let results: [AppQuitInfo] } let response = try JSONDecoder().decode(CodableJSONResponse<QuitResult>.self, from: Data(output.utf8)) #expect(response.success == true) let quitCalls = await appServiceState(appService) { $0.quitCalls } #expect(quitCalls.contains { $0.identifier == "TextEdit" && $0.force == false }) } @Test("Hide others functionality") func hideOthers() async throws { let (output, appService) = try await runAppCommandWithService([ "app", "hide", "--app", "Finder", "--others", "--json-output", ]) let response = try JSONDecoder().decode( CodableJSONResponse<[String: String]>.self, from: Data(output.utf8) ) #expect(response.success == true) let hideOtherCalls = await appServiceState(appService) { $0.hideOtherCalls } #expect(hideOtherCalls.contains("Finder")) } } // MARK: - Shared Helpers private struct CommandFailure: Error { let status: Int32 let stderr: String } private func runAppCommand( _ args: [String], configure: (@MainActor (StubApplicationService) -> Void)? = nil ) async throws -> String { let (output, _) = try await runAppCommandWithService(args, configure: configure) return output } private func runAppCommandWithService( _ args: [String], configure: (@MainActor (StubApplicationService) -> Void)? = nil ) async throws -> (String, StubApplicationService) { let context = await makeAppCommandContext() if let configure { await MainActor.run { configure(context.applicationService) } } let result = try await InProcessCommandRunner.run(args, services: context.services) let output = result.stdout.isEmpty ? result.stderr : result.stdout if result.exitStatus != 0 { throw CommandFailure(status: result.exitStatus, stderr: output) } return (output, context.applicationService) } @MainActor private func makeAppCommandContext() -> AppCommandContext { let data = defaultAppCommandData() let applicationService = StubApplicationService(applications: data.applications, windowsByApp: data.windowsByApp) let windowService = StubWindowService(windowsByApp: data.windowsByApp) let services = TestServicesFactory.makePeekabooServices( applications: applicationService, windows: windowService ) return AppCommandContext(services: services, applicationService: applicationService) } private func appServiceState<T: Sendable>( _ service: StubApplicationService, _ operation: @MainActor (StubApplicationService) -> T ) async -> T { await MainActor.run { operation(service) } } private struct AppCommandContext { let services: PeekabooServices let applicationService: StubApplicationService } @MainActor private func defaultAppCommandData() -> (applications: [ServiceApplicationInfo], windowsByApp: [String: [ServiceWindowInfo]]) { let applications = AppCommandTests.defaultApplications() let windowsByApp = AppCommandTests.defaultWindowsByApp() return (applications, windowsByApp) } extension AppCommandTests { fileprivate static func defaultApplications() -> [ServiceApplicationInfo] { [ ServiceApplicationInfo( processIdentifier: 101, bundleIdentifier: "com.apple.finder", name: "Finder", bundlePath: "/System/Library/CoreServices/Finder.app", isActive: true, isHidden: false, windowCount: 1 ), ServiceApplicationInfo( processIdentifier: 202, bundleIdentifier: "com.apple.TextEdit", name: "TextEdit", bundlePath: "/System/Applications/TextEdit.app", isActive: false, isHidden: false, windowCount: 1 ), ] } fileprivate static func defaultWindowsByApp() -> [String: [ServiceWindowInfo]] { [ "Finder": [self.finderWindow()], "TextEdit": [self.textEditWindow()], ] } fileprivate static func finderWindow() -> ServiceWindowInfo { ServiceWindowInfo( windowID: 1, title: "Finder Window", 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" ) } fileprivate static func textEditWindow() -> ServiceWindowInfo { ServiceWindowInfo( windowID: 2, title: "Document", bounds: CGRect(x: 100, y: 100, width: 700, height: 500), isMinimized: false, isMainWindow: true, windowLevel: 0, alpha: 1.0, index: 0, spaceID: 2, spaceName: "Desktop 2", screenIndex: 0, screenName: "Built-in" ) } } #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