diff --git a/PHP Monitor.xcodeproj/project.pbxproj b/PHP Monitor.xcodeproj/project.pbxproj index 34501f1..fcceb87 100644 --- a/PHP Monitor.xcodeproj/project.pbxproj +++ b/PHP Monitor.xcodeproj/project.pbxproj @@ -63,9 +63,10 @@ C40FE738282ABA4F00A302C2 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40FE736282ABA4F00A302C2 /* AppVersion.swift */; }; C40FE73B282ABB2E00A302C2 /* AppVersionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40FE739282ABB2E00A302C2 /* AppVersionTest.swift */; }; C412E5FC25700D5300A1FB67 /* HomebrewPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C412E5FB25700D5300A1FB67 /* HomebrewPackage.swift */; }; - C413E43528DA3EB100AE33C7 /* ShellTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C413E43428DA3EB100AE33C7 /* ShellTest.swift */; }; + C413E43528DA3EB100AE33C7 /* FakeShellTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C413E43428DA3EB100AE33C7 /* FakeShellTest.swift */; }; C415937F27A1B54F00D2E1B7 /* PhpFrameworks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C415937E27A1B54F00D2E1B7 /* PhpFrameworks.swift */; }; C415938027A1B54F00D2E1B7 /* PhpFrameworks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C415937E27A1B54F00D2E1B7 /* PhpFrameworks.swift */; }; + C4159AF728E4D40400545349 /* SystemShellTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4159AF628E4D40400545349 /* SystemShellTest.swift */; }; C415D3B72770F294005EF286 /* Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C415D3B62770F294005EF286 /* Actions.swift */; }; C415D3B82770F294005EF286 /* Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C415D3B62770F294005EF286 /* Actions.swift */; }; C415D3E82770F692005EF286 /* AppDelegate+InterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C415D3E72770F692005EF286 /* AppDelegate+InterApp.swift */; }; @@ -364,8 +365,9 @@ C40FE736282ABA4F00A302C2 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; C40FE739282ABB2E00A302C2 /* AppVersionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionTest.swift; sourceTree = ""; }; C412E5FB25700D5300A1FB67 /* HomebrewPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomebrewPackage.swift; sourceTree = ""; }; - C413E43428DA3EB100AE33C7 /* ShellTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellTest.swift; sourceTree = ""; }; + C413E43428DA3EB100AE33C7 /* FakeShellTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeShellTest.swift; sourceTree = ""; }; C415937E27A1B54F00D2E1B7 /* PhpFrameworks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhpFrameworks.swift; sourceTree = ""; }; + C4159AF628E4D40400545349 /* SystemShellTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemShellTest.swift; sourceTree = ""; }; C415D3B62770F294005EF286 /* Actions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Actions.swift; sourceTree = ""; }; C415D3E72770F692005EF286 /* AppDelegate+InterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+InterApp.swift"; sourceTree = ""; }; C4168F4427ADB4A3003B6C39 /* DEVELOPER.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPER.md; sourceTree = ""; }; @@ -666,7 +668,8 @@ C413E43328DA3E8F00AE33C7 /* Next */ = { isa = PBXGroup; children = ( - C413E43428DA3EB100AE33C7 /* ShellTest.swift */, + C413E43428DA3EB100AE33C7 /* FakeShellTest.swift */, + C4159AF628E4D40400545349 /* SystemShellTest.swift */, ); path = Next; sourceTree = ""; @@ -1503,7 +1506,7 @@ C41CA5EE2774F8EE00A2C80E /* DomainListVC+Actions.swift in Sources */, C4FACE81288F1C0D00FC478F /* PreferencesWindowController+Hotkey.swift in Sources */, 54D9E0B727E4F51E003B9AD9 /* HotKey.swift in Sources */, - C413E43528DA3EB100AE33C7 /* ShellTest.swift in Sources */, + C413E43528DA3EB100AE33C7 /* FakeShellTest.swift in Sources */, C4205A7F27F4D21800191A39 /* ValetProxy.swift in Sources */, C42F26742805B4B400938AC7 /* DomainListable.swift in Sources */, C46EBC4528DB95F0007ACC74 /* Shellable.swift in Sources */, @@ -1557,6 +1560,7 @@ C4FC21B128391F8E00D368BB /* MainMenu+Actions.swift in Sources */, 54D9E0B927E4F51E003B9AD9 /* KeyCombo.swift in Sources */, C4EED88A27A48778006D7272 /* InterAppHandler.swift in Sources */, + C4159AF728E4D40400545349 /* SystemShellTest.swift in Sources */, C450C8C728C919EC002A2B4B /* PreferenceName.swift in Sources */, C48D6C75279CD3E400F26D7E /* PhpVersionNumberTest.swift in Sources */, C485707B28BF458900539B36 /* VersionPopoverView.swift in Sources */, diff --git a/phpmon-tests/Next/ShellTest.swift b/phpmon-tests/Next/FakeShellTest.swift similarity index 74% rename from phpmon-tests/Next/ShellTest.swift rename to phpmon-tests/Next/FakeShellTest.swift index 28575e8..57510a6 100644 --- a/phpmon-tests/Next/ShellTest.swift +++ b/phpmon-tests/Next/FakeShellTest.swift @@ -8,20 +8,7 @@ import XCTest -class ShellTest: XCTestCase { - func test_default_shell_is_system_shell() { - XCTAssertTrue(Shell is SystemShell) - - XCTAssertTrue(Shell.sync("php -v").output.contains("Copyright (c) The PHP Group")) - } - - func test_system_shell_has_path() { - let systemShell = Shell as! SystemShell - - XCTAssertTrue(systemShell.PATH.contains(":/usr/local/bin")) - XCTAssertTrue(systemShell.PATH.contains(":/usr/bin")) - } - +class FakeShellTest: XCTestCase { func test_we_can_predefine_responses_for_dummy_shell() { let expectedPhpOutput = """ PHP 8.1.10 (cli) (built: Sep 3 2022 12:09:27) (NTS) diff --git a/phpmon-tests/Next/SystemShellTest.swift b/phpmon-tests/Next/SystemShellTest.swift new file mode 100644 index 0000000..4ddde43 --- /dev/null +++ b/phpmon-tests/Next/SystemShellTest.swift @@ -0,0 +1,57 @@ +// +// SystemShellTest.swift +// phpmon-tests +// +// Created by Nico Verbruggen on 28/09/2022. +// Copyright © 2022 Nico Verbruggen. All rights reserved. +// + +import XCTest + +class SystemShellTest: XCTestCase { + func test_system_shell_is_default() { + XCTAssertTrue(Shell is SystemShell) + + XCTAssertTrue(Shell.sync("php -v").output.contains("Copyright (c) The PHP Group")) + } + + func test_system_shell_has_path() { + let systemShell = Shell as! SystemShell + + XCTAssertTrue(systemShell.PATH.contains(":/usr/local/bin")) + XCTAssertTrue(systemShell.PATH.contains(":/usr/bin")) + } + + func test_system_shell_can_buffer_output() async { + var bits: [String] = [] + + let shellOutput = try! await Shell.attach( + "php -r \"echo 'Hello world' . PHP_EOL; usleep(200); echo 'Goodbye world';\"", + didReceiveOutput: { incoming in + bits.append(incoming.output) + }, + withTimeout: 2.0 + ) + + XCTAssertTrue(bits.contains("Hello world\n")) + XCTAssertTrue(bits.contains("Goodbye world")) + XCTAssertEqual("Hello world\nGoodbye world", shellOutput.output) + } + + func test_system_shell_can_timeout_and_throw_error() async { + let expectation = XCTestExpectation(description: #function) + + do { + _ = try await Shell.attach( + "php -r \"sleep(1);\"", + didReceiveOutput: { _ in }, + withTimeout: 0.1 + ) + } catch { + XCTAssertEqual(error as? ShellError, ShellError.timedOut) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 3.0) + } +} diff --git a/phpmon/Next/Shellable.swift b/phpmon/Next/Shellable.swift index a418a32..ceb653f 100644 --- a/phpmon/Next/Shellable.swift +++ b/phpmon/Next/Shellable.swift @@ -11,34 +11,55 @@ import Foundation struct ShellOutput: CustomStringConvertible { var output: String var isError: Bool - var description: String { return output } + + static func out(_ output: String) -> ShellOutput { + return ShellOutput(output: output, isError: false) + } + + static func err(_ output: String) -> ShellOutput { + return ShellOutput(output: output, isError: true) + } } protocol Shellable { /** Run a command synchronously. Waits until the command is done. + Returns the most relevant output (prefers error output if it exists). */ func sync(_ command: String) -> ShellOutput /** Run a command asynchronously. + Returns the most relevant output (prefers error output if it exists). */ func pipe(_ command: String) async -> ShellOutput /** Run a command asynchronously, without returning the output of the command. + Returns the most relevant output (prefers error output if it exists). */ func quiet(_ command: String) async /** - Attach to a given command and listen for progress updates. - Any data that ends up in standard out or standard error becomes available. + Runs a command asynchronously, and fires closure with `stdout` or `stderr` data as it comes in. + + You can specify how long this task should run. + The process will always be terminated after the specified time interval. + (Whether it is complete or not.) + + Unlike `sync`, `pipe` and `quiet`, you can capture both `stdout` and `stderr` with this mechanism. + The end result is still the most relevant output (where error output is preferred if it exists). */ func attach( _ command: String, - didReceiveOutput: @escaping (ShellOutput) -> Void - ) async -> ShellOutput + didReceiveOutput: @escaping (ShellOutput) -> Void, + withTimeout timeout: TimeInterval + ) async throws -> ShellOutput +} + +enum ShellError: Error { + case timedOut } diff --git a/phpmon/Next/SystemShell.swift b/phpmon/Next/SystemShell.swift index 867a6c4..93672f4 100644 --- a/phpmon/Next/SystemShell.swift +++ b/phpmon/Next/SystemShell.swift @@ -90,8 +90,8 @@ class SystemShell: Shellable { task.standardOutput = outputPipe task.standardError = errorPipe - task.waitUntilExit() task.launch() + task.waitUntilExit() let stdOut = String( data: outputPipe.fileHandleForReading.readDataToEndOfFile(), @@ -118,7 +118,45 @@ class SystemShell: Shellable { _ = await self.pipe(command) } - func attach(_ command: String, didReceiveOutput: @escaping (ShellOutput) -> Void) async -> ShellOutput { - return sync(command) + func attach( + _ command: String, + didReceiveOutput: @escaping (ShellOutput) -> Void, + withTimeout timeout: TimeInterval = 5.0 + ) async throws -> ShellOutput { + let task = getShellProcess(for: command) + + var allOut: String = "" + var allErr: String = "" + + task.listen { stdOut in + allOut += stdOut; didReceiveOutput(.out(stdOut)) + } didReceiveStandardErrorData: { stdErr in + allErr += stdErr; didReceiveOutput(.err(stdErr)) + } + + return try await withCheckedThrowingContinuation({ continuation in + var timer: Timer? + + task.terminationHandler = { process in + process.haltListening() + + timer?.invalidate() + + if !allErr.isEmpty { + return continuation.resume(returning: .err(allErr)) + } + + return continuation.resume(returning: .out(allOut)) + } + + timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { _ in + task.terminationHandler = nil + task.terminate() + return continuation.resume(throwing: ShellError.timedOut) + } + + task.launch() + task.waitUntilExit() + }) } } diff --git a/phpmon/Next/TestableShell.swift b/phpmon/Next/TestableShell.swift index 715133c..0ef9ebb 100644 --- a/phpmon/Next/TestableShell.swift +++ b/phpmon/Next/TestableShell.swift @@ -9,7 +9,6 @@ import Foundation public class TestableShell: Shellable { - public typealias Input = String init(expectations: [Input: OutputsToShell]) { @@ -26,7 +25,11 @@ public class TestableShell: Shellable { self.sync(command) } - func attach(_ command: String, didReceiveOutput: @escaping (ShellOutput) -> Void) async -> ShellOutput { + func attach( + _ command: String, + didReceiveOutput: @escaping (ShellOutput) -> Void, + withTimeout timeout: TimeInterval + ) async throws -> ShellOutput { self.sync(command) }