diff --git a/Sources/CLI/client/AppleDocumentationClient+Search.swift b/Sources/CLI/client/AppleDocumentationClient+Search.swift index 2bee16d..525dc73 100644 --- a/Sources/CLI/client/AppleDocumentationClient+Search.swift +++ b/Sources/CLI/client/AppleDocumentationClient+Search.swift @@ -1,5 +1,12 @@ +import Foundation + extension DefaultAppleDocumentationClient { func searchTypes(query: String, technology: String) async throws -> [DocumentationType] { + logger.debug( + "Searching documentation types", + metadata: [ + "query": .string(query), "apple_docs.technology": .string(technology), + ]) var slug = technology var displayName = technology var technologyURL = "https://developer.apple.com/documentation/\(technology.lowercased())" @@ -10,25 +17,34 @@ extension DefaultAppleDocumentationClient { path: "/documentation/\(technology.lowercased())" ) } catch Error.httpStatus(404) { + logger.debug("Search root not found, resolving technology") let resolved = try await resolveTechnology(named: technology) guard let resolvedSlug = resolved.documentationSlug else { + logger.notice("Technology has no searchable documentation root") throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) } slug = resolvedSlug displayName = resolved.name technologyURL = resolved.url + logger.debug("Retrying search with canonical technology", metadata: ["slug": .string(resolvedSlug)]) rootPage = try await fetchDocumentationPage( path: "/documentation/\(resolvedSlug.lowercased())" ) } - return try await searchTypes( + let matches = try await searchTypes( query: query, documentationSlug: slug, displayName: displayName, technologyURL: technologyURL, rootPage: rootPage ) + logger.info( + "Documentation search completed", + metadata: [ + "apple_docs.technology": .string(displayName), "matches": .stringConvertible(matches.count), + ]) + return matches } private func searchTypes( @@ -39,6 +55,7 @@ extension DefaultAppleDocumentationClient { rootPage: TechnologyDocumentationPageDTO ) async throws -> [DocumentationType] { let rootPath = "/documentation/\(documentationSlug.lowercased())" + logger.debug("Traversing documentation collection groups", metadata: ["path": .string(rootPath)]) var typesByPath: [String: DocumentationType] = [:] for type in documentationTypes(in: rootPage, technology: documentationSlug) { typesByPath[type.path] = type @@ -53,6 +70,11 @@ extension DefaultAppleDocumentationClient { while !pendingPaths.isEmpty { let batch = Array(pendingPaths.prefix(6)) pendingPaths.removeFirst(batch.count) + logger.trace( + "Dequeued collection group batch", + metadata: [ + "batch_size": .stringConvertible(batch.count), "pending": .stringConvertible(pendingPaths.count), + ]) let pages = await fetchDocumentationPages(paths: batch) for page in pages { @@ -74,6 +96,12 @@ extension DefaultAppleDocumentationClient { } ) guard !matches.isEmpty else { + logger.notice( + "No matching documentation types", + metadata: [ + "query": .string(query), "apple_docs.technology": .string(displayName), + "candidates": .stringConvertible(typesByPath.count), + ]) throw Error.typeSearchNoResults( query: query, technology: displayName, @@ -86,7 +114,8 @@ extension DefaultAppleDocumentationClient { private func fetchDocumentationPages( paths: [String] ) async -> [TechnologyDocumentationPageDTO] { - await withTaskGroup( + logger.debug("Fetching collection group batch", metadata: ["count": .stringConvertible(paths.count)]) + return await withTaskGroup( of: TechnologyDocumentationPageDTO?.self, returning: [TechnologyDocumentationPageDTO].self ) { group in @@ -95,6 +124,12 @@ extension DefaultAppleDocumentationClient { do { return try await fetchDocumentationPage(path: path) } catch { + let cancelled = error is CancellationError || (error as? URLError)?.code == .cancelled + logger.log( + level: cancelled ? .debug : .warning, "Skipping unavailable collection group", + metadata: [ + "path": .string(path), "error_type": .string(String(reflecting: type(of: error))), + ]) return nil } } @@ -106,6 +141,11 @@ extension DefaultAppleDocumentationClient { pages.append(page) } } + logger.debug( + "Fetched collection group batch", + metadata: [ + "requested": .stringConvertible(paths.count), "received": .stringConvertible(pages.count), + ]) return pages } } @@ -115,7 +155,7 @@ extension DefaultAppleDocumentationClient { technology: String ) -> [String] { let pathPrefix = "/documentation/\(technology.lowercased())/" - return page.references.values.compactMap { reference in + let paths: [String] = page.references.values.compactMap { reference in guard reference.role == "collectionGroup", let path = reference.url, @@ -125,5 +165,11 @@ extension DefaultAppleDocumentationClient { } return path } + logger.trace( + "Filtered collection group paths", + metadata: [ + "references": .stringConvertible(page.references.count), "count": .stringConvertible(paths.count), + ]) + return paths } } diff --git a/Sources/CLI/client/AppleDocumentationClient.swift b/Sources/CLI/client/AppleDocumentationClient.swift index 07ee2b1..d7729bc 100644 --- a/Sources/CLI/client/AppleDocumentationClient.swift +++ b/Sources/CLI/client/AppleDocumentationClient.swift @@ -1,4 +1,5 @@ import Foundation +import Logging #if canImport(FoundationNetworking) import FoundationNetworking @@ -8,6 +9,7 @@ import Foundation protocol AppleDocumentationClient: Sendable { func fetchType(named name: String, technology: String) async throws -> TypeDocumentationDocument } + extension DefaultAppleDocumentationClient: AppleDocumentationClient {} #else typealias AppleDocumentationClient = DefaultAppleDocumentationClient #endif @@ -20,39 +22,54 @@ struct DefaultAppleDocumentationClient TypeDocumentationDocument { + let metadata: Logger.Metadata = [ + "apple_docs.type": .string(name), "apple_docs.technology": .string(technology), + ] + logger.debug("Fetching type documentation", metadata: metadata) do { - return TypeDocumentationDocument( + let document = TypeDocumentationDocument( data: try await fetchData(from: typeURL(name: name, technology: technology)) ) + logger.info("Fetched type documentation", metadata: metadata) + return document } catch Error.httpStatus(404) { + logger.debug("Type path not found, resolving technology", metadata: metadata) let resolved = try await resolveTechnology(named: technology) guard let slug = resolved.documentationSlug else { + logger.notice("Technology has no documentation root", metadata: metadata) throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) } // Display names do not always match DocC path components, such as Apple CryptoKit. if slug.caseInsensitiveCompare(technology) != .orderedSame { + logger.debug("Retrying type with canonical technology", metadata: ["slug": .string(slug)]) do { - return TypeDocumentationDocument( + let document = TypeDocumentationDocument( data: try await fetchData(from: typeURL(name: name, technology: slug)) ) + logger.info("Fetched type documentation", metadata: metadata) + return document } catch Error.httpStatus(404) { - // Continue with the canonical root so the error can offer useful discovery links. + logger.debug("Canonical type path not found, looking for suggestions", metadata: metadata) } } @@ -61,6 +78,7 @@ struct DefaultAppleDocumentationClient [DocumentationType] { + logger.debug("Fetching documentation types", metadata: ["apple_docs.technology": .string(technology)]) do { return try await fetchTypesDirect(technology: technology) } catch Error.httpStatus(404) { + logger.debug("Type catalog not found, resolving technology") let resolved = try await resolveTechnology(named: technology) guard let slug = resolved.documentationSlug else { + logger.notice("Technology has no documentation root") throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) } guard slug.caseInsensitiveCompare(technology) != .orderedSame else { // Retrying the same case-insensitive path cannot produce a different result. + logger.notice("Documentation root unavailable, skipping identical retry") throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) } + logger.debug("Retrying type catalog with canonical technology", metadata: ["slug": .string(slug)]) do { return try await fetchTypesDirect(technology: slug) } catch Error.httpStatus(404) { + logger.notice("Canonical documentation root unavailable", metadata: ["slug": .string(slug)]) throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) } } } func fetchTechnologies() async throws -> [Technology] { + logger.debug("Fetching technology catalog") let url = baseURL.appending(component: "documentation") .appending(component: "technologies") .appendingPathExtension("json") let data = try await fetchData(from: url) - let page = try JSONDecoder().decode(TechnologyCatalogPageDTO.self, from: data) - return page.sections.flatMap(\.groups).flatMap(\.technologies).map { + let page: TechnologyCatalogPageDTO + do { + page = try JSONDecoder().decode(TechnologyCatalogPageDTO.self, from: data) + } catch { + logger.error("Failed to decode technology catalog", metadata: ["path": .string(url.path)]) + throw error + } + let technologies = page.sections.flatMap(\.groups).flatMap(\.technologies).map { Technology(name: $0.title, identifier: $0.destination.identifier) } + logger.info("Fetched technology catalog", metadata: ["count": .stringConvertible(technologies.count)]) + return technologies } private func fetchTypesDirect(technology: String) async throws -> [DocumentationType] { let path = "/documentation/\(technology.lowercased())" let page = try await fetchDocumentationPage(path: path) - return sortTypes(documentationTypes(in: page, technology: technology)) + let types = sortTypes(documentationTypes(in: page, technology: technology)) + logger.info( + "Fetched documentation types", metadata: ["path": .string(path), "count": .stringConvertible(types.count)]) + return types } func fetchDocumentationPage(path: String) async throws -> TechnologyDocumentationPageDTO { + logger.debug("Fetching documentation page", metadata: ["path": .string(path)]) var url = baseURL for component in path.split(separator: "/") { url.append(component: component) } url.appendPathExtension("json") let data = try await fetchData(from: url) - return try JSONDecoder().decode(TechnologyDocumentationPageDTO.self, from: data) + do { + let page = try JSONDecoder().decode(TechnologyDocumentationPageDTO.self, from: data) + logger.trace( + "Decoded documentation page", metadata: ["references": .stringConvertible(page.references.count)]) + return page + } catch { + logger.error("Failed to decode documentation page", metadata: ["path": .string(url.path)]) + throw error + } } func documentationTypes( @@ -125,7 +170,7 @@ struct DefaultAppleDocumentationClient(_ types: S) -> [DocumentationType] - where S.Element == DocumentationType { - types.sorted { + func sortTypes(_ types: S) -> [DocumentationType] where S.Element == DocumentationType { + let sorted = types.sorted { let comparison = $0.name.compare($1.name, options: .caseInsensitive) return comparison == .orderedSame ? $0.path < $1.path : comparison == .orderedAscending } + logger.trace("Sorted documentation types", metadata: ["count": .stringConvertible(sorted.count)]) + return sorted } +} + +extension DefaultAppleDocumentationClient { private func fetchData(from url: URL) async throws -> Data { - let (data, response) = try await dependencies.data(from: url) + let started = ContinuousClock.now + logger.debug("Requesting documentation data", metadata: ["path": .string(url.path)]) + defer { + logger.debug( + "Documentation request finished", + metadata: [ + "path": .string(url.path), "elapsed": .string("\(started.duration(to: .now))"), + ]) + } + let data: Data + let response: URLResponse + do { + (data, response) = try await dependencies.data(from: url) + } catch { + let cancelled = error is CancellationError || (error as? URLError)?.code == .cancelled + logger.log( + level: cancelled ? .debug : .error, "Documentation transport failed", + metadata: [ + "path": .string(url.path), "error_type": .string(String(reflecting: type(of: error))), + ]) + throw error + } guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid documentation response", metadata: ["path": .string(url.path)]) throw Error.invalidResponse } + let metadata: Logger.Metadata = [ + "path": .string(url.path), "status": .stringConvertible(httpResponse.statusCode), + "bytes": .stringConvertible(data.count), + ] + logger.debug("Received documentation response", metadata: metadata) guard (200..<300).contains(httpResponse.statusCode) else { + let level: Logger.Level = + httpResponse.statusCode == 404 + ? .debug + : (httpResponse.statusCode >= 500 ? .error : .warning) + logger.log(level: level, "Documentation request rejected", metadata: metadata) throw Error.httpStatus(httpResponse.statusCode) } return data @@ -174,10 +261,12 @@ struct DefaultAppleDocumentationClient ResolvedTechnology { + logger.debug("Resolving technology", metadata: ["apple_docs.technology": .string(requestedName)]) let technologies = try await fetchTechnologies() guard let technology = technologies.first(where: { @@ -186,9 +275,11 @@ struct DefaultAppleDocumentationClient String? { let marker = "/documentation/" guard let range = identifier.range(of: marker) else { + logger.trace("Technology identifier has no documentation slug") return nil } let remainder = identifier[range.upperBound...] guard !remainder.contains("/") else { + logger.trace("Ignoring nested documentation slug") return nil } + logger.trace("Extracted documentation slug", metadata: ["slug": .string(String(remainder))]) return String(remainder) } private func publicURL(from identifier: String) -> String { guard identifier.hasPrefix("doc://"), let pathStart = identifier.dropFirst(6).firstIndex(of: "/") else { + logger.trace("Keeping external technology URL") return identifier } + logger.trace("Converted DocC identifier to public URL") return "https://developer.apple.com\(identifier[pathStart...].lowercased())" } private func normalizedSymbolName(_ name: String) -> String { - name.lowercased().filter { $0.isLetter || $0.isNumber } + logger.trace("Normalizing symbol name for suggestions") + return name.lowercased().filter { $0.isLetter || $0.isNumber } } } - -#if DEBUG - extension DefaultAppleDocumentationClient: AppleDocumentationClient {} -#endif diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index 4903862..ebdb12a 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -1,4 +1,5 @@ import Foundation +import Logging #if canImport(FoundationNetworking) import FoundationNetworking @@ -46,6 +47,7 @@ enum Dependencies { } static let documentationClient = DefaultAppleDocumentationClient( + logger: Logger(label: "com.techprimate.apple-docs.client"), dependencies: httpDataTransport ) diff --git a/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift b/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift index 6539fb4..8e14161 100644 --- a/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientErrorTests.swift @@ -1,4 +1,5 @@ import Foundation +import Logging import Testing @testable import CLI @@ -22,6 +23,7 @@ struct AppleDocumentationClientErrorTests { URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftdata.json") ) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: LookupTestTransport( responses: [ typeURL: .init(statusCode: 404, data: Data()), diff --git a/Tests/CLITests/client/AppleDocumentationClientLoggingTests.swift b/Tests/CLITests/client/AppleDocumentationClientLoggingTests.swift new file mode 100644 index 0000000..08442db --- /dev/null +++ b/Tests/CLITests/client/AppleDocumentationClientLoggingTests.swift @@ -0,0 +1,267 @@ +import Foundation +import Logging +import Testing + +@testable import CLI + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +@Suite("Apple documentation client logging") +struct AppleDocumentationClientLoggingTests { + @available(macOS 15, *) + @Test("logs successful requests without exposing response bodies or URL credentials") + func logsSuccessfulRequest() async throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let url = try #require(URL(string: "https://user:secret@example.com/data/")) + let data = Data("private response body".utf8) + let client = DefaultAppleDocumentationClient( + logger: recorder.logger(), + dependencies: LoggingTestTransport(result: .success((data, try response(status: 200)))), + baseURL: url + ) + + // -- Act -- + let document = try await client.fetchType(named: "String", technology: "Swift") + + // -- Assert -- + #expect(document.data == data) + let events = recorder.events + #expect(events.contains { $0.level == .trace }) + let received = try #require(events.first { $0.message.description == "Received documentation response" }) + #expect(received.level == .debug) + #expect(received.metadata?["status"]?.description == "200") + #expect(received.metadata?["bytes"]?.description == "21") + #expect(received.metadata?["path"]?.description == "/data/documentation/swift/string.json") + #expect(events.contains { $0.level == .info && $0.message.description == "Fetched type documentation" }) + #expect(events.contains { $0.metadata?["elapsed"] != nil }) + #expect(!events.contains { $0.level >= .warning }) + for event in events { + let output = "\(event.message) \(event.metadata ?? [:])" + #expect(!output.contains("private response body")) + #expect(!output.contains("secret")) + #expect(!output.contains("user:")) + } + } + + @available(macOS 15, *) + @Test("classifies HTTP failures without changing the thrown error", arguments: [404, 429, 500]) + func logsHTTPFailure(status: Int) async throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let client = DefaultAppleDocumentationClient( + logger: recorder.logger(), + dependencies: LoggingTestTransport(result: .success((Data(), try response(status: status)))) + ) + let expectedLevel: Logger.Level = status == 404 ? .debug : (status >= 500 ? .error : .warning) + + // -- Act -- + await #expect(throws: DefaultAppleDocumentationClient.Error.httpStatus(status)) { + try await client.fetchDocumentationPage(path: "/documentation/swift") + } + + // -- Assert -- + let event = try #require(recorder.events.first { $0.message.description == "Documentation request rejected" }) + #expect(event.level == expectedLevel) + #expect(event.metadata?["status"]?.description == String(status)) + #expect(event.metadata?["path"]?.description == "/tutorials/data/documentation/swift.json") + #expect(!recorder.events.contains { $0.level > expectedLevel }) + } + + @available(macOS 15, *) + @Test( + "logs transport failures but treats cancellation as debug", arguments: [URLError.timedOut, URLError.cancelled]) + func logsTransportFailure(code: URLError.Code) async throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let client = DefaultAppleDocumentationClient( + logger: recorder.logger(), + dependencies: LoggingTestTransport(result: .failure(URLError(code))) + ) + + // -- Act -- + await #expect(throws: URLError(code)) { + try await client.fetchType(named: "String", technology: "Swift") + } + + // -- Assert -- + let event = try #require(recorder.events.first { $0.message.description == "Documentation transport failed" }) + #expect(event.level == (code == .cancelled ? .debug : .error)) + #expect(!recorder.events.contains { $0.level == .info }) + } + + @available(macOS 15, *) + @Test("logs invalid non-HTTP responses as errors") + func logsInvalidResponse() async throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let url = try #require(URL(string: "https://example.com")) + let response = URLResponse(url: url, mimeType: nil, expectedContentLength: 0, textEncodingName: nil) + let client = DefaultAppleDocumentationClient( + logger: recorder.logger(), + dependencies: LoggingTestTransport(result: .success((Data(), response))) + ) + + // -- Act -- + await #expect(throws: DefaultAppleDocumentationClient.Error.invalidResponse) { + try await client.fetchType(named: "String", technology: "Swift") + } + + // -- Assert -- + #expect( + recorder.events.contains { + $0.level == .error && $0.message.description == "Invalid documentation response" + }) + } + + @available(macOS 15, *) + @Test("logs decoding failures without leaking the malformed body", arguments: [false, true]) + func logsDecodingFailure(catalog: Bool) async throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let client = DefaultAppleDocumentationClient( + logger: recorder.logger(), + dependencies: LoggingTestTransport( + result: .success((Data("sensitive malformed body".utf8), try response(status: 200))) + ) + ) + + // -- Act -- + await #expect(throws: DecodingError.self) { + if catalog { + _ = try await client.fetchTechnologies() + } else { + _ = try await client.fetchDocumentationPage(path: "/documentation/swift") + } + } + + // -- Assert -- + #expect(recorder.events.contains { $0.level == .error }) + #expect( + !recorder.events.contains { "\($0.message) \($0.metadata ?? [:])".contains("sensitive malformed body") }) + #expect(!recorder.events.contains { $0.level == .info }) + } + + @available(macOS 15, *) + @Test("logs empty searches as expected outcomes rather than failures") + func logsEmptySearch() async throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let client = DefaultAppleDocumentationClient( + logger: recorder.logger(), + dependencies: LoggingTestTransport( + result: .success((Data("{\"references\":{}}".utf8), try response(status: 200))) + ) + ) + + // -- Act -- + await #expect( + throws: DefaultAppleDocumentationClient.Error.typeSearchNoResults( + query: "Missing", technology: "Swift", technologyURL: "https://developer.apple.com/documentation/swift" + ) + ) { + try await client.searchTypes(query: "Missing", technology: "Swift") + } + + // -- Assert -- + #expect( + recorder.events.contains { + $0.level == .notice && $0.message.description == "No matching documentation types" + }) + #expect(!recorder.events.contains { $0.level >= .warning }) + } + + @available(macOS 15, *) + @Test("warns about skipped collection groups while returning available search results") + func logsPartialSearch() async throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let root = """ + {"references":{ + "button":{"kind":"symbol","role":"symbol","title":"Button","url":"/documentation/swiftui/button"}, + "controls":{"role":"collectionGroup","url":"/documentation/swiftui/controls"} + }} + """ + let client = DefaultAppleDocumentationClient( + logger: recorder.logger(), + dependencies: LoggingSearchTransport(responses: [ + "/tutorials/data/documentation/swiftui.json": (200, root), + "/tutorials/data/documentation/swiftui/controls.json": (404, ""), + ]) + ) + + // -- Act -- + let types = try await client.searchTypes(query: "button", technology: "SwiftUI") + + // -- Assert -- + #expect(types.map(\.name) == ["Button"]) + let skipped = try #require( + recorder.events.first { $0.message.description == "Skipping unavailable collection group" }) + #expect(skipped.level == .warning) + #expect(skipped.metadata?["path"]?.description == "/documentation/swiftui/controls") + let completed = try #require( + recorder.events.first { $0.message.description == "Documentation search completed" }) + #expect(completed.level == .info) + #expect(completed.metadata?["matches"]?.description == "1") + #expect(!recorder.events.contains { $0.level >= .error }) + } + + @available(macOS 15, *) + @Test("logs canonical type retries without reporting an error for the initial miss") + func logsCanonicalRetry() async throws { + // -- Arrange -- + let recorder = ClientLogRecorder() + let catalog = """ + {"sections":[{"groups":[{"technologies":[{ + "title":"Apple CryptoKit", + "destination":{"identifier":"doc://com.apple.documentation/documentation/CryptoKit"} + }]}]}]} + """ + let client = DefaultAppleDocumentationClient( + logger: recorder.logger(), + dependencies: LoggingSearchTransport(responses: [ + "/tutorials/data/documentation/apple cryptokit/aes.json": (404, ""), + "/tutorials/data/documentation/technologies.json": (200, catalog), + "/tutorials/data/documentation/cryptokit/aes.json": (200, "raw document"), + ]) + ) + + // -- Act -- + let document = try await client.fetchType(named: "AES", technology: "Apple CryptoKit") + + // -- Assert -- + #expect(document.data == Data("raw document".utf8)) + let retry = try #require( + recorder.events.first { $0.message.description == "Retrying type with canonical technology" }) + #expect(retry.level == .debug) + #expect(retry.metadata?["slug"]?.description == "CryptoKit") + #expect( + recorder.events.contains { $0.level == .info && $0.message.description == "Fetched type documentation" }) + #expect(!recorder.events.contains { $0.level >= .warning }) + } + + private func response(status: Int) throws -> HTTPURLResponse { + let url = try #require(URL(string: "https://example.com")) + return try #require(HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)) + } +} + +private struct LoggingSearchTransport: HTTPDataTransport { + let responses: [String: (Int, String)] + + func data(from url: URL) async throws -> (Data, URLResponse) { + let (status, body) = try #require(responses[url.path]) + let response = try #require(HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)) + return (Data(body.utf8), response) + } +} + +private struct LoggingTestTransport: HTTPDataTransport { + let result: Result<(Data, URLResponse), Swift.Error> + + func data(from _: URL) async throws -> (Data, URLResponse) { + try result.get() + } +} diff --git a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift index 1e61a94..1fd8c60 100644 --- a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift @@ -1,4 +1,5 @@ import Foundation +import Logging import Testing @testable import CLI @@ -28,6 +29,7 @@ struct AppleDocumentationClientSearchTests { ) ) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: SearchTestTransport( responses: [ rootURL: rootSearchPage, @@ -52,6 +54,7 @@ struct AppleDocumentationClientSearchTests { URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui.json") ) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: SearchTestTransport( responses: [rootURL: duplicateRootSearchPage] ) @@ -84,6 +87,7 @@ struct AppleDocumentationClientSearchTests { ) ) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: SearchFallbackTransport( responses: [ rootURL: .init(statusCode: 200, data: partialFailureRootSearchPage), @@ -120,6 +124,7 @@ struct AppleDocumentationClientSearchTests { URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json") ) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: SearchFallbackTransport( responses: [ requestedRootURL: .init(statusCode: 404, data: Data()), @@ -156,6 +161,7 @@ struct AppleDocumentationClientSearchTests { ) ) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: SearchTestTransport( responses: [ rootURL: rootSearchPage, diff --git a/Tests/CLITests/client/AppleDocumentationClientTechnologyTests.swift b/Tests/CLITests/client/AppleDocumentationClientTechnologyTests.swift index b21eb62..e55a0ab 100644 --- a/Tests/CLITests/client/AppleDocumentationClientTechnologyTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientTechnologyTests.swift @@ -1,4 +1,5 @@ import Foundation +import Logging import Testing @testable import CLI @@ -29,7 +30,10 @@ struct AppleDocumentationClientTechnologyTests { response: response, data: data ) - let client = DefaultAppleDocumentationClient(dependencies: transport) + let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, + dependencies: transport + ) // -- Act -- let technologies = try await client.fetchTechnologies() diff --git a/Tests/CLITests/client/AppleDocumentationClientTests.swift b/Tests/CLITests/client/AppleDocumentationClientTests.swift index 6b05d84..bb1635a 100644 --- a/Tests/CLITests/client/AppleDocumentationClientTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientTests.swift @@ -1,4 +1,5 @@ import Foundation +import Logging import Testing @testable import CLI @@ -43,7 +44,10 @@ struct AppleDocumentationClientTests { response: response, data: data ) - let client = DefaultAppleDocumentationClient(dependencies: transport) + let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, + dependencies: transport + ) let document = try await client.fetchType( named: "MXHangDiagnostic", @@ -69,6 +73,7 @@ struct AppleDocumentationClientTests { ) let data = Data("{\"newUpstreamShape\":true}".utf8) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: TypePageTransport( expectedURL: expectedURL, response: response, @@ -104,6 +109,7 @@ struct AppleDocumentationClientTests { ) ) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: TypePageTransport( expectedURL: expectedURL, response: response, @@ -132,6 +138,7 @@ struct AppleDocumentationClientTests { ) ) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: TypePageTransport( expectedURL: expectedURL, response: response, diff --git a/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift b/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift index 4f61bcd..1c79881 100644 --- a/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientTypeListTests.swift @@ -1,4 +1,5 @@ import Foundation +import Logging import Testing @testable import CLI @@ -16,6 +17,7 @@ struct AppleDocumentationClientTypeListTests { URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftdata.json") ) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: TypeCatalogTestTransport( responses: [rootURL: .init(statusCode: 200, data: swiftDataRootData)] ) @@ -56,6 +58,7 @@ struct AppleDocumentationClientTypeListTests { URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json") ) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: TypeCatalogTestTransport( responses: [ requestedRootURL: .init(statusCode: 404, data: Data()), @@ -92,6 +95,7 @@ struct AppleDocumentationClientTypeListTests { URL(string: "https://developer.apple.com/tutorials/data/documentation/technologies.json") ) let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: TypeCatalogTestTransport( responses: [ rootURL: .init(statusCode: 404, data: Data()), diff --git a/Tests/CLITests/client/ClientLogRecorder.swift b/Tests/CLITests/client/ClientLogRecorder.swift new file mode 100644 index 0000000..03416f3 --- /dev/null +++ b/Tests/CLITests/client/ClientLogRecorder.swift @@ -0,0 +1,36 @@ +import Logging +import Synchronization + +@available(macOS 15, *) +final class ClientLogRecorder: Sendable { + private let storage = Mutex<[LogEvent]>([]) + + var events: [LogEvent] { + storage.withLock { $0 } + } + + func logger() -> Logger { + Logger(label: "test.client") { _ in + RecordingHandler(recorder: self) + } + } + + private func append(_ event: LogEvent) { + storage.withLock { $0.append(event) } + } + + private struct RecordingHandler: LogHandler { + let recorder: ClientLogRecorder + var logLevel: Logger.Level = .trace + var metadata: Logger.Metadata = [:] + + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } + + func log(event: LogEvent) { + recorder.append(event) + } + } +}