-
Notifications
You must be signed in to change notification settings - Fork 45
Expand file tree
/
Copy pathJsTestGenerator.kt
More file actions
446 lines (421 loc) · 18.3 KB
/
JsTestGenerator.kt
File metadata and controls
446 lines (421 loc) · 18.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
package api
import codegen.JsCodeGenerator
import com.google.javascript.rhino.Node
import framework.api.js.JsClassId
import framework.api.js.JsMethodId
import framework.api.js.JsMultipleClassId
import framework.api.js.JsUtFuzzedExecution
import framework.api.js.util.isClass
import framework.api.js.util.isJsArray
import framework.api.js.util.isJsBasic
import framework.api.js.util.jsErrorClassId
import framework.api.js.util.jsUndefinedClassId
import framework.codegen.JsImport
import framework.codegen.ModuleType
import fuzzer.JsFeedback
import fuzzer.JsFuzzingExecutionFeedback
import fuzzer.JsMethodDescription
import fuzzer.JsStatement
import fuzzer.JsTimeoutExecution
import fuzzer.JsValidExecution
import fuzzer.runFuzzing
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import mu.KotlinLogging
import org.utbot.common.runBlockingWithCancellationPredicate
import org.utbot.framework.codegen.domain.models.CgMethodTestSet
import org.utbot.framework.plugin.api.EnvironmentModels
import org.utbot.framework.plugin.api.ExecutableId
import org.utbot.framework.plugin.api.TimeoutException
import org.utbot.framework.plugin.api.UtExecution
import org.utbot.framework.plugin.api.UtExecutionResult
import org.utbot.framework.plugin.api.UtExecutionSuccess
import org.utbot.framework.plugin.api.UtExplicitlyThrownException
import org.utbot.framework.plugin.api.UtModel
import org.utbot.framework.plugin.api.UtTimeoutException
import org.utbot.fuzzing.Control
import org.utbot.fuzzing.utils.Trie
import parser.JsAstScrapper
import parser.visitors.JsFuzzerAstVisitor
import parser.JsParserUtils
import parser.JsParserUtils.getAbstractFunctionName
import parser.JsParserUtils.getAbstractFunctionParams
import parser.JsParserUtils.getClassMethods
import parser.JsParserUtils.getClassName
import parser.JsParserUtils.getParamName
import parser.JsParserUtils.runParser
import parser.visitors.JsToplevelFunctionAstVisitor
import providers.exports.IExportsProvider
import service.InstrumentationService
import service.PackageJson
import service.PackageJsonService
import service.ServiceContext
import service.TernService
import service.coverage.CoverageMode
import service.coverage.CoverageServiceProvider
import settings.JsDynamicSettings
import settings.JsTestGenerationSettings.fileUnderTestAliases
import settings.JsTestGenerationSettings.fuzzingThreshold
import settings.JsTestGenerationSettings.fuzzingTimeout
import utils.PathResolver
import utils.constructClass
import utils.data.ResultData
import utils.toJsAny
import java.io.File
import java.nio.file.Paths
import java.util.concurrent.CancellationException
import settings.JsExportsSettings.endComment
import settings.JsExportsSettings.startComment
private val logger = KotlinLogging.logger {}
class JsTestGenerator(
private val fileText: String,
private var sourceFilePath: String,
private var projectPath: String = sourceFilePath.substringBeforeLast("/"),
private val selectedMethods: List<String>? = null,
private var parentClassName: String? = null,
private var outputFilePath: String?,
private val exportsManager: ((String?, String) -> String) -> Unit,
private val settings: JsDynamicSettings,
private val isCancelled: () -> Boolean = { false }
) {
private val exports = mutableSetOf<String>()
private lateinit var parsedFile: Node
private lateinit var astScrapper: JsAstScrapper
private val utbotDir = "utbotJs"
init {
fixPathDelims()
}
private fun fixPathDelims() {
projectPath = projectPath.replace("\\", "/")
outputFilePath = outputFilePath?.replace("\\", "/")
sourceFilePath = sourceFilePath.replace("\\", "/")
}
/**
* Returns String representation of generated tests.
*/
fun run(): String {
parsedFile = runParser(fileText, sourceFilePath)
val packageJson = PackageJsonService(
sourceFilePath,
File(projectPath),
).findClosestConfig()
val moduleType = ModuleType.fromPackageJson(packageJson)
astScrapper = JsAstScrapper(
parsedFile,
sourceFilePath,
Paths.get("$projectPath/$utbotDir"),
moduleType,
settings
)
val context = ServiceContext(
utbotDir = utbotDir,
projectPath = projectPath,
filePathToInference = astScrapper.filesToInfer,
parsedFile = parsedFile,
settings = settings,
importsMap = astScrapper.importsMap,
packageJson = packageJson
)
val paramNames = mutableMapOf<ExecutableId, List<String>>()
val testSets = mutableListOf<CgMethodTestSet>()
val classNode =
JsParserUtils.searchForClassDecl(
className = parentClassName,
parsedFile = parsedFile,
strict = selectedMethods?.isNotEmpty() ?: false
)
parentClassName = classNode?.getClassName()
val classId = makeJsClassId(classNode, TernService(context))
val methods = makeMethodsToTest()
if (methods.isEmpty()) throw IllegalArgumentException("No methods to test were found!")
methods.forEach { funcNode ->
makeTestsForMethod(classId, funcNode, classNode, context, testSets, paramNames)
}
val imports = context.necessaryImports.makeImportsForCodegen(moduleType)
val codeGen = JsCodeGenerator(
classUnderTest = classId,
paramNames = paramNames,
imports = imports
)
return codeGen.generateAsStringWithTestReport(testSets).generatedCode
}
private fun Map<String, Node>.makeImportsForCodegen(moduleType: ModuleType): List<JsImport> {
val baseImports = listOf(
JsImport(
"*",
"assert",
"assert",
moduleType
),
JsImport(
"*",
fileUnderTestAliases,
"./${makeImportPrefix()}/${sourceFilePath.substringAfterLast("/")}",
moduleType
)
)
return baseImports + this.map { (key, value) ->
JsImport(
key,
key,
outputFilePath?.let { path ->
PathResolver.getRelativePath(File(path).parent, value.sourceFileName!!)
} ?: "",
moduleType
)
}
}
private fun makeTestsForMethod(
classId: JsClassId,
funcNode: Node,
classNode: Node?,
context: ServiceContext,
testSets: MutableList<CgMethodTestSet>,
paramNames: MutableMap<ExecutableId, List<String>>
) {
val execId = classId.allMethods.find {
it.name == funcNode.getAbstractFunctionName()
} ?: throw IllegalStateException()
manageExports(classNode, funcNode, execId, context.packageJson)
val executionResults = mutableListOf<JsFuzzingExecutionFeedback>()
try {
runBlockingWithCancellationPredicate(isCancelled) {
runFuzzingFlow(funcNode, execId, context).collect {
executionResults += it
}
}
} catch (e: CancellationException) {
logger.info { "Fuzzing was stopped due to test generation cancellation" }
}
if (executionResults.isEmpty()) {
if (isCancelled()) return
throw UnsupportedOperationException("No test cases were generated for ${funcNode.getAbstractFunctionName()}")
}
logger.info { "${executionResults.size} test cases were suggested after fuzzing" }
val testsForGenerator = mutableListOf<UtExecution>()
val errorsForGenerator = mutableMapOf<String, Int>()
executionResults.forEach { value ->
when (value) {
is JsTimeoutExecution -> errorsForGenerator[value.utTimeout.exception.message!!] = 1
is JsValidExecution -> testsForGenerator.add(value.utFuzzedExecution)
}
}
val testSet = CgMethodTestSet(
executableId = execId,
errors = errorsForGenerator,
executions = testsForGenerator,
)
testSets += testSet
paramNames[execId] = funcNode.getAbstractFunctionParams().map { it.getParamName() }
}
private fun makeImportPrefix(): String {
return outputFilePath?.let {
PathResolver.getRelativePath(
File(it).parent,
File(sourceFilePath).parent,
)
} ?: ""
}
private fun getUtModelResult(
execId: JsMethodId,
resultData: ResultData,
fuzzedValues: List<UtModel>
): UtExecutionResult {
if (resultData.isError && resultData.rawString == "Timeout") return UtTimeoutException(
TimeoutException("Timeout in generating test for ${
execId.parameters
.zip(fuzzedValues)
.joinToString(
prefix = "${execId.name}(",
separator = ", ",
postfix = ")"
) { (_, value) -> value.toString() }
}")
)
val (returnValue, valueClassId) = resultData.toJsAny(
if (execId.returnType.isJsBasic) JsClassId(resultData.type) else execId.returnType
)
val result = JsUtModelConstructor().construct(returnValue, valueClassId)
val utExecResult = when (result.classId) {
jsErrorClassId -> UtExplicitlyThrownException(Throwable(returnValue.toString()), false)
else -> UtExecutionSuccess(result)
}
return utExecResult
}
private fun runFuzzingFlow(
funcNode: Node,
execId: JsMethodId,
context: ServiceContext,
): Flow<JsFuzzingExecutionFeedback> = flow {
val fuzzerVisitor = JsFuzzerAstVisitor()
fuzzerVisitor.accept(funcNode)
val jsDescription = JsMethodDescription(
name = funcNode.getAbstractFunctionName(),
parameters = execId.parameters,
classId = execId.classId,
concreteValues = fuzzerVisitor.fuzzedConcreteValues,
tracer = Trie(JsStatement::number)
)
val collectedValues = mutableListOf<List<UtModel>>()
// .location field gets us "jsFile:A:B", then we get A and B as ints
val funcLocation = funcNode.firstChild!!.location.substringAfter("${funcNode.sourceFileName}:")
.split(":").map { it.toInt() }
logger.info { "Function under test location according to parser is [${funcLocation[0]}, ${funcLocation[1]}]" }
val instrService = InstrumentationService(context, funcLocation[0] to funcLocation[1])
instrService.instrument()
val coverageProvider = CoverageServiceProvider(
context,
instrService,
context.settings.coverageMode,
jsDescription
)
val allStmts = instrService.allStatements
logger.info { "Statements to cover: (${allStmts.joinToString { toString() }})" }
val currentlyCoveredStmts = mutableSetOf<Int>()
val startTime = System.currentTimeMillis()
runFuzzing(jsDescription) { description, values ->
if (isCancelled() || System.currentTimeMillis() - startTime > fuzzingTimeout)
return@runFuzzing JsFeedback(Control.STOP)
collectedValues += values
if (collectedValues.size >= if (context.settings.coverageMode == CoverageMode.FAST) fuzzingThreshold else 1) {
try {
val (coveredStmts, executionResults) = coverageProvider.get(
collectedValues,
execId
)
coveredStmts.zip(executionResults).forEach { (covData, resultData) ->
val params = collectedValues[resultData.index]
val result =
getUtModelResult(
execId = execId,
resultData = resultData,
jsDescription.thisInstance?.let { params.drop(1) } ?: params
)
if (result is UtTimeoutException) {
emit(JsTimeoutExecution(result))
return@runFuzzing JsFeedback(Control.PASS)
} else if (!currentlyCoveredStmts.containsAll(covData.additionalCoverage)) {
val (thisObject, modelList) = if (!funcNode.parent!!.isClassMembers) {
null to params
} else params[0] to params.drop(1)
val initEnv =
EnvironmentModels(thisObject, modelList, mapOf())
emit(
JsValidExecution(
JsUtFuzzedExecution(
stateBefore = initEnv,
stateAfter = initEnv,
result = result,
)
)
)
currentlyCoveredStmts += covData.additionalCoverage
val trieNode = description.tracer.add(covData.additionalCoverage.map { JsStatement(it) })
return@runFuzzing JsFeedback(control = Control.CONTINUE, result = trieNode)
}
if (currentlyCoveredStmts.containsAll(allStmts)) return@runFuzzing JsFeedback(Control.STOP)
}
} catch (e: TimeoutException) {
emit(
JsTimeoutExecution(
UtTimeoutException(
TimeoutException("Timeout on unknown test case. Consider using \"Basic\" coverage mode")
)
)
)
return@runFuzzing JsFeedback(Control.STOP)
} finally {
collectedValues.clear()
}
}
return@runFuzzing JsFeedback(Control.PASS)
}
instrService.removeTempFiles()
}
private fun manageExports(
classNode: Node?,
funcNode: Node,
execId: JsMethodId,
packageJson: PackageJson
) {
val obligatoryExport = (classNode?.getClassName() ?: funcNode.getAbstractFunctionName()).toString()
val collectedExports = collectExports(execId)
val exportsProvider = IExportsProvider.providerByPackageJson(packageJson)
exports += (collectedExports + obligatoryExport)
exportsManager { existingSection, currentFileText ->
val existingExportsSet = existingSection?.let { section ->
val trimmedSection = section.substringAfter(exportsProvider.exportsPrefix)
.substringBeforeLast(exportsProvider.exportsPostfix)
val exportRegex = exportsProvider.exportsRegex
val existingExports = trimmedSection.split(exportsProvider.exportsDelimiter)
.filter { it.contains(exportRegex) && it.isNotBlank() }
existingExports.map { rawLine ->
exportRegex.find(rawLine)?.groups?.get(1)?.value ?: throw IllegalStateException()
}.toSet()
} ?: emptySet()
val resultSet = existingExportsSet + exports.toSet()
val resSection = resultSet.joinToString(
separator = exportsProvider.exportsDelimiter,
prefix = startComment + exportsProvider.exportsPrefix,
postfix = exportsProvider.exportsPostfix + endComment,
) {
exportsProvider.getExportsFrame(it)
}
existingSection?.let { currentFileText.replace(startComment + existingSection + endComment, resSection) } ?: resSection
}
}
private fun makeMethodsToTest(): List<Node> {
return selectedMethods?.map {
getFunctionNode(
focusedMethodName = it,
parentClassName = parentClassName,
)
} ?: getMethodsToTest()
}
private fun makeJsClassId(
classNode: Node?,
ternService: TernService
): JsClassId {
return classNode?.let {
JsClassId(parentClassName!!).constructClass(ternService, classNode)
} ?: jsUndefinedClassId.constructClass(
ternService = ternService,
functions = extractToplevelFunctions()
)
}
private fun extractToplevelFunctions(): List<Node> {
val visitor = JsToplevelFunctionAstVisitor()
visitor.accept(parsedFile)
return visitor.extractedMethods
}
private fun collectExports(methodId: JsMethodId): List<String> {
return (listOf(methodId.returnType) + methodId.parameters).flatMap { it.collectExportsRecursively() }
}
private fun JsClassId.collectExportsRecursively(): List<String> {
return when {
this.isClass && !astScrapper.importsMap.contains(this.name) ->
listOf(this.name) + (this.constructor?.parameters ?: emptyList())
.flatMap { it.collectExportsRecursively() }
this is JsMultipleClassId -> this.classIds.flatMap { it.collectExportsRecursively() }
this.isJsArray -> (this.elementClassId as? JsClassId)?.collectExportsRecursively() ?: emptyList()
else -> emptyList()
}
}
private fun getFunctionNode(focusedMethodName: String, parentClassName: String?): Node {
return parentClassName?.let { astScrapper.findMethod(parentClassName, focusedMethodName, parsedFile) }
?: astScrapper.findFunction(focusedMethodName, parsedFile)
?: throw IllegalStateException(
"Couldn't locate function \"$focusedMethodName\" with class ${parentClassName ?: ""}"
)
}
private fun getMethodsToTest() =
parentClassName?.let {
getClassMethods(it)
} ?: extractToplevelFunctions().ifEmpty {
getClassMethods("")
}
private fun getClassMethods(className: String): List<Node> {
val classNode = astScrapper.findClass(className, parsedFile)
return classNode?.getClassMethods() ?: throw IllegalStateException("Can't extract methods of class $className")
}
}