SpaceCommandTests.swift•12.1 kB
import CoreGraphics
import Foundation
import Testing
@testable import PeekabooAutomation
@testable import PeekabooCLI
@testable import PeekabooCore
#if !PEEKABOO_SKIP_AUTOMATION
// MARK: - Read-only scenarios
@Suite(
"Space Command Read Tests",
.serialized,
.tags(.automation),
.enabled(if: CLITestEnvironment.runAutomationRead)
)
struct SpaceCommandReadTests {
@Test("space command exists in help")
func spaceCommandInHelp() async throws {
let output = try await self.runPeekaboo(["--help"])
#expect(output.contains("space"))
#expect(output.contains("Manage macOS Spaces"))
}
@Test("space command help shows subcommands")
func spaceCommandHelp() async throws {
let output = try await self.runPeekaboo(["space", "--help"])
#expect(output.contains("Manage macOS Spaces (virtual desktops)"))
#expect(output.contains("list"))
#expect(output.contains("switch"))
#expect(output.contains("move-window"))
}
@Test("space switch command help")
func spaceSwitchHelp() async throws {
let output = try await self.runPeekaboo(["space", "switch", "--help"])
#expect(output.contains("Switch to a different Space"))
#expect(output.contains("--to"))
}
@Test("space list command")
func spaceListCommand() async throws {
let output = try await self.runPeekaboo(["space", "list"])
#expect(!output.isEmpty)
}
@Test("space list with JSON output")
func spaceListJSON() async throws {
let output = try await self.runPeekaboo(["space", "list", "--json-output"])
let response = try JSONDecoder().decode(SpaceListResponse.self, from: Data(output.utf8))
#expect(response.success)
}
@Test("space list detailed flag")
func spaceListDetailed() async throws {
let output = try await self.runPeekaboo(["space", "list", "--detailed"])
#expect(output.contains("Space"))
}
@Test("space switch requires --to parameter")
func spaceSwitchRequiresDestination() {
#expect(throws: (any Error).self) {
try CLIOutputCapture.suppressStderr {
_ = try SwitchSubcommand.parse([])
}
}
}
@Test("space switch rejects non-numeric parameters")
func spaceSwitchRejectsNonNumeric() {
#expect(throws: (any Error).self) {
try CLIOutputCapture.suppressStderr {
_ = try SwitchSubcommand.parse(["--to", "abc"])
}
}
}
@Test("space move-window requires app parameter")
func spaceMoveWindowRequiresApp() {
#expect(throws: (any Error).self) {
try CLIOutputCapture.suppressStderr {
_ = try MoveWindowSubcommand.parse(["--to", "2"])
}
}
}
@Test("space move-window requires destination")
func spaceMoveWindowRequiresDestination() {
#expect(throws: (any Error).self) {
try CLIOutputCapture.suppressStderr {
_ = try MoveWindowSubcommand.parse(["--app", "Finder"])
}
}
}
@Test("space move-window parses follow option")
func spaceMoveWindowParsesFollowOption() throws {
let command = try MoveWindowSubcommand.parse([
"--app", "Finder",
"--to", "3",
"--follow",
])
#expect(command.app == "Finder")
#expect(command.to == 3)
#expect(command.follow == true)
}
private func runPeekaboo(_ arguments: [String]) async throws -> String {
let context = await self.makeTestContext()
let result = try await InProcessCommandRunner.run(
arguments,
services: context.services,
spaceService: context.spaceService
)
return result.stdout
}
@MainActor
func makeTestContext() -> (services: PeekabooServices, spaceService: any SpaceCommandSpaceService) {
let applications = Self.testApplications()
let windowsByApp = Self.windowsByApp()
let services = TestServicesFactory.makePeekabooServices(
applications: StubApplicationService(applications: applications, windowsByApp: windowsByApp),
windows: StubWindowService(windowsByApp: windowsByApp),
menu: StubMenuService(menusByApp: [:]),
dialogs: StubDialogService(),
screens: []
)
let spaceInfos = Self.spaceInfos()
let windowSpaces = Self.windowSpaces(from: spaceInfos)
let spaceService = StubSpaceService(spaces: spaceInfos, windowSpaces: windowSpaces)
return (services, spaceService)
}
}
extension SpaceCommandReadTests {
fileprivate static func testApplications() -> [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 windowsByApp() -> [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"
)
}
fileprivate static func spaceInfos() -> [SpaceInfo] {
[
SpaceInfo(
id: 1,
type: .user,
isActive: true,
displayID: 1,
name: "Desktop 1",
ownerPIDs: [101]
),
SpaceInfo(
id: 2,
type: .user,
isActive: false,
displayID: 1,
name: "Desktop 2",
ownerPIDs: [202]
),
]
}
fileprivate static func windowSpaces(from infos: [SpaceInfo]) -> [Int: [SpaceInfo]] {
[
1: [infos[0]],
2: [infos[1]],
]
}
}
// MARK: - Actions that mutate Spaces
@Suite(
"Space Command Action Tests",
.serialized,
.tags(.automation),
.enabled(if: CLITestEnvironment.runAutomationActions)
)
struct SpaceCommandActionTests {
@Test("space switch with valid number")
func spaceSwitchValid() async throws {
let context = await self.makeSpaceContext()
let result = try await self.runSpaceCommand([
"space", "switch",
"--to", "1",
"--json-output",
], context: context)
#expect(result.exitStatus == 0)
let response = try JSONDecoder().decode(
SpaceActionResponse.self,
from: Data(self.output(from: result).utf8)
)
#expect(response.success)
let switchCalls = await self.spaceState(context) { $0.switchCalls }
#expect(switchCalls.contains(1))
}
@Test("space move-window to current Space")
func spaceMoveWindowToCurrent() async throws {
let context = await self.makeSpaceContext()
let result = try await self.runSpaceCommand([
"space", "move-window",
"--app", "Finder",
"--to-current",
"--json-output",
], context: context)
#expect(result.exitStatus == 0)
let response = try JSONDecoder().decode(
WindowSpaceActionResponse.self,
from: Data(self.output(from: result).utf8)
)
#expect(response.success)
let moveCalls = await self.spaceState(context) { $0.moveToCurrentCalls }
#expect(!moveCalls.isEmpty)
}
@Test("space move-window with follow option")
func spaceMoveWindowWithFollow() async throws {
let context = await self.makeSpaceContext()
let result = try await self.runSpaceCommand([
"space", "move-window",
"--app", "TextEdit",
"--to", "1",
"--follow",
"--json-output",
], context: context)
#expect(result.exitStatus == 0)
let response = try JSONDecoder().decode(
WindowSpaceActionResponse.self,
from: Data(self.output(from: result).utf8)
)
#expect(response.success)
let moveCalls = await self.spaceState(context) { $0.moveWindowCalls }
#expect(moveCalls.contains { $0.spaceID == 1 })
}
private func runSpaceCommand(
_ arguments: [String],
context: SpaceHarnessContext
) async throws -> CommandRunResult {
try await SpaceCommandEnvironment.withSpaceService(context.spaceService) {
try await InProcessCommandRunner.run(
arguments,
services: context.services,
spaceService: context.spaceService
)
}
}
@MainActor
private func makeSpaceContext() async -> SpaceHarnessContext {
let base = SpaceCommandReadTests().makeTestContext()
let spaces = await base.spaceService.getAllSpaces()
let spaceService = StubSpaceService(spaces: spaces, windowSpaces: [:])
let services = base.services
return SpaceHarnessContext(services: services, spaceService: spaceService)
}
private func output(from result: CommandRunResult) -> String {
result.stdout.isEmpty ? result.stderr : result.stdout
}
private func spaceState<T: Sendable>(
_ context: SpaceHarnessContext,
_ operation: @MainActor (StubSpaceService) -> T
) async -> T {
await MainActor.run {
operation(context.spaceService)
}
}
}
private struct SpaceHarnessContext {
let services: PeekabooServices
let spaceService: StubSpaceService
}
// MARK: - Response types shared by tests
private struct SpaceListResponse: Codable {
let success: Bool
let data: SpaceListData?
let error: String?
}
private struct SpaceListData: Codable {
let spaces: [SpaceData]
}
private struct SpaceData: Codable {
let id: UInt64
let type: String
let is_active: Bool?
let display_id: UInt32?
}
private struct SpaceActionResponse: Codable {
let success: Bool
let data: SpaceActionData?
let error: String?
}
private struct SpaceActionData: Codable {
let action: String
let success: Bool
let space_id: UInt64
let space_number: Int
}
private struct WindowSpaceActionResponse: Codable {
let success: Bool
let data: WindowSpaceActionData?
let error: String?
}
private struct WindowSpaceActionData: Codable {
let action: String
let success: Bool
let window_id: UInt32
let window_title: String
let space_id: UInt64?
let space_number: Int?
let moved_to_current: Bool?
let followed: Bool?
}
#endif