From 6d0108da0c0258f75ed433f682ab148988a4288f Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Sun, 15 Dec 2024 11:59:50 -0500 Subject: [PATCH] feat: Adds createProject and createJson tests for playbook-client. --- Package.swift | 1 + .../PlaybookClient+RunPlaybook.swift | 30 +- Sources/PlaybookClient/PlaybookClient.swift | 60 ++-- .../PlaybookClient/PlaybookClientError.swift | 1 + .../PlaybookClientTests.swift | 269 +++++++++++++++++- 5 files changed, 326 insertions(+), 35 deletions(-) diff --git a/Package.swift b/Package.swift index f5ea50a..0151873 100644 --- a/Package.swift +++ b/Package.swift @@ -109,6 +109,7 @@ let package = Package( .target( name: "PlaybookClient", dependencies: [ + "CodersClient", "CommandClient", "ConfigurationClient", "FileClient", diff --git a/Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift b/Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift index 6d8b1d3..6fadea9 100644 --- a/Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift +++ b/Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift @@ -28,9 +28,16 @@ extension PlaybookClient.RunPlaybook.BuildOptions { extension PlaybookClient.RunPlaybook.CreateOptions { - func run() async throws { + func run(encoder jsonEncoder: JSONEncoder?) async throws { try await shared.run { arguments, configuration in - let json = try createJSONData(configuration: configuration) + let jsonData = try createJSONData( + configuration: configuration, + encoder: jsonEncoder + ) + + guard let json = String(data: jsonData, encoding: .utf8) else { + throw PlaybookClientError.encodingError + } arguments.append(contentsOf: [ "--tags", "setup-project", @@ -130,17 +137,20 @@ public extension PlaybookClient.RunPlaybook { // want the output to be `prettyPrinted` or anything, unless we're running // tests, so we use a supplied json encoder. // -extension PlaybookClient.RunPlaybook.CreateOptions { +@_spi(Internal) +public extension PlaybookClient.RunPlaybook.CreateOptions { func createJSONData( configuration: Configuration, - encoder: JSONEncoder = .init() + encoder: JSONEncoder? ) throws -> Data { @Dependency(\.logger) var logger - let templateDir = template.directory ?? configuration.template.directory - let templateRepo = template.url ?? configuration.template.url - let version = template.version ?? configuration.template.version + let encoder = encoder ?? jsonEncoder + + let templateDir = template?.directory ?? configuration.template.directory + let templateRepo = template?.url ?? configuration.template.url + let version = template?.version ?? configuration.template.version logger.debug(""" (\(useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo))) @@ -203,3 +213,9 @@ private struct TemplateRepo: Encodable { let version: String } } + +private let jsonEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] + return encoder +}() diff --git a/Sources/PlaybookClient/PlaybookClient.swift b/Sources/PlaybookClient/PlaybookClient.swift index 763596e..f1b28e0 100644 --- a/Sources/PlaybookClient/PlaybookClient.swift +++ b/Sources/PlaybookClient/PlaybookClient.swift @@ -43,7 +43,11 @@ public extension PlaybookClient { @DependencyClient struct RunPlaybook: Sendable { public var buildProject: @Sendable (BuildOptions) async throws -> Void - public var createProject: @Sendable (CreateOptions) async throws -> Void + public var createProject: @Sendable (CreateOptions, JSONEncoder?) async throws -> Void + + public func createProject(_ options: CreateOptions) async throws { + try await createProject(options, nil) + } public struct SharedRunOptions: Equatable, Sendable { public let extraOptions: [String]? @@ -53,11 +57,11 @@ public extension PlaybookClient { public let shell: String? public init( - extraOptions: [String]?, - inventoryFilePath: String?, + extraOptions: [String]? = nil, + inventoryFilePath: String? = nil, loggingOptions: LoggingOptions, - quiet: Bool, - shell: String? + quiet: Bool = false, + shell: String? = nil ) { self.extraOptions = extraOptions self.inventoryFilePath = inventoryFilePath @@ -73,12 +77,20 @@ public extension PlaybookClient { public let shared: SharedRunOptions public init( - extraOptions: [String]?, - inventoryFilePath: String?, + projectDirectory: String? = nil, + shared: SharedRunOptions + ) { + self.projectDirectory = projectDirectory + self.shared = shared + } + + public init( + extraOptions: [String]? = nil, + inventoryFilePath: String? = nil, loggingOptions: LoggingOptions, - quiet: Bool, - shell: String?, - projectDirectory: String + quiet: Bool = false, + shell: String? = nil, + projectDirectory: String? = nil ) { self.projectDirectory = projectDirectory self.shared = .init( @@ -99,18 +111,30 @@ public extension PlaybookClient { public struct CreateOptions: Equatable, Sendable { public let projectDirectory: String public let shared: SharedRunOptions - public let template: Configuration.Template + public let template: Configuration.Template? public let useLocalTemplateDirectory: Bool public init( - extraOptions: [String]?, - inventoryFilePath: String?, + projectDirectory: String, + shared: SharedRunOptions, + template: Configuration.Template? = nil, + useLocalTemplateDirectory: Bool + ) { + self.projectDirectory = projectDirectory + self.shared = shared + self.template = template + self.useLocalTemplateDirectory = useLocalTemplateDirectory + } + + public init( + extraOptions: [String]? = nil, + inventoryFilePath: String? = nil, loggingOptions: LoggingOptions, projectDirectory: String, - quiet: Bool, - shell: String?, - template: Configuration.Template, - useLocalTemplateDirectory: Bool + quiet: Bool = false, + shell: String? = nil, + template: Configuration.Template? = nil, + useLocalTemplateDirectory: Bool = false ) { self.projectDirectory = projectDirectory self.template = template @@ -145,7 +169,7 @@ extension PlaybookClient.RunPlaybook: DependencyKey { public static var liveValue: PlaybookClient.RunPlaybook { .init( buildProject: { try await $0.run() }, - createProject: { try await $0.run() } + createProject: { try await $0.run(encoder: $1) } ) } } diff --git a/Sources/PlaybookClient/PlaybookClientError.swift b/Sources/PlaybookClient/PlaybookClientError.swift index c90073c..039696b 100644 --- a/Sources/PlaybookClient/PlaybookClientError.swift +++ b/Sources/PlaybookClient/PlaybookClientError.swift @@ -1,6 +1,7 @@ import Foundation enum PlaybookClientError: Error { + case encodingError case projectDirectoryNotFound case templateDirectoryNotFound case templateDirectoryOrRepoNotSpecified diff --git a/Tests/PlaybookClientTests/PlaybookClientTests.swift b/Tests/PlaybookClientTests/PlaybookClientTests.swift index 5690eeb..9656b0b 100644 --- a/Tests/PlaybookClientTests/PlaybookClientTests.swift +++ b/Tests/PlaybookClientTests/PlaybookClientTests.swift @@ -1,3 +1,5 @@ +import CodersClient +@_spi(Internal) import CommandClient import ConfigurationClient import Dependencies import FileClient @@ -8,13 +10,24 @@ import Testing import TestSupport @Suite("PlaybookClientTests") -struct PlaybookClientTests { +struct PlaybookClientTests: TestCase { - @Test - func installation() async throws { + static let loggingOptions: LoggingOptions = { + let levelString = ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "debug" + let logLevel = Logger.Level(rawValue: levelString) ?? .debug + return .init(commandName: "PlaybookClientTests", logLevel: logLevel) + }() + + static var sharedRunOptions: PlaybookClient.RunPlaybook.SharedRunOptions { + .init(loggingOptions: loggingOptions) + } + + @Test(.tags(.repository)) + func repositoryInstallation() async throws { try await withDependencies { $0.fileClient = .liveValue $0.asyncShellClient = .liveValue + $0.commandClient = .liveValue } operation: { try await withTemporaryDirectory { tempDirectory in let pathUrl = tempDirectory.appending(path: "playbook") @@ -23,20 +36,256 @@ struct PlaybookClientTests { let configuration = Configuration(playbook: .init(directory: pathUrl.cleanFilePath)) try? FileManager.default.removeItem(at: pathUrl) - try await playbookClient.installPlaybook(configuration) + try await playbookClient.repository.install(configuration) let exists = FileManager.default.fileExists(atPath: pathUrl.cleanFilePath) #expect(exists) } } } - @Test(arguments: [ - (Configuration(), PlaybookClient.Constants.defaultInstallationPath), - (Configuration(playbook: .init(directory: "playbook")), "playbook") - ]) - func playbookDirectory(configuration: Configuration, expected: String) async throws { + @Test( + .tags(.repository), + arguments: [ + (Configuration(), PlaybookClient.Constants.defaultInstallationPath), + (Configuration(playbook: .init(directory: "playbook")), "playbook") + ] + ) + func repositoryDirectory(configuration: Configuration, expected: String) async throws { let client = PlaybookClient.liveValue - let result = try await client.playbookDirectory(configuration) + let result = try await client.repository.directory(configuration) #expect(result == expected) } + + @Test(.tags(.run)) + func runBuildProject() async throws { + let captured = CommandClient.CapturingClient() + + try await withMockConfiguration(captured, key: "runBuildProject") { + @Dependency(\.playbookClient) var playbookClient + + let configuration = Configuration.mock + + try await playbookClient.run.buildProject(.init(projectDirectory: "/foo", shared: Self.sharedRunOptions)) + + let arguments = await captured.options!.arguments + print(arguments) + + #expect(arguments == [ + "ansible-playbook", "~/.local/share/hpa/playbook/main.yml", + "--inventory", "~/.local/share/hpa/playbook/inventory.ini", + configuration.vault.args!.first!, + "--tags", "build-project", + "--extra-vars", "project_dir=/foo" + ]) + } + } + + @Test( + .tags(.run), + arguments: [ + (true, "\'{\"template\":{\"path\":\"\(Configuration.mock.template.directory!)\"}}\'"), + (false, "\'{\"template\":{\"repo\":{\"url\":\"\(Configuration.mock.template.url!)\",\"version\":\"\(Configuration.mock.template.version!)\"}}}\'") + ] + ) + func runCreateProject(useLocalTemplateDirectory: Bool, json: String) async throws { + let captured = CommandClient.CapturingClient() + + try await withMockConfiguration(captured, key: "runBuildProject") { + @Dependency(\.logger) var logger + @Dependency(\.playbookClient) var playbookClient + + let configuration = Configuration.mock + + try await playbookClient.run.createProject( + .init( + projectDirectory: "/project", + shared: Self.sharedRunOptions, + useLocalTemplateDirectory: useLocalTemplateDirectory + ) + ) + + let arguments = await captured.options!.arguments + logger.debug("\(arguments)") + + #expect(arguments == [ + "ansible-playbook", "~/.local/share/hpa/playbook/main.yml", + "--inventory", "~/.local/share/hpa/playbook/inventory.ini", + configuration.vault.args!.first!, + "--tags", "setup-project", + "--extra-vars", "project_dir=/project", + "--extra-vars", json + ]) + } + } + + @Test(arguments: CreateJsonTestOption.testCases) + func createJson(input: CreateJsonTestOption) { + withTestLogger(key: "generateJson") { + $0.coders.jsonEncoder = { jsonEncoder } + $0.configurationClient = .mock(input.configuration) + } operation: { + @Dependency(\.coders) var coders + + let jsonData = try? input.options.createJSONData( + configuration: input.configuration, + encoder: coders.jsonEncoder() + ) + + switch input.expectation { + case let .success(expected): + let json = String(data: jsonData!, encoding: .utf8)! + if json != expected { + print("json:", json) + print("expected:", expected) + } + #expect(json == expected) + case .failure: + #expect(jsonData == nil) + } + } + } + + func withMockConfiguration( + _ capturing: CommandClient.CapturingClient, + configuration: Configuration = .mock, + key: String, + logLevel: Logger.Level = .trace, + depednencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in }, + operation: @Sendable @escaping () async throws -> Void + ) async rethrows { + try await withDependencies { + $0.configurationClient = .mock(configuration) + $0.commandClient = .capturing(capturing) + $0.playbookClient = .liveValue + setupDependencies(&$0) + } operation: { + try await operation() + } + } + } + +struct CreateJsonTestOption: Sendable { + let options: PlaybookClient.RunPlaybook.CreateOptions + let configuration: Configuration + let expectation: Result + + static let testCases: [Self] = [ + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: nil, version: nil, directory: nil), + useLocalTemplateDirectory: true + ), + configuration: .init(), + expectation: .failing + ), + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: nil, version: nil, directory: nil), + useLocalTemplateDirectory: false + ), + configuration: .init(), + expectation: .failing + ), + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: nil, version: nil, directory: "/template"), + useLocalTemplateDirectory: true + ), + configuration: .init(template: .init(directory: "/template")), + expectation: .success(""" + { + "template" : { + "path" : "/template" + } + } + """) + ), + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: nil, version: nil, directory: "/template"), + useLocalTemplateDirectory: true + ), + configuration: .init(template: .init(directory: "/template")), + expectation: .success(""" + { + "template" : { + "path" : "/template" + } + } + """) + ), + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: "https://git.example.com/template.git", version: "main", directory: nil), + useLocalTemplateDirectory: false + ), + configuration: .init(), + expectation: .success(""" + { + "template" : { + "repo" : { + "url" : "https://git.example.com/template.git", + "version" : "main" + } + } + } + """) + ), + CreateJsonTestOption( + options: .init( + projectDirectory: "/project", + shared: PlaybookClientTests.sharedRunOptions, + template: .init(url: "https://git.example.com/template.git", version: "v0.1.0", directory: nil), + useLocalTemplateDirectory: false + ), + configuration: .init(template: .init(url: "https://git.example.com/template.git", version: "v0.1.0")), + expectation: .success(""" + { + "template" : { + "repo" : { + "url" : "https://git.example.com/template.git", + "version" : "v0.1.0" + } + } + } + """) + ) + ] +} + +extension Result where Failure == TestError { + static var failing: Self { .failure(TestError()) } +} + +extension ConfigurationClient { + static func mock(_ configuration: Configuration) -> Self { + var mock = Self.testValue + mock.find = { throw TestError() } + mock.load = { _ in configuration } + return mock + } +} + +struct TestError: Error {} + +extension Tag { + @Tag static var repository: Self + @Tag static var run: Self +} + +let jsonEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys] + return encoder +}()