mirror of
https://github.com/nicoverbruggen/phpmon.git
synced 2025-08-07 03:50:08 +02:00
🏗 WIP: Much improved Shell protocol
This commit is contained in:
@ -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 = "<group>"; };
|
||||
C40FE739282ABB2E00A302C2 /* AppVersionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionTest.swift; sourceTree = "<group>"; };
|
||||
C412E5FB25700D5300A1FB67 /* HomebrewPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomebrewPackage.swift; sourceTree = "<group>"; };
|
||||
C413E43428DA3EB100AE33C7 /* ShellTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellTest.swift; sourceTree = "<group>"; };
|
||||
C413E43428DA3EB100AE33C7 /* FakeShellTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeShellTest.swift; sourceTree = "<group>"; };
|
||||
C415937E27A1B54F00D2E1B7 /* PhpFrameworks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhpFrameworks.swift; sourceTree = "<group>"; };
|
||||
C4159AF628E4D40400545349 /* SystemShellTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemShellTest.swift; sourceTree = "<group>"; };
|
||||
C415D3B62770F294005EF286 /* Actions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Actions.swift; sourceTree = "<group>"; };
|
||||
C415D3E72770F692005EF286 /* AppDelegate+InterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+InterApp.swift"; sourceTree = "<group>"; };
|
||||
C4168F4427ADB4A3003B6C39 /* DEVELOPER.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPER.md; sourceTree = "<group>"; };
|
||||
@ -666,7 +668,8 @@
|
||||
C413E43328DA3E8F00AE33C7 /* Next */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C413E43428DA3EB100AE33C7 /* ShellTest.swift */,
|
||||
C413E43428DA3EB100AE33C7 /* FakeShellTest.swift */,
|
||||
C4159AF628E4D40400545349 /* SystemShellTest.swift */,
|
||||
);
|
||||
path = Next;
|
||||
sourceTree = "<group>";
|
||||
@ -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 */,
|
||||
|
@ -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)
|
57
phpmon-tests/Next/SystemShellTest.swift
Normal file
57
phpmon-tests/Next/SystemShellTest.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user