diff --git a/Sources/NnexKit/Building/BinaryInfo.swift b/Sources/NnexKit/Building/BinaryInfo.swift deleted file mode 100644 index 71dece7..0000000 --- a/Sources/NnexKit/Building/BinaryInfo.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// BinaryInfo.swift -// nnex -// -// Created by Nikolai Nobadi on 3/20/25. -// - -public struct BinaryInfo { - public let path: String - - public init(path: String) { - self.path = path - } -} diff --git a/Sources/NnexKit/Building/BinaryOutput.swift b/Sources/NnexKit/Building/BinaryOutput.swift index d79d689..5ecfa7c 100644 --- a/Sources/NnexKit/Building/BinaryOutput.swift +++ b/Sources/NnexKit/Building/BinaryOutput.swift @@ -5,7 +5,7 @@ // Created by Nikolai Nobadi on 12/10/25. // -public enum BinaryOutput { +public enum BinaryOutput: Equatable { public typealias BinaryPath = String case single(BinaryPath) diff --git a/Sources/NnexKit/Building/BuildResult.swift b/Sources/NnexKit/Building/BuildResult.swift new file mode 100644 index 0000000..fba00ff --- /dev/null +++ b/Sources/NnexKit/Building/BuildResult.swift @@ -0,0 +1,16 @@ +// +// BuildResult.swift +// nnex +// +// Created by Nikolai Nobadi on 12/12/25. +// + +public struct BuildResult { + public let executableName: String + public let binaryOutput: BinaryOutput + + public init(executableName: String, binaryOutput: BinaryOutput) { + self.executableName = executableName + self.binaryOutput = binaryOutput + } +} diff --git a/Sources/NnexKit/Building/ProjectBuilder.swift b/Sources/NnexKit/Building/ProjectBuilder.swift index 5bb5a9e..973d8ca 100644 --- a/Sources/NnexKit/Building/ProjectBuilder.swift +++ b/Sources/NnexKit/Building/ProjectBuilder.swift @@ -23,7 +23,7 @@ public struct ProjectBuilder { // MARK: - Build public extension ProjectBuilder { - func build() throws -> BinaryOutput { + func build() throws -> BuildResult { if !config.skipClean { try cleanProject() } @@ -32,6 +32,16 @@ public extension ProjectBuilder { try build(for: arch) } + let output = try makeBinaryOutpu() + + return .init(executableName: config.projectName, binaryOutput: output) + } +} + + +// MARK: - Private Methods +private extension ProjectBuilder { + func makeBinaryOutpu() throws -> BinaryOutput { switch config.buildType { case .arm64, .x86_64: let arch = config.buildType.archs.first! @@ -40,7 +50,7 @@ public extension ProjectBuilder { try runTests() return .single(path) - + case .universal: var results: [ReleaseArchitecture: String] = [:] for arch in config.buildType.archs { @@ -52,11 +62,7 @@ public extension ProjectBuilder { return .multiple(results) } } -} - - -// MARK: - Private Methods -private extension ProjectBuilder { + func log(_ message: String) { if let progressDelegate = progressDelegate { progressDelegate.didUpdateProgress(message) diff --git a/Sources/NnexKit/FileSystem/FileSystem.swift b/Sources/NnexKit/FileSystem/FileSystem.swift index a3adf57..b07b9b1 100644 --- a/Sources/NnexKit/FileSystem/FileSystem.swift +++ b/Sources/NnexKit/FileSystem/FileSystem.swift @@ -15,3 +15,15 @@ public protocol FileSystem { func readFile(at path: String) throws -> String func writeFile(at path: String, contents: String) throws } + + +// MARK: - Helpers +public extension FileSystem { + func getDirectoryAtPathOrCurrent(path: String?) throws -> any Directory { + guard let path else { + return currentDirectory + } + + return try directory(at: path) + } +} diff --git a/Sources/NnexKit/Formula/PublishUtilities.swift b/Sources/NnexKit/Formula/PublishUtilities.swift index f01206a..28505b9 100644 --- a/Sources/NnexKit/Formula/PublishUtilities.swift +++ b/Sources/NnexKit/Formula/PublishUtilities.swift @@ -19,7 +19,7 @@ public enum PublishUtilities { let config = BuildConfig(projectName: formula.name, projectPath: formula.localProjectPath, buildType: buildType, extraBuildArgs: formula.extraBuildArgs, skipClean: false, testCommand: testCommand) let builder = ProjectBuilder(shell: shell, config: config) - return try builder.build() + return try builder.build().binaryOutput } /// Creates tar.gz archives from binary output. diff --git a/Sources/NnexKit/Managers/BuildManager.swift b/Sources/NnexKit/Managers/BuildManager.swift new file mode 100644 index 0000000..a97c6b8 --- /dev/null +++ b/Sources/NnexKit/Managers/BuildManager.swift @@ -0,0 +1,60 @@ +// +// BuildManager.swift +// nnex +// +// Created by Nikolai Nobadi on 12/12/25. +// + +public struct BuildManager { + private let shell: any NnexShell + private let fileSystem: any FileSystem + + public init(shell: any NnexShell, fileSystem: any FileSystem) { + self.shell = shell + self.fileSystem = fileSystem + } +} + + +// MARK: - BuildExecutable +public extension BuildManager { + func buildExecutable(config: BuildConfig, outputLocation: BuildOutputLocation) throws -> BuildResult { + let result = try ProjectBuilder(shell: shell, config: config).build() + + switch outputLocation { + case .currentDirectory: + return result + case .desktop: + let desktop = try fileSystem.desktopDirectory() + + return try moveToDestination(buildResult: result, destinationPath: desktop.path) + case .custom(let parentPath): + return try moveToDestination(buildResult: result, destinationPath: parentPath) + } + } +} + + +// MARK: - Private Methods +private extension BuildManager { + func moveToDestination(buildResult: BuildResult, destinationPath: String) throws -> BuildResult { + let executableName = buildResult.executableName + + switch buildResult.binaryOutput { + case .single(let path): + let finalPath = destinationPath + "/" + executableName + try shell.runAndPrint(bash: "cp \"\(path)\" \"\(finalPath)\"") + return .init(executableName: executableName, binaryOutput: .single(finalPath)) + case .multiple(let binaries): + var results: [ReleaseArchitecture: String] = [:] + + for (arch, path) in binaries { + let finalPath = destinationPath + "/" + executableName + "-\(arch.name)" + try shell.runAndPrint(bash: "cp \"\(path)\" \"\(finalPath)\"") + results[arch] = finalPath + } + + return .init(executableName: executableName, binaryOutput: .multiple(results)) + } + } +} diff --git a/Sources/nnex/Commands/BuildCommand/BuildExecutable.swift b/Sources/nnex/Commands/BuildCommand/BuildExecutable.swift index 523b92e..f99893a 100644 --- a/Sources/nnex/Commands/BuildCommand/BuildExecutable.swift +++ b/Sources/nnex/Commands/BuildCommand/BuildExecutable.swift @@ -5,8 +5,8 @@ // Created by Nikolai Nobadi on 4/21/25. // -import ArgumentParser import NnexKit +import ArgumentParser extension Nnex { struct Build: ParsableCommand { @@ -32,9 +32,15 @@ extension Nnex { let context = try Nnex.makeContext() let fileSystem = Nnex.makeFileSystem() let buildType = buildType ?? context.loadDefaultBuildType() - let manager = BuildExecutionManager(shell: shell, picker: picker, fileSystem: fileSystem) + let manager = BuildManager(shell: shell, fileSystem: fileSystem) + let folderBrowser = Nnex.makeFolderBrowser(picker: picker, fileSystem: fileSystem) + let controller = BuildController(shell: shell, picker: picker, fileSystem: fileSystem, buildService: manager, folderBrowser: folderBrowser) - try manager.executeBuild(projectPath: path, buildType: buildType, clean: clean, openInFinder: openInFinder) + try controller.buildExecutable(path: path, buildType: buildType, clean: clean, openInFinder: openInFinder) } } } + + +// MARK: - Extension Dependencies +extension BuildManager: BuildService { } diff --git a/Sources/nnex/Controllers/BuildController.swift b/Sources/nnex/Controllers/BuildController.swift new file mode 100644 index 0000000..c6d5271 --- /dev/null +++ b/Sources/nnex/Controllers/BuildController.swift @@ -0,0 +1,108 @@ +// +// BuildController.swift +// nnex +// +// Created by Nikolai Nobadi on 12/12/25. +// + +import NnexKit + +struct BuildController { + private let shell: any NnexShell + private let picker: any NnexPicker + private let fileSystem: any FileSystem + private let buildService: any BuildService + private let folderBrowser: any DirectoryBrowser + + init( + shell: any NnexShell, + picker: any NnexPicker, + fileSystem: any FileSystem, + buildService: any BuildService, + folderBrowser: any DirectoryBrowser + ) { + self.shell = shell + self.picker = picker + self.fileSystem = fileSystem + self.buildService = buildService + self.folderBrowser = folderBrowser + } +} + + +// MARK: - Actions +extension BuildController { + func buildExecutable(path: String?, buildType: BuildType, clean: Bool, openInFinder: Bool) throws { + let projectFolder = try fileSystem.getDirectoryAtPathOrCurrent(path: path) + let executableName = try getExecutableName(for: projectFolder) + let outputLocation = try selectOutputLocation(buildType: buildType) + let config = BuildConfig(projectName: executableName, projectPath: projectFolder.path, buildType: buildType, extraBuildArgs: [], skipClean: !clean, testCommand: nil) + let result = try buildService.buildExecutable(config: config, outputLocation: outputLocation) + + displayBuildResult(result, openInFinder: openInFinder) + } +} + + +// MARK: - Private Methods +private extension BuildController { + func getExecutableName(for folder: any Directory) throws -> String { + let names = try ExecutableNameResolver.getExecutableNames(from: folder) + + guard names.count > 1 else { + return names.first! + } + + do { + return try picker.requiredSingleSelection("Which executable would you like to build?", items: names) + } catch { + throw BuildExecutionError.failedToSelectExecutable(reason: error.localizedDescription) + } + } + + func selectOutputLocation(buildType: BuildType) throws -> BuildOutputLocation { + let options: [BuildOutputLocation] = [.currentDirectory(buildType), .desktop, .custom("")] + let selection = try picker.requiredSingleSelection("Where would you like to place the built binary?", items: options) + + if case .custom = selection { + return try handleCustomLocationInput() + } + + return selection + } + + func handleCustomLocationInput() throws -> BuildOutputLocation { + let parentFolder = try folderBrowser.browseForDirectory(prompt: "Select the folder where you would like to save the build.") + + try picker.requiredPermission(prompt: "The binary will be placed at: \(parentFolder.path). Continue?") + + return .custom(parentFolder.path) + } + + func displayBuildResult(_ result: BuildResult, openInFinder: Bool) { + switch result.binaryOutput { + case .single(let path): + print("\(result.executableName.lightGreen) was built at \(path)") + openFinder(path: openInFinder ? path : nil) + case .multiple(let binaries): + print("\(result.executableName.lightGreen) builds:".underline) + for (arch, path) in binaries { + print(" \(arch.name): \(path)") + } + + openFinder(path: openInFinder ? binaries.values.first : nil) + } + } + + func openFinder(path: String?) { + if let path { + try? shell.runAndPrint(bash: "open -R \(path)") + } + } +} + + +// MARK: - Dependencies +protocol BuildService { + func buildExecutable(config: BuildConfig, outputLocation: BuildOutputLocation) throws -> BuildResult +} diff --git a/Sources/nnex/Managers/BuildExecutionManager.swift b/Sources/nnex/Managers/BuildExecutionManager.swift deleted file mode 100644 index 93b7d1c..0000000 --- a/Sources/nnex/Managers/BuildExecutionManager.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// BuildExecutionManager.swift -// nnex -// -// Created by Nikolai Nobadi on 8/26/25. -// - -import NnexKit -import Foundation - -struct BuildExecutionManager { - private let shell: any NnexShell - private let picker: any NnexPicker - private let fileSystem: any FileSystem - private let copyUtility: BinaryCopyUtility - - init(shell: any NnexShell, picker: any NnexPicker, fileSystem: any FileSystem) { - self.shell = shell - self.picker = picker - self.fileSystem = fileSystem - self.copyUtility = .init(shell: shell, fileSystem: fileSystem) - } - - func executeBuild(projectPath: String?, buildType: BuildType, clean: Bool, openInFinder: Bool) throws { - let projectFolder = try getProjectFolder(at: projectPath) - let executableName = try getExecutableName(for: projectFolder) - let outputLocation = try selectOutputLocation(buildType: buildType) - let config = BuildConfig(projectName: executableName, projectPath: projectFolder.path, buildType: buildType, extraBuildArgs: [], skipClean: !clean, testCommand: nil) - let builder = ProjectBuilder(shell: shell, config: config) - let binaryOutput = try builder.build() - - let finalPaths = try copyUtility.copyBinaryToLocation(binaryOutput: binaryOutput, outputLocation: outputLocation, executableName: executableName) - - displayBuildResult(finalPaths, openInFinder: openInFinder) - } -} - - -// MARK: - Private Methods -private extension BuildExecutionManager { - func getProjectFolder(at path: String?) throws -> any Directory { - if let path { - return try fileSystem.directory(at: path) - } - - return fileSystem.currentDirectory - } - - func displayBuildResult(_ binaryOutput: BinaryOutput, openInFinder: Bool) { - switch binaryOutput { - case .single(let path): - print("New binary was built at \(path)") - if openInFinder { - try? shell.runAndPrint(bash: "open -R \(path)") - } - case .multiple(let binaries): - print("Universal binary built:") - for (arch, path) in binaries { - print(" \(arch.name): \(path)") - } - if openInFinder, let firstPath = binaries.values.first { - try? shell.runAndPrint(bash: "open -R \(firstPath)") - } - } - } - - func getExecutableName(for folder: any Directory) throws -> String { - let names = try ExecutableNameResolver.getExecutableNames(from: folder) - - guard names.count > 1 else { - return names.first! - } - - do { - return try picker.requiredSingleSelection("Which executable would you like to build?", items: names) - } catch { - throw BuildExecutionError.failedToSelectExecutable(reason: error.localizedDescription) - } - } - - func selectOutputLocation(buildType: BuildType) throws -> BuildOutputLocation { - let options: [BuildOutputLocation] = [ - .currentDirectory(buildType), - .desktop, - .custom("") - ] - - let selection = try picker.requiredSingleSelection("Where would you like to place the built binary?", items: options) - - if case .custom = selection { - return try handleCustomLocationInput() - } - - return selection - } - - func handleCustomLocationInput() throws -> BuildOutputLocation { - let parentPath = try picker.getRequiredInput(prompt: "Enter the path to the parent directory where you want to place the binary:") - - guard let parentFolder = try? fileSystem.directory(at: parentPath) else { - throw BuildExecutionError.invalidCustomPath(path: parentPath) - } - - let confirmed = picker.getPermission(prompt: "The binary will be placed at: \(parentFolder.path). Continue?") - guard confirmed else { - throw BuildExecutionError.buildCancelledByUser - } - - return .custom(parentFolder.path) - } -} diff --git a/Tests/NnexKitTests/BuildManagerTests.swift b/Tests/NnexKitTests/BuildManagerTests.swift new file mode 100644 index 0000000..0fbbe6d --- /dev/null +++ b/Tests/NnexKitTests/BuildManagerTests.swift @@ -0,0 +1,101 @@ +// BuildManagerTests.swift +// NnexKitTests +// +// Created by Nikolai Nobadi on 3/31/25. +// + +import Testing +import NnShellTesting +import NnexSharedTestHelpers +@testable import NnexKit + +struct BuildManagerTests { + private let projectName = "TestExecutable" + private let projectPath = "/path/to/project" +} + + +// MARK: - Tests +extension BuildManagerTests { + @Test("Returns original result when output is current directory") + func returnsCurrentDirectoryResult() throws { + let (sut, shell) = makeSUT(results: ["", ""]) + let config = makeConfig(buildType: .arm64, skipClean: true) + let result = try sut.buildExecutable(config: config, outputLocation: .currentDirectory(.arm64)) + + switch result.binaryOutput { + case .single(let path): + #expect(path.contains(projectPath)) + #expect(path.contains(projectName)) + #expect(path.contains("arm64-apple-macosx")) + default: + Issue.record("Unexpected binary output") + } + + #expect(!shell.executedCommand(containing: "cp")) + } + + @Test("Copies single binary to desktop") + func copiesSingleBinaryToDesktop() throws { + let desktop = MockDirectory(path: "/Users/Home/Desktop") + let (sut, shell) = makeSUT(results: ["", "", ""], desktop: desktop) + let config = makeConfig(buildType: .arm64, skipClean: true) + let result = try sut.buildExecutable(config: config, outputLocation: .desktop) + + switch result.binaryOutput { + case .single(let path): + #expect(path == "\(desktop.path)/\(projectName)") + default: + Issue.record("Unexpected binary output") + } + + #expect(shell.executedCommand(containing: "cp")) + #expect(shell.executedCommand(containing: desktop.path)) + } + + @Test("Copies universal binaries to custom location with arch suffixes") + func copiesUniversalBinaryToCustomLocation() throws { + let (sut, shell) = makeSUT(results: ["", "", "", "", "", ""]) + let destination = "/custom/output" + let config = makeConfig(buildType: .universal, skipClean: true) + let result = try sut.buildExecutable(config: config, outputLocation: .custom(destination)) + + switch result.binaryOutput { + case .multiple(let binaries): + let armPath = binaries[.arm] + let intelPath = binaries[.intel] + + #expect(armPath == "\(destination)/\(projectName)-arm64") + #expect(intelPath == "\(destination)/\(projectName)-x86_64") + default: + Issue.record("Unexpected binary output") + } + + #expect(shell.executedCommand(containing: "cp")) + #expect(shell.executedCommand(containing: "\(projectName)-arm64")) + #expect(shell.executedCommand(containing: "\(projectName)-x86_64")) + } +} + + +// MARK: - Helpers +private extension BuildManagerTests { + func makeSUT(results: [String] = [], desktop: MockDirectory? = nil) -> (sut: BuildManager, shell: MockShell) { + let shell = MockShell(results: results) + let fileSystem = MockFileSystem(desktop: desktop) + let sut = BuildManager(shell: shell, fileSystem: fileSystem) + + return (sut, shell) + } + + func makeConfig(buildType: BuildType, skipClean: Bool) -> BuildConfig { + .init( + projectName: projectName, + projectPath: projectPath, + buildType: buildType, + extraBuildArgs: [], + skipClean: skipClean, + testCommand: nil + ) + } +} diff --git a/Tests/NnexKitTests/ProjectBuilderTests.swift b/Tests/NnexKitTests/ProjectBuilderTests.swift index 343ca50..a47c3af 100644 --- a/Tests/NnexKitTests/ProjectBuilderTests.swift +++ b/Tests/NnexKitTests/ProjectBuilderTests.swift @@ -16,6 +16,7 @@ struct ProjectBuilderTests { private let customTestCommand = "swift test --filter SomeTests" } + // MARK: - Success Tests extension ProjectBuilderTests { @Test("Successfully builds a universal binary") @@ -28,8 +29,9 @@ extension ProjectBuilderTests { let sut = makeSUT(runResults: shellResults).sut let result = try sut.discardableBuild() + let output = result.binaryOutput - switch result { + switch output { case .single: Issue.record("Expected .multiple BinaryOutput but found .single") case .multiple(let dict): @@ -44,8 +46,9 @@ extension ProjectBuilderTests { func buildSingleBinary(buildType: BuildType) throws { let sut = makeSUT(buildType: buildType, runResults: ["", ""]).sut let result = try sut.discardableBuild() + let output = result.binaryOutput - switch result { + switch output { case .single(let path): #expect(path.contains(projectPath)) #expect(path.contains("\(buildType.rawValue)-apple-macosx")) @@ -60,11 +63,12 @@ extension ProjectBuilderTests { // Need results for: clean, build arm64, build x86_64, test let (sut, shell) = makeSUT(runResults: ["", "", "", ""]) let result = try sut.discardableBuild() + let output = result.binaryOutput let expectedCommandPart = extraArgs.joined(separator: " ") #expect(shell.executedCommands.contains { $0.contains(expectedCommandPart) }) - switch result { + switch output { case .single: Issue.record("Expected .multiple BinaryOutput but found .single") case .multiple(let dict): @@ -216,37 +220,6 @@ extension ProjectBuilderTests { try sut.discardableBuild() } } - -// @Test("Throws TestFailureError when tests fail") -// func testFailureThrowsTestFailureError() throws { -// // Create a custom mock shell that succeeds for build commands but throws ShellError for test commands -// let shell = TestFailureMockShell() -// -// let config = BuildConfig( -// projectName: projectName, -// projectPath: projectPath, -// buildType: .arm64, -// extraBuildArgs: [], -// skipClean: false, -// testCommand: .defaultCommand -// ) -// -// let sut = ProjectBuilder(shell: shell, config: config) -// -// var caughtError: TestFailureError? -// do { -// try sut.discardableBuild() -// } catch let error as TestFailureError { -// caughtError = error -// } catch { -// Issue.record("Expected TestFailureError but got \(type(of: error)): \(error)") -// } -// -// #expect(caughtError != nil, "Should have caught a TestFailureError") -// #expect(caughtError?.command.contains("swift test") == true, "Error should contain the test command") -// #expect(caughtError?.output.contains("Test failed") == true, "Error should contain test output") -// #expect(caughtError?.errorDescription?.contains("Tests failed when running") == true, "Error should have descriptive message") -// } } @@ -273,7 +246,7 @@ private extension ProjectBuilderTests { // MARK: - Extension Helpers public extension ProjectBuilder { @discardableResult - func discardableBuild() throws -> BinaryOutput { + func discardableBuild() throws -> BuildResult { return try build() } } diff --git a/Tests/nnexTests/IntegrationTests/BuildTests/BuildExecutableTests.swift b/Tests/nnexTests/IntegrationTests/BuildTests/BuildExecutableTests.swift index 2748f42..92455f8 100644 --- a/Tests/nnexTests/IntegrationTests/BuildTests/BuildExecutableTests.swift +++ b/Tests/nnexTests/IntegrationTests/BuildTests/BuildExecutableTests.swift @@ -32,7 +32,6 @@ final class BuildTests { extension BuildTests { @Test("Builds project and outputs binary path") func successfulBuildOutputsPath() throws { - // Universal build results: clean, build arm64, build x86_64, shasum arm, shasum intel let armSha256 = "arm123def456" let intelSha256 = "intel123def456" let shell = MockShell(results: ["", "", "", "\(armSha256) /path/to/binary", "\(intelSha256) /path/to/binary"]) @@ -42,12 +41,11 @@ extension BuildTests { let output = try runCommand(factory) - #expect(output.contains("Universal binary built:")) + #expect(output.contains("builds")) } @Test("Opens binary in Finder when openInFinder flag is set") func openBinaryInFinder() throws { - // Universal build results: clean, build arm64, build x86_64, shasum arm, shasum intel let armSha256 = "arm123def456" let intelSha256 = "intel123def456" let shell = MockShell(results: ["", "", "", "\(armSha256) /path/to/binary", "\(intelSha256) /path/to/binary"]) @@ -57,7 +55,7 @@ extension BuildTests { _ = try runCommand(factory, openInFinder: true) - #expect(shell.executedCommands.contains { $0.contains("open -R") }) + #expect(shell.executedCommand(containing: "open -R")) } @Test("Fails when Package.swift is missing") @@ -72,7 +70,6 @@ extension BuildTests { @Test("Clean flag defaults to true and sets skipClean to false") func cleanFlagDefaultsToTrue() throws { - // Universal build results: clean, build arm64, build x86_64, shasum arm, shasum intel let armSha256 = "arm123def456" let intelSha256 = "intel123def456" let shell = MockShell(results: ["", "", "", "\(armSha256) /path/to/binary", "\(intelSha256) /path/to/binary"]) @@ -82,13 +79,12 @@ extension BuildTests { let output = try runCommand(factory) - // Verify build was called (clean flag default is true, so skipClean should be false) - #expect(output.contains("Universal binary built:")) + #expect(shell.executedCommand(containing: "clean")) + #expect(output.contains("builds")) } @Test("No-clean flag sets skipClean to true") func noCleanFlagSetsSkipCleanToTrue() throws { - // No-clean build results: build arm64, build x86_64, shasum arm, shasum intel (no clean command) let armSha256 = "arm123def456" let intelSha256 = "intel123def456" let shell = MockShell(results: ["", "", "\(armSha256) /path/to/binary", "\(intelSha256) /path/to/binary"]) @@ -98,7 +94,8 @@ extension BuildTests { let output = try runCommand(factory, clean: false) - #expect(output.contains("Universal binary built:")) + #expect(!shell.executedCommand(containing: "clean")) + #expect(output.contains("builds")) } @Test("Builds to current directory when selected") @@ -113,14 +110,12 @@ extension BuildTests { let output = try runCommand(factory) - #expect(output.contains("Universal binary built:")) - // Should not contain any cp commands since it stays in current location - #expect(!shell.executedCommands.contains { $0.contains("cp") }) + #expect(output.contains("builds")) + #expect(!shell.executedCommand(containing: "cp")) } @Test("Builds to desktop when selected") func buildsToDesktopWhenSelected() throws { - // Universal build results: clean, build arm64, build x86_64, shasum arm, shasum intel let armSha256 = "arm123def456" let intelSha256 = "intel123def456" let shell = MockShell(results: ["", "", "", "\(armSha256) /path/to/binary", "\(intelSha256) /path/to/binary"]) @@ -130,38 +125,38 @@ extension BuildTests { let output = try runCommand(factory) - #expect(output.contains("Universal binary built:")) - // Should contain cp command to copy to desktop - #expect(shell.executedCommands.contains { $0.contains("cp") && $0.contains("Desktop") }) + #expect(output.contains("builds")) + #expect(shell.executedCommand(containing: "cp")) + #expect(shell.executedCommand(containing: "Desktop")) } @Test("Prompts for custom location and confirms path") func promptsForCustomLocationAndConfirmsPath() throws { - // Universal build results: clean, build arm64, build x86_64, shasum arm, shasum intel let armSha256 = "arm123def456" let intelSha256 = "intel123def456" let customPath = "/tmp" + let customDirectory = MockDirectory(path: customPath) + let shell = MockShell(results: ["", "", "", "\(armSha256) /path/to/binary", "\(intelSha256) /path/to/binary"]) let factory = MockContextFactory( runResults: ["", "", "", "\(armSha256) /path/to/binary", "\(intelSha256) /path/to/binary"], selectedItemIndices: [2], // Select custom (index 2) inputResponses: [customPath], permissionResponses: [true], // Confirm the path - shell: MockShell(results: ["", "", "", "\(armSha256) /path/to/binary", "\(intelSha256) /path/to/binary"]) + shell: shell, + browsedDirectory: customDirectory ) try createPackageManifest(name: executableName) let output = try runCommand(factory) - #expect(output.contains("Universal binary built:")) - // Should contain cp command to copy to custom location - let shell = factory.makeShell() as! MockShell - #expect(shell.executedCommands.contains { $0.contains("cp") && $0.contains(customPath) }) + #expect(output.contains("builds")) + #expect(shell.executedCommand(containing: "cp")) + #expect(shell.executedCommand(containing: customPath)) } @Test("Handles custom location input cancellation gracefully") func handlesCustomLocationInputCancellationGracefully() throws { - // Universal build results: clean, build arm64, build x86_64, shasum arm, shasum intel let armSha256 = "arm123def456" let intelSha256 = "intel123def456" let customPath = "/tmp" @@ -183,7 +178,6 @@ extension BuildTests { @Test("Copies binary to selected output location") func copiesBinaryToSelectedOutputLocation() throws { - // Universal build results: clean, build arm64, build x86_64, shasum arm, shasum intel let armSha256 = "arm123def456" let intelSha256 = "intel123def456" let shell = MockShell(results: ["", "", "", "\(armSha256) /path/to/binary", "\(intelSha256) /path/to/binary"]) @@ -199,7 +193,6 @@ extension BuildTests { @Test("Shows final binary location in output message") func showsFinalBinaryLocationInOutputMessage() throws { - // Universal build results: clean, build arm64, build x86_64, shasum arm, shasum intel let armSha256 = "arm123def456" let intelSha256 = "intel123def456" let shell = MockShell(results: ["", "", "", "\(armSha256) /path/to/binary", "\(intelSha256) /path/to/binary"]) @@ -209,7 +202,7 @@ extension BuildTests { let output = try runCommand(factory) - #expect(output.contains("Universal binary built:")) + #expect(output.contains("builds")) #expect(output.contains("arm64")) // Should show architecture info #expect(output.contains("x86_64")) // Should show architecture info } @@ -226,6 +219,7 @@ private extension BuildTests { if !clean { args.append("--no-clean") } + return try Nnex.testRun(contextFactory: factory, args: args) } diff --git a/Tests/nnexTests/IntegrationTests/BuildTests/BuildExecutionManagerTests.swift b/Tests/nnexTests/IntegrationTests/BuildTests/BuildExecutionManagerTests.swift deleted file mode 100644 index d37550f..0000000 --- a/Tests/nnexTests/IntegrationTests/BuildTests/BuildExecutionManagerTests.swift +++ /dev/null @@ -1,240 +0,0 @@ -// -// BuildExecutionManagerTests.swift -// nnex -// -// Created by Nikolai Nobadi on 8/26/25. -// - -import NnexKit -import Testing -import Foundation -import NnShellTesting -import SwiftPickerTesting -import NnexSharedTestHelpers -@testable import nnex -@preconcurrency import Files - -final class BuildExecutionManagerTests { - private let projectFolder: Folder - private let projectName = "testProject-buildManager" - private let executableName = "testExecutable" - - init() throws { - let tempFolder = Folder.temporary - self.projectFolder = try tempFolder.createSubfolder(named: "\(projectName)-\(UUID().uuidString)") - } - - deinit { - deleteFolderContents(projectFolder) - try? projectFolder.delete() - } -} - - -// MARK: - Tests -extension BuildExecutionManagerTests { - @Test("Successfully executes build with single executable") - func successfullyExecutesBuildWithSingleExecutable() throws { - try createPackageSwift(executableName: executableName) - - let (sut, shell) = makeSUT(selectedItemIndices: [0]) - - try sut.executeBuild(projectPath: projectFolder.path, buildType: .universal, clean: true, openInFinder: false) - - // Verify shell was called for build operations - #expect(shell.executedCommands.count >= 3) // At least clean, build arm64, build x86_64 - } - - @Test("Successfully executes build with multiple executables requiring selection") - func successfullyExecutesBuildWithMultipleExecutables() throws { - try createPackageSwiftWithMultipleExecutables() - - let sut = makeSUT(selectedItemIndices: [0, 0]).sut - let path = projectFolder.path - - #expect(throws: Never.self) { - try sut.executeBuild(projectPath: path, buildType: .universal, clean: true, openInFinder: false) - } - - // Build should complete successfully with multiple executables - } - - @Test("Executes build and opens in Finder when flag is set") - func executesBuildAndOpensInFinderWhenFlagSet() throws { - try createPackageSwift(executableName: executableName) - - let (sut, shell) = makeSUT(selectedItemIndices: [0]) - - try sut.executeBuild(projectPath: projectFolder.path, buildType: .universal, clean: true, openInFinder: true) - - // Verify Finder open command was executed - #expect(shell.executedCommands.contains { $0.contains("open -R") }) - } - - @Test("Executes build with custom output location") - func executesBuildWithCustomOutputLocation() throws { - try createPackageSwift(executableName: executableName) - - let (sut, shell) = makeSUT( - selectedItemIndices: [2], // Select custom location - inputResponses: ["/tmp"], // Custom path - permissionResponses: [true] // Confirm path - ) - - try sut.executeBuild(projectPath: projectFolder.path, buildType: .universal, clean: true, openInFinder: false) - - // Verify copy command was executed - #expect(shell.executedCommands.contains { $0.contains("cp") && $0.contains("/tmp") }) - } - - @Test("Uses default build type when none provided") - func usesDefaultBuildTypeWhenNoneProvided() throws { - try createPackageSwift(executableName: executableName) - - let (sut, shell) = makeSUT(selectedItemIndices: [0]) - - try sut.executeBuild(projectPath: projectFolder.path, buildType: .universal, clean: true, openInFinder: false) - - // Should complete without error using default build type - #expect(shell.executedCommands.count >= 3) - } - - @Test("Skips clean when clean flag is false") - func skipsCleanWhenCleanFlagFalse() throws { - try createPackageSwift(executableName: executableName) - - let (sut, shell) = makeSUT(selectedItemIndices: [0]) - - try sut.executeBuild(projectPath: projectFolder.path, buildType: .universal, clean: false, openInFinder: false) - - // Should have one less command (no clean) - #expect(shell.executedCommands.count >= 2) - } -} - - -// MARK: - Error Tests -extension BuildExecutionManagerTests { - // TODO: - need to enable MockSwiftPicker errors - @Test("Throws error when picker fails to select executable", .disabled()) - func throwsErrorWhenPickerFailsToSelectExecutable() throws { - try createPackageSwiftWithMultipleExecutables() - - let sut = makeSUT(throwPickerError: true).sut - let path = projectFolder.path - - #expect(throws: BuildExecutionError.failedToSelectExecutable(reason: "MockPicker error")) { - try sut.executeBuild(projectPath: path, buildType: .universal, clean: true, openInFinder: false) - } - } - - @Test("Throws error when custom path is invalid") - func throwsErrorWhenCustomPathIsInvalid() throws { - try createPackageSwift(executableName: executableName) - - let (sut, _) = makeSUT( - selectedItemIndices: [2], // Select custom location - inputResponses: ["/nonexistent/path"] // Invalid path - ) - let path = projectFolder.path - - #expect(throws: BuildExecutionError.invalidCustomPath(path: "/nonexistent/path")) { - try sut.executeBuild(projectPath: path, buildType: .universal, clean: true, openInFinder: false) - } - } - - @Test("Throws error when user cancels custom path confirmation") - func throwsErrorWhenUserCancelsCustomPathConfirmation() throws { - try createPackageSwift(executableName: executableName) - - let (sut, _) = makeSUT( - selectedItemIndices: [2], // Select custom location - inputResponses: ["/tmp"], // Valid path - permissionResponses: [false] // Cancel confirmation - ) - let path = projectFolder.path - - #expect(throws: BuildExecutionError.buildCancelledByUser) { - try sut.executeBuild(projectPath: path, buildType: .universal, clean: true, openInFinder: false) - } - } - - @Test("Propagates ExecutableNameResolver errors") - func propagatesExecutableNameResolverErrors() throws { - let sut = makeSUT().sut - let path = projectFolder.path - - #expect(throws: ExecutableNameResolverError.missingPackageSwift(path: path)) { - try sut.executeBuild(projectPath: path, buildType: .universal, clean: true, openInFinder: false) - } - } - - @Test("Propagates build errors from ProjectBuilder") - func propagatesBuildErrorsFromProjectBuilder() throws { - try createPackageSwift(executableName: executableName) - - let sut = makeSUT(selectedItemIndices: [0], throwShellError: true).sut - let path = projectFolder.path - - #expect(throws: (any Error).self) { - try sut.executeBuild(projectPath: path, buildType: .universal, clean: true, openInFinder: false) - } - } -} - - -// MARK: - Private Methods -private extension BuildExecutionManagerTests { - func makeSUT(selectedItemIndices: [Int] = [], inputResponses: [String] = [], permissionResponses: [Bool] = [], throwShellError: Bool = false, throwPickerError: Bool = false) -> (sut: BuildExecutionManager, shell: MockShell) { - let shell = MockShell(shouldThrowErrorOnFinal: throwShellError) - let picker = MockSwiftPicker( - inputResult: .init(type: .ordered(inputResponses)), - permissionResult: .init(type: .ordered(permissionResponses)), - selectionResult: .init(singleType: .ordered(selectedItemIndices.map({ .index($0) }))) - ) - let fileSystem = DefaultFileSystem() - let sut = BuildExecutionManager(shell: shell, picker: picker, fileSystem: fileSystem) - - return (sut, shell) - } - - func createPackageSwift(executableName: String) throws { - let packageContent = """ - // swift-tools-version: 6.0 - import PackageDescription - - let package = Package( - name: "\(projectName)", - platforms: [.macOS(.v14)], - products: [ - .executable(name: "\(executableName)", targets: ["\(executableName)"]) - ], - targets: [ - .executableTarget(name: "\(executableName)") - ] - ) - """ - try projectFolder.createFile(named: "Package.swift", contents: packageContent.data(using: .utf8)!) - } - - func createPackageSwiftWithMultipleExecutables() throws { - let packageContent = """ - // swift-tools-version: 6.0 - import PackageDescription - - let package = Package( - name: "\(projectName)", - platforms: [.macOS(.v14)], - products: [ - .executable(name: "FirstExecutable", targets: ["FirstExecutable"]), - .executable(name: "SecondExecutable", targets: ["SecondExecutable"]) - ], - targets: [ - .executableTarget(name: "FirstExecutable"), - .executableTarget(name: "SecondExecutable") - ] - ) - """ - try projectFolder.createFile(named: "Package.swift", contents: packageContent.data(using: .utf8)!) - } -} diff --git a/Tests/nnexTests/UnitTests/BuildControllerTests.swift b/Tests/nnexTests/UnitTests/BuildControllerTests.swift new file mode 100644 index 0000000..fb203a2 --- /dev/null +++ b/Tests/nnexTests/UnitTests/BuildControllerTests.swift @@ -0,0 +1,212 @@ +// +// BuildControllerTests.swift +// nnex +// +// Created by Nikolai Nobadi on 3/31/25. +// + +import NnexKit +import Testing +import Foundation +import NnShellTesting +import SwiftPickerTesting +import NnexSharedTestHelpers +@testable import nnex + +final class BuildControllerTests { + @Test("Starting values empty") + func startingValuesEmpty() { + let (_, service, _) = makeSUT() + + #expect(service.capturedConfig == nil) + #expect(service.capturedOutputLocation == nil) + } + + @Test("Builds executable with provided path and defaults") + func buildExecutableWithProvidedPath() throws { + let project = try makeProjectDirectory(path: "/project/", executableNames: ["App"]) + let (sut, service, _) = makeSUT(projectDirectory: project, selectedIndex: 0) + + try sut.buildExecutable(path: project.path, buildType: .universal, clean: true, openInFinder: false) + + let config = try #require(service.capturedConfig) + let outputLocation = try #require(service.capturedOutputLocation) + + #expect(config.projectName == "App") + #expect(config.projectPath == project.path) + #expect(config.buildType == .universal) + #expect(config.skipClean == false) // clean flag true => skipClean false + #expect(config.extraBuildArgs.isEmpty) + #expect(config.testCommand == nil) + + switch outputLocation { + case .currentDirectory: + break + default: + Issue.record("Unexpected output location") + } + } + + @Test("Prompts when multiple executables and uses selected name") + func buildExecutableWithMultipleNames() throws { + let project = try makeProjectDirectory(path: "/project", executableNames: ["App", "Helper"]) + // Single selection index will be used for both executable and output location prompts. + let (sut, service, _) = makeSUT(projectDirectory: project, selectedIndex: 1) + + try sut.buildExecutable(path: project.path, buildType: .arm64, clean: false, openInFinder: false) + + let config = try #require(service.capturedConfig) + let outputLocation = try #require(service.capturedOutputLocation) + + #expect(config.projectName == "Helper") + #expect(config.buildType == .arm64) + #expect(config.skipClean == true) // clean flag false => skipClean true + + switch outputLocation { + case .desktop: + break + default: + Issue.record("Unexpected output location") + } + } + + @Test("Uses custom output location after confirmation") + func buildExecutableWithCustomOutputLocation() throws { + let project = try makeProjectDirectory(path: "/project", executableNames: ["App"]) + let customDir = MockDirectory(path: "/custom/output") + let (sut, service, _) = makeSUT( + projectDirectory: project, + selectedIndex: 2, // choose custom output + permissionResponses: [true], + browsedDirectory: customDir + ) + + try sut.buildExecutable(path: project.path, buildType: .x86_64, clean: true, openInFinder: false) + + let outputLocation = try #require(service.capturedOutputLocation) + + switch outputLocation { + case .custom(let path): + #expect(path == customDir.path) + default: + Issue.record("Unexpected output location") + } + } + + @Test("Opens Finder when requested for single build") + func buildExecutableOpensFinderOnSuccess() throws { + let project = try makeProjectDirectory(path: "/project", executableNames: ["App"]) + let binaryPath = "/project/.build/arm64-apple-macosx/release/App" + let shell = MockShell() + let result = BuildResult(executableName: "App", binaryOutput: .single(binaryPath)) + let (sut, service, _) = makeSUT( + projectDirectory: project, + selectedIndex: 0, + shell: shell, + resultToReturn: result + ) + + try sut.buildExecutable(path: project.path, buildType: .arm64, clean: true, openInFinder: true) + + #expect(shell.executedCommands.contains { $0.contains("open -R \(binaryPath)") }) + #expect(service.capturedConfig?.projectName == "App") + } + + @Test("Propagates build service errors") + func buildExecutablePropagatesErrors() { + let project = try! makeProjectDirectory(path: "/project", executableNames: ["App"]) + let (sut, service, _) = makeSUT(projectDirectory: project, selectedIndex: 0, throwServiceError: true) + + #expect(throws: (any Error).self) { + try sut.buildExecutable(path: project.path, buildType: .universal, clean: true, openInFinder: false) + } + + #expect(service.capturedConfig == nil) + } +} + + +// MARK: - SUT +private extension BuildControllerTests { + func makeSUT( + projectDirectory: MockDirectory? = nil, + selectedIndex: Int = 0, + permissionResponses: [Bool] = [], + browsedDirectory: MockDirectory? = nil, + shell: MockShell = MockShell(), + resultToReturn: BuildResult = .init(executableName: "App", binaryOutput: .single("/tmp/App")), + throwServiceError: Bool = false + ) -> (sut: BuildController, service: MockBuildService, projectDirectory: MockDirectory) { + let projectDirectory = projectDirectory ?? MockDirectory(path: "/project") + let picker = MockSwiftPicker( + inputResult: .init(type: .ordered([])), + permissionResult: .init(type: .ordered(permissionResponses)), + selectionResult: .init(defaultSingle: .index(selectedIndex)) + ) + let fileSystem = MockFileSystem(currentDirectory: projectDirectory, directoryMap: [projectDirectory.path: projectDirectory]) + let folderBrowser = MockDirectoryBrowser(filePathToReturn: nil, directoryToReturn: browsedDirectory) + let service = MockBuildService(resultToReturn: resultToReturn, throwError: throwServiceError) + let sut = BuildController(shell: shell, picker: picker, fileSystem: fileSystem, buildService: service, folderBrowser: folderBrowser) + + return (sut, service, projectDirectory) + } + + func makeProjectDirectory(path: String, executableNames: [String]) throws -> MockDirectory { + let directory = MockDirectory(path: path) + let products = executableNames + .map { """ + .executable(name: "\($0)", targets: ["\($0)"]) + """ } + .joined(separator: ",") + + let targets = executableNames + .map { """ + .executableTarget(name: "\($0)") + """ } + .joined(separator: ",") + + let packageContent = """ + // swift-tools-version: 6.0 + import PackageDescription + + let package = Package( + name: "TestPackage", + platforms: [.macOS(.v14)], + products: [ + \(products) + ], + targets: [ + \(targets) + ] + ) + """ + + try directory.createFile(named: "Package.swift", contents: packageContent) + + return directory + } +} + + +// MARK: - Mocks +private extension BuildControllerTests { + final class MockBuildService: BuildService { + private let resultToReturn: BuildResult + private let throwError: Bool + + private(set) var capturedConfig: BuildConfig? + private(set) var capturedOutputLocation: BuildOutputLocation? + + init(resultToReturn: BuildResult, throwError: Bool) { + self.resultToReturn = resultToReturn + self.throwError = throwError + } + + func buildExecutable(config: BuildConfig, outputLocation: BuildOutputLocation) throws -> BuildResult { + if throwError { throw NSError(domain: "Test", code: 0) } + capturedConfig = config + capturedOutputLocation = outputLocation + return resultToReturn + } + } +}