From a5cca1e09d50f0834f2ac04fc52c17a5260dab47 Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Fri, 27 Feb 2026 14:51:03 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Make=20testable=20modifications=20p?= =?UTF-8?q?ossible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Filesystem/TestableFileSystem.swift | 25 ++++ .../Testable/TrackedTestableShell.swift | 8 +- phpmon/Common/Shell/TestableShell.swift | 130 +++++++++++++++++- phpmon/Container/Container.swift | 8 +- tests/ui/StartupTest.swift | 15 +- 5 files changed, 177 insertions(+), 9 deletions(-) diff --git a/phpmon/Common/Filesystem/TestableFileSystem.swift b/phpmon/Common/Filesystem/TestableFileSystem.swift index 545d08e5..71a57fa9 100644 --- a/phpmon/Common/Filesystem/TestableFileSystem.swift +++ b/phpmon/Common/Filesystem/TestableFileSystem.swift @@ -178,6 +178,31 @@ class TestableFileSystem: FileSystemProtocol { } } + // MARK: - Transaction Helpers + + func createSymlink(_ path: String, destination: String) { + let path = path.replacingTildeWithHomeDirectory + let destination = destination.replacingTildeWithHomeDirectory + + accessQueue.sync { + self.createIntermediateDirectories(path) + self.files[path] = .fake(.symlink, destination) + } + } + + func writeFile(_ path: String, content: String, overwrite: Bool) throws { + let path = path.replacingTildeWithHomeDirectory + + try accessQueue.sync { + if !overwrite, files[path] != nil { + throw TestableFileSystemError.alreadyExists + } + + self.createIntermediateDirectories(path) + self.files[path] = .fake(.text, content) + } + } + // MARK: - Checks func isExecutableFile(_ path: String) -> Bool { diff --git a/phpmon/Common/Monitoring/Testable/TrackedTestableShell.swift b/phpmon/Common/Monitoring/Testable/TrackedTestableShell.swift index ca1c548a..33bcf8e8 100644 --- a/phpmon/Common/Monitoring/Testable/TrackedTestableShell.swift +++ b/phpmon/Common/Monitoring/Testable/TrackedTestableShell.swift @@ -11,9 +11,13 @@ import Foundation final class TrackableTestableShell: TestableShell { private let commandTracker: CommandTracker - init(expectations: [String: BatchFakeShellOutput], _ commandTracker: CommandTracker) { + init( + expectations: [String: BatchFakeShellOutput], + filesystem: TestableFileSystem?, + _ commandTracker: CommandTracker + ) { self.commandTracker = commandTracker - super.init(expectations: expectations) + super.init(expectations: expectations, filesystem: filesystem) } override func sync(_ command: String) -> ShellOutput { diff --git a/phpmon/Common/Shell/TestableShell.swift b/phpmon/Common/Shell/TestableShell.swift index 9e32492b..d701b483 100644 --- a/phpmon/Common/Shell/TestableShell.swift +++ b/phpmon/Common/Shell/TestableShell.swift @@ -13,11 +13,13 @@ public class TestableShell: ShellProtocol { return "/usr/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin" } - init(expectations: [String: BatchFakeShellOutput]) { + init(expectations: [String: BatchFakeShellOutput], filesystem: TestableFileSystem? = nil) { self.expectations = expectations + self.filesystem = filesystem } var expectations: [String: BatchFakeShellOutput] = [:] + var filesystem: TestableFileSystem? @discardableResult func sync(_ command: String) -> ShellOutput { @@ -28,7 +30,9 @@ public class TestableShell: ShellProtocol { return .err("No Expected Output") } - return expectation.syncOutput() + let output = expectation.syncOutput() + applyTransactions(for: expectation) + return output } @discardableResult @@ -66,6 +70,7 @@ public class TestableShell: ShellProtocol { didReceiveOutput(output, type) }, ignoreDelay: isRunningTests) + applyTransactions(for: expectation) return (Process(), output) } @@ -73,6 +78,20 @@ public class TestableShell: ShellProtocol { // does nothing } + private func applyTransactions(for expectation: BatchFakeShellOutput) { + if !expectation.transactions.isEmpty { + assert(filesystem != nil, "Transactions require a filesystem") + } + + guard let filesystem else { + return + } + + expectation.transactions.forEach { transaction in + transaction.apply(to: filesystem, shell: self) + } + } + } struct FakeShellOutput: Codable { @@ -91,6 +110,7 @@ struct FakeShellOutput: Codable { struct BatchFakeShellOutput: Codable { var items: [FakeShellOutput] + var transactions: [FakeShellTransaction] = [] static func with(_ items: [FakeShellOutput]) -> BatchFakeShellOutput { return BatchFakeShellOutput(items: items) @@ -122,6 +142,8 @@ struct BatchFakeShellOutput: Codable { await delay(seconds: item.delay) } + didReceiveOutput(item.output, item.stream) + if item.stream == .stdErr { output.err += item.output } else if item.stream == .stdOut { @@ -164,3 +186,107 @@ struct BatchFakeShellOutput: Codable { return await self.output(didReceiveOutput: didReceiveOutput, ignoreDelay: true) } } + +struct FakeShellTransaction: Codable { + enum TransactionType: String, Codable { + case createSymlink + case writeFile + case remove + case move + case createDirectory + case makeExecutable + case setShellOutput + } + + var type: TransactionType + var path: String? + var destination: String? + var content: String? + var overwrite: Bool? + var from: String? + var to: String? + var command: String? + var output: BatchFakeShellOutput? + + static func createSymlink(path: String, destination: String) -> FakeShellTransaction { + FakeShellTransaction(type: .createSymlink, path: path, destination: destination) + } + + static func symlink(_ path: String, to destination: String) -> FakeShellTransaction { + createSymlink(path: path, destination: destination) + } + + static func writeFile(path: String, content: String, overwrite: Bool) -> FakeShellTransaction { + FakeShellTransaction(type: .writeFile, path: path, content: content, overwrite: overwrite) + } + + static func file(_ path: String, content: String, overwrite: Bool) -> FakeShellTransaction { + writeFile(path: path, content: content, overwrite: overwrite) + } + + static func remove(path: String) -> FakeShellTransaction { + FakeShellTransaction(type: .remove, path: path) + } + + static func remove(_ path: String) -> FakeShellTransaction { + remove(path: path) + } + + static func move(from: String, to: String) -> FakeShellTransaction { + FakeShellTransaction(type: .move, from: from, to: to) + } + + static func move(_ from: String, to: String) -> FakeShellTransaction { + move(from: from, to: to) + } + + static func createDirectory(path: String) -> FakeShellTransaction { + FakeShellTransaction(type: .createDirectory, path: path) + } + + static func directory(_ path: String) -> FakeShellTransaction { + createDirectory(path: path) + } + + static func makeExecutable(path: String) -> FakeShellTransaction { + FakeShellTransaction(type: .makeExecutable, path: path) + } + + static func executable(_ path: String) -> FakeShellTransaction { + makeExecutable(path: path) + } + + static func setShellOutput(command: String, output: BatchFakeShellOutput) -> FakeShellTransaction { + FakeShellTransaction(type: .setShellOutput, command: command, output: output) + } + + static func shellOutput(_ command: String, output: BatchFakeShellOutput) -> FakeShellTransaction { + setShellOutput(command: command, output: output) + } + + func apply(to filesystem: TestableFileSystem, shell: TestableShell) { + switch type { + case .createSymlink: + assert(path != nil && destination != nil, "createSymlink requires path and destination") + filesystem.createSymlink(path!, destination: destination!) + case .writeFile: + assert(path != nil && content != nil && overwrite != nil, "writeFile requires path, content, overwrite") + try? filesystem.writeFile(path!, content: content!, overwrite: overwrite!) + case .remove: + assert(path != nil, "remove requires path") + try? filesystem.remove(path!) + case .move: + assert(from != nil && to != nil, "move requires from and to") + try? filesystem.move(from: from!, to: to!) + case .createDirectory: + assert(path != nil, "createDirectory requires path") + try? filesystem.createDirectory(path!, withIntermediateDirectories: true) + case .makeExecutable: + assert(path != nil, "makeExecutable requires path") + try? filesystem.makeExecutable(path!) + case .setShellOutput: + assert(command != nil && output != nil, "setShellOutput requires command and output") + shell.expectations[command!] = output! + } + } +} diff --git a/phpmon/Container/Container.swift b/phpmon/Container/Container.swift index 8b1ecdd6..02e085ac 100644 --- a/phpmon/Container/Container.swift +++ b/phpmon/Container/Container.swift @@ -116,16 +116,18 @@ class Container: @unchecked Sendable { ) { self.commandTracker = CommandTracker() + let filesystem = TestableFileSystem(files: fileSystemFiles) + // Depending on whether we want to fire command tracking, load different handlers if commandTracking { - self.shell = TrackableTestableShell(expectations: shellExpectations, commandTracker) + self.shell = TrackableTestableShell(expectations: shellExpectations, filesystem: filesystem, commandTracker) self.command = TrackableTestableCommand(commands: commands, commandTracker) } else { - self.shell = TestableShell(expectations: shellExpectations) + self.shell = TestableShell(expectations: shellExpectations, filesystem: filesystem) self.command = TestableCommand(commands: commands) } - self.filesystem = TestableFileSystem(files: fileSystemFiles) + self.filesystem = filesystem self.webApi = TestableWebApi( getResponses: webApiGetResponses, diff --git a/tests/ui/StartupTest.swift b/tests/ui/StartupTest.swift index 36ccb986..a116ce93 100644 --- a/tests/ui/StartupTest.swift +++ b/tests/ui/StartupTest.swift @@ -73,8 +73,19 @@ final class StartupTest: UITestCase { final func test_launch_halts_and_automic_fix_can_be_applied() throws { var configuration = TestableConfigurations.working - // TODO: Make fake shell output closure accessible w/ container so fake tests can manipulate the app's state - configuration.shellOutput["/opt/homebrew/bin/brew link php"] = .delayed(0.5, "Linked PHP.", .stdOut) + configuration.shellOutput["/opt/homebrew/bin/brew link php"] = BatchFakeShellOutput( + items: [.delayed(0.5, "Linked PHP.", .stdOut)], + transactions: [ + .symlink( + "/opt/homebrew/bin/php", + to: "/opt/homebrew/Cellar/php/8.4.5/bin/php" + ), + .shellOutput( + "/opt/homebrew/bin/brew link php", + output: .instant("PHP already linked.") + ) + ] + ) configuration.filesystem["/opt/homebrew/bin/php"] = nil // PHP binary must be missing let app = launch(