From f29561e0e9929e9ced1341c4c4821595bfbc936b Mon Sep 17 00:00:00 2001 From: gayoung Date: Thu, 26 Mar 2026 16:40:56 +0900 Subject: [PATCH] feat: harden gemini provider path --- .../diff2test/android/cli/D2tConfigTest.kt | 21 ++++++ docs/providers/gemini.md | 37 ++++++++++ .../OpenAiResponsesTestGeneratorTest.kt | 68 +++++++++++++++++-- 3 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 docs/providers/gemini.md diff --git a/apps/cli/src/test/kotlin/dev/diff2test/android/cli/D2tConfigTest.kt b/apps/cli/src/test/kotlin/dev/diff2test/android/cli/D2tConfigTest.kt index 023acc9..7880fbb 100644 --- a/apps/cli/src/test/kotlin/dev/diff2test/android/cli/D2tConfigTest.kt +++ b/apps/cli/src/test/kotlin/dev/diff2test/android/cli/D2tConfigTest.kt @@ -142,6 +142,27 @@ class D2tConfigTest { assertContains(report, "Supported now: yes") } + @Test + fun `defaults gemini config to native generate content protocol`() { + val configFile = Files.createTempFile("d2t-gemini-config", ".toml") + Files.writeString( + configFile, + """ + [ai] + provider = "gemini" + """.trimIndent(), + ) + val loadResult = loadConfig(configFile) + val resolved = resolveAiConfiguration(loadResult, mapOf("GEMINI_API_KEY" to "sk-gem")) + + assertEquals(AiProvider.GEMINI, resolved?.provider) + assertEquals(AiProtocol.GEMINI_GENERATE_CONTENT, resolved?.protocol) + assertEquals("GEMINI_API_KEY", resolved?.apiKeyEnv) + assertEquals("gemini-2.5-pro", resolved?.model) + assertEquals("https://generativelanguage.googleapis.com/v1beta", resolved?.baseUrl) + assertTrue(resolved?.supportedByGenerator == true) + } + @Test fun `template does not include actual secrets`() { val template = defaultConfigTemplate() diff --git a/docs/providers/gemini.md b/docs/providers/gemini.md new file mode 100644 index 0000000..0220dfb --- /dev/null +++ b/docs/providers/gemini.md @@ -0,0 +1,37 @@ +# Gemini Provider + +`d2t` supports the native Gemini GenerateContent API. + +## Config + +```toml +[ai] +enabled = true +provider = "gemini" +protocol = "gemini-generate-content" +api_key_env = "GEMINI_API_KEY" +model = "gemini-2.5-pro" +base_url = "https://generativelanguage.googleapis.com/v1beta" +connect_timeout_seconds = 30 +request_timeout_seconds = 300 +``` + +## Environment + +```bash +export GEMINI_API_KEY="..." +``` + +## Verify + +```bash +d2t doctor +d2t auto --ai +``` + +## Notes + +- `d2t` calls `POST /v1beta/models/{model}:generateContent` +- authentication is sent with `x-goog-api-key` +- the request asks Gemini for JSON output with the generated test schema +- use `--strict-ai` if you want the command to fail instead of falling back when AI generation is rejected diff --git a/modules/test-generator/src/test/kotlin/dev/diff2test/android/testgenerator/OpenAiResponsesTestGeneratorTest.kt b/modules/test-generator/src/test/kotlin/dev/diff2test/android/testgenerator/OpenAiResponsesTestGeneratorTest.kt index f4a26d3..3feb00f 100644 --- a/modules/test-generator/src/test/kotlin/dev/diff2test/android/testgenerator/OpenAiResponsesTestGeneratorTest.kt +++ b/modules/test-generator/src/test/kotlin/dev/diff2test/android/testgenerator/OpenAiResponsesTestGeneratorTest.kt @@ -26,9 +26,10 @@ import javax.net.ssl.SSLContext import javax.net.ssl.SSLParameters import javax.net.ssl.SSLSession import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull import kotlin.test.assertTrue class OpenAiResponsesTestGeneratorTest { @@ -201,6 +202,30 @@ class OpenAiResponsesTestGeneratorTest { assertEquals(listOf("gemini"), payload.warnings) } + @Test + fun `gemini generator sends native header to generate content endpoint`() { + val capture = RequestCapture() + val generator = GeminiGenerateContentTestGenerator( + config = GeminiGenerateContentConfig( + apiKey = "sk-gem", + model = "gemini-2.5-pro", + baseUrl = "https://generativelanguage.googleapis.com/v1beta", + ), + httpClient = capturingHttpClient(capture, 200, geminiResponseBody()), + ) + + val bundle = generator.generate(plan(), context(), analysis()) + + assertContains(bundle.files.single().content, "class SignUpViewModelGeneratedTest") + val request = assertNotNull(capture.request) + assertEquals( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent", + request.uri().toString(), + ) + assertEquals("sk-gem", request.headers().firstValue("x-goog-api-key").orElse(null)) + assertEquals("application/json", request.headers().firstValue("Accept").orElse(null)) + } + @Test fun `normalizes junit test imports to kotlin test`() { val content = """ @@ -588,7 +613,19 @@ class OpenAiResponsesTestGeneratorTest { ) } + private data class RequestCapture( + var request: HttpRequest? = null, + ) + private fun fakeHttpClient(statusCode: Int, body: String): HttpClient { + return capturingHttpClient(RequestCapture(), statusCode, body) + } + + private fun capturingHttpClient( + capture: RequestCapture, + statusCode: Int, + body: String, + ): HttpClient { return object : HttpClient() { override fun cookieHandler(): Optional = Optional.empty() override fun connectTimeout(): Optional = Optional.empty() @@ -603,8 +640,13 @@ class OpenAiResponsesTestGeneratorTest { request: HttpRequest?, responseBodyHandler: HttpResponse.BodyHandler?, ): HttpResponse { + capture.request = request @Suppress("UNCHECKED_CAST") - return fakeResponse(statusCode, body) as HttpResponse + return fakeResponse( + statusCode = statusCode, + body = body, + uri = request?.uri()?.toString() ?: "http://127.0.0.1:12345/responses", + ) as HttpResponse } override fun sendAsync( @@ -624,16 +666,32 @@ class OpenAiResponsesTestGeneratorTest { } } - private fun fakeResponse(statusCode: Int, body: String): HttpResponse { + private fun fakeResponse(statusCode: Int, body: String, uri: String): HttpResponse { return object : HttpResponse { override fun statusCode(): Int = statusCode - override fun request(): HttpRequest = HttpRequest.newBuilder().uri(URI.create("http://127.0.0.1:12345/responses")).build() + override fun request(): HttpRequest = HttpRequest.newBuilder().uri(URI.create(uri)).build() override fun previousResponse(): Optional> = Optional.empty() override fun headers(): HttpHeaders = HttpHeaders.of(emptyMap()) { _, _ -> true } override fun body(): String = body override fun sslSession(): Optional = Optional.empty() - override fun uri(): URI = URI.create("http://127.0.0.1:12345/responses") + override fun uri(): URI = URI.create(uri) override fun version(): HttpClient.Version = HttpClient.Version.HTTP_1_1 } } + + private fun geminiResponseBody(): String = """ + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "{\"content\":\"package com.example.auth\\n\\nclass SignUpViewModelGeneratedTest\",\"warnings\":[]}" + } + ] + } + } + ] + } + """.trimIndent() }