diff --git a/README.md b/README.md index a426bca..35d59cd 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,8 @@ By analogy with Shell, you can make other wrappers over the `ProcessCommand` Using simctl, you can search for iPhone 12, turn it on and launch the application. ```swift -let xcrun = XCRun() -let simulator = xcrun.simctl +let xcrun = XCRun.current +let simulator = try await xcrun.simctl let list = try await simulator.list(.devices, "iPhone 12", available: true).json.decode() let devices = list.devices.flatMap { $0.value } diff --git a/Sources/XSTooling/ProcessCommand.swift b/Sources/XSTooling/ProcessCommand.swift index 93916f5..f34c024 100644 --- a/Sources/XSTooling/ProcessCommand.swift +++ b/Sources/XSTooling/ProcessCommand.swift @@ -104,7 +104,7 @@ public struct ProcessCommand: Hashable, Sendable { process.executableURL = executableURL process.currentDirectoryURL = currentDirectoryURL process.arguments = arguments - if let environment = environment { + if let environment { process.environment = environment } if let standardOutput { @@ -189,7 +189,6 @@ extension Process { self.terminate() // crash if not running } } - try Task.checkCancellation() } } @@ -198,10 +197,10 @@ extension Process { extension FileHandle { fileprivate func stream() -> AsyncStream { AsyncStream { continuation in + continuation.onTermination = { [weak self] _ in + self?.readabilityHandler = nil // stop + } self.readabilityHandler = { fileHandle in - continuation.onTermination = { _ in - fileHandle.readabilityHandler = nil // stop - } let data = fileHandle.availableData if data.isEmpty { fileHandle.readabilityHandler = nil // stop diff --git a/Sources/XSTooling/Shell.swift b/Sources/XSTooling/Shell.swift index 11d8c7b..a80c66f 100644 --- a/Sources/XSTooling/Shell.swift +++ b/Sources/XSTooling/Shell.swift @@ -30,20 +30,6 @@ public struct Shell: Equatable, Sendable { self.arguments = arguments } - // MARK: - Options - - public var verbose: Shell { option("--verbose") } - - public var login: Shell { option("--login") } - - private func option(_ value: String) -> Shell { - var shell = self - shell.arguments.append(value) - return shell - } - - // MARK: - Commands - /// Show version information for this instance of bash on the standard output and exit successfully. public var version: ProcessCommand { ProcessCommand(path: path, arguments: arguments).appending(argument: "--version") diff --git a/Tests/XSToolingTests/Core/ProcessCommandTests.swift b/Tests/XSToolingTests/Core/ProcessCommandTests.swift deleted file mode 100644 index 282c590..0000000 --- a/Tests/XSToolingTests/Core/ProcessCommandTests.swift +++ /dev/null @@ -1,135 +0,0 @@ -import XCTest -import XSTooling - -extension ProcessCommand { - static func bash(_ command: String, successCode: Int32? = 0) -> ProcessCommand { - ProcessCommand(path: "/bin/bash", arguments: ["-c", command]) - } -} - -final class ProcessCommandTests: GHTestCase { - - func testInitWithDefaults() { - let command = ProcessCommand(path: "/bin/cat") - - XCTAssertEqual(command.executableURL, URL(fileURLWithPath: "/bin/cat")) - XCTAssertEqual(command.arguments, []) - XCTAssertNil(command.environment) - XCTAssertNil(command.currentDirectoryURL) - } - - func testRead() async throws { - let command = ProcessCommand.bash("echo 'hello'") - - let output = try await command.read() - - XCTAssertEqual(output.data, Data("hello\n".utf8)) - } - - func testReadStandardError() async throws { - let command = ProcessCommand.bash("echo 'hello'; echo 'world!' >&2;") - - let output = try await command.read(standardError: .standardOutput) - - XCTAssertEqual(output.string, "hello\nworld!") - } - - func testRunWithRedirection() async throws { - let command = ProcessCommand.bash("echo 'test'") - - try await command.run(standardOutput: .standardOutput, standardError: .standardOutput) - } - - func testEnvironment() async throws { - var command = ProcessCommand.bash("echo $XSTOOLING_TEST_VALUE") - command.environment = ["XSTOOLING_TEST_VALUE": "a"] - - let output = try await command.read() - - XCTAssertEqual(output.string, "a") - } - - func testEnvironmentFromParentProcess() async throws { - var command = ProcessCommand.bash("echo $XSTOOLING_TEST_VALUE") - command.environment = nil - - precondition(setenv("XSTOOLING_TEST_VALUE", "b", 1) == 0) - addTeardownBlock { - precondition(unsetenv("XSTOOLING_TEST_VALUE") == 0) - } - let output = try await command.read() - - XCTAssertEqual(output.string, "b") - } - - func testSuccessCodeCheck() async { - let command = ProcessCommand.bash("exit 1") - let expectedError = ProcessError( - executableURL: command.executableURL, - arguments: command.arguments, - terminationStatus: 1, - terminationReason: .exit - ) - do { - try await command.run() - XCTFail("The exit code has not been checked") - } catch let error as ProcessError { - XCTAssertEqual(error, expectedError) - } catch { - XCTFail("Unexpected error: \(error)") - } - } - - func testRunWithError() async throws { - let command = ProcessCommand(path: "/usr/local/bin/not/found") - do { - try await command.run() - XCTFail("The exit code has not been checked") - } catch { - XCTAssertFalse(error is ProcessError) - } - } - - func testCancelRead() async throws { - let task = Task(priority: .low) { - try await ProcessCommand.bash("sleep 3").read() - } - task.cancel() - do { - let output = try await task.value - XCTFail("Task not cancelled. Output: \(output.string)") - } catch { - XCTAssert(error is CancellationError, "Unexpected error: \(error)") - } - } - - func testCancelWithRedirection() async throws { - let task = Task(priority: .low) { - try await ProcessCommand.bash("sleep 2").run() - } - task.cancel() - do { - _ = try await task.value - XCTFail("Task not cancelled") - } catch { - XCTAssert(error is CancellationError, "Unexpected error: \(error)") - } - } - - func testTerminate() async throws { - try XCTSkipIf(isLinux) - - let task = Task { - try await ProcessCommand.bash("sleep 2 && echo 'end'", successCode: nil).run() - } - Task { - try await Task.sleep(nanoseconds: 1_000_000) - task.cancel() - } - do { - _ = try await task.value - } catch { - XCTAssert(error is CancellationError, "Unexpected error: \(error)") - } - } -} diff --git a/Tests/XSToolingTests/Core/ProcessOutputTests.swift b/Tests/XSToolingTests/Core/ProcessOutputTests.swift deleted file mode 100644 index 529455e..0000000 --- a/Tests/XSToolingTests/Core/ProcessOutputTests.swift +++ /dev/null @@ -1,21 +0,0 @@ -import XCTest -import XSTooling - -final class ProcessOutputTests: GHTestCase { - - func testString() { - let output1 = ProcessOutput(data: Data("output\n".utf8)) - XCTAssertEqual(output1.string, "output") - - let output2 = ProcessOutput(data: Data("output\n".utf8)) - XCTAssertEqual(output2.string, "output") - } - - func testDecode() { - struct Status: Decodable { - let code: Int - } - let output = ProcessOutput(data: Data(#"{ "code": 2} "#.utf8)) - XCTAssertEqual(try output.decode(Status.self).code, 2) - } -} diff --git a/Tests/XSToolingTests/GHTest/GHActions.swift b/Tests/XSToolingTests/GHTest/GHActions.swift deleted file mode 100644 index 3cebd7a..0000000 --- a/Tests/XSToolingTests/GHTest/GHActions.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -/// GitHub Actions -struct GHActions { - let isEnabled: Bool - - func error(file: String, line: Int, message: String) { - // ::error file={name},line={line},endLine={endLine},title={title}::{message} - print("::error file=\(file),line=\(line)::\(message)") - } - - func error(message: String) { - print("::error::\(message)") - } -} - -extension GHActions { - static let shared = GHActions(environment: ProcessInfo.processInfo.environment) - - init(environment: [String: String]) { - isEnabled = environment["GITHUB_ACTIONS"] == "true" - } -} diff --git a/Tests/XSToolingTests/GHTest/GHTestCase.swift b/Tests/XSToolingTests/GHTest/GHTestCase.swift deleted file mode 100644 index e22d247..0000000 --- a/Tests/XSToolingTests/GHTest/GHTestCase.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// GHTestCase.swift -// -// -// Created by Alexander Ignatiev on 12.12.2022. -// - -import XCTest - -class GHTestCase: XCTestCase { - var github: GHActions { .shared } - - var isLinux: Bool { - #if os(Linux) - return true - #else - return false - #endif - } - - #if os(Linux) - - override func recordFailure( - withDescription description: String, inFile filePath: String, atLine lineNumber: Int, expected: Bool - ) { - if github.isEnabled { - github.error(file: filePath, line: lineNumber, message: description) - } - super.recordFailure(withDescription: description, inFile: filePath, atLine: lineNumber, expected: expected) - } - - #else // os(Darwin) - - override func record(_ issue: XCTIssue) { - if github.isEnabled { - let message = "\(self.name): \(issue.compactDescription)" - if let location = issue.sourceCodeContext.location { - github.error(file: location.fileURL.absoluteString, line: location.lineNumber, message: message) - } else { - github.error(message: message) - } - } - super.record(issue) - } - - #endif -} diff --git a/Tests/XSToolingTests/ProcessCommandTests.swift b/Tests/XSToolingTests/ProcessCommandTests.swift new file mode 100644 index 0000000..7fd8028 --- /dev/null +++ b/Tests/XSToolingTests/ProcessCommandTests.swift @@ -0,0 +1,140 @@ +import Foundation +import Testing +import XSTooling + +@Suite(.timeLimit(.minutes(1)), .serialized, .gitHub) +struct ProcessCommandTests { + + private func bash(_ command: String) -> ProcessCommand { + ProcessCommand(path: "/bin/bash", arguments: ["-c", command]) + } + + @Test func defaults() { + let command = ProcessCommand(path: "/bin/cat") + + #expect(command.executableURL == URL(fileURLWithPath: "/bin/cat")) + #expect(command.currentDirectoryURL == nil) + #expect(command.arguments == []) + #expect(command.environment == nil) + } + + @Test func `find executable in PATH`() { + let command = ProcessCommand.find("ls") + #if os(macOS) + #expect(command == ProcessCommand(path: "/bin/ls")) + #elseif os(Linux) + #expect(command == ProcessCommand(path: "/usr/bin/ls")) + #else + #expect(command == ProcessCommand(path: "/bin/ls")) + #endif + } + + @Test func `not found executable in PATH`() { + let command = ProcessCommand.find("ls-2") + #expect(command == nil) + } + + @Test func `read from stdout`() async throws { + let command = bash("echo 'hello'; echo 'world!' >&2;") + let output = try await command.read() + + #expect(output.data == Data("hello\n".utf8)) + } + + @Test func `read stdout and stderr combined`() async throws { + let command = bash("echo 'hello'; echo 'world!' >&2;") + let output = try await command.read(standardError: .standardOutput) + + #expect(output.string == "hello\nworld!") + } + + @Test(.temporaryDirectory) + func `redirect stdout and stderr to file`() async throws { + let url = Test.temporaryDirectory!.appending( + component: "logs.txt", + directoryHint: .notDirectory + ) + try #require(FileManager.default.createFile(atPath: url.path, contents: nil)) + let file = try FileHandle(forUpdating: url) + + let command = bash("echo 'Start'; echo 'Done!' >&2;") + try await command.run(standardOutput: file, standardError: file) + + let string = try String(contentsOf: url, encoding: .utf8) + #expect(string == "Start\nDone!\n") + } + + @Test func `environment with custom variable`() async throws { + var command = bash("echo $TEST_VALUE") + command.environment = ["TEST_VALUE": "a"] + + let output = try await command.read() + + #expect(output.string == "a") + } + + @Test func `environment from parent process`() async throws { + precondition(setenv("TEST_VALUE", "b", 1) == 0) + defer { + precondition(unsetenv("TEST_VALUE") == 0) + } + let command = bash("echo $TEST_VALUE") + let output = try await command.read() + + #expect(output.string == "b") + } + + @Test func `run with error`() async { + let error = await #expect(throws: CocoaError.self) { + try await ProcessCommand(path: "/usr/local/bin/not/found").run() + } + #expect(error?.code == .fileNoSuchFile) + } + + @Test func `exit status check`() async { + let command = bash("exit 2") + let error = ProcessError( + executableURL: command.executableURL, + arguments: command.arguments, + terminationStatus: 2, + terminationReason: .exit + ) + await #expect(throws: error) { + try await command.run() + } + } + + @Test func termination() async { + let command = bash("sleep 2 && echo 'end'") + let task = Task { + try await command.read().string + } + let task2 = Task { + try await Task.sleep(for: .seconds(1)) + task.cancel() + } + defer { + task2.cancel() + } + let error = ProcessError( + executableURL: command.executableURL, + arguments: command.arguments, + terminationStatus: 15, + terminationReason: .uncaughtSignal + ) + await #expect(throws: error) { + try await task.value + } + } + + @Test func cancel() async throws { + let task = Task(priority: .low) { + await Task.yield() + try await bash("sleep 3").run() + } + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + } +} diff --git a/Tests/XSToolingTests/ProcessOutputTests.swift b/Tests/XSToolingTests/ProcessOutputTests.swift new file mode 100644 index 0000000..0d1770c --- /dev/null +++ b/Tests/XSToolingTests/ProcessOutputTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +import XSTooling + +@Suite(.gitHub) +struct ProcessOutputTests { + + @Test func string() { + let output1 = ProcessOutput(data: Data("output\n".utf8)) + #expect(output1.string == "output") + + let output2 = ProcessOutput(data: Data("output".utf8)) + #expect(output2.string == "output") + } + + @Test(arguments: [true, false]) + func string(strippingNewline: Bool) { + let output = ProcessOutput(data: Data("done\n".utf8)) + let expected = strippingNewline ? "done" : "done\n" + #expect(output.string(strippingNewline: strippingNewline) == expected) + } + + @Test func decodeJson() throws { + struct Status: Decodable { + let code: Int + } + let output = ProcessOutput(data: Data(#"{ "code": 2} "#.utf8)) + #expect(try output.decode(Status.self).code == 2) + } +} diff --git a/Tests/XSToolingTests/ShellTests.swift b/Tests/XSToolingTests/ShellTests.swift index 9fec58d..3599688 100644 --- a/Tests/XSToolingTests/ShellTests.swift +++ b/Tests/XSToolingTests/ShellTests.swift @@ -1,72 +1,87 @@ -import XCTest +import Foundation +import Testing import XSTooling -final class ShellTests: GHTestCase { - private var shell: Shell! - private var path: String! +@Suite(.timeLimit(.minutes(1)), .gitHub) +struct ShellTests { - override func setUp() { - super.setUp() - path = "/bin/bash/\(name)" - shell = Shell(path: path) - } - - func testSh() async throws { - shell = Shell.sh - XCTAssertEqual(shell.path, "/bin/sh") + @Test func sh() async throws { + let shell = Shell.sh + #expect(shell == Shell(path: "/bin/sh", arguments: [])) let string = try await shell("echo 'hello world'").read().string - XCTAssertEqual(string, "hello world") + #expect(string == "hello world") } - func testBash() async throws { - shell = Shell.bash - XCTAssertEqual(shell.path, "/bin/bash") + @Test func bash() async throws { + let shell = Shell.bash + #expect(shell == Shell(path: "/bin/bash", arguments: [])) let string = try await shell("echo 'hello world'").read().string - XCTAssertEqual(string, "hello world") + #expect(string == "hello world") } - func testZsh() async throws { - try XCTSkipIf(isLinux) - - shell = Shell.zsh - XCTAssertEqual(shell.path, "/bin/zsh") + @Test func zsh() async throws { + let shell = Shell.zsh + #expect(shell == Shell(path: "/bin/zsh", arguments: [])) + #if os(macOS) let string = try await shell("echo 'hello world'").read().string - XCTAssertEqual(string, "hello world") + #expect(string == "hello world") + #endif } - func testVerbose() { - XCTAssertEqual(shell.verbose, Shell(path: path, arguments: ["--verbose"])) + @Test func current() throws { + var shell = Shell.current + #expect(shell.arguments == []) + + #if os(macOS) + let expected = try #require(ProcessInfo.processInfo.environment["SHELL"]) + #expect(shell.path == expected) + #elseif os(Linux) + #expect(shell.path == "/bin/bash") + #endif + + shell.path = "/bin/sh" + shell.arguments = ["--verbose"] + Shell.$current.withValue(shell) { + let shell = Shell.current + #expect(shell.path == "/bin/sh") + #expect(shell.arguments == ["--verbose"]) + } } - func testLogin() { - XCTAssertEqual(shell.login, Shell(path: path, arguments: ["--login"])) - } + @Test func arguments() { + var shell = Shell.zsh + shell.arguments = ["--login", "--verbose"] - func testVersion() { - XCTAssertEqual(shell.version, ProcessCommand(path: path, arguments: ["--version"])) + let command = shell.command(string: "echo 'hello world'") + let expected = ProcessCommand( + path: "/bin/zsh", + arguments: ["--login", "--verbose", "-c", "echo 'hello world'"] + ) + #expect(command == expected) } - func testVerboseLoginVersion() { - let command = shell.verbose.login.version + @Test func version() { + let shell = Shell.sh + let command = shell.version let expected = ProcessCommand( - path: path, - arguments: ["--verbose", "--login", "--version"] + path: "/bin/sh", + arguments: ["--version"] ) - XCTAssertEqual(command, expected) - + #expect(command == expected) } - func testCallAsFunction() { + @Test func callAsFunction() { + let shell = Shell.bash let command = shell("xcrun xcodebuild -version") let expected = ProcessCommand( - path: path, + path: "/bin/bash", arguments: ["-c", "xcrun xcodebuild -version"] ) - XCTAssertEqual(command, expected) + #expect(command == expected) } } diff --git a/Tests/XSToolingTests/SimctlTests.swift b/Tests/XSToolingTests/SimctlTests.swift index f309d48..8d7936c 100644 --- a/Tests/XSToolingTests/SimctlTests.swift +++ b/Tests/XSToolingTests/SimctlTests.swift @@ -1,180 +1,159 @@ -#if os(macOS) +import Foundation +import Testing -import XCTest @testable import XSTooling -final class SimctlTests: GHTestCase { - private var simctl: Simctl! - private var path: String! +@Suite(.timeLimit(.minutes(1)), .gitHub) +struct SimctlTests { + private let simctl: Simctl + private let path: String - override func setUp() { - super.setUp() - path = "/usr/bin/simctl/\(name)" - simctl = Simctl(path: path) + init() { + self.path = "/usr/bin/simctl/\(Test.current!.name)" + self.simctl = Simctl(path: path) } - // MARK: - Device control - - func testDeviceBoot() { - let command = simctl.device("2").boot - - let expected = ProcessCommand(path: path, arguments: ["boot", "2"]) - XCTAssertEqual(command, expected) + private func command(_ arguments: String...) -> ProcessCommand { + ProcessCommand(path: path, arguments: arguments) } - func testDeviceShutdown() { - let command = simctl.device("3").shutdown + // MARK: - Device control - let expected = ProcessCommand(path: path, arguments: ["shutdown", "3"]) - XCTAssertEqual(command, expected) + @Test func boot() { + let actual = simctl.device("2").boot + let expected = command("boot", "2") + #expect(actual == expected) } - func testDeviceOpenURL() { - let command = simctl.device("4").open(url: "https://example.com") - - let expected = ProcessCommand( - path: path, - arguments: ["openurl", "4", "https://example.com"] - ) - XCTAssertEqual(command, expected) + @Test func shutdown() { + let actual = simctl.device("2").shutdown + let expected = command("shutdown", "2") + #expect(actual == expected) } - func testBootedDeviceOpenURL() { - let command = simctl.booted.open(url: "https://test.com") - - let expected = ProcessCommand( - path: path, - arguments: ["openurl", "booted", "https://test.com"] - ) - XCTAssertEqual(command, expected) + @Test func openURL() { + let actual = simctl.booted.open(url: "https://test.com") + let expected = command("openurl", "booted", "https://test.com") + #expect(actual == expected) } // MARK: - App control - func testDeviceAppLaunch() { - let command = simctl.device("4").app("com.bundle.app").launch - - let expected = ProcessCommand( - path: path, - arguments: ["launch", "4", "com.bundle.app"] - ) - XCTAssertEqual(command, expected) + @Test func launch() { + let actual = simctl.device("4").app("com.bundle.app").launch + let expected = command("launch", "4", "com.bundle.app") + #expect(actual == expected) } - func testDeviceAppTerminate() { - let command = simctl.device("5").app("com.bundle.app2").terminate - - let expected = ProcessCommand( - path: path, - arguments: ["terminate", "5", "com.bundle.app2"] - ) - XCTAssertEqual(command, expected) + @Test func terminate() { + let actual = simctl.device("5").app("com.bundle.app2").terminate + let expected = command("terminate", "5", "com.bundle.app2") + #expect(actual == expected) } // MARK: - App container - func testDeviceAppContainerApp() { - let command = simctl.device("6").app("com.bundle.app3").container.app - - let expected = ProcessCommand( - path: path, - arguments: ["get_app_container", "6", "com.bundle.app3", "app"] - ) - XCTAssertEqual(command, expected) + @Test func appContainer() { + let actual = simctl.device("6").app("com.bundle.app3").container.app + let expected = command("get_app_container", "6", "com.bundle.app3", "app") + #expect(actual == expected) } - func testDeviceAppContainerData() { - let command = simctl.device("7").app("com.bundle.app4").container.data - - let expected = ProcessCommand( - path: path, - arguments: ["get_app_container", "7", "com.bundle.app4", "data"] - ) - XCTAssertEqual(command, expected) + @Test func dataContainer() { + let actual = simctl.device("7").app("com.bundle.app4").container.data + let expected = command("get_app_container", "7", "com.bundle.app4", "data") + #expect(actual == expected) } - func testDeviceAppContainerGroups() { - let command = simctl.device("8").app("com.bundle.app5").container.groups - - let expected = ProcessCommand( - path: path, - arguments: ["get_app_container", "8", "com.bundle.app5", "groups"] - ) - XCTAssertEqual(command, expected) + @Test func groupsContainer() { + let actual = simctl.device("8").app("com.bundle.app5").container.groups + let expected = command("get_app_container", "8", "com.bundle.app5", "groups") + #expect(actual == expected) } - func testDeviceAppContainerGroup() { - let command = simctl.device("9").app("com.bundle.app6").container.group("g") - - let expected = ProcessCommand( - path: path, - arguments: ["get_app_container", "9", "com.bundle.app6", "g"] - ) - XCTAssertEqual(command, expected) + @Test func groupContainer() { + let actual = simctl.device("9").app("com.bundle.app6").container.group("g") + let expected = command("get_app_container", "9", "com.bundle.app6", "g") + #expect(actual == expected) } // MARK: - Device list - func testDeviceList() { - let command = simctl.list.command - - let expected = ProcessCommand(path: path, arguments: ["list"]) - XCTAssertEqual(command, expected) - } - - func testDeviceListJson() { - let command = simctl.list.json.command - - let expected = ProcessCommand(path: path, arguments: ["list", "--json"]) - XCTAssertEqual(command, expected) - } - - func testDeviceListJsonDecode() async throws { - simctl = try await XCRun.current.simctl - let deviceList = try await simctl.list.json.decode() - XCTAssertFalse(deviceList.devices.isEmpty) - } - - func testDeviceListFilter() { - var command = simctl.list(.devices).command - XCTAssertEqual(command, ProcessCommand(path: path, arguments: ["list", "devices"])) - - command = simctl.list(.devices, "iPhone 8").command - XCTAssertEqual(command, ProcessCommand(path: path, arguments: ["list", "devices", "iPhone 8"])) - - command = simctl.list(.devices, available: true).command - XCTAssertEqual(command, ProcessCommand(path: path, arguments: ["list", "devices", "available"])) + @Test func list() { + let actual = simctl.list.command + let expected = command("list") + #expect(actual == expected) } - func testDeviceListBooted() throws { - let deviceList = try readDeviceList() - let devices = deviceList.booted - XCTAssertEqual(devices.count, 1) - XCTAssertTrue(devices.allSatisfy({ $0.state == "Booted" })) + @Test func listJson() { + let actual = simctl.list.json.command + let expected = command("list", "--json") + #expect(actual == expected) } - func testDeviceListDeviceWhere() throws { - let deviceList = try readDeviceList() - let device = deviceList.device(where: { $0.state == "Booted" }) - XCTAssertEqual(device?.state, "Booted") - } + #if os(macOS) - func testDeviceListDevicesWhere() throws { - let deviceList = try readDeviceList() - let devices = deviceList.devices(where: { $0.name.hasPrefix("iPhone") }) - XCTAssertEqual(devices.count, 4) - XCTAssertTrue(devices.allSatisfy({ $0.name.hasPrefix("iPhone") })) - } - - private func readDeviceList() throws -> Simctl.DeviceList { - let url = Bundle.module.url( - forResource: "deviceList", - withExtension: "json", - subdirectory: "Fixtures/Simctl") - let validUrl = try XCTUnwrap(url) - let data = try Data(contentsOf: validUrl) - return try JSONDecoder().decode(Simctl.DeviceList.self, from: data) + @Test static func decodeListJson() async throws { + let simctl = try await XCRun.current.simctl + let deviceList = try await simctl.list.json.decode() + #expect(!deviceList.devices.isEmpty) + } + + #endif // os(macOS) + + @Test func deviceListFilter() { + do { + let actual = simctl.list(.devices).command + let expected = command("list", "devices") + #expect(actual == expected) + } + do { + let actual = simctl.list(.devices, "iPhone 8").command + let expected = command("list", "devices", "iPhone 8") + #expect(actual == expected) + } + do { + let actual = simctl.list(.devices, "iPhone 8", available: true).command + let expected = command("list", "devices", "iPhone 8", "available") + #expect(actual == expected) + } + } + + struct DeviceListTests { + let deviceList: Simctl.DeviceList + + init() throws { + let resourceURL = Bundle.module.url( + forResource: "deviceList", + withExtension: "json", + subdirectory: "Fixtures/Simctl", + ) + let url = try #require(resourceURL) + let data = try Data(contentsOf: url) + self.deviceList = try JSONDecoder().decode(Simctl.DeviceList.self, from: data) + } + + @Test func devicesWhere() throws { + let devices = deviceList.devices(where: { $0.name.hasPrefix("iPhone") }) + #expect(devices.count == 4) + #expect(devices.allSatisfy({ $0.name.hasPrefix("iPhone") })) + } + + @Test func booted() throws { + let devices = deviceList.booted + #expect(devices.count == 1) + #expect(devices.allSatisfy({ $0.state == "Booted" })) + } + + @Test func `device where state == Booted`() throws { + let device = deviceList.device(where: { $0.state == "Booted" }) + #expect(device?.state == "Booted") + } + + @Test func `devices where name iPhone`() throws { + let devices = deviceList.devices(where: { $0.name.hasPrefix("iPhone") }) + #expect(devices.count == 4) + #expect(devices.allSatisfy({ $0.name.hasPrefix("iPhone") })) + } } } - -#endif // os(macOS) diff --git a/Tests/XSToolingTests/Traits/GitHubTrait.swift b/Tests/XSToolingTests/Traits/GitHubTrait.swift new file mode 100644 index 0000000..978cca7 --- /dev/null +++ b/Tests/XSToolingTests/Traits/GitHubTrait.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing + +extension SuiteTrait where Self == GitHubTrait { + static var gitHub: GitHubTrait { + GitHubTrait() + } +} + +struct GitHubTrait: SuiteTrait, TestScoping { + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + guard GitHub.isActionsEnabled else { + return try await function() + } + return try await _gitHubIssueHandlingTrait.provideScope( + for: test, + testCase: testCase, + performing: function + ) + } +} + +private let _gitHubIssueHandlingTrait = IssueHandlingTrait.compactMapIssues { (issue: Issue) in + GitHub.log( + issue.workflowCommand, + file: issue.sourceLocation?.filePath, + line: issue.sourceLocation?.line, + message: "\(issue)" + ) + return issue +} + +extension Issue { + fileprivate var workflowCommand: GitHub.WorkflowCommand { + switch severity { + case .warning: + GitHub.WorkflowCommand.warning + case .error: + GitHub.WorkflowCommand.error + @unknown default: + GitHub.WorkflowCommand.error + } + } +} + +enum GitHub: Sendable { + static let isActionsEnabled: Bool = ProcessInfo.processInfo.environment["GITHUB_ACTIONS"] == "true" + + /// [Workflow commands for GitHub Actions](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands) + enum WorkflowCommand: String, Sendable { + case warning + case error + } + + static func warning(file: String? = #filePath, line: Int? = #line, title: String? = nil, message: String) { + log(.warning, file: file, line: line, message: message) + } + + static func error(file: String? = #filePath, line: Int? = #line, title: String? = nil, message: String) { + log(.error, file: file, line: line, message: message) + } + + static func log( + _ command: WorkflowCommand, + file: String? = #filePath, + line: Int? = #line, + title: String? = nil, + message: String, + ) { + func joinParameters() -> String { + var parameters: [String] = [] + if let file { + parameters.append("file=\(file)") + } + if let line { + parameters.append("line=\(line)") + } + if let title { + parameters.append("title=\(title)") + } + if parameters.isEmpty { + return "" + } + return " " + parameters.joined(separator: ",") + } + print("::\(command)\(joinParameters())::\(message)") + } +} diff --git a/Tests/XSToolingTests/Traits/TemporaryDirectoryTrait.swift b/Tests/XSToolingTests/Traits/TemporaryDirectoryTrait.swift new file mode 100644 index 0000000..ec6d99e --- /dev/null +++ b/Tests/XSToolingTests/Traits/TemporaryDirectoryTrait.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing + +extension Trait where Self == TemporaryDirectoryTrait { + static var temporaryDirectory: TemporaryDirectoryTrait { + TemporaryDirectoryTrait() + } +} + +extension Test { + @TaskLocal + static var temporaryDirectory: URL? +} + +struct TemporaryDirectoryTrait: TestTrait, SuiteTrait, TestScoping { + var isRecursive: Bool { true } + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + guard Test.temporaryDirectory == nil else { + return try await function() + } + let fileManager = FileManager.default + + let url = fileManager.temporaryDirectory.appendingPathComponent( + "\(test.sourceLocation.fileName)-\(test.sourceLocation.line)", + isDirectory: true + ) + try fileManager.createDirectory( + at: url, + withIntermediateDirectories: false + ) + defer { + try? fileManager.removeItem(at: url) + } + try await Test.$temporaryDirectory.withValue(url, operation: function) + } +} diff --git a/Tests/XSToolingTests/XCRunTests.swift b/Tests/XSToolingTests/XCRunTests.swift index a8044e0..e967523 100644 --- a/Tests/XSToolingTests/XCRunTests.swift +++ b/Tests/XSToolingTests/XCRunTests.swift @@ -1,23 +1,32 @@ #if os(macOS) -import XCTest +import Testing import XSTooling -final class XCRunTests: GHTestCase { +@Suite(.timeLimit(.minutes(1)), .gitHub) +struct XCRunTests { private let xcrun = XCRun.current - func testExecute() async throws { - try await xcrun("xcodebuild", "-version").run() + @Test func run() async { + await #expect(throws: Never.self) { + try await xcrun("xcodebuild", "-version").run(standardOutput: .nullDevice) + } } - func testFind() async throws { - let path = try await xcrun.find("xcodebuild") - XCTAssertTrue(path.hasSuffix("/usr/bin/xcodebuild")) + @Test func version() { + let command = xcrun.version + let expected = ProcessCommand(path: "/usr/bin/xcrun", arguments: ["--version"]) + #expect(command == expected) } - func testSimctl() async throws { + @Test func simulator() async throws { let simulator = try await xcrun.simctl - XCTAssertTrue(simulator.path.hasSuffix("/usr/bin/simctl")) + #expect(simulator.path.hasSuffix("/usr/bin/simctl")) + } + + @Test func find() async throws { + let path = try await xcrun.find("xcodebuild") + #expect(path.hasSuffix("/usr/bin/xcodebuild")) } }