Skip to content

Commit 0a6840f

Browse files
committed
Merge remote-tracking branch 'origin/master' into regex-support-extension
2 parents 194cc96 + 1539098 commit 0a6840f

10 files changed

Lines changed: 302 additions & 24 deletions

File tree

core/src/main/kotlin/org/evomaster/core/llm/Prompts.kt

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,42 @@ object Prompts {
5151
the following error message:
5252
"""
5353

54+
const val NEW_TEST_CASE_NAME = """
55+
You are an expert software engineer specializing in test naming.
56+
57+
Given the following test case written in [targetLanguage], produce a descriptive suffix to append to its existing name.
58+
59+
## Rules
60+
61+
- The suffix must follow the naming conventions of [targetLanguage] (e.g. snake_case for Python, camelCase for Java).
62+
- The suffix must be derived directly from the code: what unit is being exercised, under what conditions, and what outcome is asserted.
63+
- The suffix should follow the pattern `<method/feature>_<condition>_<expectedOutcome>` or a close variant natural to [targetLanguage] test frameworks. Pick whichever fits the test best.
64+
- Use only information present in the test body — do not invent context.
65+
- Be specific: prefer `createUser_duplicateEmail_throwsConflict` over `createUser_fails`.
66+
- Do not include words like "test", "check", "verify", or "ensure" in the suffix.
67+
- The suffix must not exceed [remainingNameChars] characters.
68+
- The suffix must not match any of the already assigned names listed below.
69+
- Output only the suffix, nothing else.
70+
71+
## Language
72+
73+
[targetLanguage]
74+
75+
## Max suffix length
76+
77+
[remainingNameChars] characters
78+
79+
## Already assigned names
80+
81+
[generatedNames]
82+
83+
## Test case
84+
85+
[testLines]
86+
"""
87+
88+
const val RE_ITERATE_TEST_CASE_NAME = "Your previous response contained more than just the suffix. Output only the suffix, nothing else. No explanation, no punctuation, no extra text, do not exceed max chars."
89+
5490
fun getPromptForNameDescription(name: String, description: String?): Pair<String,String> {
5591
var user = "Your input is\n [name]:$name"
5692
if(description != null) {
@@ -63,6 +99,24 @@ object Prompts {
6399
return Pair(VALUE_BASED_ON_NAME_FAILURE, error)
64100
}
65101

102+
/**
103+
* Used for prompting an LLM to return a new test case name
104+
* @param targetLanguage the target language of the test case, used by the LLM to return a name following naming conventions of the language
105+
* @param remainingNameChars the maximum amount of chars the returned test case name should have
106+
* @param generatedNames set of already returned names from the LLM to avoid duplication of names
107+
* @param testLines test case content to feed the LLM context for generating a test case name
108+
*
109+
* @return [Pair] containing the system prompt with all the scaffolding and rules as first and the user message with the current test information as second.
110+
*/
111+
fun getPromptForTestCaseName(targetLanguage: String, remainingNameChars: Int, generatedNames: MutableSet<String>, testLines: String): Pair<String,String>{
112+
val userMessage = """Your input is
113+
[targetLanguage]:$targetLanguage"
114+
[remainingNameChars]: $remainingNameChars
115+
[generatedNames]: $generatedNames
116+
[testLines]: $testLines"""
117+
return Pair(NEW_TEST_CASE_NAME, userMessage)
118+
}
119+
66120
}
67121

68122

core/src/main/kotlin/org/evomaster/core/llm/service/LlmService.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,11 @@ class LlmService {
8484
}
8585

8686
fun chat(userMessage: String) : String{
87-
return LlmSupport.chat(syncModel, "", userMessage)
87+
return LlmSupport.chat(syncModel, userMessage)
88+
}
89+
90+
fun chat(systemMessage: String, userMessage: String) : String{
91+
return LlmSupport.chat(syncModel, systemMessage, userMessage)
8892
}
8993

9094
/**
@@ -126,4 +130,4 @@ class LlmService {
126130
}
127131
callback(name, list)
128132
}
129-
}
133+
}

core/src/main/kotlin/org/evomaster/core/output/TestSuiteOrganizer.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package org.evomaster.core.output
22

33
import org.evomaster.core.EMConfig
4+
import org.evomaster.core.llm.service.LlmService
45
import org.evomaster.core.output.naming.TestCaseNamingStrategyFactory
6+
import org.evomaster.core.output.service.TestCaseWriter
57
import org.evomaster.core.output.sorting.SortingHelper
68
import org.evomaster.core.search.Solution
79

810

911
class TestSuiteOrganizer(
10-
private val config: EMConfig
12+
private val config: EMConfig,
13+
private val llmService: LlmService
1114
) {
1215

1316
private val sortingHelper = SortingHelper()
@@ -27,15 +30,15 @@ class TestSuiteOrganizer(
2730
* WARNING: side-effect of sorting tests inside input [solution] object
2831
*
2932
*/
30-
fun createSortedTestCases(solution: Solution<*>): List<TestCase> {
33+
fun createSortedTestCases(solution: Solution<*>, testCaseWriter: TestCaseWriter): List<TestCase> {
3134

3235
/*
3336
Tests MUST be sorted before they are named, as their position might influence
3437
their name (eg, "test_0_...")
3538
*/
3639
sortingHelper.sort(solution.individuals, config.testCaseSortingStrategy)
3740

38-
val namingStrategy = TestCaseNamingStrategyFactory(config).create(solution)
41+
val namingStrategy = TestCaseNamingStrategyFactory(config, testCaseWriter, llmService).create(solution)
3942

4043
val tests = namingStrategy.getTestCases()
4144

core/src/main/kotlin/org/evomaster/core/output/TestWriterUtils.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,11 @@ object TestWriterUtils {
127127

128128
/**
129129
* Some character shouldn't be used for variable names, as it would lead to compilation/runtime errors.
130-
* Those are replaced with safe ones
130+
* Those are replaced with [replacementChar], which by default is '_'.
131131
*/
132-
fun safeVariableName(name: String): String {
132+
fun safeVariableName(name: String, replacementChar: String = "_"): String {
133133

134-
val safe = name.replace(Regex("[^0-9a-zA-Z_]"), "_")
134+
val safe = name.replace(Regex("[^0-9a-zA-Z_]"), replacementChar)
135135
val first = safe.codePointAt(0)
136136
if(first >= '0'.code && first <= '9'.code ) {
137137
//can't start a variable name with a number

core/src/main/kotlin/org/evomaster/core/output/naming/ActionTestCaseNamingStrategy.kt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ abstract class ActionTestCaseNamingStrategy(
1818
protected val maxTestCaseNameLength: Int
1919
) : NumberedTestCaseNamingStrategy(solution) {
2020

21-
private val testCasesSize = solution.individuals.size
22-
2321
protected val on = "on"
2422
protected val throws = "throws"
2523
protected val returns = "returns"
@@ -93,11 +91,6 @@ abstract class ActionTestCaseNamingStrategy(
9391
addEnvironmentActions(individual, nameTokens, newRemainingNameChars)
9492
}
9593

96-
protected fun namePrefixChars(): Int {
97-
val digitsUsedForTestNumbering = testCasesSize.toString().length
98-
return TEST_NAME_PREFIX.length + digitsUsedForTestNumbering + 1
99-
}
100-
10194
protected fun addNameTokensIfAllowed(nameTokens: MutableList<String>, targetStrings: List<String>, remainingNameChars: Int): Int {
10295
val charsToBeUsed = targetStrings.sumOf { it.length }
10396
if ((remainingNameChars - charsToBeUsed) >= 0) {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package org.evomaster.core.output.naming
2+
3+
import org.evomaster.core.llm.Prompts.RE_ITERATE_TEST_CASE_NAME
4+
import org.evomaster.core.llm.Prompts.getPromptForTestCaseName
5+
import org.evomaster.core.llm.service.LlmService
6+
import org.evomaster.core.output.Lines
7+
import org.evomaster.core.output.OutputFormat
8+
import org.evomaster.core.output.TestCase
9+
import org.evomaster.core.output.TestWriterUtils
10+
import org.evomaster.core.output.service.TestCaseWriter
11+
import org.evomaster.core.output.service.TestSuiteWriter
12+
import org.evomaster.core.search.EvaluatedIndividual
13+
import org.evomaster.core.search.Solution
14+
import org.slf4j.Logger
15+
import org.slf4j.LoggerFactory
16+
17+
class LlmServiceTestCaseNamingStrategy(
18+
solution: Solution<*>,
19+
private val outputFormat: OutputFormat,
20+
private val llmService: LlmService,
21+
maxTestCaseNameLength: Int,
22+
private val testCaseWriter: TestCaseWriter
23+
) : NumberedTestCaseNamingStrategy(solution) {
24+
25+
private val log: Logger = LoggerFactory.getLogger(TestSuiteWriter::class.java)
26+
private val generatedNames = mutableSetOf<String>()
27+
28+
private val remainingNameChars = maxTestCaseNameLength - namePrefixChars()
29+
30+
override fun expandName(
31+
individual: EvaluatedIndividual<*>,
32+
nameTokens: MutableList<String>,
33+
ambiguitySolvers: List<AmbiguitySolver>
34+
): String {
35+
val newName = generateLlmName(TestCase(individual, "test"))
36+
return if (newName.isNotEmpty()) "_$newName" else ""
37+
}
38+
39+
private fun generateLlmName(test: TestCase): String {
40+
var newName = sanitizeName(getNewName(test))
41+
while (!isValidSuffix(newName)) {
42+
newName = sanitizeName(promptReIterateName())
43+
}
44+
generatedNames.add(newName)
45+
return newName
46+
}
47+
48+
// LLM is sometimes returning names as "\n\ntheNewName_" so we need to fix that and return "theNewName".
49+
private fun sanitizeName(testName: String): String {
50+
return TestWriterUtils.safeVariableName(testName.trim().replace("\n", ""), "")
51+
}
52+
53+
private fun getNewName(test: TestCase): String {
54+
val testLines = getTestSourceCode(test)
55+
val targetLanguage = getTargetLanguage()
56+
val prompt = getPromptForTestCaseName(targetLanguage, remainingNameChars, generatedNames, testLines.toString())
57+
return llmService.chat(prompt.first, prompt.second)
58+
}
59+
60+
// Just in case the LLM did not follow the directive of just giving the new name as output.
61+
private fun promptReIterateName(): String {
62+
return llmService.chat(RE_ITERATE_TEST_CASE_NAME)
63+
}
64+
65+
// With this regex, we check that the output by the LLM is only the test case name. We validate:
66+
// 1. Whitespace check — if the output contains a newline, it's not a bare name.
67+
// 2. Word count — split on spaces
68+
// 3. Illegal character check — a valid name contains only alphanumeric characters and underscores
69+
// 4. Length check — if the output exceeds it, it's invalid regardless of format.
70+
private fun isValidSuffix(output: String): Boolean {
71+
val stripped = output.trim()
72+
return stripped.matches(Regex("[A-Za-z0-9_]+")) && stripped.length <= remainingNameChars
73+
}
74+
75+
private fun getTargetLanguage(): String {
76+
return when {
77+
outputFormat.isJava() -> "Java"
78+
outputFormat.isKotlin() -> "Kotlin"
79+
outputFormat.isJavaScript() -> "JavaScript"
80+
outputFormat.isPython() -> "Python"
81+
else -> throw IllegalStateException("Unrecognized language: $outputFormat")
82+
}
83+
}
84+
85+
private fun getTestSourceCode(test: TestCase): Lines {
86+
return try {
87+
testCaseWriter.convertToCompilableTestCode(test, TestSuiteWriter.baseUrlOfSut, null)
88+
} catch (ex: Exception) {
89+
log.warn(
90+
"A failure has occurred in generating test code ${test.name} for LLM naming strategy. \n "
91+
+ "Exception: ${ex.localizedMessage} \n"
92+
+ "At ${ex.stackTrace.joinToString(separator = " \n -> ")}. "
93+
)
94+
assert(false) // in our tests, this should not happen... but should not crash in production
95+
Lines(outputFormat)
96+
}
97+
}
98+
}

core/src/main/kotlin/org/evomaster/core/output/naming/NumberedTestCaseNamingStrategy.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ open class NumberedTestCaseNamingStrategy(
99
solution: Solution<*>
1010
) : TestCaseNamingStrategy(solution) {
1111

12+
protected val testCasesSize = solution.individuals.size
13+
1214
companion object{
1315
const val TEST_NAME_PREFIX = "test_"
1416
}
@@ -17,8 +19,6 @@ open class NumberedTestCaseNamingStrategy(
1719
return generateNames(solution.individuals)
1820
}
1921

20-
21-
2222
// numbered strategy will not expand the name unless it is using the namingHelper
2323
override fun expandName(individual: EvaluatedIndividual<*>, nameTokens: MutableList<String>, ambiguitySolvers: List<AmbiguitySolver>): String {
2424
return ""
@@ -29,6 +29,11 @@ open class NumberedTestCaseNamingStrategy(
2929
return emptyMap()
3030
}
3131

32+
protected fun namePrefixChars(): Int {
33+
val digitsUsedForTestNumbering = testCasesSize.toString().length
34+
return TEST_NAME_PREFIX.length + digitsUsedForTestNumbering + 1
35+
}
36+
3237
private fun concatName(counter: Int, expandedName: String): String {
3338
return "$TEST_NAME_PREFIX${counter}${expandedName}"
3439
}

core/src/main/kotlin/org/evomaster/core/output/naming/TestCaseNamingStrategyFactory.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package org.evomaster.core.output.naming
22

33
import org.evomaster.core.EMConfig
4+
import org.evomaster.core.llm.service.LlmService
5+
import org.evomaster.core.output.OutputFormat
46
import org.evomaster.core.output.naming.rest.RestActionTestCaseNamingStrategy
7+
import org.evomaster.core.output.service.TestCaseWriter
58
import org.evomaster.core.problem.graphql.GraphQLIndividual
69
import org.evomaster.core.problem.rest.data.RestIndividual
710
import org.evomaster.core.problem.rpc.RPCIndividual
@@ -14,10 +17,13 @@ class TestCaseNamingStrategyFactory(
1417
private val namingStrategy: NamingStrategy,
1518
private val languageConventionFormatter: LanguageConventionFormatter,
1619
private val nameWithQueryParameters: Boolean,
17-
private val maxTestCaseNameLength: Int
20+
private val maxTestCaseNameLength: Int,
21+
private val outputFormat: OutputFormat,
22+
private val testCaseWriter: TestCaseWriter,
23+
private val llmService: LlmService
1824
) {
1925

20-
constructor(config: EMConfig): this(config.namingStrategy, LanguageConventionFormatter(config.outputFormat), config.nameWithQueryParameters, config.maxTestCaseNameLength)
26+
constructor(config: EMConfig, testCaseWriter: TestCaseWriter, llmService: LlmService): this(config.namingStrategy, LanguageConventionFormatter(config.outputFormat), config.nameWithQueryParameters, config.maxTestCaseNameLength, config.outputFormat, testCaseWriter, llmService)
2127

2228
companion object {
2329
private val log: Logger = LoggerFactory.getLogger(TestCaseNamingStrategyFactory::class.java)
@@ -27,7 +33,7 @@ class TestCaseNamingStrategyFactory(
2733
return when(namingStrategy) {
2834
NamingStrategy.NUMBERED -> NamingHelperNumberedTestCaseNamingStrategy(solution)
2935
NamingStrategy.DETERMINISTIC -> deterministicActionBasedNamingStrategy(solution)
30-
//TODO LLM
36+
NamingStrategy.LLM -> LlmServiceTestCaseNamingStrategy(solution, outputFormat, llmService, maxTestCaseNameLength, testCaseWriter)
3137
else -> throw IllegalStateException("Unrecognized naming strategy $namingStrategy")
3238
}
3339
}

core/src/main/kotlin/org/evomaster/core/output/service/TestSuiteWriter.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import org.evomaster.core.output.*
1010
import org.evomaster.core.output.TestWriterUtils.getWireMockVariableName
1111
import org.evomaster.core.output.TestWriterUtils.handleDefaultStubForAsJavaOrKotlin
1212
import org.evomaster.core.output.dto.DtoWriter
13+
import org.evomaster.core.llm.service.LlmService
1314
import org.evomaster.core.output.naming.NumberedTestCaseNamingStrategy
14-
import org.evomaster.core.output.naming.TestCaseNamingStrategyFactory
1515
import org.evomaster.core.problem.api.ApiWsIndividual
1616
import org.evomaster.core.problem.enterprise.service.EnterpriseSampler
1717
import org.evomaster.core.problem.externalservice.httpws.HttpWsExternalService
@@ -60,7 +60,7 @@ class TestSuiteWriter {
6060

6161
private val log: Logger = LoggerFactory.getLogger(TestSuiteWriter::class.java)
6262

63-
private const val baseUrlOfSut = "baseUrlOfSut"
63+
const val baseUrlOfSut = "baseUrlOfSut"
6464
private const val fixtureClass = "ControllerFixture"
6565
private const val fixture = "_fixture"
6666
private const val browser = "browser"
@@ -95,6 +95,9 @@ class TestSuiteWriter {
9595
@Inject
9696
private lateinit var httpCallbackVerifier: HttpCallbackVerifier
9797

98+
@Inject
99+
private lateinit var llmService: LlmService
100+
98101

99102
fun writeTests(testSuiteCode: TestSuiteCode){
100103
saveToDisk(testSuiteCode.code, Paths.get(config.outputFolder, testSuiteCode.testSuitePath))
@@ -137,7 +140,7 @@ class TestSuiteWriter {
137140
): TestSuiteCode {
138141

139142
val lines = Lines(config.outputFormat)
140-
val testSuiteOrganizer = TestSuiteOrganizer(config)
143+
val testSuiteOrganizer = TestSuiteOrganizer(config, llmService)
141144

142145
header(solution, testSuiteFileName, lines, timestamp, controllerName)
143146

@@ -156,7 +159,7 @@ class TestSuiteWriter {
156159
//catch any sorting problems (see NPE is SortingHelper on Trello)
157160
val tests = try {
158161
// TODO skip to sort RPC for the moment
159-
testSuiteOrganizer.createSortedTestCases(solution)
162+
testSuiteOrganizer.createSortedTestCases(solution, testCaseWriter)
160163
} catch (ex: Exception) {
161164
log.warn(
162165
"A failure has occurred with the test sorting. Reverting to default settings. \n"

0 commit comments

Comments
 (0)