Skip to content

Commit 0f24c98

Browse files
committed
- refactor testParser.ts to handle multiple failures
- FIX #1351
1 parent 79613de commit 0f24c98

File tree

1 file changed

+174
-99
lines changed

1 file changed

+174
-99
lines changed

src/testParser.ts

Lines changed: 174 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,143 @@ async function parseSuite(
420420
}
421421
}
422422

423+
/**
424+
* Helper function to create an annotation for a test case
425+
*/
426+
async function createTestCaseAnnotation(
427+
testcase: any,
428+
failure: any | null,
429+
failureIndex: number,
430+
totalFailures: number,
431+
suiteName: string,
432+
suiteFile: string | null,
433+
suiteLine: string | null,
434+
breadCrumb: string,
435+
testTime: number,
436+
skip: boolean,
437+
success: boolean,
438+
annotationLevel: 'failure' | 'notice' | 'warning',
439+
flakyFailuresCount: number,
440+
annotateNotice: boolean,
441+
failed: boolean,
442+
excludeSources: string[],
443+
checkTitleTemplate: string | undefined,
444+
testFilesPrefix: string,
445+
transformer: Transformer[],
446+
followSymlink: boolean,
447+
truncateStackTraces: boolean,
448+
resolveIgnoreClassname: boolean
449+
): Promise<Annotation> {
450+
// Extract stack trace based on whether we have a failure or error
451+
const stackTrace: string = (
452+
(failure && failure._cdata) ||
453+
(failure && failure._text) ||
454+
(testcase.error && testcase.error._cdata) ||
455+
(testcase.error && testcase.error._text) ||
456+
''
457+
)
458+
.toString()
459+
.trim()
460+
461+
const stackTraceMessage = truncateStackTraces ? stackTrace.split('\n').slice(0, 2).join('\n') : stackTrace
462+
463+
// Extract message based on failure or error
464+
const message: string = (
465+
(failure && failure._attributes && failure._attributes.message) ||
466+
(testcase.error && testcase.error._attributes && testcase.error._attributes.message) ||
467+
stackTraceMessage ||
468+
testcase._attributes.name
469+
).trim()
470+
471+
// Determine class name for resolution
472+
let resolveClassname = testcase._attributes.name
473+
if (!resolveIgnoreClassname && testcase._attributes.classname) {
474+
resolveClassname = testcase._attributes.classname
475+
}
476+
477+
// Resolve file and line information
478+
const pos = await resolveFileAndLine(
479+
testcase._attributes.file || failure?._attributes?.file || suiteFile,
480+
testcase._attributes.line || failure?._attributes?.line || suiteLine,
481+
resolveClassname,
482+
stackTrace
483+
)
484+
485+
// Apply transformations to filename
486+
let transformedFileName = pos.fileName
487+
for (const r of transformer) {
488+
transformedFileName = applyTransformer(r, transformedFileName)
489+
}
490+
491+
// Resolve the full path
492+
const githubWorkspacePath = process.env['GITHUB_WORKSPACE']
493+
let resolvedPath: string = transformedFileName
494+
if (failed || (annotateNotice && success)) {
495+
if (fs.existsSync(transformedFileName)) {
496+
resolvedPath = transformedFileName
497+
} else if (githubWorkspacePath && fs.existsSync(`${githubWorkspacePath}${transformedFileName}`)) {
498+
resolvedPath = `${githubWorkspacePath}${transformedFileName}`
499+
} else {
500+
resolvedPath = await resolvePath(githubWorkspacePath || '', transformedFileName, excludeSources, followSymlink)
501+
}
502+
}
503+
504+
core.debug(`Path prior to stripping: ${resolvedPath}`)
505+
if (githubWorkspacePath) {
506+
resolvedPath = resolvedPath.replace(`${githubWorkspacePath}/`, '') // strip workspace prefix, make the path relative
507+
}
508+
509+
// Generate title
510+
let title = ''
511+
if (checkTitleTemplate) {
512+
// ensure to not duplicate the test_name if file_name is equal
513+
const fileName = pos.fileName !== testcase._attributes.name ? pos.fileName : ''
514+
const baseClassName = testcase._attributes.classname ? testcase._attributes.classname : testcase._attributes.name
515+
const className = baseClassName.split('.').slice(-1)[0]
516+
title = checkTitleTemplate
517+
.replace(templateVar('FILE_NAME'), fileName)
518+
.replace(templateVar('BREAD_CRUMB'), breadCrumb ?? '')
519+
.replace(templateVar('SUITE_NAME'), suiteName ?? '')
520+
.replace(templateVar('TEST_NAME'), testcase._attributes.name)
521+
.replace(templateVar('CLASS_NAME'), className)
522+
} else if (pos.fileName !== testcase._attributes.name) {
523+
// special handling to use class name only for title in case class name was ignored for `resolveClassname`
524+
if (resolveIgnoreClassname && testcase._attributes.classname) {
525+
title = `${testcase._attributes.classname}.${testcase._attributes.name}`
526+
} else {
527+
title = `${pos.fileName}.${testcase._attributes.name}`
528+
}
529+
} else {
530+
title = `${testcase._attributes.name}`
531+
}
532+
533+
// Add failure index to title if multiple failures exist
534+
if (totalFailures > 1) {
535+
title = `${title} (failure ${failureIndex + 1}/${totalFailures})`
536+
}
537+
538+
// optionally attach the prefix to the path
539+
resolvedPath = testFilesPrefix ? pathHelper.join(testFilesPrefix, resolvedPath) : resolvedPath
540+
541+
const testTimeString = testTime > 0 ? `${testTime}s` : ''
542+
core.info(`${resolvedPath}:${pos.line} | ${message.split('\n', 1)[0]}${testTimeString}`)
543+
544+
return {
545+
path: resolvedPath,
546+
start_line: pos.line,
547+
end_line: pos.line,
548+
start_column: 0,
549+
end_column: 0,
550+
retries: (testcase.retries || 0) + flakyFailuresCount,
551+
annotation_level: annotationLevel,
552+
status: skip ? 'skipped' : success ? 'success' : 'failure',
553+
title: escapeEmoji(title),
554+
message: escapeEmoji(message),
555+
raw_details: escapeEmoji(stackTrace),
556+
time: testTime
557+
}
558+
}
559+
423560
async function parseTestCases(
424561
suiteName: string,
425562
suiteFile: string | null,
@@ -482,7 +619,7 @@ async function parseTestCases(
482619
const testFailure = testcase.failure || testcase.error // test failed
483620
const skip =
484621
testcase.skipped || testcase._attributes.status === 'disabled' || testcase._attributes.status === 'ignored'
485-
const failed = testFailure && !skip // test faiure, but was skipped -> don't fail if a ignored test failed
622+
const failed = testFailure && !skip // test failure, but was skipped -> don't fail if a ignored test failed
486623
const success = !testFailure // not a failure -> thus a success
487624
const annotationLevel = success || skip ? 'notice' : 'failure' // a skipped test shall not fail the run
488625

@@ -501,9 +638,7 @@ async function parseTestCases(
501638
? Array.isArray(testcase.failure)
502639
? testcase.failure
503640
: [testcase.failure]
504-
: undefined
505-
// the action only supports 1 failure per testcase
506-
const failure = failures ? failures[0] : undefined
641+
: []
507642

508643
// identify the number of flaky failures
509644
const flakyFailuresCount = testcase.flakyFailure
@@ -512,103 +647,43 @@ async function parseTestCases(
512647
: 1
513648
: 0
514649

515-
const stackTrace: string = (
516-
(failure && failure._cdata) ||
517-
(failure && failure._text) ||
518-
(testcase.error && testcase.error._cdata) ||
519-
(testcase.error && testcase.error._text) ||
520-
''
521-
)
522-
.toString()
523-
.trim()
524-
525-
const stackTraceMessage = truncateStackTraces ? stackTrace.split('\n').slice(0, 2).join('\n') : stackTrace
526-
527-
const message: string = (
528-
(failure && failure._attributes && failure._attributes.message) ||
529-
(testcase.error && testcase.error._attributes && testcase.error._attributes.message) ||
530-
stackTraceMessage ||
531-
testcase._attributes.name
532-
).trim()
533-
534-
let resolveClassname = testcase._attributes.name
535-
if (!resolveIgnoreClassname && testcase._attributes.classname) {
536-
resolveClassname = testcase._attributes.classname
537-
}
538-
539-
const pos = await resolveFileAndLine(
540-
testcase._attributes.file || failure?._attributes?.file || suiteFile,
541-
testcase._attributes.line || failure?._attributes?.line || suiteLine,
542-
resolveClassname,
543-
stackTrace
544-
)
545-
546-
let transformedFileName = pos.fileName
547-
for (const r of transformer) {
548-
transformedFileName = applyTransformer(r, transformedFileName)
549-
}
550-
551-
const githubWorkspacePath = process.env['GITHUB_WORKSPACE']
552-
let resolvedPath: string = transformedFileName
553-
if (failed || (annotateNotice && success)) {
554-
if (fs.existsSync(transformedFileName)) {
555-
resolvedPath = transformedFileName
556-
} else if (githubWorkspacePath && fs.existsSync(`${githubWorkspacePath}${transformedFileName}`)) {
557-
resolvedPath = `${githubWorkspacePath}${transformedFileName}`
558-
} else {
559-
resolvedPath = await resolvePath(githubWorkspacePath || '', transformedFileName, excludeSources, followSymlink)
560-
}
561-
}
562-
563-
core.debug(`Path prior to stripping: ${resolvedPath}`)
564-
if (githubWorkspacePath) {
565-
resolvedPath = resolvedPath.replace(`${githubWorkspacePath}/`, '') // strip workspace prefix, make the path relative
650+
// Handle multiple failures or single case (success/skip/error)
651+
const failuresToProcess = failures.length > 0 ? failures : [null] // Process at least once for non-failure cases
652+
653+
for (let failureIndex = 0; failureIndex < failuresToProcess.length; failureIndex++) {
654+
const failure = failuresToProcess[failureIndex]
655+
656+
const annotation = await createTestCaseAnnotation(
657+
testcase,
658+
failure,
659+
failureIndex,
660+
failures.length,
661+
suiteName,
662+
suiteFile,
663+
suiteLine,
664+
breadCrumb,
665+
testTime,
666+
skip,
667+
success,
668+
annotationLevel,
669+
flakyFailuresCount,
670+
annotateNotice,
671+
failed,
672+
excludeSources,
673+
checkTitleTemplate,
674+
testFilesPrefix,
675+
transformer,
676+
followSymlink,
677+
truncateStackTraces,
678+
resolveIgnoreClassname
679+
)
680+
681+
annotations.push(annotation)
682+
683+
if (limit >= 0 && annotations.length >= limit) break
566684
}
567685

568-
let title = ''
569-
if (checkTitleTemplate) {
570-
// ensure to not duplicate the test_name if file_name is equal
571-
const fileName = pos.fileName !== testcase._attributes.name ? pos.fileName : ''
572-
const baseClassName = testcase._attributes.classname ? testcase._attributes.classname : testcase._attributes.name
573-
const className = baseClassName.split('.').slice(-1)[0]
574-
title = checkTitleTemplate
575-
.replace(templateVar('FILE_NAME'), fileName)
576-
.replace(templateVar('BREAD_CRUMB'), breadCrumb ?? '')
577-
.replace(templateVar('SUITE_NAME'), suiteName ?? '')
578-
.replace(templateVar('TEST_NAME'), testcase._attributes.name)
579-
.replace(templateVar('CLASS_NAME'), className)
580-
} else if (pos.fileName !== testcase._attributes.name) {
581-
// special handling to use class name only for title in face class name was ignored for `resolveClassname1
582-
if (resolveIgnoreClassname && testcase._attributes.classname) {
583-
title = `${testcase._attributes.classname}.${testcase._attributes.name}`
584-
} else {
585-
title = `${pos.fileName}.${testcase._attributes.name}`
586-
}
587-
} else {
588-
title = `${testcase._attributes.name}`
589-
}
590-
591-
// optionally attach the prefix to the path
592-
resolvedPath = testFilesPrefix ? pathHelper.join(testFilesPrefix, resolvedPath) : resolvedPath
593-
594-
const testTimeString = testTime > 0 ? `${testTime}s` : ''
595-
core.info(`${resolvedPath}:${pos.line} | ${message.split('\n', 1)[0]}${testTimeString}`)
596-
597-
annotations.push({
598-
path: resolvedPath,
599-
start_line: pos.line,
600-
end_line: pos.line,
601-
start_column: 0,
602-
end_column: 0,
603-
retries: (testcase.retries || 0) + flakyFailuresCount,
604-
annotation_level: annotationLevel,
605-
status: skip ? 'skipped' : success ? 'success' : 'failure',
606-
title: escapeEmoji(title),
607-
message: escapeEmoji(message),
608-
raw_details: escapeEmoji(stackTrace),
609-
time: testTime
610-
})
611-
686+
// Break from the outer testcase loop if we've reached the limit
612687
if (limit >= 0 && annotations.length >= limit) break
613688
}
614689

0 commit comments

Comments
 (0)