diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift index 081ea75..dac3a2a 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsInstallCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser struct AgentSkillsInstallCommand: ParsableCommand, GlobalOptionsProviding { #if DEBUG - typealias Deps = any AgentSkillInstallerProvider + typealias Deps = any AgentSkillInstallationServiceProvider #else typealias Deps = Dependencies #endif @@ -39,7 +39,7 @@ struct AgentSkillsInstallCommand: ParsableCommand, GlobalOptionsProviding { } func run(deps: Deps) throws { - let output = try deps.agentSkillInstaller().install( + let output = try deps.agentSkillInstallationService().install( selection.selectedSkills(), root: selection.installationRoot(fileManager: deps.agentSkillFileManager()), dryRun: selection.dryRun, force: force ) diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift index 23ab634..d487a64 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser struct AgentSkillsListCommand: ParsableCommand, GlobalOptionsProviding { #if DEBUG - typealias Deps = any TelemetryProvider + typealias Deps = any (TelemetryProvider & CommandOutputWriterProvider) #else typealias Deps = Dependencies #endif @@ -21,7 +21,7 @@ struct AgentSkillsListCommand: ParsableCommand, GlobalOptionsProviding { func run(deps: Deps) { deps.telemetry.startCommand(.agentSkillsList) for skill in BundledAgentSkills.all { - print("\(skill.name)\t\(skill.shortDescription)") + deps.commandOutputWriter.write("\(skill.name)\t\(skill.shortDescription)") } } } diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsSelectionOptions.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsSelectionOptions.swift index fdca608..0e86c33 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsSelectionOptions.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsSelectionOptions.swift @@ -36,7 +36,7 @@ struct AgentSkillsSelectionOptions: ParsableArguments { } } - func installationRoot(fileManager: FileManager) -> String { + func installationRoot(fileManager: AgentSkillFileSystem) -> String { if project { let currentDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath).standardizedFileURL var directory = currentDirectory diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift index 19cf3ff..574d2ad 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsUninstallCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser struct AgentSkillsUninstallCommand: ParsableCommand, GlobalOptionsProviding { #if DEBUG - typealias Deps = any AgentSkillInstallerProvider + typealias Deps = any AgentSkillInstallationServiceProvider #else typealias Deps = Dependencies #endif @@ -42,7 +42,7 @@ struct AgentSkillsUninstallCommand: ParsableCommand, GlobalOptionsProviding { } func run(deps: Deps) throws { - let output = try deps.agentSkillInstaller().uninstall( + let output = try deps.agentSkillInstallationService().uninstall( selection.selectedSkills(), root: selection.installationRoot(fileManager: deps.agentSkillFileManager()), dryRun: selection.dryRun ) diff --git a/Sources/CLI/cmd/cache/CacheCleanCommand.swift b/Sources/CLI/cmd/cache/CacheCleanCommand.swift index cd406cb..92cd2e9 100644 --- a/Sources/CLI/cmd/cache/CacheCleanCommand.swift +++ b/Sources/CLI/cmd/cache/CacheCleanCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser struct CacheCleanCommand: ParsableCommand, GlobalOptionsProviding { #if DEBUG - typealias Deps = any (TelemetryProvider & DocumentationCacheProvider) + typealias Deps = any (TelemetryProvider & DocumentationCacheProvider & CommandOutputWriterProvider) #else typealias Deps = Dependencies #endif @@ -24,6 +24,6 @@ struct CacheCleanCommand: ParsableCommand, GlobalOptionsProviding { cache: deps.documentationCache ) let result = runner.run() - print(result.output) + deps.commandOutputWriter.write(result.output) } } diff --git a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift index 66014e7..744bb71 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift @@ -4,7 +4,7 @@ struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding { #if DEBUG typealias Deps = any ( TelemetryProvider & TerminalCapabilitiesProvider & TechnologyCatalogClientProvider - & TechnologyListRendererProvider + & TechnologyListRendererProvider & CommandOutputWriterProvider ) #else typealias Deps = Dependencies @@ -31,6 +31,6 @@ struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding { ) let result = try await runner.run(mode: deps.terminalCapabilities.mode(for: output)) deps.telemetry.record(.technologyCatalog(count: result.technologyCount), context: context) - print(result.output) + deps.commandOutputWriter.write(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesListCommand.swift b/Sources/CLI/cmd/types/TypesListCommand.swift index ec17f4a..b0f38f7 100644 --- a/Sources/CLI/cmd/types/TypesListCommand.swift +++ b/Sources/CLI/cmd/types/TypesListCommand.swift @@ -4,7 +4,7 @@ struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding { #if DEBUG typealias Deps = any ( TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeCatalogClientProvider - & DocumentationTypeListRendererProvider + & DocumentationTypeListRendererProvider & CommandOutputWriterProvider ) #else typealias Deps = Dependencies @@ -34,6 +34,6 @@ struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding { ) let result = try await runner.run(technology: technology, mode: deps.terminalCapabilities.mode(for: output)) deps.telemetry.record(.typeCatalog(count: result.typeCount), context: context) - print(result.output) + deps.commandOutputWriter.write(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesSearchCommand.swift b/Sources/CLI/cmd/types/TypesSearchCommand.swift index 4d9c82c..73e98c0 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommand.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommand.swift @@ -5,7 +5,7 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { #if DEBUG typealias Deps = any ( TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeSearchClientProvider - & DocumentationTypeListRendererProvider + & DocumentationTypeListRendererProvider & CommandOutputWriterProvider ) #else typealias Deps = Dependencies @@ -44,8 +44,8 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { let warning = "Warning: search results are incomplete. " + "\(result.unavailableCollectionCount) collections were unavailable.\n" - FileHandle.standardError.write(Data(warning.utf8)) + deps.commandOutputWriter.writeWarning(warning) } - print(result.output) + deps.commandOutputWriter.write(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesViewCommand.swift b/Sources/CLI/cmd/types/TypesViewCommand.swift index 2bc073f..daeacd7 100644 --- a/Sources/CLI/cmd/types/TypesViewCommand.swift +++ b/Sources/CLI/cmd/types/TypesViewCommand.swift @@ -4,7 +4,7 @@ struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { #if DEBUG typealias Deps = any ( TelemetryProvider & TerminalCapabilitiesProvider & AppleDocumentationClientProvider - & TypeDocumentationRendererProvider + & TypeDocumentationRendererProvider & CommandOutputWriterProvider ) #else typealias Deps = Dependencies @@ -38,6 +38,6 @@ struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { let result = try await runner.run( name: name, technology: technology, mode: deps.terminalCapabilities.mode(for: output)) deps.telemetry.record(.typeView(responseBytes: result.responseByteCount), context: context) - print(result.output) + deps.commandOutputWriter.write(result.output) } } diff --git a/Sources/CLI/main/CommandOutputWriter.swift b/Sources/CLI/main/CommandOutputWriter.swift new file mode 100644 index 0000000..65d6254 --- /dev/null +++ b/Sources/CLI/main/CommandOutputWriter.swift @@ -0,0 +1,29 @@ +import Foundation + +#if DEBUG + protocol CommandOutputWriting { + func write(_ line: String) + func writeWarning(_ warning: String) + } + + extension DefaultCommandOutputWriter: CommandOutputWriting {} + + protocol CommandOutputWriterProvider { + associatedtype OutputWriter: CommandOutputWriting + var commandOutputWriter: OutputWriter { get } + } + + extension Dependencies: CommandOutputWriterProvider {} +#else + typealias CommandOutputWriting = DefaultCommandOutputWriter +#endif + +struct DefaultCommandOutputWriter { + func write(_ line: String) { + print(line) + } + + func writeWarning(_ warning: String) { + FileHandle.standardError.write(Data(warning.utf8)) + } +} diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index 9f95c59..fc159a9 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -9,6 +9,7 @@ struct Dependencies: Sendable { static let shared = Dependencies() let terminalCapabilities = DefaultTerminalCapabilities() + let commandOutputWriter = DefaultCommandOutputWriter() let terminalSetup = TerminalSetup() let telemetry = DefaultTelemetry( // Telemetry starts before SwiftLog is bootstrapped. Resolve its logger only when logging an event. @@ -50,8 +51,11 @@ struct Dependencies: Sendable { .default } - func agentSkillInstaller() -> AgentSkillInstaller { - AgentSkillInstaller(logger: Logger(label: "com.techprimate.apple-docs.skills.installer")) + func agentSkillInstallationService() -> DefaultAgentSkillInstallationService { + DefaultAgentSkillInstallationService( + logger: Logger(label: "com.techprimate.apple-docs.skills.installer"), + fileManager: agentSkillFileManager() + ) } func documentationRenderer(output: OutputOptions) -> DefaultTypeDocumentationRenderer { diff --git a/Sources/CLI/skills/AgentSkillFileSystem.swift b/Sources/CLI/skills/AgentSkillFileSystem.swift new file mode 100644 index 0000000..4b68774 --- /dev/null +++ b/Sources/CLI/skills/AgentSkillFileSystem.swift @@ -0,0 +1,20 @@ +import Foundation + +#if DEBUG + protocol AgentSkillFileSystem { + var currentDirectoryPath: String { get } + var homeDirectoryForCurrentUser: URL { get } + func fileExists(atPath path: String) -> Bool + func createDirectory( + at url: URL, withIntermediateDirectories createIntermediates: Bool, + attributes: [FileAttributeKey: Any]? + ) throws + func removeItem(at url: URL) throws + func contentsOfDirectory(atPath path: String) throws -> [String] + func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] + } + + extension FileManager: AgentSkillFileSystem {} +#else + typealias AgentSkillFileSystem = FileManager +#endif diff --git a/Sources/CLI/skills/AgentSkillInstaller.swift b/Sources/CLI/skills/AgentSkillInstallationService.swift similarity index 92% rename from Sources/CLI/skills/AgentSkillInstaller.swift rename to Sources/CLI/skills/AgentSkillInstallationService.swift index c1eb9aa..7057e8d 100644 --- a/Sources/CLI/skills/AgentSkillInstaller.swift +++ b/Sources/CLI/skills/AgentSkillInstallationService.swift @@ -3,7 +3,7 @@ import Foundation import Logging /// Owns only SKILL.md and its installation receipt, never an entire skill directory. -struct AgentSkillInstaller { +struct DefaultAgentSkillInstallationService { private struct Receipt: Codable { let name: String let content: Data @@ -18,10 +18,11 @@ struct AgentSkillInstaller { let receipt: Receipt? } - private let fileManager = FileManager.default + private let fileManager: AgentSkillFileSystem private let logger: Logger - init(logger: Logger) { + init(logger: Logger, fileManager: AgentSkillFileSystem = FileManager.default) { + self.fileManager = fileManager self.logger = logger logger.trace("Initialized agent skill installer") } @@ -116,7 +117,8 @@ struct AgentSkillInstaller { ) } if !dryRun { - try fileManager.createDirectory(at: installation.directory, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: installation.directory, withIntermediateDirectories: true, attributes: nil) try content.write(to: installation.file, options: .atomic) let receipt = Receipt(name: installation.skill.name, content: content) try JSONEncoder().encode(receipt).write(to: installation.receiptFile, options: .atomic) @@ -221,18 +223,21 @@ struct AgentSkillInstaller { } #if DEBUG - protocol AgentSkillInstalling { + protocol AgentSkillInstallationService { func install(_ skills: [BundledAgentSkill], root: String, dryRun: Bool, force: Bool) throws -> String func uninstall(_ skills: [BundledAgentSkill], root: String, dryRun: Bool) throws -> String } - extension AgentSkillInstaller: AgentSkillInstalling {} + extension DefaultAgentSkillInstallationService: AgentSkillInstallationService {} - protocol AgentSkillInstallerProvider { - associatedtype Installer: AgentSkillInstalling - func agentSkillInstaller() -> Installer - func agentSkillFileManager() -> FileManager + protocol AgentSkillInstallationServiceProvider { + associatedtype Service: AgentSkillInstallationService + associatedtype FileSystem: AgentSkillFileSystem + func agentSkillInstallationService() -> Service + func agentSkillFileManager() -> FileSystem } - extension Dependencies: AgentSkillInstallerProvider {} + extension Dependencies: AgentSkillInstallationServiceProvider {} +#else + typealias AgentSkillInstallationService = DefaultAgentSkillInstallationService #endif diff --git a/Tests/CLITests/cmd/CommandTestDoubles.swift b/Tests/CLITests/cmd/CommandTestDoubles.swift new file mode 100644 index 0000000..f285096 --- /dev/null +++ b/Tests/CLITests/cmd/CommandTestDoubles.swift @@ -0,0 +1,22 @@ +import Logging + +@testable import CLI + +final class RecordingCommandOutputWriter: CommandOutputWriting { + private(set) var lines: [String] = [] + private(set) var warnings: [String] = [] + + func write(_ line: String) { lines.append(line) } + func writeWarning(_ warning: String) { warnings.append(warning) } +} + +final class RecordingCommandTelemetry: Telemetry, @unchecked Sendable { + private(set) var commands: [String] = [] + private(set) var metrics: [TelemetryMetric] = [] + + func start() {} + func startCommand(_ context: TelemetryCommandContext) { commands.append(context.command) } + func record(_ metric: TelemetryMetric, context: TelemetryCommandContext) { metrics.append(metric) } + func finishCommand(error: (any Error)?) {} + func makeLogHandler() -> (any LogHandler)? { nil } +} diff --git a/Tests/CLITests/cmd/agent/skills/AgentSkillsCommandTests.swift b/Tests/CLITests/cmd/agent/skills/AgentSkillsCommandTests.swift index 9b969d2..ccbfb00 100644 --- a/Tests/CLITests/cmd/agent/skills/AgentSkillsCommandTests.swift +++ b/Tests/CLITests/cmd/agent/skills/AgentSkillsCommandTests.swift @@ -4,20 +4,49 @@ import Testing @Suite("Agent skills commands") struct AgentSkillsCommandTests { + @Test("list writes the bundled skill names through the injected output") + func writesSkillList() throws { + // -- Arrange -- + let command = try AgentSkillsListCommand.parse([]) + let deps = SkillListDependencies() + + // -- Act -- + command.run(deps: deps) + + // -- Assert -- + #expect(deps.output.lines.count == BundledAgentSkills.all.count) + #expect(deps.output.lines.first?.hasPrefix("apple-docs\t") == true) + #expect(deps.telemetry.commands == ["agent.skills.list"]) + } + @Test("registers the nested list command") func parsesListCommand() throws { - let command = try CLI.parseAsRoot(["agent", "skills", "list"]) + // -- Arrange -- + let arguments = ["agent", "skills", "list"] + + // -- Act -- + let command = try CLI.parseAsRoot(arguments) + // -- Assert -- #expect(command is AgentSkillsListCommand) } @Test("registers the nested get command with a skill name") func parsesGetCommand() throws { - let command = try CLI.parseAsRoot([ - "agent", "skills", "get", "apple-docs", - ]) - let getCommand = try #require(command as? AgentSkillsGetCommand) + // -- Arrange -- + let arguments = ["agent", "skills", "get", "apple-docs"] + + // -- Act -- + let command = try CLI.parseAsRoot(arguments) + // -- Assert -- + let getCommand = try #require(command as? AgentSkillsGetCommand) #expect(getCommand.name == "apple-docs") } } + +private struct SkillListDependencies: TelemetryProvider, CommandOutputWriterProvider { + let telemetry = RecordingCommandTelemetry() + let output = RecordingCommandOutputWriter() + var commandOutputWriter: RecordingCommandOutputWriter { output } +} diff --git a/Tests/CLITests/cmd/agent/skills/AgentSkillsProjectInstallationTests.swift b/Tests/CLITests/cmd/agent/skills/AgentSkillsProjectInstallationTests.swift index 02c4f5c..e11c7a8 100644 --- a/Tests/CLITests/cmd/agent/skills/AgentSkillsProjectInstallationTests.swift +++ b/Tests/CLITests/cmd/agent/skills/AgentSkillsProjectInstallationTests.swift @@ -1,4 +1,5 @@ import Foundation +import Logging import Testing @testable import CLI @@ -120,6 +121,25 @@ struct AgentSkillsProjectInstallationTests { #expect(try String(contentsOf: unrelated, encoding: .utf8) == "keep") } + @Test("installation uses the injected file manager for filesystem changes") + func honorsInjectedFileManager() throws { + // -- Arrange -- + let home = temporaryRoot() + defer { try? FileManager.default.removeItem(at: home) } + let fileManager = DenyingInstallationFileManager(home: home) + let deps = TestAgentSkillDependencies(fileManager: fileManager) + let command = try #require( + CLI.parseAsRoot(["agent", "skills", "install", "apple-docs"]) as? AgentSkillsInstallCommand) + + // -- Act -- + #expect(throws: DenyingInstallationFileManager.Denied.self) { + try command.run(deps: deps) + } + + // -- Assert -- + #expect(!FileManager.default.fileExists(atPath: home.path)) + } + @Test("project and dir cannot be combined") func rejectsAmbiguousInstallationRoot() throws { // -- Arrange -- @@ -162,12 +182,34 @@ struct AgentSkillsProjectInstallationTests { } } -private struct TestAgentSkillDependencies: AgentSkillInstallerProvider { +private struct TestAgentSkillDependencies: AgentSkillInstallationServiceProvider { let fileManager: FileManager func agentSkillFileManager() -> FileManager { fileManager } - func agentSkillInstaller() -> AgentSkillInstaller { Dependencies.shared.agentSkillInstaller() } + func agentSkillInstallationService() -> DefaultAgentSkillInstallationService { + DefaultAgentSkillInstallationService(logger: Logger(label: "test"), fileManager: fileManager) + } +} + +private final class DenyingInstallationFileManager: FileManager { + enum Denied: Error { case createDirectory } + + let home: URL + + init(home: URL) { + self.home = home + super.init() + } + + override var homeDirectoryForCurrentUser: URL { home } + + override func createDirectory( + at url: URL, withIntermediateDirectories createIntermediates: Bool, + attributes: [FileAttributeKey: Any]? = nil + ) throws { + throw Denied.createDirectory + } } private final class TestHomeFileManager: FileManager { diff --git a/Tests/CLITests/cmd/cache/CacheCleanCommandTests.swift b/Tests/CLITests/cmd/cache/CacheCleanCommandTests.swift index 24e5aa0..f254df7 100644 --- a/Tests/CLITests/cmd/cache/CacheCleanCommandTests.swift +++ b/Tests/CLITests/cmd/cache/CacheCleanCommandTests.swift @@ -1,9 +1,28 @@ +import Foundation import Testing @testable import CLI +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @Suite("Cache clean command") struct CacheCleanCommandTests { + @Test("writes the unavailable-cache result through the injected output") + func writesUnavailableCache() throws { + // -- Arrange -- + let command = try CacheCleanCommand.parse([]) + let deps = CacheCleanTestDependencies() + + // -- Act -- + command.run(deps: deps) + + // -- Assert -- + #expect(deps.output.lines == ["Apple documentation cache is unavailable."]) + #expect(deps.telemetry.commands == ["cache.clean"]) + } + @Test("registers the cache clean command hierarchy") func parsesCacheCleanCommand() throws { // -- Arrange -- @@ -16,3 +35,10 @@ struct CacheCleanCommandTests { #expect(command is CacheCleanCommand) } } + +private struct CacheCleanTestDependencies: TelemetryProvider, DocumentationCacheProvider, CommandOutputWriterProvider { + let telemetry = RecordingCommandTelemetry() + let output = RecordingCommandOutputWriter() + var documentationCache: URLCache? { nil } + var commandOutputWriter: RecordingCommandOutputWriter { output } +} diff --git a/Tests/CLITests/cmd/technologies/TechnologiesListCommandTests.swift b/Tests/CLITests/cmd/technologies/TechnologiesListCommandTests.swift new file mode 100644 index 0000000..865ce18 --- /dev/null +++ b/Tests/CLITests/cmd/technologies/TechnologiesListCommandTests.swift @@ -0,0 +1,47 @@ +import Testing + +@testable import CLI + +@Suite("Technologies list command execution") +struct TechnologiesListCommandTests { + @Test("writes rendered technologies and records the catalog size") + func writesTechnologies() async throws { + // -- Arrange -- + let command = try TechnologiesListCommand.parse(["--json"]) + let deps = TechnologiesDependencies() + + // -- Act -- + try await command.run(deps: deps) + + // -- Assert -- + #expect(deps.output.lines == ["technologies"]) + #expect(deps.telemetry.commands == ["technologies.list"]) + guard case .technologyCatalog(count: 1) = deps.telemetry.metrics.first else { + Issue.record("Expected the catalog-size metric") + return + } + } +} + +private typealias TechnologiesProviders = TelemetryProvider & TerminalCapabilitiesProvider + & TechnologyCatalogClientProvider & TechnologyListRendererProvider & CommandOutputWriterProvider + +private struct TechnologiesDependencies: TechnologiesProviders { + let telemetry = RecordingCommandTelemetry() + let terminalCapabilities = DefaultTerminalCapabilities() + let documentationClient = TechnologiesTestClient() + let output = RecordingCommandOutputWriter() + var commandOutputWriter: RecordingCommandOutputWriter { output } + + func technologyListRenderer(output: OutputOptions) -> TechnologiesTestRenderer { TechnologiesTestRenderer() } +} + +private struct TechnologiesTestClient: TechnologyCatalogClient { + func fetchTechnologies() async throws -> [Technology] { + [Technology(name: "Swift", identifier: "doc://com.apple.documentation/documentation/Swift")] + } +} + +private struct TechnologiesTestRenderer: TechnologyListRenderer { + func render(_ technologies: [Technology]) -> String { "technologies" } +} diff --git a/Tests/CLITests/cmd/types/TypesListCommandTests.swift b/Tests/CLITests/cmd/types/TypesListCommandTests.swift index 81626fa..864b540 100644 --- a/Tests/CLITests/cmd/types/TypesListCommandTests.swift +++ b/Tests/CLITests/cmd/types/TypesListCommandTests.swift @@ -18,16 +18,20 @@ struct TypesListCommandTests { // -- Assert -- #expect(telemetry.commands == ["types.list"]) #expect(telemetry.typeCounts == [1]) + #expect(deps.output.lines.count == 1) + #expect(deps.output.lines[0].contains("\"name\" : \"Model\"")) } } private typealias TestProviders = TelemetryProvider & TerminalCapabilitiesProvider - & DocumentationTypeCatalogClientProvider & DocumentationTypeListRendererProvider + & DocumentationTypeCatalogClientProvider & DocumentationTypeListRendererProvider & CommandOutputWriterProvider private struct TestDeps: TestProviders { let telemetry = TypesListTelemetryRecorder() let terminalCapabilities = TypesListTestTerminal() let documentationClient = TypesListTestClient() + let output = RecordingCommandOutputWriter() + var commandOutputWriter: RecordingCommandOutputWriter { output } typealias Renderer = DefaultDocumentationTypeListRenderer diff --git a/Tests/CLITests/cmd/types/TypesSearchCommandTests.swift b/Tests/CLITests/cmd/types/TypesSearchCommandTests.swift new file mode 100644 index 0000000..b682d93 --- /dev/null +++ b/Tests/CLITests/cmd/types/TypesSearchCommandTests.swift @@ -0,0 +1,52 @@ +import Testing + +@testable import CLI + +@Suite("Types search command execution") +struct TypesSearchCommandTests { + @Test("writes results and an incomplete-search warning to separate streams") + func writesPartialSearch() async throws { + // -- Arrange -- + let command = try TypesSearchCommand.parse(["Button", "--technology", "SwiftUI", "--json"]) + let deps = SearchDependencies() + + // -- Act -- + try await command.run(deps: deps) + + // -- Assert -- + #expect(deps.output.lines == ["[]"]) + #expect(deps.output.warnings == ["Warning: search results are incomplete. 1 collections were unavailable.\n"]) + #expect(deps.telemetry.commands == ["types.search"]) + guard case .typeSearch(matches: 0) = deps.telemetry.metrics.first else { + Issue.record("Expected the match-count metric") + return + } + } +} + +private typealias SearchProviders = TelemetryProvider & TerminalCapabilitiesProvider + & DocumentationTypeSearchClientProvider & DocumentationTypeListRendererProvider & CommandOutputWriterProvider + +private struct SearchDependencies: SearchProviders { + let telemetry = RecordingCommandTelemetry() + let terminalCapabilities = DefaultTerminalCapabilities() + let documentationClient = SearchTestClient() + let output = RecordingCommandOutputWriter() + var commandOutputWriter: RecordingCommandOutputWriter { output } + + func documentationTypeListRenderer(output: OutputOptions, technology: String) -> SearchTestRenderer { + SearchTestRenderer() + } +} + +private struct SearchTestClient: DocumentationTypeSearchClient { + func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult { + #expect(query == "Button") + #expect(technology == "SwiftUI") + return DocumentationSearchResult(types: [], unavailableCollectionPaths: ["/documentation/swiftui/controls"]) + } +} + +private struct SearchTestRenderer: DocumentationTypeListRenderer { + func render(_ types: [DocumentationType]) -> String { "[]" } +} diff --git a/Tests/CLITests/cmd/types/TypesViewCommandExecutionTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandExecutionTests.swift new file mode 100644 index 0000000..fdbf010 --- /dev/null +++ b/Tests/CLITests/cmd/types/TypesViewCommandExecutionTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Types view command execution") +struct TypesViewCommandExecutionTests { + @Test("writes rendered documentation and records the response size") + func writesDocumentation() async throws { + // -- Arrange -- + let command = try TypesViewCommand.parse(["String", "--technology", "Swift", "--json"]) + let deps = ViewDependencies() + + // -- Act -- + try await command.run(deps: deps) + + // -- Assert -- + #expect(deps.output.lines == ["rendered page"]) + #expect(deps.telemetry.commands == ["types.view"]) + guard case .typeView(responseBytes: 4) = deps.telemetry.metrics.first else { + Issue.record("Expected the response size metric") + return + } + } +} + +private typealias ViewProviders = TelemetryProvider & TerminalCapabilitiesProvider + & AppleDocumentationClientProvider & TypeDocumentationRendererProvider & CommandOutputWriterProvider + +private struct ViewDependencies: ViewProviders { + let telemetry = RecordingCommandTelemetry() + let terminalCapabilities = DefaultTerminalCapabilities() + let documentationClient = ViewTestClient() + let output = RecordingCommandOutputWriter() + var commandOutputWriter: RecordingCommandOutputWriter { output } + + func documentationRenderer(output: OutputOptions) -> ViewTestRenderer { ViewTestRenderer() } +} + +private struct ViewTestClient: AppleDocumentationClient { + func fetchType(named name: String, technology: String) async throws -> TypeDocumentationDocument { + #expect(name == "String") + #expect(technology == "Swift") + return TypeDocumentationDocument( + data: Data("page".utf8), + destination: DocumentationDestination(technology: "swift", path: "/documentation/swift/string") + ) + } +} + +private struct ViewTestRenderer: TypeDocumentationRenderer { + func render(_ document: TypeDocumentationDocument) -> String { "rendered page" } +} diff --git a/Tests/CLITests/skills/AgentSkillInstallerTests.swift b/Tests/CLITests/skills/AgentSkillInstallationServiceTests.swift similarity index 93% rename from Tests/CLITests/skills/AgentSkillInstallerTests.swift rename to Tests/CLITests/skills/AgentSkillInstallationServiceTests.swift index 3e29ff9..0d7a658 100644 --- a/Tests/CLITests/skills/AgentSkillInstallerTests.swift +++ b/Tests/CLITests/skills/AgentSkillInstallationServiceTests.swift @@ -6,13 +6,13 @@ import Testing @testable import CLI @Suite("Agent skill installer logging") -struct AgentSkillInstallerTests { +struct DefaultAgentSkillInstallationServiceTests { @available(macOS 15, *) @Test("logs installation, unchanged files, removal, and absent installations") func logsLifecycle() throws { // -- Arrange -- let recorder = ClientLogRecorder() - let installer = AgentSkillInstaller(logger: recorder.logger()) + let installer = DefaultAgentSkillInstallationService(logger: recorder.logger()) let root = temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } let skill = try #require(BundledAgentSkills.skill(named: "apple-docs")) @@ -48,7 +48,7 @@ struct AgentSkillInstallerTests { func logsDryRuns() throws { // -- Arrange -- let recorder = ClientLogRecorder() - let installer = AgentSkillInstaller(logger: recorder.logger()) + let installer = DefaultAgentSkillInstallationService(logger: recorder.logger()) let root = temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } let skill = try #require(BundledAgentSkills.skill(named: "apple-docs")) @@ -76,7 +76,7 @@ struct AgentSkillInstallerTests { func logsUnmanagedConflict(uninstall: Bool) throws { // -- Arrange -- let recorder = ClientLogRecorder() - let installer = AgentSkillInstaller(logger: recorder.logger()) + let installer = DefaultAgentSkillInstallationService(logger: recorder.logger()) let root = temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } let skill = try #require(BundledAgentSkills.skill(named: "apple-docs")) @@ -107,7 +107,7 @@ struct AgentSkillInstallerTests { func logsForcedReplacement() throws { // -- Arrange -- let recorder = ClientLogRecorder() - let installer = AgentSkillInstaller(logger: recorder.logger()) + let installer = DefaultAgentSkillInstallationService(logger: recorder.logger()) let root = temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } let skill = try #require(BundledAgentSkills.skill(named: "apple-docs")) @@ -130,7 +130,7 @@ struct AgentSkillInstallerTests { func logsInvalidReceipt() throws { // -- Arrange -- let recorder = ClientLogRecorder() - let installer = AgentSkillInstaller(logger: recorder.logger()) + let installer = DefaultAgentSkillInstallationService(logger: recorder.logger()) let root = temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } let skill = try #require(BundledAgentSkills.skill(named: "apple-docs")) @@ -157,7 +157,7 @@ struct AgentSkillInstallerTests { func logsFilesystemFailure(uninstall: Bool) throws { // -- Arrange -- let recorder = ClientLogRecorder() - let installer = AgentSkillInstaller(logger: recorder.logger()) + let installer = DefaultAgentSkillInstallationService(logger: recorder.logger()) let root = temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)