diff --git a/app/GraphQL/Validators/UpdateProjectInputValidator.php b/app/GraphQL/Validators/UpdateProjectInputValidator.php index 0dc314ed3a..9d176a6436 100644 --- a/app/GraphQL/Validators/UpdateProjectInputValidator.php +++ b/app/GraphQL/Validators/UpdateProjectInputValidator.php @@ -3,6 +3,7 @@ namespace App\GraphQL\Validators; use App\Models\Project; +use App\Rules\NotRunSkippedDetailsRegexRule; use App\Rules\ProjectAuthenticateSubmissionsRule; use App\Rules\ProjectNameRule; use App\Rules\ProjectVisibilityRule; @@ -25,6 +26,11 @@ public function rules(): array 'authenticateSubmissions' => [ new ProjectAuthenticateSubmissionsRule(), ], + 'notRunSkippedDetailsRegex' => [ + 'nullable', + 'string', + new NotRunSkippedDetailsRegexRule(), + ], ]; } diff --git a/app/Models/Build.php b/app/Models/Build.php index 12f4908378..72364a3231 100644 --- a/app/Models/Build.php +++ b/app/Models/Build.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Utils\TestDisplay; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -386,4 +387,22 @@ public function percentCoverageForPath(string $path): ?float return ($loctested / $total_lines) * 100; } + + /** + * The number of not-run tests whose details do not match the project's skipped pattern. + */ + public function notRunTestsWarningCount(): int + { + $patterns = $this->project?->notrun_skipped_details_regex + ?? TestDisplay::DEFAULT_NOTRUN_SKIPPED_DETAILS_REGEX; + + $count = 0; + foreach ($this->tests()->where('status', Test::NOTRUN)->pluck('details') as $details) { + if (!TestDisplay::isAcceptableNotRun($details, $patterns)) { + $count++; + } + } + + return $count; + } } diff --git a/app/Models/Project.php b/app/Models/Project.php index 91f97712cf..8812677c85 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Utils\TestDisplay; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; @@ -47,6 +48,7 @@ * @property ?string $banner * @property ?string $logoUrl * @property ?string $cmakeprojectroot + * @property string $notrun_skipped_details_regex * * @method Builder forUser() * @method Builder administeredByUser() @@ -93,6 +95,7 @@ class Project extends Model 'ldapfilter', 'banner', 'cmakeprojectroot', + 'notrun_skipped_details_regex', ]; protected $casts = [ @@ -135,6 +138,7 @@ class Project extends Model 'uploadquota' => 10, 'showcoveragecode' => true, 'authenticatesubmissions' => false, + 'notrun_skipped_details_regex' => TestDisplay::DEFAULT_NOTRUN_SKIPPED_DETAILS_REGEX, ]; public const PROJECT_ADMIN = 2; diff --git a/app/Rules/NotRunSkippedDetailsRegexRule.php b/app/Rules/NotRunSkippedDetailsRegexRule.php new file mode 100644 index 0000000000..d6d9c402dd --- /dev/null +++ b/app/Rules/NotRunSkippedDetailsRegexRule.php @@ -0,0 +1,26 @@ + + */ + public static function parsePatterns(?string $patternsText): array + { + if ($patternsText === null || trim($patternsText) === '') { + return []; + } + + $patterns = []; + foreach (preg_split("/\r\n|\n|\r/", $patternsText) as $line) { + $line = trim($line); + if ($line !== '') { + $patterns[] = $line; + } + } + + return $patterns; + } + + public static function patternToPcre(string $pattern): string + { + $pattern = trim($pattern); + if ($pattern === '') { + return ''; + } + + if (str_starts_with($pattern, '/')) { + return $pattern; + } + + $parts = preg_split('/(\*|\?)/', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE); + if ($parts === false) { + return ''; + } + + $regex = ''; + foreach ($parts as $part) { + if ($part === '*') { + $regex .= '.*'; + } elseif ($part === '?') { + $regex .= '.'; + } else { + $regex .= preg_quote($part, '/'); + } + } + + return '/(?i)' . $regex . '/'; + } + + public static function detailsMatchesSkippedPattern(?string $details, ?string $patternsText): bool + { + if ($details === null || $details === '') { + return false; + } + + foreach (self::parsePatterns($patternsText) as $pattern) { + $pcre = self::patternToPcre($pattern); + if ($pcre === '') { + continue; + } + + $result = @preg_match($pcre, $details); + if ($result === 1) { + return true; + } + } + + return false; + } + + public static function isAcceptableNotRun(?string $details, ?string $patternsText): bool + { + return self::detailsMatchesSkippedPattern($details, $patternsText); + } + + public static function statusColorClass(string $status, ?string $details, ?string $patternsText): string + { + if ($status === Test::NOTRUN && self::isAcceptableNotRun($details, $patternsText)) { + return 'normal'; + } + + return match ($status) { + Test::PASSED => 'normal', + Test::FAILED => 'error', + Test::NOTRUN => 'warning', + default => '', + }; + } + + public static function statusTextColorClass(string $status, ?string $details, ?string $patternsText): string + { + return match (self::statusColorClass($status, $details, $patternsText)) { + 'normal' => 'normal-text', + 'warning' => 'warning-text', + 'error' => 'error-text', + default => '', + }; + } + + public static function graphqlStatusColorClass(string $status, ?string $details, ?string $patternsText): string + { + $dbStatus = match ($status) { + 'NOT_RUN' => Test::NOTRUN, + 'PASSED' => Test::PASSED, + 'FAILED' => Test::FAILED, + default => strtolower($status), + }; + + return self::statusColorClass($dbStatus, $details, $patternsText); + } + + public static function isValidPatternsText(?string $patternsText): bool + { + foreach (self::parsePatterns($patternsText) as $pattern) { + $pcre = self::patternToPcre($pattern); + if ($pcre === '') { + return false; + } + + set_error_handler(static fn (): bool => true); + $result = preg_match($pcre, ''); + restore_error_handler(); + + if ($result === false) { + return false; + } + } + + return true; + } +} diff --git a/app/cdash/app/Controller/Api/Index.php b/app/cdash/app/Controller/Api/Index.php index b70f94fd90..7542f771bf 100644 --- a/app/cdash/app/Controller/Api/Index.php +++ b/app/cdash/app/Controller/Api/Index.php @@ -17,6 +17,7 @@ namespace CDash\Controller\Api; +use App\Models\Build as EloquentBuild; use App\Utils\TestingDay; use CDash\Database; use CDash\Model\BuildGroup; @@ -1098,6 +1099,7 @@ public function generateBuildResponseFromRow(array $build_array): array|false } $test_response['notrun'] = $nnotrun; + $test_response['notrunwarning'] = $this->computeNotRunTestsWarningCount((int) $buildid, $nnotrun); $test_response['fail'] = $nfail; $test_response['pass'] = $npass; @@ -1555,4 +1557,16 @@ public function recordGenerationTime(array &$response): void { $this->pageTimer->end($response); } + + /** + * Count not-run tests that should display as warnings on the index page. + */ + private function computeNotRunTestsWarningCount(int $buildid, int $notRunCount): int + { + if ($notRunCount <= 0) { + return 0; + } + + return EloquentBuild::find($buildid)?->notRunTestsWarningCount() ?? 0; + } } diff --git a/app/cdash/app/Controller/Api/QueryTests.php b/app/cdash/app/Controller/Api/QueryTests.php index 7f00f75295..0899abe869 100644 --- a/app/cdash/app/Controller/Api/QueryTests.php +++ b/app/cdash/app/Controller/Api/QueryTests.php @@ -20,6 +20,7 @@ use App\Models\PinnedTestMeasurement; use App\Models\Project as EloquentProject; use App\Models\TestMeasurement; +use App\Utils\TestDisplay; use CDash\Database; use CDash\Model\Build; use CDash\Model\Project; @@ -375,6 +376,7 @@ public function getResponse(): array ", $query_params); // Rows of test data to be displayed to the user. + $notRunSkippedDetailsRegex = EloquentProject::findOrFail($this->project->Id)->notrun_skipped_details_regex; $tests = []; foreach ($rows as $row) { $test = []; @@ -423,7 +425,11 @@ public function getResponse(): array case 'notrun': $test['status'] = 'Not Run'; - $test['statusclass'] = 'warning'; + $test['statusclass'] = TestDisplay::statusColorClass( + 'notrun', + $row->details, + $notRunSkippedDetailsRegex, + ); break; } diff --git a/app/cdash/tests/test_testhistory.php b/app/cdash/tests/test_testhistory.php index 7f06fb55fc..e9bd2f3a24 100644 --- a/app/cdash/tests/test_testhistory.php +++ b/app/cdash/tests/test_testhistory.php @@ -134,6 +134,9 @@ private function generateXML($mm, $timestamp, $include_sporadic, $flaky_passed) ./notrun + + Skipped + diff --git a/database/migrations/2026_05_26_120000_add_notrun_skipped_details_regex_to_project.php b/database/migrations/2026_05_26_120000_add_notrun_skipped_details_regex_to_project.php new file mode 100644 index 0000000000..e2a61e4547 --- /dev/null +++ b/database/migrations/2026_05_26_120000_add_notrun_skipped_details_regex_to_project.php @@ -0,0 +1,15 @@ + - +
diff --git a/resources/js/vue/components/BuildSummary.vue b/resources/js/vue/components/BuildSummary.vue index ce0e703b4a..a0bbd71311 100644 --- a/resources/js/vue/components/BuildSummary.vue +++ b/resources/js/vue/components/BuildSummary.vue @@ -710,6 +710,7 @@ export default { buildWarningsCount failedTestsCount notRunTestsCount + notRunTestsWarningCount site { id name @@ -730,6 +731,7 @@ export default { buildWarningsCount failedTestsCount notRunTestsCount + notRunTestsWarningCount } nextBuild: build(id: $nextId) @include(if: $hasNext) { id @@ -739,6 +741,7 @@ export default { buildWarningsCount failedTestsCount notRunTestsCount + notRunTestsWarningCount } } `, @@ -767,7 +770,7 @@ export default { nerrors: Math.max(0, prev.buildErrorsCount), nwarnings: Math.max(0, prev.buildWarningsCount), ntestfailed: Math.max(0, prev.failedTestsCount), - ntestnotrun: Math.max(0, prev.notRunTestsCount), + ntestnotrun: Math.max(0, prev.notRunTestsWarningCount), }; } else { @@ -783,7 +786,7 @@ export default { nerrors: Math.max(0, next.buildErrorsCount), nwarnings: Math.max(0, next.buildWarningsCount), ntestfailed: Math.max(0, next.failedTestsCount), - ntestnotrun: Math.max(0, next.notRunTestsCount), + ntestnotrun: Math.max(0, next.notRunTestsWarningCount), }; } else { @@ -809,7 +812,7 @@ export default { this.cdash.test = { nfailed: Math.max(0, build.failedTestsCount), - nnotrun: Math.max(0, build.notRunTestsCount), + nnotrun: Math.max(0, build.notRunTestsWarningCount), }; this.cdash.projectname_encoded = encodeURIComponent(build.project.name); diff --git a/resources/js/vue/components/BuildTestsPage.vue b/resources/js/vue/components/BuildTestsPage.vue index 215e6ce991..0852ee9a16 100644 --- a/resources/js/vue/components/BuildTestsPage.vue +++ b/resources/js/vue/components/BuildTestsPage.vue @@ -73,6 +73,7 @@ import LoadingIndicator from './shared/LoadingIndicator.vue'; import BuildSummaryCard from './shared/BuildSummaryCard.vue'; import BuildSidebar from './shared/BuildSidebar.vue'; import {DateTime} from 'luxon'; +import {testStatusToColorClass} from './shared/TestDisplay'; const TEST_QUERY = gql` query( @@ -82,6 +83,9 @@ const TEST_QUERY = gql` ) { build(id: $buildid) { id + project { + notRunSkippedDetailsRegex + } tests(filters: $filters, first: 1000000) { edges { node { @@ -205,6 +209,11 @@ export default { }); return tests; }, + result({ data }) { + if (data?.build?.project?.notRunSkippedDetailsRegex !== undefined) { + this.notRunSkippedDetailsRegex = data.build.project.notRunSkippedDetailsRegex; + } + }, variables() { return { buildid: this.buildId, @@ -237,6 +246,11 @@ export default { }); return tests; }, + result({ data }) { + if (data?.build?.project?.notRunSkippedDetailsRegex !== undefined) { + this.notRunSkippedDetailsRegex = data.build.project.notRunSkippedDetailsRegex; + } + }, variables() { return { buildid: this.previousBuildId, @@ -259,6 +273,7 @@ export default { data() { return { changedFilters: JSON.parse(JSON.stringify(this.initialFilters)), + notRunSkippedDetailsRegex: '*skip*', }; }, @@ -308,6 +323,7 @@ export default { }, formattedTestRows() { + const notRunSkippedDetailsRegex = this.notRunSkippedDetailsRegex; return this.filteredTests?.map(edge => { return { name: { @@ -325,14 +341,14 @@ export default { value: edge.node.status, text: this.humanReadableTestStatus(edge.node.status), href: `${this.$baseURL}/tests/${edge.node.id}`, - classes: [this.testStatusToColorClass(edge.node.status)], + classes: [testStatusToColorClass(edge.node.status, edge.node.details, notRunSkippedDetailsRegex)], }, subProject: edge.subProject ?? '', timeStatus: { value: edge.node.timeStatusCategory, text: this.humanReadableTestStatus(edge.node.timeStatusCategory), href: `${this.$baseURL}/tests/${edge.node.id}?graph=time`, - classes: [this.testStatusToColorClass(edge.node.timeStatusCategory)], + classes: [testStatusToColorClass(edge.node.timeStatusCategory, edge.node.details, notRunSkippedDetailsRegex)], }, history: { value: '', @@ -349,19 +365,6 @@ export default { }, methods: { - testStatusToColorClass(status) { - switch (status) { - case 'PASSED': - return 'normal'; - case 'FAILED': - return 'error'; - case 'NOT_RUN': - return 'warning'; - default: - return ''; - } - }, - humanReadableTestStatus(status) { switch (status) { case 'PASSED': diff --git a/resources/js/vue/components/ProjectSettings/GeneralTab.vue b/resources/js/vue/components/ProjectSettings/GeneralTab.vue index 6fdb6c0abd..cc0e68fd21 100644 --- a/resources/js/vue/components/ProjectSettings/GeneralTab.vue +++ b/resources/js/vue/components/ProjectSettings/GeneralTab.vue @@ -362,7 +362,8 @@ + + @@ -533,6 +542,7 @@ export default { fileUploadLimit: 50, showCoverageCode: true, banner: '', + notRunSkippedDetailsRegex: '*skip*', }, validationErrors: {}, fatalError: null, @@ -576,6 +586,7 @@ export default { fileUploadLimit showCoverageCode banner + notRunSkippedDetailsRegex } } `, diff --git a/resources/js/vue/components/ProjectSettingsPage.vue b/resources/js/vue/components/ProjectSettingsPage.vue index 95afb86d03..8294870bd7 100644 --- a/resources/js/vue/components/ProjectSettingsPage.vue +++ b/resources/js/vue/components/ProjectSettingsPage.vue @@ -95,5 +95,22 @@ export default { currentSection: 'general', }; }, + + mounted() { + this.navigateToHashSection(); + }, + + methods: { + navigateToHashSection() { + if (window.location.hash !== '#Testing') { + return; + } + + this.currentSection = 'general'; + this.$nextTick(() => { + document.getElementById('Testing')?.scrollIntoView(); + }); + }, + }, }; diff --git a/resources/js/vue/components/TestDetailsPage.vue b/resources/js/vue/components/TestDetailsPage.vue index 5e2d6515ec..370ce5d6f4 100644 --- a/resources/js/vue/components/TestDetailsPage.vue +++ b/resources/js/vue/components/TestDetailsPage.vue @@ -276,6 +276,7 @@ import BuildSidebar from './shared/BuildSidebar.vue'; import gql from 'graphql-tag'; import {getRepository} from './shared/RepositoryIntegrations'; import Utils from './shared/Utils'; +import {testStatusToTextColorClass} from './shared/TestDisplay'; export default { name: 'TestDetails', @@ -322,6 +323,7 @@ export default { name enableTestTiming testTimeStdMultiplier + notRunSkippedDetailsRegex vcsViewer vcsUrl cmakeProjectRoot @@ -473,16 +475,15 @@ export default { * TODO: Convert these to Tailwind colors */ testStatusColorClass() { - switch (this.test.status) { - case 'PASSED': - return 'normal-text'; - case 'FAILED': - return 'error-text'; - case 'NOT_RUN': - return 'warning-text'; - default: + if (!this.test) { return ''; } + + return testStatusToTextColorClass( + this.test.status, + this.test.details, + this.build?.project?.notRunSkippedDetailsRegex ?? '*skip*', + ); }, testTimeStatus() { diff --git a/resources/js/vue/components/shared/BuildSummaryCard.vue b/resources/js/vue/components/shared/BuildSummaryCard.vue index 732ab8e6e6..005d2f9f39 100644 --- a/resources/js/vue/components/shared/BuildSummaryCard.vue +++ b/resources/js/vue/components/shared/BuildSummaryCard.vue @@ -347,6 +347,7 @@ export default { passedTestsCount failedTestsCount notRunTestsCount + notRunTestsWarningCount site { id name @@ -454,9 +455,15 @@ export default { return 'No Submission'; } + const notRunWarningCount = Math.max(0, this.build.notRunTestsWarningCount); + const notRunSkippedCount = Math.max(0, this.build.notRunTestsCount - notRunWarningCount); + let retval = ''; - if (this.build.notRunTestsCount > 0) { - retval += `${this.build.notRunTestsCount} Not Run${this.commaSeparator(this.build.failedTestsCount > 0 || this.build.passedTestsCount)}`; + if (notRunWarningCount > 0) { + retval += `${notRunWarningCount} Not Run${this.commaSeparator(this.build.failedTestsCount > 0 || this.build.passedTestsCount > 0 || notRunSkippedCount > 0)}`; + } + if (notRunSkippedCount > 0) { + retval += `${notRunSkippedCount} Skipped${this.commaSeparator(this.build.failedTestsCount > 0 || this.build.passedTestsCount > 0)}`; } if (this.build.failedTestsCount > 0) { retval += `${this.build.failedTestsCount} Failed${this.commaSeparator(this.build.passedTestsCount)}`; @@ -575,21 +582,25 @@ export default { if (this.build.failedTestsCount > 0) { return 'tw-bg-red-400'; } - else { - return 'tw-bg-green-400'; + if (this.build.notRunTestsWarningCount > 0) { + return 'tw-bg-orange-400'; } + + return 'tw-bg-green-400'; }, testHighlightColor() { if (!this.hasTest) { return 'tw-border-x-gray-400'; } - else if (this.build.failedTestsCount > 0) { + if (this.build.failedTestsCount > 0) { return 'tw-border-x-red-400'; } - else { - return 'tw-border-x-green-400'; + if (this.build.notRunTestsWarningCount > 0) { + return 'tw-border-x-orange-400'; } + + return 'tw-border-x-green-400'; }, /** diff --git a/resources/js/vue/components/shared/FormSection.vue b/resources/js/vue/components/shared/FormSection.vue index 611d76698c..09c193bad7 100644 --- a/resources/js/vue/components/shared/FormSection.vue +++ b/resources/js/vue/components/shared/FormSection.vue @@ -1,5 +1,8 @@