1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2025-08-07 20:10:08 +02:00

🏗 WIP: Much improved Shell protocol

This commit is contained in:
2022-09-28 21:28:51 +02:00
parent 513a86ec39
commit bbac2632a2
6 changed files with 138 additions and 28 deletions

View File

@ -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 */,

View File

@ -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)

View 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)
}
}

View File

@ -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
}

View File

@ -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()
})
}
}

View File

@ -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)
}