diff --git a/FirebaseAI.podspec b/FirebaseAI.podspec index 0df48cf0a70..65fa42d28f6 100644 --- a/FirebaseAI.podspec +++ b/FirebaseAI.podspec @@ -63,7 +63,7 @@ Build AI-powered apps and features with the Gemini API using the Firebase AI SDK unit_tests_dir + 'Snippets/**/*.swift', ] unit_tests.resources = [ - unit_tests_dir + 'vertexai-sdk-test-data/mock-responses/vertexai', + unit_tests_dir + 'vertexai-sdk-test-data/mock-responses', unit_tests_dir + 'Resources/**/*', ] end diff --git a/FirebaseAI/Tests/Unit/ChatTests.swift b/FirebaseAI/Tests/Unit/ChatTests.swift index 72dd14fa448..40373a47494 100644 --- a/FirebaseAI/Tests/Unit/ChatTests.swift +++ b/FirebaseAI/Tests/Unit/ChatTests.swift @@ -40,57 +40,58 @@ final class ChatTests: XCTestCase { let fileURL = try XCTUnwrap(bundle.url( forResource: "streaming-success-basic-reply-parts", withExtension: "txt", - subdirectory: "vertexai" + subdirectory: "mock-responses/vertexai" )) // Skip tests using MockURLProtocol on watchOS; unsupported in watchOS 2 and later, see // https://developer.apple.com/documentation/foundation/urlprotocol for details. #if os(watchOS) throw XCTSkip("Custom URL protocols are unsupported in watchOS 2 and later.") - #endif // os(watchOS) - MockURLProtocol.requestHandler = { request in - let response = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! - return (response, fileURL.lines) - } + #else // os(watchOS) + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (response, fileURL.lines) + } - let app = FirebaseApp(instanceWithName: "testApp", - options: FirebaseOptions(googleAppID: "ignore", - gcmSenderID: "ignore")) - let model = GenerativeModel( - modelName: modelName, - modelResourceName: modelResourceName, - firebaseInfo: FirebaseInfo( - projectID: "my-project-id", - apiKey: "API_KEY", - firebaseAppID: "My app ID", - firebaseApp: app - ), - apiConfig: FirebaseAI.defaultVertexAIAPIConfig, - tools: nil, - requestOptions: RequestOptions(), - urlSession: urlSession - ) - let chat = Chat(model: model, history: []) - let input = "Test input" - let stream = try chat.sendMessageStream(input) + let app = FirebaseApp(instanceWithName: "testApp", + options: FirebaseOptions(googleAppID: "ignore", + gcmSenderID: "ignore")) + let model = GenerativeModel( + modelName: modelName, + modelResourceName: modelResourceName, + firebaseInfo: FirebaseInfo( + projectID: "my-project-id", + apiKey: "API_KEY", + firebaseAppID: "My app ID", + firebaseApp: app + ), + apiConfig: FirebaseAI.defaultVertexAIAPIConfig, + tools: nil, + requestOptions: RequestOptions(), + urlSession: urlSession + ) + let chat = Chat(model: model, history: []) + let input = "Test input" + let stream = try chat.sendMessageStream(input) - // Ensure the values are parsed correctly - for try await value in stream { - XCTAssertNotNil(value.text) - } + // Ensure the values are parsed correctly + for try await value in stream { + XCTAssertNotNil(value.text) + } - XCTAssertEqual(chat.history.count, 2) - let part = try XCTUnwrap(chat.history[0].parts[0]) - let textPart = try XCTUnwrap(part as? TextPart) - XCTAssertEqual(textPart.text, input) + XCTAssertEqual(chat.history.count, 2) + let part = try XCTUnwrap(chat.history[0].parts[0]) + let textPart = try XCTUnwrap(part as? TextPart) + XCTAssertEqual(textPart.text, input) - let finalText = "1 2 3 4 5 6 7 8" - let assembledExpectation = ModelContent(role: "model", parts: finalText) - XCTAssertEqual(chat.history[1], assembledExpectation) + let finalText = "1 2 3 4 5 6 7 8" + let assembledExpectation = ModelContent(role: "model", parts: finalText) + XCTAssertEqual(chat.history[1], assembledExpectation) + #endif // os(watchOS) } } diff --git a/FirebaseAI/Tests/Unit/Fakes/AppCheckInteropFake.swift b/FirebaseAI/Tests/Unit/Fakes/AppCheckInteropFake.swift new file mode 100644 index 00000000000..62fc753ae68 --- /dev/null +++ b/FirebaseAI/Tests/Unit/Fakes/AppCheckInteropFake.swift @@ -0,0 +1,66 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAppCheckInterop +import Foundation + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +class AppCheckInteropFake: NSObject, AppCheckInterop { + /// The placeholder token value returned when an error occurs + static let placeholderTokenValue = "placeholder-token" + + var token: String + var error: Error? + + private init(token: String, error: Error?) { + self.token = token + self.error = error + } + + convenience init(token: String) { + self.init(token: token, error: nil) + } + + convenience init(error: Error) { + self.init(token: AppCheckInteropFake.placeholderTokenValue, error: error) + } + + func getToken(forcingRefresh: Bool) async -> any FIRAppCheckTokenResultInterop { + return AppCheckTokenResultInteropFake(token: token, error: error) + } + + func tokenDidChangeNotificationName() -> String { + fatalError("\(#function) not implemented.") + } + + func notificationTokenKey() -> String { + fatalError("\(#function) not implemented.") + } + + func notificationAppNameKey() -> String { + fatalError("\(#function) not implemented.") + } + + private class AppCheckTokenResultInteropFake: NSObject, FIRAppCheckTokenResultInterop { + var token: String + var error: Error? + + init(token: String, error: Error?) { + self.token = token + self.error = error + } + } +} + +struct AppCheckErrorFake: Error {} diff --git a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift new file mode 100644 index 00000000000..130dc47b8b4 --- /dev/null +++ b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift @@ -0,0 +1,392 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAppCheckInterop +import FirebaseAuthInterop +import FirebaseCore +import XCTest + +@testable import FirebaseAI + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +final class GenerativeModelGoogleAITests: XCTestCase { + let testPrompt = "What sorts of questions can I ask you?" + let safetyRatingsNegligible: [SafetyRating] = [ + .init( + category: .sexuallyExplicit, + probability: .negligible, + probabilityScore: 0.0, + severity: SafetyRating.HarmSeverity(rawValue: "HARM_SEVERITY_UNSPECIFIED"), + severityScore: 0.0, + blocked: false + ), + .init( + category: .hateSpeech, + probability: .negligible, + probabilityScore: 0.0, + severity: SafetyRating.HarmSeverity(rawValue: "HARM_SEVERITY_UNSPECIFIED"), + severityScore: 0.0, + blocked: false + ), + .init( + category: .harassment, + probability: .negligible, + probabilityScore: 0.0, + severity: SafetyRating.HarmSeverity(rawValue: "HARM_SEVERITY_UNSPECIFIED"), + severityScore: 0.0, + blocked: false + ), + .init( + category: .dangerousContent, + probability: .negligible, + probabilityScore: 0.0, + severity: SafetyRating.HarmSeverity(rawValue: "HARM_SEVERITY_UNSPECIFIED"), + severityScore: 0.0, + blocked: false + ), + ].sorted() + let testModelName = "test-model" + let testModelResourceName = "projects/test-project-id/models/test-model" + let apiConfig = FirebaseAI.defaultVertexAIAPIConfig + + let googleAISubdirectory = "mock-responses/googleai" + + var urlSession: URLSession! + var model: GenerativeModel! + + override func setUp() async throws { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + urlSession = try XCTUnwrap(URLSession(configuration: configuration)) + model = GenerativeModel( + modelName: testModelName, + modelResourceName: testModelResourceName, + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(), + apiConfig: apiConfig, + tools: nil, + requestOptions: RequestOptions(), + urlSession: urlSession + ) + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + } + + // MARK: - Generate Content + + func testGenerateContent_success_basicReplyLong() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-basic-reply-long", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + let finishReason = try XCTUnwrap(candidate.finishReason) + XCTAssertEqual(finishReason, .stop) + XCTAssertEqual(candidate.safetyRatings.count, 4) + XCTAssertEqual(candidate.content.parts.count, 1) + let part = try XCTUnwrap(candidate.content.parts.first) + let partText = try XCTUnwrap(part as? TextPart).text + XCTAssertTrue(partText.hasPrefix("Making professional-quality")) + XCTAssertEqual(response.text, partText) + XCTAssertEqual(response.functionCalls, []) + XCTAssertEqual(response.inlineDataParts, []) + } + + func testGenerateContent_success_basicReplyShort() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-basic-reply-short", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + let finishReason = try XCTUnwrap(candidate.finishReason) + XCTAssertEqual(finishReason, .stop) + XCTAssertEqual(candidate.safetyRatings.sorted(), safetyRatingsNegligible) + XCTAssertEqual(candidate.content.parts.count, 1) + let part = try XCTUnwrap(candidate.content.parts.first) + let textPart = try XCTUnwrap(part as? TextPart) + XCTAssertTrue(textPart.text.hasPrefix("Google's headquarters")) + XCTAssertEqual(response.text, textPart.text) + XCTAssertEqual(response.functionCalls, []) + XCTAssertEqual(response.inlineDataParts, []) + } + + func testGenerateContent_success_citations() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-citations", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + XCTAssertEqual(candidate.content.parts.count, 1) + let text = try XCTUnwrap(response.text) + XCTAssertTrue(text.hasPrefix("Okay, let's break down quantum mechanics.")) + let citationMetadata = try XCTUnwrap(candidate.citationMetadata) + XCTAssertEqual(citationMetadata.citations.count, 4) + let citationSource1 = try XCTUnwrap(citationMetadata.citations[0]) + XCTAssertEqual(citationSource1.uri, "https://www.example.com/some-citation-1") + XCTAssertEqual(citationSource1.startIndex, 548) + XCTAssertEqual(citationSource1.endIndex, 690) + XCTAssertNil(citationSource1.title) + XCTAssertEqual(citationSource1.license, "mit") + XCTAssertNil(citationSource1.publicationDate) + let citationSource2 = try XCTUnwrap(citationMetadata.citations[1]) + XCTAssertEqual(citationSource2.uri, "https://www.example.com/some-citation-1") + XCTAssertEqual(citationSource2.startIndex, 1240) + XCTAssertEqual(citationSource2.endIndex, 1407) + XCTAssertNil(citationSource2.title, "some-citation-2") + XCTAssertNil(citationSource2.license) + XCTAssertNil(citationSource2.publicationDate) + let citationSource3 = try XCTUnwrap(citationMetadata.citations[2]) + XCTAssertEqual(citationSource3.startIndex, 1942) + XCTAssertEqual(citationSource3.endIndex, 2149) + XCTAssertNil(citationSource3.uri) + XCTAssertNil(citationSource3.license) + XCTAssertNil(citationSource3.title) + XCTAssertNil(citationSource3.publicationDate) + let citationSource4 = try XCTUnwrap(citationMetadata.citations[3]) + XCTAssertEqual(citationSource4.startIndex, 2036) + XCTAssertEqual(citationSource4.endIndex, 2175) + XCTAssertNil(citationSource4.uri) + XCTAssertNil(citationSource4.license) + XCTAssertNil(citationSource4.title) + XCTAssertNil(citationSource4.publicationDate) + } + + func testGenerateContent_usageMetadata() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-basic-reply-short", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + let usageMetadata = try XCTUnwrap(response.usageMetadata) + XCTAssertEqual(usageMetadata.promptTokenCount, 7) + XCTAssertEqual(usageMetadata.promptTokensDetails.count, 1) + XCTAssertEqual(usageMetadata.promptTokensDetails[0].modality, .text) + XCTAssertEqual(usageMetadata.promptTokensDetails[0].tokenCount, 7) + XCTAssertEqual(usageMetadata.candidatesTokenCount, 22) + XCTAssertEqual(usageMetadata.candidatesTokensDetails.count, 1) + XCTAssertEqual(usageMetadata.candidatesTokensDetails[0].modality, .text) + XCTAssertEqual(usageMetadata.candidatesTokensDetails[0].tokenCount, 22) + } + + func testGenerateContent_failure_invalidAPIKey() async throws { + let expectedStatusCode = 400 + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-failure-api-key", + withExtension: "json", + subdirectory: googleAISubdirectory, + statusCode: expectedStatusCode + ) + + do { + _ = try await model.generateContent(testPrompt) + XCTFail("Should throw GenerateContentError.internalError; no error thrown.") + } catch let GenerateContentError.internalError(error as BackendError) { + XCTAssertEqual(error.httpResponseCode, 400) + XCTAssertEqual(error.status, .invalidArgument) + XCTAssertEqual(error.message, "API key not valid. Please pass a valid API key.") + XCTAssertTrue(error.localizedDescription.contains(error.message)) + XCTAssertTrue(error.localizedDescription.contains(error.status.rawValue)) + XCTAssertTrue(error.localizedDescription.contains("\(error.httpResponseCode)")) + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "\(Constants.baseErrorDomain).\(BackendError.self)") + XCTAssertEqual(nsError.code, error.httpResponseCode) + return + } catch { + XCTFail("Should throw GenerateContentError.internalError(RPCError); error thrown: \(error)") + } + } + + func testGenerateContent_failure_finishReasonSafety() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-failure-finish-reason-safety", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + do { + _ = try await model.generateContent(testPrompt) + XCTFail("Should throw") + } catch let GenerateContentError.responseStoppedEarly(reason, response) { + XCTAssertEqual(reason, .safety) + XCTAssertEqual(response.text, "Safety error incoming in 5, 4, 3, 2...") + } catch { + XCTFail("Should throw a responseStoppedEarly") + } + } + + func testGenerateContent_failure_unknownModel() async throws { + let expectedStatusCode = 404 + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-failure-unknown-model", + withExtension: "json", + subdirectory: googleAISubdirectory, + statusCode: 404 + ) + + do { + _ = try await model.generateContent(testPrompt) + XCTFail("Should throw GenerateContentError.internalError; no error thrown.") + } catch let GenerateContentError.internalError(underlying: rpcError as BackendError) { + XCTAssertEqual(rpcError.status, .notFound) + XCTAssertEqual(rpcError.httpResponseCode, expectedStatusCode) + XCTAssertTrue(rpcError.message.hasPrefix("models/gemini-5.0-flash is not found")) + } catch { + XCTFail("Should throw GenerateContentError.internalError; error thrown: \(error)") + } + } + + // MARK: - Generate Content (Streaming) + + func testGenerateContentStream_successBasicReplyLong() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-basic-reply-long", + withExtension: "txt", + subdirectory: googleAISubdirectory + ) + + var responses = 0 + let stream = try model.generateContentStream("Hi") + for try await content in stream { + XCTAssertNotNil(content.text) + responses += 1 + } + + XCTAssertEqual(responses, 36) + } + + func testGenerateContentStream_successBasicReplyShort() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-basic-reply-short", + withExtension: "txt", + subdirectory: googleAISubdirectory + ) + + var responses = 0 + let stream = try model.generateContentStream("Hi") + for try await content in stream { + XCTAssertNotNil(content.text) + responses += 1 + } + + XCTAssertEqual(responses, 3) + } + + func testGenerateContentStream_successWithCitations() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-citations", + withExtension: "txt", + subdirectory: googleAISubdirectory + ) + + let stream = try model.generateContentStream("Hi") + var citations = [Citation]() + var responses = [GenerateContentResponse]() + for try await content in stream { + responses.append(content) + XCTAssertNotNil(content.text) + let candidate = try XCTUnwrap(content.candidates.first) + if let sources = candidate.citationMetadata?.citations { + citations.append(contentsOf: sources) + } + } + + let lastCandidate = try XCTUnwrap(responses.last?.candidates.first) + XCTAssertEqual(lastCandidate.finishReason, .stop) + XCTAssertEqual(citations.count, 1) + let citation = try XCTUnwrap(citations.first) + XCTAssertEqual(citation.startIndex, 111) + XCTAssertEqual(citation.endIndex, 236) + let citationURI = try XCTUnwrap(citation.uri) + XCTAssertTrue(citationURI.starts(with: "https://www.")) + XCTAssertNil(citation.license) + XCTAssertNil(citation.title) + XCTAssertNil(citation.publicationDate) + } + + func testGenerateContentStream_failureInvalidAPIKey() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-failure-api-key", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + do { + let stream = try model.generateContentStream("Hi") + for try await _ in stream { + XCTFail("No content is there, this shouldn't happen.") + } + } catch let GenerateContentError.internalError(error as BackendError) { + XCTAssertEqual(error.httpResponseCode, 400) + XCTAssertEqual(error.status, .invalidArgument) + XCTAssertEqual(error.message, "API key not valid. Please pass a valid API key.") + XCTAssertTrue(error.localizedDescription.contains(error.message)) + XCTAssertTrue(error.localizedDescription.contains(error.status.rawValue)) + XCTAssertTrue(error.localizedDescription.contains("\(error.httpResponseCode)")) + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "\(Constants.baseErrorDomain).\(BackendError.self)") + XCTAssertEqual(nsError.code, error.httpResponseCode) + return + } + + XCTFail("Should have caught an error.") + } + + func testGenerateContentStream_failureFinishRecitation() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-failure-recitation-no-content", + withExtension: "txt", + subdirectory: googleAISubdirectory + ) + + var responses = [GenerateContentResponse]() + do { + let stream = try model.generateContentStream("Hi") + for try await response in stream { + responses.append(response) + } + XCTFail("Expected a GenerateContentError.responseStoppedEarly error, but got no error.") + } catch let GenerateContentError.responseStoppedEarly(reason, response) { + XCTAssertEqual(reason, .recitation) + let candidate = try XCTUnwrap(response.candidates.first) + XCTAssertEqual(candidate.finishReason, reason) + } catch { + XCTFail("Expected a GenerateContentError.responseStoppedEarly error, but got error: \(error)") + } + + XCTAssertEqual(responses.count, 8) + let firstResponse = try XCTUnwrap(responses.first) + XCTAssertEqual(firstResponse.text, "text1") + let lastResponse = try XCTUnwrap(responses.last) + XCTAssertEqual(lastResponse.text, "text8") + } +} diff --git a/FirebaseAI/Tests/Unit/GenerativeModelTests.swift b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift similarity index 84% rename from FirebaseAI/Tests/Unit/GenerativeModelTests.swift rename to FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift index 89bd8a13699..aa58238705c 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelTests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift @@ -20,7 +20,7 @@ import XCTest @testable import FirebaseAI @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -final class GenerativeModelTests: XCTestCase { +final class GenerativeModelVertexAITests: XCTestCase { let testPrompt = "What sorts of questions can I ask you?" let safetyRatingsNegligible: [SafetyRating] = [ .init( @@ -61,7 +61,7 @@ final class GenerativeModelTests: XCTestCase { "projects/test-project-id/locations/test-location/publishers/google/models/test-model" let apiConfig = FirebaseAI.defaultVertexAIAPIConfig - let vertexSubdirectory = "vertexai" + let vertexSubdirectory = "mock-responses/vertexai" var urlSession: URLSession! var model: GenerativeModel! @@ -73,7 +73,7 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(), apiConfig: apiConfig, tools: nil, requestOptions: RequestOptions(), @@ -89,7 +89,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_success_basicReplyLong() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-reply-long", withExtension: "json", subdirectory: vertexSubdirectory @@ -108,11 +108,12 @@ final class GenerativeModelTests: XCTestCase { XCTAssertTrue(partText.hasPrefix("1. **Use Freshly Ground Coffee**:")) XCTAssertEqual(response.text, partText) XCTAssertEqual(response.functionCalls, []) + XCTAssertEqual(response.inlineDataParts, []) } func testGenerateContent_success_basicReplyShort() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-reply-short", withExtension: "json", subdirectory: vertexSubdirectory @@ -135,7 +136,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_success_basicReplyFullUsageMetadata() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-response-long-usage-metadata", withExtension: "json", subdirectory: vertexSubdirectory @@ -160,7 +161,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_success_citations() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-citations", withExtension: "json", subdirectory: vertexSubdirectory @@ -205,7 +206,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_success_quoteReply() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-quote-reply", withExtension: "json", subdirectory: vertexSubdirectory @@ -256,7 +257,7 @@ final class GenerativeModelTests: XCTestCase { ), ] MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-unknown-enum-safety-ratings", withExtension: "json", subdirectory: vertexSubdirectory @@ -271,7 +272,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_success_prefixedModelName() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-reply-short", withExtension: "json", subdirectory: vertexSubdirectory @@ -279,7 +280,7 @@ final class GenerativeModelTests: XCTestCase { let model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(), apiConfig: apiConfig, tools: nil, requestOptions: RequestOptions(), @@ -291,7 +292,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_success_functionCall_emptyArguments() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-function-call-empty-arguments", withExtension: "json", subdirectory: vertexSubdirectory @@ -314,7 +315,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_success_functionCall_noArguments() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-function-call-no-arguments", withExtension: "json", subdirectory: vertexSubdirectory @@ -337,7 +338,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_success_functionCall_withArguments() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-function-call-with-arguments", withExtension: "json", subdirectory: vertexSubdirectory @@ -364,7 +365,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_success_functionCall_parallelCalls() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-function-call-parallel-calls", withExtension: "json", subdirectory: vertexSubdirectory @@ -381,7 +382,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_success_functionCall_mixedContent() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-function-call-mixed-content", withExtension: "json", subdirectory: vertexSubdirectory @@ -403,14 +404,16 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(appCheck: AppCheckInteropFake(token: appCheckToken)), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo( + appCheck: AppCheckInteropFake(token: appCheckToken) + ), apiConfig: apiConfig, tools: nil, requestOptions: RequestOptions(), urlSession: urlSession ) MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-reply-short", withExtension: "json", subdirectory: vertexSubdirectory, @@ -425,15 +428,16 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(appCheck: AppCheckInteropFake(token: appCheckToken), - privateAppID: true), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo( + appCheck: AppCheckInteropFake(token: appCheckToken), privateAppID: true + ), apiConfig: apiConfig, tools: nil, requestOptions: RequestOptions(), urlSession: urlSession ) MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-reply-short", withExtension: "json", subdirectory: vertexSubdirectory, @@ -448,14 +452,16 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(appCheck: AppCheckInteropFake(error: AppCheckErrorFake())), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo( + appCheck: AppCheckInteropFake(error: AppCheckErrorFake()) + ), apiConfig: apiConfig, tools: nil, requestOptions: RequestOptions(), urlSession: urlSession ) MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-reply-short", withExtension: "json", subdirectory: vertexSubdirectory, @@ -470,14 +476,16 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(auth: AuthInteropFake(token: authToken)), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo( + auth: AuthInteropFake(token: authToken) + ), apiConfig: apiConfig, tools: nil, requestOptions: RequestOptions(), urlSession: urlSession ) MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-reply-short", withExtension: "json", subdirectory: vertexSubdirectory, @@ -491,14 +499,14 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(auth: AuthInteropFake(token: nil)), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(auth: AuthInteropFake(token: nil)), apiConfig: apiConfig, tools: nil, requestOptions: RequestOptions(), urlSession: urlSession ) MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-reply-short", withExtension: "json", subdirectory: vertexSubdirectory, @@ -512,14 +520,16 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(auth: AuthInteropFake(error: AuthErrorFake())), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo( + auth: AuthInteropFake(error: AuthErrorFake()) + ), apiConfig: apiConfig, tools: nil, requestOptions: RequestOptions(), urlSession: urlSession ) MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-reply-short", withExtension: "json", subdirectory: vertexSubdirectory, @@ -538,7 +548,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_usageMetadata() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-reply-short", withExtension: "json", subdirectory: vertexSubdirectory @@ -557,7 +567,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_invalidAPIKey() async throws { let expectedStatusCode = 400 MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-api-key", withExtension: "json", subdirectory: vertexSubdirectory, @@ -586,7 +596,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_firebaseVertexAIAPINotEnabled() async throws { let expectedStatusCode = 403 MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-firebasevertexai-api-not-enabled", withExtension: "json", subdirectory: vertexSubdirectory, @@ -610,7 +620,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_emptyContent() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-empty-content", withExtension: "json", subdirectory: vertexSubdirectory @@ -634,7 +644,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_finishReasonSafety() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-finish-reason-safety", withExtension: "json", subdirectory: vertexSubdirectory @@ -653,7 +663,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_finishReasonSafety_noContent() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-finish-reason-safety-no-content", withExtension: "json", subdirectory: vertexSubdirectory @@ -673,7 +683,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_imageRejected() async throws { let expectedStatusCode = 400 MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-image-rejected", withExtension: "json", subdirectory: vertexSubdirectory, @@ -694,7 +704,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_promptBlockedSafety() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-prompt-blocked-safety", withExtension: "json", subdirectory: vertexSubdirectory @@ -715,7 +725,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_promptBlockedSafetyWithMessage() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-prompt-blocked-safety-with-message", withExtension: "json", subdirectory: vertexSubdirectory @@ -736,7 +746,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_unknownEnum_finishReason() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-unknown-enum-finish-reason", withExtension: "json", subdirectory: vertexSubdirectory @@ -756,7 +766,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_unknownEnum_promptBlocked() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-unknown-enum-prompt-blocked", withExtension: "json", subdirectory: vertexSubdirectory @@ -777,7 +787,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_unknownModel() async throws { let expectedStatusCode = 404 MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-unknown-model", withExtension: "json", subdirectory: vertexSubdirectory, @@ -797,7 +807,7 @@ final class GenerativeModelTests: XCTestCase { } func testGenerateContent_failure_nonHTTPResponse() async throws { - MockURLProtocol.requestHandler = try nonHTTPRequestHandler() + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.nonHTTPRequestHandler() var responseError: Error? var content: GenerateContentResponse? @@ -821,7 +831,7 @@ final class GenerativeModelTests: XCTestCase { } func testGenerateContent_failure_invalidResponse() async throws { - MockURLProtocol.requestHandler = try httpRequestHandler( + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-invalid-response", withExtension: "json", subdirectory: vertexSubdirectory @@ -852,7 +862,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_failure_malformedContent() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-malformed-content", withExtension: "json", subdirectory: vertexSubdirectory @@ -885,7 +895,7 @@ final class GenerativeModelTests: XCTestCase { } func testGenerateContentMissingSafetyRatings() async throws { - MockURLProtocol.requestHandler = try httpRequestHandler( + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-missing-safety-ratings", withExtension: "json", subdirectory: vertexSubdirectory @@ -900,7 +910,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContent_requestOptions_customTimeout() async throws { let expectedTimeout = 150.0 MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-basic-reply-short", withExtension: "json", subdirectory: vertexSubdirectory, @@ -910,7 +920,7 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(), apiConfig: apiConfig, tools: nil, requestOptions: requestOptions, @@ -926,7 +936,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_failureInvalidAPIKey() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-api-key", withExtension: "json", subdirectory: vertexSubdirectory @@ -956,7 +966,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_failure_vertexAIInFirebaseAPINotEnabled() async throws { let expectedStatusCode = 403 MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-firebasevertexai-api-not-enabled", withExtension: "json", subdirectory: vertexSubdirectory, @@ -982,7 +992,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_failureEmptyContent() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-failure-empty-content", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1003,7 +1013,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_failureFinishReasonSafety() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-failure-finish-reason-safety", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1027,7 +1037,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_failurePromptBlockedSafety() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-failure-prompt-blocked-safety", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1050,7 +1060,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_failurePromptBlockedSafetyWithMessage() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-failure-prompt-blocked-safety-with-message", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1073,7 +1083,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_failureUnknownFinishEnum() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-failure-unknown-finish-enum", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1095,7 +1105,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_successBasicReplyLong() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-success-basic-reply-long", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1113,7 +1123,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_successBasicReplyShort() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-success-basic-reply-short", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1131,7 +1141,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_successUnknownSafetyEnum() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-success-unknown-safety-enum", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1160,7 +1170,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_successWithCitations() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-success-citations", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1215,14 +1225,16 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(appCheck: AppCheckInteropFake(token: appCheckToken)), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo( + appCheck: AppCheckInteropFake(token: appCheckToken) + ), apiConfig: apiConfig, tools: nil, requestOptions: RequestOptions(), urlSession: urlSession ) MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-success-basic-reply-short", withExtension: "txt", subdirectory: vertexSubdirectory, @@ -1237,14 +1249,16 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(appCheck: AppCheckInteropFake(error: AppCheckErrorFake())), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo( + appCheck: AppCheckInteropFake(error: AppCheckErrorFake()) + ), apiConfig: apiConfig, tools: nil, requestOptions: RequestOptions(), urlSession: urlSession ) MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-success-basic-reply-short", withExtension: "txt", subdirectory: vertexSubdirectory, @@ -1257,7 +1271,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_usageMetadata() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-success-basic-reply-short", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1283,7 +1297,7 @@ final class GenerativeModelTests: XCTestCase { } func testGenerateContentStream_errorMidStream() async throws { - MockURLProtocol.requestHandler = try httpRequestHandler( + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-failure-error-mid-stream", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1309,7 +1323,7 @@ final class GenerativeModelTests: XCTestCase { } func testGenerateContentStream_nonHTTPResponse() async throws { - MockURLProtocol.requestHandler = try nonHTTPRequestHandler() + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.nonHTTPRequestHandler() let stream = try model.generateContentStream("Hi") do { @@ -1326,7 +1340,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_invalidResponse() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-failure-invalid-json", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1351,7 +1365,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_malformedContent() async throws { MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-failure-malformed-content", withExtension: "txt", subdirectory: vertexSubdirectory @@ -1378,7 +1392,7 @@ final class GenerativeModelTests: XCTestCase { func testGenerateContentStream_requestOptions_customTimeout() async throws { let expectedTimeout = 150.0 MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-success-basic-reply-short", withExtension: "txt", subdirectory: vertexSubdirectory, @@ -1388,7 +1402,7 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(), apiConfig: apiConfig, tools: nil, requestOptions: requestOptions, @@ -1408,7 +1422,7 @@ final class GenerativeModelTests: XCTestCase { // MARK: - Count Tokens func testCountTokens_succeeds() async throws { - MockURLProtocol.requestHandler = try httpRequestHandler( + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-total-tokens", withExtension: "json", subdirectory: vertexSubdirectory @@ -1421,7 +1435,7 @@ final class GenerativeModelTests: XCTestCase { } func testCountTokens_succeeds_detailed() async throws { - MockURLProtocol.requestHandler = try httpRequestHandler( + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-detailed-token-response", withExtension: "json", subdirectory: vertexSubdirectory @@ -1439,7 +1453,7 @@ final class GenerativeModelTests: XCTestCase { } func testCountTokens_succeeds_allOptions() async throws { - MockURLProtocol.requestHandler = try httpRequestHandler( + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-total-tokens", withExtension: "json", subdirectory: vertexSubdirectory @@ -1465,7 +1479,7 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(), apiConfig: apiConfig, generationConfig: generationConfig, tools: [Tool(functionDeclarations: [sumFunction])], @@ -1481,7 +1495,7 @@ final class GenerativeModelTests: XCTestCase { } func testCountTokens_succeeds_noBillableCharacters() async throws { - MockURLProtocol.requestHandler = try httpRequestHandler( + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-no-billable-characters", withExtension: "json", subdirectory: vertexSubdirectory @@ -1494,7 +1508,7 @@ final class GenerativeModelTests: XCTestCase { } func testCountTokens_modelNotFound() async throws { - MockURLProtocol.requestHandler = try httpRequestHandler( + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-model-not-found", withExtension: "json", subdirectory: vertexSubdirectory, statusCode: 404 @@ -1516,7 +1530,7 @@ final class GenerativeModelTests: XCTestCase { func testCountTokens_requestOptions_customTimeout() async throws { let expectedTimeout = 150.0 MockURLProtocol - .requestHandler = try httpRequestHandler( + .requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-total-tokens", withExtension: "json", subdirectory: vertexSubdirectory, @@ -1526,7 +1540,7 @@ final class GenerativeModelTests: XCTestCase { model = GenerativeModel( modelName: testModelName, modelResourceName: testModelResourceName, - firebaseInfo: testFirebaseInfo(), + firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(), apiConfig: apiConfig, tools: nil, requestOptions: requestOptions, @@ -1537,157 +1551,8 @@ final class GenerativeModelTests: XCTestCase { XCTAssertEqual(response.totalTokens, 6) } - - // MARK: - Helpers - - private func testFirebaseInfo(appCheck: AppCheckInterop? = nil, - auth: AuthInterop? = nil, - privateAppID: Bool = false) -> FirebaseInfo { - let app = FirebaseApp(instanceWithName: "testApp", - options: FirebaseOptions(googleAppID: "ignore", - gcmSenderID: "ignore")) - app.isDataCollectionDefaultEnabled = !privateAppID - return FirebaseInfo( - appCheck: appCheck, - auth: auth, - projectID: "my-project-id", - apiKey: "API_KEY", - firebaseAppID: "My app ID", - firebaseApp: app - ) - } - - private func nonHTTPRequestHandler() throws -> ((URLRequest) -> ( - URLResponse, - AsyncLineSequence? - )) { - // Skip tests using MockURLProtocol on watchOS; unsupported in watchOS 2 and later, see - // https://developer.apple.com/documentation/foundation/urlprotocol for details. - #if os(watchOS) - throw XCTSkip("Custom URL protocols are unsupported in watchOS 2 and later.") - #endif // os(watchOS) - return { request in - // This is *not* an HTTPURLResponse - let response = URLResponse( - url: request.url!, - mimeType: nil, - expectedContentLength: 0, - textEncodingName: nil - ) - return (response, nil) - } - } - - private func httpRequestHandler(forResource name: String, - withExtension ext: String, - subdirectory subpath: String, - statusCode: Int = 200, - timeout: TimeInterval = RequestOptions().timeout, - appCheckToken: String? = nil, - authToken: String? = nil, - dataCollection: Bool = true) throws -> ((URLRequest) throws -> ( - URLResponse, - AsyncLineSequence? - )) { - // Skip tests using MockURLProtocol on watchOS; unsupported in watchOS 2 and later, see - // https://developer.apple.com/documentation/foundation/urlprotocol for details. - #if os(watchOS) - throw XCTSkip("Custom URL protocols are unsupported in watchOS 2 and later.") - #endif // os(watchOS) - let bundle = BundleTestUtil.bundle() - let fileURL = try XCTUnwrap( - bundle.url(forResource: name, withExtension: ext, subdirectory: subpath) - ) - return { request in - let requestURL = try XCTUnwrap(request.url) - XCTAssertEqual(requestURL.path.occurrenceCount(of: "models/"), 1) - XCTAssertEqual(request.timeoutInterval, timeout) - let apiClientTags = try XCTUnwrap(request.value(forHTTPHeaderField: "x-goog-api-client")) - .components(separatedBy: " ") - XCTAssert(apiClientTags.contains(GenerativeAIService.languageTag)) - XCTAssert(apiClientTags.contains(GenerativeAIService.firebaseVersionTag)) - XCTAssertEqual(request.value(forHTTPHeaderField: "X-Firebase-AppCheck"), appCheckToken) - - let firebaseAppID = request.value(forHTTPHeaderField: "X-Firebase-AppId") - let appVersion = request.value(forHTTPHeaderField: "X-Firebase-AppVersion") - let expectedAppVersion = - try? XCTUnwrap(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) - XCTAssertEqual(firebaseAppID, dataCollection ? "My app ID" : nil) - XCTAssertEqual(appVersion, dataCollection ? expectedAppVersion : nil) - - if let authToken { - XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Firebase \(authToken)") - } else { - XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) - } - let response = try XCTUnwrap(HTTPURLResponse( - url: requestURL, - statusCode: statusCode, - httpVersion: nil, - headerFields: nil - )) - return (response, fileURL.lines) - } - } -} - -private extension String { - /// Returns the number of occurrences of `substring` in the `String`. - func occurrenceCount(of substring: String) -> Int { - return components(separatedBy: substring).count - 1 - } } -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -class AppCheckInteropFake: NSObject, AppCheckInterop { - /// The placeholder token value returned when an error occurs - static let placeholderTokenValue = "placeholder-token" - - var token: String - var error: Error? - - private init(token: String, error: Error?) { - self.token = token - self.error = error - } - - convenience init(token: String) { - self.init(token: token, error: nil) - } - - convenience init(error: Error) { - self.init(token: AppCheckInteropFake.placeholderTokenValue, error: error) - } - - func getToken(forcingRefresh: Bool) async -> any FIRAppCheckTokenResultInterop { - return AppCheckTokenResultInteropFake(token: token, error: error) - } - - func tokenDidChangeNotificationName() -> String { - fatalError("\(#function) not implemented.") - } - - func notificationTokenKey() -> String { - fatalError("\(#function) not implemented.") - } - - func notificationAppNameKey() -> String { - fatalError("\(#function) not implemented.") - } - - private class AppCheckTokenResultInteropFake: NSObject, FIRAppCheckTokenResultInterop { - var token: String - var error: Error? - - init(token: String, error: Error?) { - self.token = token - self.error = error - } - } -} - -struct AppCheckErrorFake: Error {} - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) extension SafetyRating: Swift.Comparable { public static func < (lhs: SafetyRating, rhs: SafetyRating) -> Bool { diff --git a/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift b/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift new file mode 100644 index 00000000000..9b00f0b0c87 --- /dev/null +++ b/FirebaseAI/Tests/Unit/TestUtilities/GenerativeModelTestUtil.swift @@ -0,0 +1,127 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAppCheckInterop +import FirebaseAuthInterop +import FirebaseCore +import Foundation +import XCTest + +@testable import FirebaseAI + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +enum GenerativeModelTestUtil { + /// Returns an HTTP request handler + static func httpRequestHandler(forResource name: String, + withExtension ext: String, + subdirectory subpath: String, + statusCode: Int = 200, + timeout: TimeInterval = RequestOptions().timeout, + appCheckToken: String? = nil, + authToken: String? = nil, + dataCollection: Bool = true) throws -> ((URLRequest) throws -> ( + URLResponse, + AsyncLineSequence? + )) { + // Skip tests using MockURLProtocol on watchOS; unsupported in watchOS 2 and later, see + // https://developer.apple.com/documentation/foundation/urlprotocol for details. + #if os(watchOS) + throw XCTSkip("Custom URL protocols are unsupported in watchOS 2 and later.") + #else // os(watchOS) + let bundle = BundleTestUtil.bundle() + let fileURL = try XCTUnwrap( + bundle.url(forResource: name, withExtension: ext, subdirectory: subpath) + ) + return { request in + let requestURL = try XCTUnwrap(request.url) + XCTAssertEqual(requestURL.path.occurrenceCount(of: "models/"), 1) + XCTAssertEqual(request.timeoutInterval, timeout) + let apiClientTags = try XCTUnwrap(request.value(forHTTPHeaderField: "x-goog-api-client")) + .components(separatedBy: " ") + XCTAssert(apiClientTags.contains(GenerativeAIService.languageTag)) + XCTAssert(apiClientTags.contains(GenerativeAIService.firebaseVersionTag)) + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Firebase-AppCheck"), appCheckToken) + + let firebaseAppID = request.value(forHTTPHeaderField: "X-Firebase-AppId") + let appVersion = request.value(forHTTPHeaderField: "X-Firebase-AppVersion") + let expectedAppVersion = + try? XCTUnwrap(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) + XCTAssertEqual(firebaseAppID, dataCollection ? "My app ID" : nil) + XCTAssertEqual(appVersion, dataCollection ? expectedAppVersion : nil) + + if let authToken { + XCTAssertEqual( + request.value(forHTTPHeaderField: "Authorization"), + "Firebase \(authToken)" + ) + } else { + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + } + let response = try XCTUnwrap(HTTPURLResponse( + url: requestURL, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )) + return (response, fileURL.lines) + } + #endif // os(watchOS) + } + + static func nonHTTPRequestHandler() throws -> ((URLRequest) -> ( + URLResponse, + AsyncLineSequence? + )) { + // Skip tests using MockURLProtocol on watchOS; unsupported in watchOS 2 and later, see + // https://developer.apple.com/documentation/foundation/urlprotocol for details. + #if os(watchOS) + throw XCTSkip("Custom URL protocols are unsupported in watchOS 2 and later.") + #else // os(watchOS) + return { request in + // This is *not* an HTTPURLResponse + let response = URLResponse( + url: request.url!, + mimeType: nil, + expectedContentLength: 0, + textEncodingName: nil + ) + return (response, nil) + } + #endif // os(watchOS) + } + + static func testFirebaseInfo(appCheck: AppCheckInterop? = nil, + auth: AuthInterop? = nil, + privateAppID: Bool = false) -> FirebaseInfo { + let app = FirebaseApp(instanceWithName: "testApp", + options: FirebaseOptions(googleAppID: "ignore", + gcmSenderID: "ignore")) + app.isDataCollectionDefaultEnabled = !privateAppID + return FirebaseInfo( + appCheck: appCheck, + auth: auth, + projectID: "my-project-id", + apiKey: "API_KEY", + firebaseAppID: "My app ID", + firebaseApp: app + ) + } +} + +private extension String { + /// Returns the number of occurrences of `substring` in the `String`. + func occurrenceCount(of substring: String) -> Int { + return components(separatedBy: substring).count - 1 + } +} diff --git a/Package.swift b/Package.swift index 1fa0ccc82a6..c121735282e 100644 --- a/Package.swift +++ b/Package.swift @@ -208,7 +208,7 @@ let package = Package( ], path: "FirebaseAI/Tests/Unit", resources: [ - .copy("vertexai-sdk-test-data/mock-responses/vertexai"), + .copy("vertexai-sdk-test-data/mock-responses"), .process("Resources"), ], cSettings: [