diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 8b13789..8300c2f 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1 +1,4 @@ - +dependencies { + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} diff --git a/core/src/main/kotlin/org/openprojectx/ai/plugin/core/GenerationRequest.kt b/core/src/main/kotlin/org/openprojectx/ai/plugin/core/GenerationRequest.kt index c9fc96b..bb8f859 100644 --- a/core/src/main/kotlin/org/openprojectx/ai/plugin/core/GenerationRequest.kt +++ b/core/src/main/kotlin/org/openprojectx/ai/plugin/core/GenerationRequest.kt @@ -15,5 +15,6 @@ data class GenerationRequest( val packageName: String? = null, val className: String, val outputNotes: String?, - val dependentMethodSignatures: String = "" + val dependentMethodSignatures: String = "", + val environmentContext: String = "" ) diff --git a/core/src/main/kotlin/org/openprojectx/ai/plugin/core/PromptBuilder.kt b/core/src/main/kotlin/org/openprojectx/ai/plugin/core/PromptBuilder.kt index db713b4..f25d814 100644 --- a/core/src/main/kotlin/org/openprojectx/ai/plugin/core/PromptBuilder.kt +++ b/core/src/main/kotlin/org/openprojectx/ai/plugin/core/PromptBuilder.kt @@ -33,7 +33,9 @@ object PromptBuilder { Target: Java unit/integration tests for the provided Java source. Requirements: - Generate a single JUnit 5 test class: {{qualifiedClassName}}. - - Include one executable helper method (for local run) that can run this class's tests. + - Do NOT add a main() method or any manual JUnit Platform launcher/runner; the IDE and Maven/Gradle run the tests. + - Only import packages that are available on a standard JUnit 5 test classpath (org.junit.jupiter.*) plus the project's own classes; do not import org.junit.platform.launcher.* or invent dependencies. + - Use only the test libraries listed under "Test Environment" below; do not import assertion/mock libraries that are not listed (e.g. do not assume AssertJ or Mockito unless they appear there). - Focus only on methods present in the provided source; do not invent methods. - Prioritize the method referenced by user notes when provided. - For each tested method, include meaningful assertions for behavior and edge cases. @@ -94,6 +96,10 @@ object PromptBuilder { )) } else "" + val environmentBlock = if (req.environmentContext.isNotBlank()) { + "\n" + req.environmentContext.trim() + } else "" + return render(template.wrapper, mapOf( "contractType" to when (req.contractType) { ContractType.OPENAPI -> "OpenAPI/REST API" @@ -101,7 +107,7 @@ object PromptBuilder { }, "baseUrlHint" to (req.baseUrl ?: "not provided"), "outputNotes" to (req.outputNotes ?: "(none)"), - "frameworkRules" to frameworkRules + dependentMethodsBlock, + "frameworkRules" to frameworkRules + environmentBlock + dependentMethodsBlock, "contractText" to req.contractText )) } diff --git a/core/src/test/kotlin/org/openprojectx/ai/plugin/core/PromptBuilderTest.kt b/core/src/test/kotlin/org/openprojectx/ai/plugin/core/PromptBuilderTest.kt new file mode 100644 index 0000000..be3967f --- /dev/null +++ b/core/src/test/kotlin/org/openprojectx/ai/plugin/core/PromptBuilderTest.kt @@ -0,0 +1,41 @@ +package org.openprojectx.ai.plugin.core + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PromptBuilderTest { + + private fun javaRequest(environmentContext: String) = GenerationRequest( + contractText = "public class Foo { public int bar() { return 1; } }", + framework = Framework.REST_ASSURED, + contractType = ContractType.JAVA, + packageName = "com.example", + className = "FooTest", + outputNotes = null, + environmentContext = environmentContext + ) + + @Test + fun `injects environment context block into the java prompt`() { + val env = "## Test Environment (use ONLY what is listed here)\n" + + "- Java language level: 17\n" + + "- Test libraries available: JUnit 5, AssertJ" + + val prompt = PromptBuilder.build(javaRequest(env)) + + assertTrue( + prompt.contains("## Test Environment (use ONLY what is listed here)"), + "prompt should contain the environment heading" + ) + assertTrue(prompt.contains("Java language level: 17"), "prompt should contain detected language level") + assertTrue(prompt.contains("JUnit 5, AssertJ"), "prompt should contain detected test libraries") + } + + @Test + fun `omits environment block when context is blank`() { + val prompt = PromptBuilder.build(javaRequest("")) + + assertFalse(prompt.contains("## Test Environment"), "no environment heading when nothing was detected") + } +} diff --git a/llm-client/src/test/kotlin/org/openprojectx/ai/plugin/llm/TemplateRequestHandlebarsTest.kt b/llm-client/src/test/kotlin/org/openprojectx/ai/plugin/llm/TemplateRequestHandlebarsTest.kt new file mode 100644 index 0000000..a267a10 --- /dev/null +++ b/llm-client/src/test/kotlin/org/openprojectx/ai/plugin/llm/TemplateRequestHandlebarsTest.kt @@ -0,0 +1,60 @@ +package org.openprojectx.ai.plugin.llm + +import com.github.jknack.handlebars.EscapingStrategy +import com.github.jknack.handlebars.Handlebars +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TemplateRequestHandlebarsTest { + + private val bodyTemplate = """{"username": "{{username}}", "password": "{{password}}"}""" + + @Test + fun `password with double-quote is corrupted by default HTML escaping`() { + val handlebars = Handlebars() + val template = handlebars.compileInline(bodyTemplate) + val rendered = template.apply(mapOf("username" to "user1", "password" to """pass"123""")) + + assertEquals( + """{"username": "user1", "password": "pass"123"}""", + rendered + ) + } + + @Test + fun `password with ampersand is corrupted by default HTML escaping`() { + val handlebars = Handlebars() + val template = handlebars.compileInline(bodyTemplate) + val rendered = template.apply(mapOf("username" to "user1", "password" to "pass&123")) + + assertEquals( + """{"username": "user1", "password": "pass&123"}""", + rendered + ) + } + + @Test + fun `password with special chars is preserved with no-op escaping strategy`() { + val handlebars = Handlebars().with(EscapingStrategy { it }) + val template = handlebars.compileInline(bodyTemplate) + + val passwordsToTest = listOf( + """pass"123""", + "pass&123", + "pass<456", + "pass>456", + "pass'456", + "normal_password", + "P@ssw0rd!@#$%^" + ) + + for (password in passwordsToTest) { + val rendered = template.apply(mapOf("username" to "user1", "password" to password)) + assertEquals( + """{"username": "user1", "password": "$password"}""", + rendered, + "Password '$password' should be preserved verbatim in rendered output" + ) + } + } +} diff --git a/plugin-idea/build.gradle.kts b/plugin-idea/build.gradle.kts index 5007fc8..6fc2ec2 100644 --- a/plugin-idea/build.gradle.kts +++ b/plugin-idea/build.gradle.kts @@ -5,11 +5,11 @@ plugins { signing } -val restrictedNetworkMode = providers.gradleProperty("restrictedNetworkMode") - .map(String::toBooleanStrictOrNull) +val restrictedNetworkMode: Boolean = providers.gradleProperty("restrictedNetworkMode") + .map { it.toBooleanStrictOrNull() ?: false } .orElse( providers.environmentVariable("RESTRICTED_NETWORK_MODE") - .map(String::toBooleanStrictOrNull) + .map { it.toBooleanStrictOrNull() ?: false } ) .getOrElse(false) val localIntellijPlatformPath = providers.gradleProperty("intellijPlatformLocalPath") diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt index 3bb980f..97403b3 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsConfigurable.kt @@ -53,6 +53,10 @@ class AiTestSettingsConfigurable( private lateinit var httpDisableTlsVerification: JCheckBox private lateinit var showLogTabCheckbox: JCheckBox + private lateinit var sharedUsernameField: JTextField + private lateinit var sharedPasswordField: JPasswordField + private lateinit var advancedModeCheckbox: JCheckBox + private lateinit var llmTemplateEnabled: JCheckBox private lateinit var llmTemplateMethod: JComboBox private lateinit var llmTemplateUrl: JTextField @@ -71,12 +75,9 @@ class AiTestSettingsConfigurable( private lateinit var loginPanel: JPanel private lateinit var loginCardPanel: JPanel - private lateinit var generationPromptWrapperField: JTextArea private lateinit var generationPromptRestAssuredField: JTextArea private lateinit var generationPromptKarateField: JTextArea - private lateinit var commitPromptField: JTextArea private lateinit var pullRequestPromptField: JTextArea - private lateinit var branchDiffPromptField: JTextArea private lateinit var generationPromptProfileDefaultField: JTextField private lateinit var generationPromptProfilesYamlField: JTextArea private lateinit var commitPromptProfileDefaultField: JTextField @@ -112,6 +113,9 @@ class AiTestSettingsConfigurable( private var initialState: AiTestSettingsModel = AiTestSettingsModel() private var selectedPromptSelection: PromptSelection? = null private var suppressedGlobalPrompts: Set = emptySet() + private var advancedPanel: JPanel? = null + private var initialSharedUsername: String = "" + private var initialSharedPassword: String = "" private enum class PromptCategory(val label: String) { TEST("Test"), @@ -152,6 +156,11 @@ class AiTestSettingsConfigurable( apiKeyEnvField = JTextField() httpDisableTlsVerification = JCheckBox("Disable TLS certificate verification (insecure, use only on trusted networks)") showLogTabCheckbox = JCheckBox("Show Log tab in AI Context Box") + sharedUsernameField = JTextField() + sharedPasswordField = JPasswordField() + advancedModeCheckbox = JCheckBox("Advanced settings").apply { + addActionListener { updateAdvancedVisibility() } + } llmTemplateEnabled = JCheckBox("Use template-based LLM request") llmTemplateMethod = methodCombo() @@ -183,12 +192,9 @@ class AiTestSettingsConfigurable( title = "Login Request Template" ) - generationPromptWrapperField = textArea(10) generationPromptRestAssuredField = textArea(12) generationPromptKarateField = textArea(10) - commitPromptField = textArea(12) pullRequestPromptField = textArea(14) - branchDiffPromptField = textArea(12) generationPromptProfileDefaultField = JTextField() generationPromptProfilesYamlField = textArea(12) commitPromptProfileDefaultField = JTextField() @@ -230,7 +236,39 @@ class AiTestSettingsConfigurable( addTab("Prompts", promptsTab()) } - val toolbar = JPanel(BorderLayout()).apply { + val importButton = JButton("Import Repo Config").apply { + addActionListener { + usage.record("settings.toolbar.import_repo_config") + val state = collectState() + runOffEdt( + label = "Import repo config", + block = { LlmSettingsLoader.importConfigFromRepo(project, state) }, + onSuccess = { sourcePath -> + reset() + Messages.showInfoMessage( + project, + "Imported config from: $sourcePath", + "Code Quality Improver" + ) + }, + onFailure = { ex -> + Messages.showErrorDialog( + project, + detailedErrorMessage("Import repo config failed", ex), + "Code Quality Improver" + ) + } + ) + } + } + + val advancedContent = JPanel(BorderLayout(0, 8)).apply { + add(JPanel(FlowLayout(FlowLayout.RIGHT, 8, 0)).apply { add(importButton) }, BorderLayout.NORTH) + add(tabs, BorderLayout.CENTER) + } + advancedPanel = advancedContent + + val header = JPanel(BorderLayout()).apply { border = BorderFactory.createCompoundBorder( BorderFactory.createEmptyBorder(12, 12, 8, 12), BorderFactory.createCompoundBorder( @@ -238,50 +276,10 @@ class AiTestSettingsConfigurable( BorderFactory.createEmptyBorder(10, 12, 10, 12) ) ) - add(JLabel("Configure LLM access, login automation, and generation defaults.").apply { + add(JLabel("Sign in with your username and password. Turn on Advanced for provider, prompts, and per-service settings.").apply { horizontalAlignment = SwingConstants.LEFT }, BorderLayout.CENTER) - add(JPanel(FlowLayout(FlowLayout.RIGHT, 8, 0)).apply { - add(JButton("Login Now").apply { - addActionListener { - usage.record("settings.toolbar.login_now") - if (saveLlmStateForLoginNow()) { - LlmAuthSessionService.getInstance(project).loginNowWithFeedback() - } - } - }) - add(JButton("Reload").apply { - addActionListener { - usage.record("settings.toolbar.reload") - reset() - } - }) - add(JButton("Import Repo Config").apply { - addActionListener { - usage.record("settings.toolbar.import_repo_config") - val state = collectState() - runOffEdt( - label = "Import repo config", - block = { LlmSettingsLoader.importConfigFromRepo(project, state) }, - onSuccess = { sourcePath -> - reset() - Messages.showInfoMessage( - project, - "Imported config from: $sourcePath", - "Code Quality Improver" - ) - }, - onFailure = { ex -> - Messages.showErrorDialog( - project, - detailedErrorMessage("Import repo config failed", ex), - "Code Quality Improver" - ) - } - ) - } - }) - }, BorderLayout.EAST) + add(JPanel(FlowLayout(FlowLayout.RIGHT, 8, 0)).apply { add(advancedModeCheckbox) }, BorderLayout.EAST) } pathLabel = JLabel().apply { @@ -294,9 +292,15 @@ class AiTestSettingsConfigurable( }) } + val center = JPanel(BorderLayout(0, 8)).apply { + border = BorderFactory.createEmptyBorder(0, 12, 0, 12) + add(basicPanel(), BorderLayout.NORTH) + add(advancedContent, BorderLayout.CENTER) + } + rootPanel = JPanel(BorderLayout(0, 8)).apply { - add(toolbar, BorderLayout.NORTH) - add(tabs, BorderLayout.CENTER) + add(header, BorderLayout.NORTH) + add(center, BorderLayout.CENTER) add(JPanel(BorderLayout()).apply { border = BorderFactory.createEmptyBorder(0, 12, 12, 12) add(pathLabel, BorderLayout.WEST) @@ -305,15 +309,35 @@ class AiTestSettingsConfigurable( } reset() + updateAdvancedVisibility() return rootPanel as JPanel } - override fun isModified(): Boolean = collectState() != initialState + override fun isModified(): Boolean = + collectState() != initialState || + sharedUsernameField.text.trim() != initialSharedUsername || + String(sharedPasswordField.password).trim() != initialSharedPassword override fun apply() { val state = collectState() - validate(state) + try { + validate(state) + } catch (ex: IllegalArgumentException) { + Messages.showErrorDialog(project, ex.message ?: "Invalid settings", "Settings Error") + return + } LlmSettingsLoader.saveSettingsModel(project, state) + val sharedUsername = sharedUsernameField.text.trim() + val sharedPassword = String(sharedPasswordField.password).trim() + // Only persist when changed, so an Apply before the async prefill completes can't clobber + // a stored credential with blanks. PasswordSafe must not be touched on the EDT, so save off it. + if (sharedUsername != initialSharedUsername || sharedPassword != initialSharedPassword) { + initialSharedUsername = sharedUsername + initialSharedPassword = sharedPassword + ApplicationManager.getApplication().executeOnPooledThread { + SharedCredentialStore.save(sharedUsername, sharedPassword) + } + } initialState = state updatePathLabel() } @@ -323,11 +347,73 @@ class AiTestSettingsConfigurable( applyState(state) initialState = state updatePathLabel() + loadSharedCredentialAsync() } override fun disposeUIResources() { rootPanel = null pathLabel = null + advancedPanel = null + } + + private fun basicPanel(): JComponent { + val loginButton = JButton("Login").apply { + addActionListener { basicLogin() } + } + val reloadButton = JButton("Reload").apply { + addActionListener { + ButtonUsageReportService.getInstance(project).record("settings.toolbar.reload") + reset() + } + } + return formSection("Sign In", listOf( + "Username" to sharedUsernameField, + "Password" to sharedPasswordField, + "" to JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)).apply { + add(loginButton) + add(reloadButton) + } + )) + } + + private fun basicLogin() { + ButtonUsageReportService.getInstance(project).record("settings.toolbar.login_now") + val username = sharedUsernameField.text.trim() + val password = String(sharedPasswordField.password).trim() + if (username.isBlank() || password.isBlank()) { + Messages.showErrorDialog(project, "Username and password are required to log in.", "Code Quality Improver") + return + } + initialSharedUsername = username + initialSharedPassword = password + if (!saveLlmStateForLoginNow()) return + // PasswordSafe access must not run on the EDT; persist the shared credential off-EDT, then log in. + ApplicationManager.getApplication().executeOnPooledThread { + SharedCredentialStore.save(username, password) + ApplicationManager.getApplication().invokeLater({ + LlmAuthSessionService.getInstance(project).loginNowWithFeedback() + }, ModalityState.any()) + } + } + + private fun updateAdvancedVisibility() { + advancedPanel?.isVisible = advancedModeCheckbox.isSelected + rootPanel?.revalidate() + rootPanel?.repaint() + } + + /** Prefill the shared username/password off the EDT — PasswordSafe access is a slow operation. */ + private fun loadSharedCredentialAsync() { + ApplicationManager.getApplication().executeOnPooledThread { + val shared = SharedCredentialStore.load() + ApplicationManager.getApplication().invokeLater({ + if (rootPanel == null) return@invokeLater + sharedUsernameField.text = shared.username + sharedPasswordField.setText(shared.password) + initialSharedUsername = shared.username + initialSharedPassword = shared.password + }, ModalityState.any()) + } } @@ -473,16 +559,11 @@ class AiTestSettingsConfigurable( layout = BoxLayout(this, BoxLayout.Y_AXIS) border = BorderFactory.createEmptyBorder(12, 12, 12, 12) add(infoBanner("Built-in prompts remain the defaults. Edit any field below to override the default template saved in .ai-test.yaml.")) - add(formSection("Generation Wrapper", listOf( - "Template" to JScrollPane(generationPromptWrapperField) - ))) add(formSection("Generation Rules", listOf( "Rest Assured rules" to JScrollPane(generationPromptRestAssuredField), "Karate rules" to JScrollPane(generationPromptKarateField) ))) add(formSection("AI Actions", listOf( - "Commit message prompt" to JScrollPane(commitPromptField), - "Branch diff summary prompt" to JScrollPane(branchDiffPromptField), "Pull request prompt" to JScrollPane(pullRequestPromptField) ))) add(unifiedPromptManagerSection()) @@ -716,9 +797,9 @@ class AiTestSettingsConfigurable( private fun defaultPromptTemplateByCategory(category: PromptCategory): String { return when (category) { - PromptCategory.TEST -> generationPromptWrapperField.text.ifBlank { AiPromptDefaults.GENERATION_WRAPPER } - PromptCategory.COMMIT -> commitPromptField.text.ifBlank { AiPromptDefaults.COMMIT_MESSAGE } - PromptCategory.BRANCH_DIFF -> branchDiffPromptField.text.ifBlank { AiPromptDefaults.BRANCH_DIFF_SUMMARY } + PromptCategory.TEST -> AiPromptDefaults.GENERATION_WRAPPER + PromptCategory.COMMIT -> AiPromptDefaults.COMMIT_MESSAGE + PromptCategory.BRANCH_DIFF -> AiPromptDefaults.BRANCH_DIFF_SUMMARY PromptCategory.CODE_GENERATE -> AiPromptDefaults.CODE_GENERATE PromptCategory.CODE_REVIEW -> AiPromptDefaults.CODE_REVIEW } @@ -836,6 +917,7 @@ class AiTestSettingsConfigurable( llmApiKeyEnv = apiKeyEnvField.text.trim(), httpDisableTlsVerification = httpDisableTlsVerification.isSelected, showLogTab = showLogTabCheckbox.isSelected, + advancedMode = advancedModeCheckbox.isSelected, llmTemplateEnabled = llmTemplateEnabled.isSelected, llmTemplateMethod = llmTemplateMethod.selectedItem?.toString().orEmpty(), llmTemplateUrl = llmTemplateUrl.text.trim(), @@ -848,12 +930,12 @@ class AiTestSettingsConfigurable( loginHeaders = loginHeaders.text.trim(), loginBody = loginBody.text, loginResponsePath = loginResponsePath.text.trim(), - generationPromptWrapper = generationPromptWrapperField.text, + generationPromptWrapper = AiPromptDefaults.GENERATION_WRAPPER, generationPromptRestAssured = generationPromptRestAssuredField.text, generationPromptKarate = generationPromptKarateField.text, - commitPrompt = commitPromptField.text, + commitPrompt = AiPromptDefaults.COMMIT_MESSAGE, pullRequestPrompt = pullRequestPromptField.text, - branchDiffPrompt = branchDiffPromptField.text, + branchDiffPrompt = AiPromptDefaults.BRANCH_DIFF_SUMMARY, generationPromptProfileDefault = generationPromptProfileDefaultField.text.trim(), generationPromptProfilesYaml = generationPromptProfilesYamlField.text, commitPromptProfileDefault = commitPromptProfileDefaultField.text.trim(), @@ -894,6 +976,7 @@ class AiTestSettingsConfigurable( apiKeyEnvField.text = state.llmApiKeyEnv httpDisableTlsVerification.isSelected = state.httpDisableTlsVerification showLogTabCheckbox.isSelected = state.showLogTab + advancedModeCheckbox.isSelected = state.advancedMode llmTemplateEnabled.isSelected = state.llmTemplateEnabled llmTemplateMethod.selectedItem = state.llmTemplateMethod @@ -908,11 +991,8 @@ class AiTestSettingsConfigurable( loginHeaders.text = state.loginHeaders loginBody.text = state.loginBody loginResponsePath.text = state.loginResponsePath - generationPromptWrapperField.text = state.generationPromptWrapper generationPromptRestAssuredField.text = state.generationPromptRestAssured generationPromptKarateField.text = state.generationPromptKarate - commitPromptField.text = state.commitPrompt - branchDiffPromptField.text = state.branchDiffPrompt pullRequestPromptField.text = state.pullRequestPrompt generationPromptProfileDefaultField.text = state.generationPromptProfileDefault generationPromptProfilesYamlField.text = state.generationPromptProfilesYaml @@ -946,6 +1026,7 @@ class AiTestSettingsConfigurable( refreshPromptManager() toggleTemplateCards() updatePathLabel() + updateAdvancedVisibility() } private fun validate(state: AiTestSettingsModel) { @@ -991,11 +1072,9 @@ class AiTestSettingsConfigurable( } private fun requirePromptProfiles(label: String, text: String) { + if (text.isBlank()) return val parsed = Yaml().load(text) as? Map<*, *> ?: throw IllegalArgumentException("$label must be a YAML map of profileName: promptTemplate") - if (parsed.isEmpty()) { - throw IllegalArgumentException("$label cannot be empty") - } } private fun parseYamlMap(text: String): Map { diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt index e61e340..385983c 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt @@ -9,6 +9,7 @@ data class AiTestSettingsModel( val llmApiKeyEnv: String = "", val httpDisableTlsVerification: Boolean = true, val showLogTab: Boolean = true, + val advancedMode: Boolean = false, val llmTemplateEnabled: Boolean = false, val llmTemplateMethod: String = "POST", val llmTemplateUrl: String = "", diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsDialog.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsDialog.kt index 1a6aeea..9e762fe 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsDialog.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsDialog.kt @@ -2,7 +2,9 @@ package org.openprojectx.ai.plugin import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo import org.openprojectx.ai.plugin.core.Framework +import org.openprojectx.ai.plugin.testgen.EnvironmentContextCollector import java.awt.BorderLayout import java.awt.CardLayout import javax.swing.* @@ -16,6 +18,10 @@ class GenerateTestsDialog( private val config = LlmSettingsLoader.loadConfig(project) + // Detected once at construction so createCenterPanel() can show a (non-blocking) heads-up. + private val missingTestLibraries: Boolean = + EnvironmentContextCollector.hasNoTestLibrariesOnClasspath(project, sourceFile) + private val frameworkCombo = JComboBox(Framework.entries.toTypedArray()) private val generationPromptProfiles = config.prompts.profiles.generation.items private val generationPromptProfileCombo = JComboBox(generationPromptProfiles.keys.toTypedArray()) @@ -65,9 +71,22 @@ class GenerateTestsDialog( updateFrameworkSpecificFields(selectedFramework()) panel.add(form, BorderLayout.CENTER) + if (missingTestLibraries) { + panel.add(testLibraryWarningLabel(), BorderLayout.NORTH) + } return panel } + /** Non-blocking heads-up shown when the module has no test library on its classpath. */ + private fun testLibraryWarningLabel(): JComponent = + JLabel( + "No test libraries (JUnit, REST Assured, etc.) " + + "were detected on this module's classpath. The generated test may not compile " + + "until you add a test dependency — you can still generate it.", + com.intellij.icons.AllIcons.General.Warning, + SwingConstants.LEFT + ) + private fun applyDefaults(framework: Framework) { val derivedJavaMainTestLocation = JavaHeuristics.deriveTestLocationForMainJava(sourceFile, project.basePath) val derivedJavaPackageName = JavaHeuristics.derivePackageNameForJava(sourceFile, project.basePath) @@ -126,6 +145,26 @@ class GenerateTestsDialog( return if (trimmed.endsWith("Test")) trimmed else "${trimmed}Test" } + override fun doValidate(): ValidationInfo? { + val cls = clsField.text.trim() + if (cls.isBlank()) return ValidationInfo("Class Name is required", clsField) + if (!cls.matches(Regex("[A-Za-z][A-Za-z0-9_]*"))) + return ValidationInfo("Class Name must be a valid Java identifier (e.g. OrderServiceTest)", clsField) + + val loc = location.text.trim() + if (loc.isBlank()) return ValidationInfo("Location is required", location) + + val framework = selectedFramework() + if (framework == Framework.REST_ASSURED) { + val pkg = packageNameField.text.trim() + if (pkg.isBlank()) return ValidationInfo("Package Name is required for REST Assured tests", packageNameField) + if (!pkg.matches(Regex("[a-z][a-z0-9]*(\\.[a-z][a-z0-9_]*)*"))) + return ValidationInfo("Package Name must be a valid Java package (e.g. com.example.tests)", packageNameField) + } + + return null + } + fun result(): UiResult { val framework = selectedFramework() val frameworkConfig = when (framework) { diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsService.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsService.kt index 9f1f19c..39e9738 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsService.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateTestsService.kt @@ -15,6 +15,7 @@ import org.openprojectx.ai.plugin.core.Framework import org.openprojectx.ai.plugin.core.GenerationRequest import org.openprojectx.ai.plugin.core.PromptBuilder import org.openprojectx.ai.plugin.testgen.DependentMethodCollector +import org.openprojectx.ai.plugin.testgen.EnvironmentContextCollector import org.slf4j.LoggerFactory @@ -62,6 +63,10 @@ class GenerateTestsService(private val project: Project) { DependentMethodCollector.collect(project, file) } else "" + val environmentContext = if (contractType == ContractType.JAVA) { + EnvironmentContextCollector.collect(project, file) + } else "" + val req = GenerationRequest( contractText = contractText, framework = effectiveFramework, @@ -71,7 +76,8 @@ class GenerateTestsService(private val project: Project) { packageName = packageName, className = ui.className, outputNotes = ui.notes, - dependentMethodSignatures = dependentMethodSignatures + dependentMethodSignatures = dependentMethodSignatures, + environmentContext = environmentContext ) val generationTemplate = config.prompts.generation.copy( @@ -92,26 +98,37 @@ class GenerateTestsService(private val project: Project) { } ContextBoxStateService.getInstance(project).addUserMessage(userMessage) + val contextBox = ContextBoxStateService.getInstance(project) + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Generating tests for ${ui.className}", true) { override fun run(indicator: ProgressIndicator) { try { indicator.text = "Preparing request for ${ui.className}..." indicator.fraction = 0.1 + contextBox.addAssistantMessage("[1/4] Preparing request for ${ui.className}...", "Progress") + + if (indicator.isCanceled) return indicator.text = "Calling LLM..." indicator.isIndeterminate = true + contextBox.addAssistantMessage("[2/4] Calling LLM — this may take a moment...", "Progress") + val code = authSession.withReloginOnUnauthorized { settings -> val provider = LlmProviderFactory.create(settings) kotlinx.coroutines.runBlocking { provider.generateCode(prompt) } } indicator.isIndeterminate = false + if (indicator.isCanceled) return + indicator.text = "Processing result..." indicator.fraction = 0.8 + contextBox.addAssistantMessage("[3/4] Processing and sanitizing generated code...", "Progress") val sanitizedCode = sanitizeGeneratedCode(code) indicator.text = "Writing ${ui.className}.java..." indicator.fraction = 0.95 + contextBox.addAssistantMessage("[4/4] Writing ${ui.className} to disk...", "Progress") writeGenerated( project = project, framework = effectiveFramework, @@ -137,7 +154,10 @@ class GenerateTestsService(private val project: Project) { log.error("Test generation failed:", e) notificationState.clearState(file.path) EditorNotifications.getInstance(project).updateNotifications(file) - Notifications.error(project, "Test generation failed:", e.message ?: e.toString()) + + val userMessage = buildActionableErrorMessage(e) + contextBox.addAssistantMessage("Test generation failed: $userMessage", "Error") + Notifications.errorWithLogs(project, "Test generation failed", userMessage) } } }) @@ -154,10 +174,16 @@ class GenerateTestsService(private val project: Project) { val projectRoot = project.guessProjectDir() ?: throw IllegalStateException("Cannot resolve project root") + // Drive the output directory from the package the LLM actually declared in the file, + // not the package we asked for. A custom prompt (e.g. "match the source file's package") + // can make the model deviate from the requested package; without this, the file would be + // written under the requested package path while declaring a different one, which IntelliJ + // flags as "Package name does not correspond to the file path". + val effectivePackage = JavaHeuristics.extractDeclaredPackage(code) ?: packageName val normalizedLocation = resolveOutputLocation( framework = framework, location = location, - packageName = packageName + packageName = effectivePackage ) val fileName = when (framework) { @@ -257,6 +283,27 @@ class GenerateTestsService(private val project: Project) { .removePrefix("/") .removeSuffix("/") + private fun buildActionableErrorMessage(e: Exception): String { + val raw = e.message ?: e.javaClass.simpleName + return when { + raw.contains("401") || raw.contains("unauthorized", ignoreCase = true) -> + "Authentication failed. Check your LLM provider credentials in Settings > AI Test Assistant." + raw.contains("timeout", ignoreCase = true) || raw.contains("SocketTimeout") -> + "Request timed out. The LLM endpoint may be slow or unreachable. Check network/settings." + raw.contains("packageName is required", ignoreCase = true) -> + "Package name is missing. Fill in the Package Name field in the dialog, or verify the source file is under src/main/java." + raw.contains("Cannot resolve project root") -> + "Could not determine project root. Ensure the project is properly opened in IntelliJ." + raw.contains("Cannot create target directory") -> + "Could not create the output directory. Check that the Location path is valid and writable." + raw.contains("403") || raw.contains("forbidden", ignoreCase = true) -> + "Access denied by the LLM provider. Verify your API key/token in Settings > AI Test Assistant." + raw.contains("Connection refused") || raw.contains("UnknownHost") -> + "Cannot reach the LLM endpoint. Check the provider URL and your network connection." + else -> raw + } + } + private fun sanitizeGeneratedCode(raw: String): String { val trimmed = raw.trim() val withoutStartFence = trimmed.replaceFirst(Regex("^```(?:\\w+)?\\s*\\n?"), "") diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/JavaHeuristics.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/JavaHeuristics.kt index 0a95aaa..22819bd 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/JavaHeuristics.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/JavaHeuristics.kt @@ -3,6 +3,9 @@ package org.openprojectx.ai.plugin import com.intellij.openapi.vfs.VirtualFile object JavaHeuristics { + + private const val MAIN_JAVA_MARKER = "/src/main/java/" + fun looksLikeJavaSource(file: VirtualFile, text: String): Boolean { if (!file.name.lowercase().endsWith(".java")) return false @@ -21,41 +24,57 @@ object JavaHeuristics { return normalized.contains("/src/test/java/") } - fun deriveTestLocationForMainJava(file: VirtualFile, projectBasePath: String?): String? { - val basePath = projectBasePath ?: return null - val normalizedFilePath = file.path.replace('\\', '/') - val normalizedBasePath = basePath.replace('\\', '/').removeSuffix("/") - val prefix = "$normalizedBasePath/" - if (!normalizedFilePath.startsWith(prefix)) return null - - val relative = normalizedFilePath.removePrefix(prefix) - val marker = "/src/main/java/" - val index = relative.indexOf(marker) - if (index < 0) return null + fun deriveTestLocationForMainJava(file: VirtualFile, projectBasePath: String?): String? = + deriveTestLocationForMainJava(file.path, projectBasePath) - val modulePrefix = relative.substring(0, index).trim('/') + fun deriveTestLocationForMainJava(filePath: String, projectBasePath: String?): String? { + val (modulePrefix, _) = splitAtMainJava(filePath, projectBasePath) ?: return null return listOfNotNull( modulePrefix.takeIf { it.isNotBlank() }, "src/test/java" ).joinToString("/") } - fun derivePackageNameForJava(file: VirtualFile, projectBasePath: String?): String? { + fun derivePackageNameForJava(file: VirtualFile, projectBasePath: String?): String? = + derivePackageNameForJava(file.path, projectBasePath) + + fun derivePackageNameForJava(filePath: String, projectBasePath: String?): String? { + val (_, afterMarker) = splitAtMainJava(filePath, projectBasePath) ?: return null + val packagePath = afterMarker.substringBeforeLast('/', missingDelimiterValue = "") + if (packagePath.isBlank()) return null + return packagePath.replace('/', '.').trim('.').takeIf { it.isNotBlank() } + } + + /** Reads the `package x.y.z;` declaration from generated source, or null if none is present. */ + fun extractDeclaredPackage(code: String): String? = + code.lineSequence() + .map { it.trim() } + .firstOrNull { it.startsWith("package ") } + ?.removePrefix("package ") + ?.substringBefore(';') + ?.trim() + ?.takeIf { it.isNotEmpty() } + + /** + * Splits [filePath] at the `src/main/java` source root, returning + * `(modulePrefix, pathAfterMarker)` or null when the file is not under such a root. + */ + private fun splitAtMainJava(filePath: String, projectBasePath: String?): Pair? { val basePath = projectBasePath ?: return null - val normalizedFilePath = file.path.replace('\\', '/') + val normalizedFilePath = filePath.replace('\\', '/') val normalizedBasePath = basePath.replace('\\', '/').removeSuffix("/") val prefix = "$normalizedBasePath/" if (!normalizedFilePath.startsWith(prefix)) return null - val relative = normalizedFilePath.removePrefix(prefix) - val marker = "/src/main/java/" - val index = relative.indexOf(marker) + // Prepend a leading slash so a file located directly under + // /src/main/java/... (single-module layout) matches the marker + // exactly like a multi-module //src/main/java/... file does. + val relative = "/" + normalizedFilePath.removePrefix(prefix) + val index = relative.indexOf(MAIN_JAVA_MARKER) if (index < 0) return null - val afterMain = relative.substring(index + marker.length) - val packagePath = afterMain.substringBeforeLast('/', missingDelimiterValue = "") - if (packagePath.isBlank()) return null - - return packagePath.replace('/', '.').trim('.').takeIf { it.isNotBlank() } + val modulePrefix = relative.substring(0, index).trim('/') + val afterMarker = relative.substring(index + MAIN_JAVA_MARKER.length) + return modulePrefix to afterMarker } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmAuthSessionService.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmAuthSessionService.kt index 268dc80..c66dbfc 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmAuthSessionService.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmAuthSessionService.kt @@ -1,8 +1,6 @@ package org.openprojectx.ai.plugin -import com.intellij.credentialStore.CredentialAttributes import com.intellij.credentialStore.Credentials -import com.intellij.ide.passwordSafe.PasswordSafe import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.Service @@ -211,25 +209,23 @@ class LlmAuthSessionService( return credentials } - private fun loadSavedCredentials(settings: LlmSettings): Credentials? { - return PasswordSafe.instance.get(getCredentialAttributes(settings)) - } - - private fun saveCredentials(settings: LlmSettings, credentials: LoginCredentials) { - PasswordSafe.instance.set( - getCredentialAttributes(settings), - Credentials(credentials.username, credentials.password) - ) + // Login uses the single shared credential (see SharedCredentialStore), so the same username/password + // entered in the basic settings view drives LLM login as well as the other services. + private fun loadSavedCredentials(@Suppress("UNUSED_PARAMETER") settings: LlmSettings): Credentials? { + val shared = SharedCredentialStore.load() + return if (shared.username.isNotBlank() && shared.password.isNotBlank()) { + Credentials(shared.username, shared.password) + } else { + null + } } - private fun clearSavedCredentials(settings: LlmSettings) { - PasswordSafe.instance.set(getCredentialAttributes(settings), null) + private fun saveCredentials(@Suppress("UNUSED_PARAMETER") settings: LlmSettings, credentials: LoginCredentials) { + SharedCredentialStore.save(credentials.username, credentials.password) } - private fun getCredentialAttributes(settings: LlmSettings): CredentialAttributes { - val endpointKey = settings.endpoint ?: settings.auth?.login?.url ?: "default" - val serviceName = "OpenProjectX.AI.Login.$endpointKey" - return CredentialAttributes(serviceName) + private fun clearSavedCredentials(@Suppress("UNUSED_PARAMETER") settings: LlmSettings) { + SharedCredentialStore.clear() } companion object { diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt index 3462837..4b57f3a 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt @@ -510,6 +510,7 @@ object LlmSettingsLoader { llmApiKeyEnv = llm.string("apiKeyEnv"), httpDisableTlsVerification = http?.get("disableTlsVerification") as? Boolean ?: true, showLogTab = ui["showLogTab"] as? Boolean ?: true, + advancedMode = ui["advancedMode"] as? Boolean ?: false, llmTemplateEnabled = template != null, llmTemplateMethod = template.string("method").ifBlank { "POST" }, llmTemplateUrl = template.string("url"), @@ -532,35 +533,47 @@ object LlmSettingsLoader { generationPromptProfilesYaml = dumpPromptProfilesYaml( applyGlobalProfilesPreloaded("test", globalPromptsByCategory, parsePromptProfileItems( - prompts.map("generationProfiles").map("items"), + seedLegacyIntoProfiles( + prompts.map("generationProfiles").map("items"), + promptGeneration.string("wrapper"), + AiPromptDefaults.GENERATION_WRAPPER + ), AiPromptDefaults.GENERATION_WRAPPER, includeDefault = false ), suppressedGlobalPrompts ) - ), - commitPromptProfileDefault = GLOBAL_DIFF_REVIEW_PROFILE, + ).ifBlank { AiTestSettingsModel().generationPromptProfilesYaml }, + commitPromptProfileDefault = prompts.map("commitMessageProfiles").string("selected").ifBlank { PromptProfileSet.DEFAULT_NAME }, commitPromptProfilesYaml = dumpPromptProfilesYaml( applyGlobalProfilesPreloaded("commit", globalPromptsByCategory, parsePromptProfileItems( - prompts.map("commitMessageProfiles").map("items"), - AiPromptDefaults.COMMIT_MESSAGE, - includeDefault = false + seedLegacyIntoProfiles( + prompts.map("commitMessageProfiles").map("items"), + prompts.string("commitMessage"), + AiPromptDefaults.COMMIT_MESSAGE + ), + AiPromptDefaults.COMMIT_MESSAGE, + includeDefault = false ), suppressedGlobalPrompts ) - ), - branchDiffPromptProfileDefault = GLOBAL_DIFF_REVIEW_PROFILE, + ).ifBlank { AiTestSettingsModel().commitPromptProfilesYaml }, + branchDiffPromptProfileDefault = prompts.map("branchDiffSummaryProfiles").string("selected").ifBlank { PromptProfileSet.DEFAULT_NAME }, branchDiffPromptProfilesYaml = dumpPromptProfilesYaml( applyGlobalProfilesPreloaded("branchDiff", globalPromptsByCategory, parsePromptProfileItems( - prompts.map("branchDiffSummaryProfiles").map("items"), - AiPromptDefaults.BRANCH_DIFF_SUMMARY, - includeDefault = false + seedLegacyIntoProfiles( + prompts.map("branchDiffSummaryProfiles").map("items"), + prompts.string("branchDiffSummary"), + AiPromptDefaults.BRANCH_DIFF_SUMMARY + ), + AiPromptDefaults.BRANCH_DIFF_SUMMARY, + includeDefault = false ), suppressedGlobalPrompts ) - ), + ).ifBlank { AiTestSettingsModel().branchDiffPromptProfilesYaml }, codeGeneratePromptProfileDefault = prompts.map("codeGenerateProfiles").string("selected").ifBlank { PromptProfileSet.DEFAULT_NAME }, codeGeneratePromptProfilesYaml = dumpPromptProfilesYaml( applyGlobalProfilesPreloaded("codeGenerate", globalPromptsByCategory, @@ -571,7 +584,7 @@ object LlmSettingsLoader { ), suppressedGlobalPrompts ) - ), + ).ifBlank { AiTestSettingsModel().codeGeneratePromptProfilesYaml }, codeReviewPromptProfileDefault = prompts.map("codeReviewProfiles").string("selected").ifBlank { PromptProfileSet.DEFAULT_NAME }, codeReviewPromptProfilesYaml = dumpPromptProfilesYaml( applyGlobalProfilesPreloaded("codeReview", globalPromptsByCategory, @@ -582,7 +595,7 @@ object LlmSettingsLoader { ), suppressedGlobalPrompts ) - ), + ).ifBlank { AiTestSettingsModel().codeReviewPromptProfilesYaml }, bitbucketPromptRepoEnabled = remoteRepo["enabled"] as? Boolean ?: false, bitbucketPromptRepoUrl = remoteRepo.string("url"), bitbucketPromptRepoBranch = remoteRepo.string("branch").ifBlank { "main" }, @@ -933,13 +946,13 @@ object LlmSettingsLoader { ) val globalPrompts = loadGlobalPrompts(project, remoteRepoConfig) prompts["generation"] = linkedMapOf( - "wrapper" to model.generationPromptWrapper, + "wrapper" to extractDefaultFromProfilesYaml(model.generationPromptProfilesYaml, AiPromptDefaults.GENERATION_WRAPPER), "restAssuredRules" to model.generationPromptRestAssured, "karateRules" to model.generationPromptKarate ) - prompts["commitMessage"] = model.commitPrompt + prompts["commitMessage"] = extractDefaultFromProfilesYaml(model.commitPromptProfilesYaml, AiPromptDefaults.COMMIT_MESSAGE) prompts["pullRequest"] = model.pullRequestPrompt - prompts["branchDiffSummary"] = model.branchDiffPrompt + prompts["branchDiffSummary"] = extractDefaultFromProfilesYaml(model.branchDiffPromptProfilesYaml, AiPromptDefaults.BRANCH_DIFF_SUMMARY) prompts["generationProfiles"] = buildPromptProfileMap( selected = model.generationPromptProfileDefault, yamlText = dumpPromptProfilesYaml( @@ -955,7 +968,7 @@ object LlmSettingsLoader { defaultTemplate = model.generationPromptWrapper ) prompts["commitMessageProfiles"] = buildPromptProfileMap( - selected = GLOBAL_DIFF_REVIEW_PROFILE, + selected = model.commitPromptProfileDefault.ifBlank { PromptProfileSet.DEFAULT_NAME }, yamlText = dumpPromptProfilesYaml( applyGlobalProfilesPreloaded("commit", globalPrompts, parsePromptProfileItems( @@ -969,7 +982,7 @@ object LlmSettingsLoader { defaultTemplate = model.commitPrompt ) prompts["branchDiffSummaryProfiles"] = buildPromptProfileMap( - selected = GLOBAL_DIFF_REVIEW_PROFILE, + selected = model.branchDiffPromptProfileDefault.ifBlank { PromptProfileSet.DEFAULT_NAME }, yamlText = dumpPromptProfilesYaml( applyGlobalProfilesPreloaded("branchDiff", globalPrompts, parsePromptProfileItems( @@ -1174,6 +1187,7 @@ object LlmSettingsLoader { private fun buildUiMap(existing: Map<*, *>?, model: AiTestSettingsModel): MutableMap { val ui = existing.toMutableLinkedMap() ui["showLogTab"] = model.showLogTab + ui["advancedMode"] = model.advancedMode return ui } @@ -1324,6 +1338,27 @@ object LlmSettingsLoader { ) } + /** + * If [legacyValue] is a user-customised value (different from [defaultFallback]) and the + * profile [items] map has no "default" entry, seed it so the legacy standalone prompt is + * migrated into the profiles system on the next save. + */ + private fun seedLegacyIntoProfiles( + items: Map<*, *>, + legacyValue: String, + defaultFallback: String + ): Map<*, *> { + if (legacyValue.isBlank() || legacyValue == defaultFallback) return items + if (items.containsKey(PromptProfileSet.DEFAULT_NAME)) return items + return items.toMutableMap().also { it[PromptProfileSet.DEFAULT_NAME] = legacyValue } + } + + private fun extractDefaultFromProfilesYaml(yamlText: String, fallback: String): String { + val items = Yaml().load(yamlText) as? Map<*, *> ?: return fallback + val defaultEntry = items[PromptProfileSet.DEFAULT_NAME]?.toString()?.trim() + return defaultEntry?.takeIf { it.isNotBlank() } ?: fallback + } + private fun dumpPromptProfilesYaml(items: Map): String { val options = DumperOptions().apply { defaultFlowStyle = DumperOptions.FlowStyle.BLOCK @@ -1747,7 +1782,13 @@ object LlmSettingsLoader { val configuredCredentials = if (config.username.isNotBlank() && config.password.isNotBlank()) { listOf(BitbucketCredential("settings", config.username, config.password)) } else { - emptyList() + // Fall back to the shared username/password (basic auth only — tokens are never shared). + val shared = SharedCredentialStore.load() + if (shared.username.isNotBlank() && shared.password.isNotBlank()) { + listOf(BitbucketCredential("shared-credential", shared.username, shared.password)) + } else { + emptyList() + } } val gitCredentials = GitCredentialHelper.resolve(config.repoUrl) ?.let { listOf(BitbucketCredential("git-credential-helper", it.username, it.password)) } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/Notifications.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/Notifications.kt index 53477ce..ce8d50f 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/Notifications.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/Notifications.kt @@ -4,7 +4,10 @@ import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindowManager import com.intellij.openapi.vfs.VirtualFile object Notifications { @@ -35,6 +38,42 @@ object Notifications { .notify(project) } + fun errorWithLogs(project: Project, title: String, message: String) { + RuntimeLogStore.append("ERROR | $title | $message") + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(GROUP_ID) + .createNotification(title, message, NotificationType.ERROR) + notification.addAction( + NotificationAction.createSimple("View Logs") { + ToolWindowManager.getInstance(project).getToolWindow("AI Context Box")?.show() + } + ) + notification.notify(project) + } + + fun errorWithSettings(project: Project, title: String, message: String, settingsClass: Class? = null) { + RuntimeLogStore.append("ERROR | $title | $message") + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(GROUP_ID) + .createNotification(title, message, NotificationType.ERROR) + notification.addAction( + NotificationAction.createSimple("Open Settings") { + val cls = settingsClass + if (cls != null) { + ShowSettingsUtil.getInstance().showSettingsDialog(project, cls) + } else { + ShowSettingsUtil.getInstance().showSettingsDialog(project, "AI Test Assistant") + } + } + ) + notification.addAction( + NotificationAction.createSimple("View Logs") { + ToolWindowManager.getInstance(project).getToolWindow("AI Context Box")?.show() + } + ) + notification.notify(project) + } + fun notifyFileGenerated(project: Project, title: String, message: String, file: VirtualFile) { val notification = diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SharedCredentialResolver.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SharedCredentialResolver.kt new file mode 100644 index 0000000..5fa0a69 --- /dev/null +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SharedCredentialResolver.kt @@ -0,0 +1,33 @@ +package org.openprojectx.ai.plugin + +/** + * Resolves the effective username/password for a service that can fall back to the shared + * credential entered in the basic settings view. + * + * Tokens/PATs are deliberately NOT handled here: a token is service-specific and must never be + * shared across services. Callers apply token auth first (when the service has its own token) and + * only use this resolver for the basic-auth (username/password) path. + */ +object SharedCredentialResolver { + + data class UsernamePassword(val username: String, val password: String) + + /** + * Returns the service's own username/password when both are non-blank; otherwise the shared + * pair when it is complete; otherwise the (blank) service values. + */ + fun resolveUsernamePassword( + serviceUsername: String, + servicePassword: String, + sharedUsername: String, + sharedPassword: String + ): UsernamePassword { + if (serviceUsername.isNotBlank() && servicePassword.isNotBlank()) { + return UsernamePassword(serviceUsername, servicePassword) + } + if (sharedUsername.isNotBlank() && sharedPassword.isNotBlank()) { + return UsernamePassword(sharedUsername, sharedPassword) + } + return UsernamePassword(serviceUsername, servicePassword) + } +} diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SharedCredentialStore.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SharedCredentialStore.kt new file mode 100644 index 0000000..09efe94 --- /dev/null +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SharedCredentialStore.kt @@ -0,0 +1,42 @@ +package org.openprojectx.ai.plugin + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.ide.passwordSafe.PasswordSafe + +/** + * The single shared username/password entered in the basic settings view, used as the cross-service + * fallback credential (LLM login, Bitbucket prompt repo, SonarQube). Stored in IntelliJ PasswordSafe + * under one fixed key — never written to .ai-test.yaml. + * + * Tokens/PATs are intentionally NOT stored here: a token is service-specific and must never be shared + * across services (see [SharedCredentialResolver]). + */ +object SharedCredentialStore { + private const val SERVICE_NAME = "OpenProjectX.AI.SharedCredential" + + private val attributes: CredentialAttributes + get() = CredentialAttributes(SERVICE_NAME) + + fun load(): SharedCredentialResolver.UsernamePassword { + // Defensive: PasswordSafe requires a running Application. Outside one (e.g. plain unit tests) + // treat the shared credential as absent rather than throwing. + val creds = runCatching { PasswordSafe.instance.get(attributes) }.getOrNull() + return SharedCredentialResolver.UsernamePassword( + username = creds?.userName.orEmpty(), + password = creds?.getPasswordAsString().orEmpty() + ) + } + + fun save(username: String, password: String) { + if (username.isBlank() && password.isBlank()) { + clear() + return + } + runCatching { PasswordSafe.instance.set(attributes, Credentials(username, password)) } + } + + fun clear() { + runCatching { PasswordSafe.instance.set(attributes, null) } + } +} diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeAuth.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeAuth.kt index 915be83..219bf4c 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeAuth.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarQubeAuth.kt @@ -10,10 +10,10 @@ object SonarQubeAuth { private const val CACHE_KEY_PREFIX = "OpenProjectX.AI.SonarQube" fun authorizationHeader(config: SonarQubeConfig): String? { - // 1. Try config credentials first + // 1. Try config credentials first (token is service-specific; username/password may fall back + // to the shared credential). val token = config.resolvedToken - val user = config.username - val pass = config.resolvedPassword + val (user, pass) = effectiveUsernamePassword(config) val header = buildHeader(token, user, pass) if (header != null) { cacheCredentials(config.serverUrl, token, user, pass) @@ -32,6 +32,37 @@ object SonarQubeAuth { fun authorizationHeader(token: String, username: String, password: String): String? = buildHeader(token, username, password) + /** + * Like [authorizationHeader] but fails fast when no credentials are configured, so callers + * can surface an actionable message instead of silently sending an unauthenticated request. + * Uses only the config-supplied credentials (no PasswordSafe lookup) so it is side-effect free. + */ + fun requireAuthorizationHeader(config: SonarQubeConfig): String { + val (user, pass) = effectiveUsernamePassword(config) + return buildHeader(config.resolvedToken, user, pass) + ?: throw IllegalStateException( + "Configure SonarQube Token/PAT (or username + password) in Settings > AI Test Assistant " + + "before contacting ${config.serverUrl.ifBlank { "SonarQube" }}." + ) + } + + /** + * The username/password to authenticate with: the service's own when set, otherwise the shared + * credential. The service token stays service-specific and is never sourced from the shared store — + * when a token is present we keep the config's own username/password untouched. + */ + private fun effectiveUsernamePassword(config: SonarQubeConfig): Pair { + if (config.resolvedToken.isNotBlank()) return config.username to config.resolvedPassword + val shared = SharedCredentialStore.load() + val resolved = SharedCredentialResolver.resolveUsernamePassword( + serviceUsername = config.username, + servicePassword = config.resolvedPassword, + sharedUsername = shared.username, + sharedPassword = shared.password + ) + return resolved.username to resolved.password + } + private fun buildHeader(token: String, username: String, password: String): String? { val normalizedToken = token.trim() if (normalizedToken.isNotBlank()) { diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/testgen/DependentMethodCollector.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/testgen/DependentMethodCollector.kt index eda0b80..6bfb729 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/testgen/DependentMethodCollector.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/testgen/DependentMethodCollector.kt @@ -4,10 +4,13 @@ import com.intellij.openapi.application.ReadAction import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.JavaRecursiveElementVisitor +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiField import com.intellij.psi.PsiJavaFile import com.intellij.psi.PsiManager import com.intellij.psi.PsiMethod import com.intellij.psi.PsiMethodCallExpression +import com.intellij.psi.PsiModifier object DependentMethodCollector { @@ -18,8 +21,22 @@ object DependentMethodCollector { val psiClass = psiFile.classes.firstOrNull() ?: return@compute "" val qualifiedName = psiClass.qualifiedName ?: psiClass.name ?: "" - val externalSignatures = linkedSetOf() + val classAnnotations = psiClass.annotations + .mapNotNull { it.qualifiedName?.substringAfterLast('.') } + .joinToString(", ") + + // Fields that are candidates for mocking (non-primitive, non-JDK, non-self) + val mockableFields = psiClass.fields.mapNotNull { field -> + val typeClass = resolveFieldClass(field) ?: return@mapNotNull null + val typeQName = typeClass.qualifiedName ?: return@mapNotNull null + if (isSkippable(typeQName)) return@mapNotNull null + if (typeQName == qualifiedName) return@mapNotNull null + Triple(field, typeClass, typeQName) + } + + // Direct method calls from this class's own methods + val directCallSignatures = linkedSetOf() for (method in psiClass.methods) { val body = method.body ?: continue body.accept(object : JavaRecursiveElementVisitor() { @@ -28,28 +45,126 @@ object DependentMethodCollector { val target = call.resolveMethod() ?: return val targetClass = target.containingClass ?: return val targetQName = targetClass.qualifiedName ?: return - - // Skip methods in the same class (already in contractText) if (targetQName == qualifiedName) return - // Skip JDK methods - if (targetQName.startsWith("java.") || targetQName.startsWith("javax.")) return - // Skip Kotlin stdlib and common libraries - if (targetQName.startsWith("kotlin.")) return - - externalSignatures.add(formatSignature(target, targetQName)) + if (isSkippable(targetQName)) return + directCallSignatures.add(formatSignature(target, targetQName)) } }) } - if (externalSignatures.isEmpty()) return@compute "" + // For each mockable field type: collect ALL public methods (full mock API surface) + // and nested calls from those methods (one level deeper) + val mockTypeDetails = mutableListOf() + for ((field, typeClass, typeQName) in mockableFields) { + val fieldAnnotations = field.annotations + .mapNotNull { it.qualifiedName?.substringAfterLast('.') } + .joinToString(", ") - buildString { - appendLine("## Methods called by this class (for mock/stub reference)") - externalSignatures.forEach { appendLine(it) } + val publicMethods = typeClass.allMethods + .filter { m -> + m.containingClass?.qualifiedName?.let { !isSkippable(it) } == true && + m.hasModifierProperty(PsiModifier.PUBLIC) && + !m.isConstructor + } + .map { formatSignature(it, typeQName) } + .distinct() + + // Nested deps: collect methods called inside the mock type's methods (one level) + val nestedSignatures = linkedSetOf() + for (m in typeClass.methods) { + val body = m.body ?: continue + body.accept(object : JavaRecursiveElementVisitor() { + override fun visitMethodCallExpression(call: PsiMethodCallExpression) { + super.visitMethodCallExpression(call) + val target = call.resolveMethod() ?: return + val tc = target.containingClass ?: return + val tqn = tc.qualifiedName ?: return + if (tqn == typeQName || tqn == qualifiedName) return + if (isSkippable(tqn)) return + nestedSignatures.add(formatSignature(target, tqn)) + } + }) + } + + mockTypeDetails.add(MockTypeDetail( + fieldName = field.name, + fieldAnnotations = fieldAnnotations, + typeName = typeClass.name ?: typeQName, + qualifiedTypeName = typeQName, + isInterface = typeClass.isInterface, + publicMethods = publicMethods, + nestedDependencies = nestedSignatures.toList() + )) } + + if (directCallSignatures.isEmpty() && mockTypeDetails.isEmpty()) return@compute "" + + buildString { + if (classAnnotations.isNotBlank()) { + appendLine("## Source Class Annotations") + appendLine("@$classAnnotations") + appendLine() + } + + if (mockTypeDetails.isNotEmpty()) { + appendLine("## Injectable Fields (Mock these in tests)") + for (detail in mockTypeDetails) { + val annotations = if (detail.fieldAnnotations.isNotBlank()) " [@${detail.fieldAnnotations}]" else "" + val kind = if (detail.isInterface) "interface" else "class" + appendLine(" ${detail.typeName} ${detail.fieldName}$annotations [$kind: ${detail.qualifiedTypeName}]") + } + appendLine() + + appendLine("## Full Mock API — all public methods available for stubbing/verification") + for (detail in mockTypeDetails) { + appendLine("### ${detail.typeName} (${detail.qualifiedTypeName})") + if (detail.publicMethods.isEmpty()) { + appendLine(" (no public methods found)") + } else { + detail.publicMethods.forEach { appendLine(" $it") } + } + if (detail.nestedDependencies.isNotEmpty()) { + appendLine(" -- collaborators of ${detail.typeName} (may also need mocking) --") + detail.nestedDependencies.forEach { appendLine(" $it") } + } + appendLine() + } + } + + if (directCallSignatures.isNotEmpty()) { + appendLine("## Methods directly called by this class (confirmed call sites)") + directCallSignatures.forEach { appendLine(" $it") } + } + }.trimEnd() } } + private data class MockTypeDetail( + val fieldName: String, + val fieldAnnotations: String, + val typeName: String, + val qualifiedTypeName: String, + val isInterface: Boolean, + val publicMethods: List, + val nestedDependencies: List + ) + + private fun resolveFieldClass(field: PsiField): PsiClass? { + val type = field.type + // Handle generic types like Repository — resolve the raw class + return com.intellij.psi.util.PsiUtil.resolveClassInClassTypeOnly(type) + } + + private fun isSkippable(qualifiedName: String): Boolean = + qualifiedName.startsWith("java.") || + qualifiedName.startsWith("javax.") || + qualifiedName.startsWith("jakarta.") || + qualifiedName.startsWith("kotlin.") || + qualifiedName.startsWith("org.springframework.") || + qualifiedName.startsWith("com.google.common.") || + qualifiedName.startsWith("org.slf4j.") || + qualifiedName.startsWith("org.apache.logging.") + private fun formatSignature(method: PsiMethod, qualifiedClassName: String): String { val returnType = method.returnType?.presentableText ?: "void" val params = method.parameterList.parameters.joinToString(", ") { param -> diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/testgen/EnvironmentContextCollector.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/testgen/EnvironmentContextCollector.kt new file mode 100644 index 0000000..aee0dff --- /dev/null +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/testgen/EnvironmentContextCollector.kt @@ -0,0 +1,164 @@ +package org.openprojectx.ai.plugin.testgen + +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.module.ModuleUtilCore +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.roots.LanguageLevelModuleExtension +import com.intellij.openapi.roots.LanguageLevelProjectExtension +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.roots.OrderEnumerator +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.search.GlobalSearchScope + +/** Toolchain facts about the module under test that the LLM cannot infer from the production source alone. */ +data class EnvironmentInfo( + val languageLevel: String?, + val buildTool: String?, + val testLibraries: List, + val keyVersions: List, + val lombokPresent: Boolean +) + +/** + * Detects the test toolchain of the module a source file belongs to (available test libraries, + * Java language level, behavior-defining library versions) and renders it as a prompt block, so the + * generated tests only use libraries that exist and reason correctly about version-sensitive behavior. + */ +object EnvironmentContextCollector { + + fun collect(project: Project, sourceFile: VirtualFile): String { + return ReadAction.compute { + val module = ModuleUtilCore.findModuleForFile(sourceFile, project) ?: return@compute "" + val scope = GlobalSearchScope.moduleWithDependenciesAndLibrariesScope(module, true) + val facade = JavaPsiFacade.getInstance(project) + fun has(fqn: String): Boolean = facade.findClass(fqn, scope) != null + + val testLibraries = buildList { + when { + has("org.junit.jupiter.api.Test") -> add("JUnit 5") + has("org.junit.Test") -> add("JUnit 4") + } + if (has("org.assertj.core.api.Assertions")) add("AssertJ") + if (has("org.mockito.Mockito")) { + add(if (has("org.mockito.junit.jupiter.MockitoExtension")) "Mockito (+ mockito-junit-jupiter)" else "Mockito") + } + if (has("org.springframework.boot.test.context.SpringBootTest")) add("Spring Boot Test") + if (has("org.hamcrest.MatcherAssert")) add("Hamcrest") + } + + val lombokPresent = has("lombok.Builder") || has("lombok.Data") + + // Versions only for libraries whose behavior is version-sensitive enough to matter. + val versionTargets = linkedMapOf( + "lombok" to "Lombok", + "spring-boot" to "Spring Boot", + "junit-jupiter-api" to "JUnit Jupiter", + "assertj-core" to "AssertJ", + "mockito-core" to "Mockito" + ) + val foundVersions = linkedMapOf() + OrderEnumerator.orderEntries(module).librariesOnly().forEachLibrary { lib -> + val name = lib.name + if (name != null) { + for ((artifact, label) in versionTargets) { + if (!foundVersions.containsKey(label)) { + parseLibraryVersion(name, artifact)?.let { foundVersions[label] = "$label $it" } + } + } + } + true + } + + val languageLevel = runCatching { + val level = ModuleRootManager.getInstance(module) + .getModuleExtension(LanguageLevelModuleExtension::class.java) + ?.languageLevel + ?: LanguageLevelProjectExtension.getInstance(project).languageLevel + level.toJavaVersion().feature.toString() + }.getOrNull() + + formatEnvironmentBlock( + EnvironmentInfo( + languageLevel = languageLevel, + buildTool = detectBuildTool(project), + testLibraries = testLibraries, + keyVersions = foundVersions.values.toList(), + lombokPresent = lombokPresent + ) + ) + } + } + + /** + * Fully-qualified marker classes whose presence means the module has at least one usable test + * library. Superset of the libraries listed in the prompt block — it also includes REST Assured, + * which matters for the OpenAPI→REST Assured generation path but is not a Java-unit-test assertion lib. + */ + private val TEST_LIBRARY_MARKERS = listOf( + "org.junit.jupiter.api.Test", // JUnit 5 + "org.junit.Test", // JUnit 4 + "org.assertj.core.api.Assertions", // AssertJ + "org.mockito.Mockito", // Mockito + "org.springframework.boot.test.context.SpringBootTest", // Spring Boot Test + "org.hamcrest.MatcherAssert", // Hamcrest + "io.restassured.RestAssured" // REST Assured + ) + + /** Pure decision: true when NONE of the recognized test-library markers are present. */ + fun noTestLibrariesPresent(presentClassNames: Collection): Boolean = + TEST_LIBRARY_MARKERS.none { it in presentClassNames } + + /** + * True when the module owning [sourceFile] has no recognized test library on its classpath. + * Returns false when the module can't be resolved, so an undetermined environment never raises a + * false warning. Read-only and side-effect free; does not affect the prompt sent to the LLM. + */ + fun hasNoTestLibrariesOnClasspath(project: Project, sourceFile: VirtualFile): Boolean { + return ReadAction.compute { + val module = ModuleUtilCore.findModuleForFile(sourceFile, project) ?: return@compute false + val scope = GlobalSearchScope.moduleWithDependenciesAndLibrariesScope(module, true) + val facade = JavaPsiFacade.getInstance(project) + val present = TEST_LIBRARY_MARKERS.filter { facade.findClass(it, scope) != null } + noTestLibrariesPresent(present) + } + } + + /** Extracts the version from an IntelliJ library name like `Maven: org.projectlombok:lombok:1.18.30`. */ + fun parseLibraryVersion(libraryName: String, artifactId: String): String? { + val coordinate = libraryName.substringAfter(": ", libraryName).trim() + val parts = coordinate.split(':') + if (parts.size < 3 || parts[1] != artifactId) return null + return parts[2].takeIf { it.isNotBlank() } + } + + /** Renders [info] as a markdown prompt block, or "" when nothing was detected. */ + fun formatEnvironmentBlock(info: EnvironmentInfo): String { + val lines = mutableListOf() + info.languageLevel?.let { lines += "- Java language level: $it" } + info.buildTool?.let { lines += "- Build tool: $it" } + if (info.testLibraries.isNotEmpty()) { + lines += "- Test libraries available: ${info.testLibraries.joinToString(", ")}" + } + if (info.keyVersions.isNotEmpty()) { + lines += "- Key versions: ${info.keyVersions.joinToString(", ")}" + } + if (info.lombokPresent) { + lines += "- Notes: Lombok present — @Builder.Default field initialization differs by creation path " + + "(builder vs constructor) and by Lombok version; prefer empty/default or robust assertions over " + + "guessing null. Do not import test libraries not listed above." + } + if (lines.isEmpty()) return "" + return (listOf("## Test Environment (use ONLY what is listed here)") + lines).joinToString("\n") + } + + private fun detectBuildTool(project: Project): String? { + val root = project.guessProjectDir() ?: return null + return when { + root.findChild("pom.xml") != null -> "Maven" + root.findChild("build.gradle") != null || root.findChild("build.gradle.kts") != null -> "Gradle" + else -> null + } + } +} diff --git a/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/JavaHeuristicsTest.kt b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/JavaHeuristicsTest.kt new file mode 100644 index 0000000..f3eae87 --- /dev/null +++ b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/JavaHeuristicsTest.kt @@ -0,0 +1,66 @@ +package org.openprojectx.ai.plugin + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class JavaHeuristicsTest { + + private val base = "C:/Users/dev/IdeaProjects/java-test-fixtures" + + @Test + fun `derives package for class directly under project-root src-main-java (single module)`() { + val path = "$base/src/main/java/com/example/fixtures/model/Order.java" + assertEquals("com.example.fixtures.model", JavaHeuristics.derivePackageNameForJava(path, base)) + } + + @Test + fun `derives package for class in a multi-module layout`() { + val path = "$base/moduleA/src/main/java/com/example/foo/Bar.java" + assertEquals("com.example.foo", JavaHeuristics.derivePackageNameForJava(path, base)) + } + + @Test + fun `derives test location under project-root (single module)`() { + val path = "$base/src/main/java/com/example/fixtures/model/Order.java" + assertEquals("src/test/java", JavaHeuristics.deriveTestLocationForMainJava(path, base)) + } + + @Test + fun `derives test location preserving module prefix (multi module)`() { + val path = "$base/moduleA/src/main/java/com/example/foo/Bar.java" + assertEquals("moduleA/src/test/java", JavaHeuristics.deriveTestLocationForMainJava(path, base)) + } + + @Test + fun `returns null when file is not under src-main-java`() { + val path = "$base/src/test/java/com/example/foo/BarTest.java" + assertNull(JavaHeuristics.derivePackageNameForJava(path, base)) + assertNull(JavaHeuristics.deriveTestLocationForMainJava(path, base)) + } + + @Test + fun `normalizes windows backslash paths`() { + val winBase = "C:\\dev\\proj" + val winPath = "C:\\dev\\proj\\src\\main\\java\\com\\example\\Win.java" + assertEquals("com.example", JavaHeuristics.derivePackageNameForJava(winPath, winBase)) + } + + @Test + fun `extracts declared package from generated java code`() { + val code = """ + package com.example.fixtures.model; + + import org.junit.jupiter.api.Test; + + class OrderTest { } + """.trimIndent() + assertEquals("com.example.fixtures.model", JavaHeuristics.extractDeclaredPackage(code)) + } + + @Test + fun `extractDeclaredPackage returns null when no package declared`() { + val code = "import org.junit.jupiter.api.Test;\nclass OrderTest { }" + assertNull(JavaHeuristics.extractDeclaredPackage(code)) + } +} diff --git a/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/SharedCredentialResolverTest.kt b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/SharedCredentialResolverTest.kt new file mode 100644 index 0000000..63d1471 --- /dev/null +++ b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/SharedCredentialResolverTest.kt @@ -0,0 +1,53 @@ +package org.openprojectx.ai.plugin + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SharedCredentialResolverTest { + + private val shared = SharedCredentialResolver.UsernamePassword("shared-user", "shared-pass") + + @Test + fun `uses service credentials when both username and password are set`() { + val result = SharedCredentialResolver.resolveUsernamePassword( + serviceUsername = "svc-user", + servicePassword = "svc-pass", + sharedUsername = shared.username, + sharedPassword = shared.password + ) + assertEquals(SharedCredentialResolver.UsernamePassword("svc-user", "svc-pass"), result) + } + + @Test + fun `falls back to shared when service credentials are blank`() { + val result = SharedCredentialResolver.resolveUsernamePassword( + serviceUsername = "", + servicePassword = "", + sharedUsername = shared.username, + sharedPassword = shared.password + ) + assertEquals(shared, result) + } + + @Test + fun `falls back to shared when service has username but no password`() { + val result = SharedCredentialResolver.resolveUsernamePassword( + serviceUsername = "svc-user", + servicePassword = "", + sharedUsername = shared.username, + sharedPassword = shared.password + ) + assertEquals(shared, result) + } + + @Test + fun `returns blanks when neither service nor shared credentials are complete`() { + val result = SharedCredentialResolver.resolveUsernamePassword( + serviceUsername = "", + servicePassword = "", + sharedUsername = "shared-user", + sharedPassword = "" + ) + assertEquals(SharedCredentialResolver.UsernamePassword("", ""), result) + } +} diff --git a/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/SonarQubeAuthTest.kt b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/SonarQubeAuthTest.kt new file mode 100644 index 0000000..cd01ad0 --- /dev/null +++ b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/SonarQubeAuthTest.kt @@ -0,0 +1,22 @@ +package org.openprojectx.ai.plugin + +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class SonarQubeAuthTest { + + @Test + fun `requires configured credentials before online SonarQube requests`() { + val error = assertFailsWith { + SonarQubeAuth.requireAuthorizationHeader( + SonarQubeConfig( + serverUrl = "https://sonarqube.example.com", + projectKey = "my-service" + ) + ) + } + + assertTrue(error.message.orEmpty().contains("Configure SonarQube Token/PAT")) + } +} diff --git a/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/testgen/EnvironmentContextCollectorTest.kt b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/testgen/EnvironmentContextCollectorTest.kt new file mode 100644 index 0000000..21ce192 --- /dev/null +++ b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/testgen/EnvironmentContextCollectorTest.kt @@ -0,0 +1,97 @@ +package org.openprojectx.ai.plugin.testgen + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EnvironmentContextCollectorTest { + + @Test + fun `parses maven coordinate version`() { + assertEquals( + "1.18.30", + EnvironmentContextCollector.parseLibraryVersion("Maven: org.projectlombok:lombok:1.18.30", "lombok") + ) + } + + @Test + fun `parses gradle-style coordinate version`() { + assertEquals( + "3.24.2", + EnvironmentContextCollector.parseLibraryVersion("Gradle: org.assertj:assertj-core:3.24.2", "assertj-core") + ) + } + + @Test + fun `parses bare coordinate without prefix`() { + assertEquals( + "5.11.4", + EnvironmentContextCollector.parseLibraryVersion("org.junit.jupiter:junit-jupiter-api:5.11.4", "junit-jupiter-api") + ) + } + + @Test + fun `returns null when artifact does not match`() { + assertNull(EnvironmentContextCollector.parseLibraryVersion("Maven: com.h2database:h2:2.2.224", "lombok")) + } + + @Test + fun `returns null for non-coordinate names`() { + assertNull(EnvironmentContextCollector.parseLibraryVersion("some-random-name", "lombok")) + } + + @Test + fun `formats a full environment block`() { + val info = EnvironmentInfo( + languageLevel = "17", + buildTool = "Maven", + testLibraries = listOf("JUnit 5", "AssertJ", "Mockito (+ mockito-junit-jupiter)"), + keyVersions = listOf("Lombok 1.18.30", "Spring Boot 3.2.5"), + lombokPresent = true + ) + + val block = EnvironmentContextCollector.formatEnvironmentBlock(info) + + assertTrue(block.startsWith("## Test Environment"), "starts with heading") + assertTrue(block.contains("Java language level: 17")) + assertTrue(block.contains("Build tool: Maven")) + assertTrue(block.contains("Test libraries available: JUnit 5, AssertJ, Mockito (+ mockito-junit-jupiter)")) + assertTrue(block.contains("Key versions: Lombok 1.18.30, Spring Boot 3.2.5")) + assertTrue(block.contains("Lombok present"), "includes the Lombok @Builder.Default caveat") + } + + @Test + fun `returns empty string when nothing detected`() { + val info = EnvironmentInfo( + languageLevel = null, + buildTool = null, + testLibraries = emptyList(), + keyVersions = emptyList(), + lombokPresent = false + ) + + assertEquals("", EnvironmentContextCollector.formatEnvironmentBlock(info)) + } + + @Test + fun `warns when no test library markers are present`() { + assertTrue(EnvironmentContextCollector.noTestLibrariesPresent(emptyList())) + } + + @Test + fun `does not warn when REST Assured is present`() { + assertFalse(EnvironmentContextCollector.noTestLibrariesPresent(listOf("io.restassured.RestAssured"))) + } + + @Test + fun `does not warn when JUnit 5 is present`() { + assertFalse(EnvironmentContextCollector.noTestLibrariesPresent(listOf("org.junit.jupiter.api.Test"))) + } + + @Test + fun `warns when only unrelated classes are present`() { + assertTrue(EnvironmentContextCollector.noTestLibrariesPresent(listOf("com.h2database.Driver"))) + } +}