diff --git a/Makefile b/Makefile index b50a894..bce758f 100644 --- a/Makefile +++ b/Makefile @@ -83,6 +83,13 @@ run: test: swift test +## Run live CLI integration tests +# +# Builds the release executable and runs network-dependent command tests against Apple documentation. +.PHONY: test-integration +test-integration: build + APPLE_DOCS_EXECUTABLE="$(CURDIR)/$(CLI_BINARY)" swift test --filter CLIIntegrationTests + ## Run SwiftLint # # Checks project-owned Swift files using .swiftlint.yml. Warnings fail the target. diff --git a/Package.swift b/Package.swift index 0e7024e..5337955 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser") ]), .testTarget(name: "CLITests", dependencies: ["CLI"]), + .testTarget(name: "CLIIntegrationTests"), ], swiftLanguageModes: [.v6] ) diff --git a/Tests/CLIIntegrationTests/AppleDocsCommand.swift b/Tests/CLIIntegrationTests/AppleDocsCommand.swift new file mode 100644 index 0000000..9b63fa4 --- /dev/null +++ b/Tests/CLIIntegrationTests/AppleDocsCommand.swift @@ -0,0 +1,48 @@ +import Foundation + +enum AppleDocsCommandError: Error, LocalizedError { + case failed(status: Int32, stderr: String) + case invalidUTF8(stream: String) + case missingExecutable + + var errorDescription: String? { + switch self { + case .failed(let status, let stderr): + return "apple-docs exited with status \(status): \(stderr)" + case .invalidUTF8(let stream): + return "apple-docs returned invalid UTF-8 on \(stream)." + case .missingExecutable: + return "APPLE_DOCS_EXECUTABLE is not set. Run the tests with make test-integration." + } + } +} + +func runAppleDocs(_ arguments: [String]) throws -> String { + guard let executablePath = ProcessInfo.processInfo.environment["APPLE_DOCS_EXECUTABLE"] else { + throw AppleDocsCommandError.missingExecutable + } + + let process = Process() + let standardOutput = Pipe() + let standardError = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.standardOutput = standardOutput + process.standardError = standardError + + try process.run() + let outputData = standardOutput.fileHandleForReading.readDataToEndOfFile() + let errorData = standardError.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard let output = String(data: outputData, encoding: .utf8) else { + throw AppleDocsCommandError.invalidUTF8(stream: "standard output") + } + guard let error = String(data: errorData, encoding: .utf8) else { + throw AppleDocsCommandError.invalidUTF8(stream: "standard error") + } + guard process.terminationStatus == 0 else { + throw AppleDocsCommandError.failed(status: process.terminationStatus, stderr: error) + } + return output +} diff --git a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift new file mode 100644 index 0000000..d0772b3 --- /dev/null +++ b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing + +private let integrationTestsEnabled = + ProcessInfo.processInfo.environment["APPLE_DOCS_EXECUTABLE"] != nil + +@Suite( + "CLI integration", + .enabled(if: integrationTestsEnabled, "Run with make test-integration."), + .serialized +) +struct AppleDocsCommandIntegrationTests { + @Test("returns Swift String documentation as JSON") + func returnsSwiftStringJSON() throws { + // -- Arrange -- + let arguments = ["types", "view", "String", "--technology", "Swift", "--json"] + + // -- Act -- + let output = try runAppleDocs(arguments) + let document = try JSONDecoder().decode(TypeDocument.self, from: Data(output.utf8)) + + // -- Assert -- + #expect(document.metadata.title == "String") + #expect(document.metadata.modules.map(\.name) == ["Swift"]) + #expect(document.metadata.symbolKind == "struct") + } + + @Test("returns Foundation URL documentation as JSON") + func returnsFoundationURLJSON() throws { + // -- Arrange -- + let arguments = ["types", "view", "URL", "--technology", "Foundation", "--json"] + + // -- Act -- + let output = try runAppleDocs(arguments) + let document = try JSONDecoder().decode(TypeDocument.self, from: Data(output.utf8)) + + // -- Assert -- + #expect(document.metadata.title == "URL") + #expect(document.metadata.modules.map(\.name) == ["Foundation"]) + #expect(document.metadata.symbolKind == "struct") + } + + @Test("renders Swift String documentation as text") + func rendersSwiftStringText() throws { + // -- Arrange -- + let arguments = ["types", "view", "String", "--technology", "Swift"] + + // -- Act -- + let output = try runAppleDocs(arguments) + + // -- Assert -- + #expect(output.hasPrefix("String\nStructure ยท Swift\n")) + #expect(output.contains("Declaration\n\n @frozen struct String")) + } + + @Test("lists stable technologies as JSON") + func listsStableTechnologies() throws { + // -- Arrange -- + let arguments = ["technologies", "list", "--json"] + + // -- Act -- + let output = try runAppleDocs(arguments) + let technologies = try JSONDecoder().decode([Technology].self, from: Data(output.utf8)) + + // -- Assert -- + #expect( + technologies.contains( + Technology( + identifier: "doc://com.apple.documentation/documentation/Foundation", + name: "Foundation" + ) + ) + ) + #expect( + technologies.contains( + Technology( + identifier: "doc://com.apple.documentation/documentation/Swift", + name: "Swift" + ) + ) + ) + } +} + +private struct TypeDocument: Decodable { + let metadata: Metadata + + struct Metadata: Decodable { + let modules: [Module] + let symbolKind: String + let title: String + } + + struct Module: Decodable { + let name: String + } +} + +private struct Technology: Decodable, Equatable { + let identifier: String + let name: String +}