Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/GraphQL/Validators/UpdateProjectInputValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,11 @@ public function rules(): array
'authenticateSubmissions' => [
new ProjectAuthenticateSubmissionsRule(),
],
'notRunSkippedDetailsRegex' => [
'nullable',
'string',
new NotRunSkippedDetailsRegexRule(),
],
];
}

Expand Down
19 changes: 19 additions & 0 deletions app/Models/Build.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
4 changes: 4 additions & 0 deletions app/Models/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -47,6 +48,7 @@
* @property ?string $banner
* @property ?string $logoUrl
* @property ?string $cmakeprojectroot
* @property string $notrun_skipped_details_regex
*
* @method Builder<Project> forUser()
* @method Builder<Project> administeredByUser()
Expand Down Expand Up @@ -93,6 +95,7 @@ class Project extends Model
'ldapfilter',
'banner',
'cmakeprojectroot',
'notrun_skipped_details_regex',
];

protected $casts = [
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions app/Rules/NotRunSkippedDetailsRegexRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace App\Rules;

use App\Utils\TestDisplay;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

final class NotRunSkippedDetailsRegexRule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $value === '') {
return;
}

if (!is_string($value)) {
$fail('The skipped test details pattern must be a string.');
return;
}

if (!TestDisplay::isValidPatternsText($value)) {
$fail('The skipped test details pattern contains an invalid regular expression.');
}
}
}
142 changes: 142 additions & 0 deletions app/Utils/TestDisplay.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php

namespace App\Utils;

use App\Models\Test;

final class TestDisplay
{
public const DEFAULT_NOTRUN_SKIPPED_DETAILS_REGEX = '*skip*';

/**
* @return list<string>
*/
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;
}
}
14 changes: 14 additions & 0 deletions app/cdash/app/Controller/Api/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
}
8 changes: 7 additions & 1 deletion app/cdash/app/Controller/Api/QueryTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions app/cdash/tests/test_testhistory.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ private function generateXML($mm, $timestamp, $include_sporadic, $flaky_passed)
<FullName>./notrun</FullName>
<FullCommandLine></FullCommandLine>
<Results>
<NamedMeasurement type="text/string" name="Exit Code">
<Value>Skipped</Value>
</NamedMeasurement>
<NamedMeasurement type="text/string" name="Command Line">
<Value></Value>
</NamedMeasurement>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration {
public function up(): void
{
DB::statement("ALTER TABLE project ADD COLUMN notrun_skipped_details_regex text NOT NULL DEFAULT '*skip*'");
}

public function down(): void
{
}
};
19 changes: 19 additions & 0 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,13 @@ type Project {
"Invitations to this project which have not been accepted yet."
invitations: [ProjectInvitation!]! @canRoot(ability: "inviteUser") @hasMany(type: CONNECTION) @orderBy(column: "id")

"""
Regular expression(s), one per line, matched against not-run test details. Matching tests are
displayed in green instead of yellow. Glob wildcards (*) are supported. The default pattern is
*skip* (case insensitive).
"""
notRunSkippedDetailsRegex: String! @rename(attribute: "notrun_skipped_details_regex")

"""
Pinned test measurements are test measurements, identified by name, which should be shown
alongside Test details throughout the site.
Expand Down Expand Up @@ -506,6 +513,12 @@ input UpdateProjectInput @validator {
showCoverageCode: Boolean @rename(attribute: "showcoveragecode")

banner: String

"""
Regular expression(s), one per line, matched against not-run test details. Matching tests are
displayed in green instead of yellow.
"""
notRunSkippedDetailsRegex: String @rename(attribute: "notrun_skipped_details_regex")
}


Expand Down Expand Up @@ -847,6 +860,12 @@ type Build {
"The number of tests not run for this build."
notRunTestsCount: Int @rename(attribute: "testnotrun")

"""
The number of not-run tests counted as warnings (excludes tests whose details match the
project's skipped-test pattern).
"""
notRunTestsWarningCount: Int! @method(name: "notRunTestsWarningCount")

"The duration of the test step in seconds."
testDuration: Int! @rename(attribute: "testduration")

Expand Down
2 changes: 1 addition & 1 deletion resources/js/angular/views/partials/build.html
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@
</div>
</td>

<td ng-if="::buildgroup.hastestdata" align="center" ng-class="::{'warning': build.test.notrun > 0, 'normal': build.test.notrun == 0}">
<td ng-if="::buildgroup.hastestdata" align="center" ng-class="::{'warning': build.test.notrunwarning > 0, 'normal': build.test.notrunwarning == 0}">
<div ng-if="::build.hastest"
ng-class="::{'valuewithsub': build.test.nnotrundiffp > 0 || build.test.nnotrundiffn > 0}">
<a class="cdash-link" ng-href="{{ 'builds/' + build.id + '/tests?filters=%7B%22all%22%3A%5B%7B%22eq%22%3A%7B%22status%22%3A%22NOT_RUN%22%7D%7D%5D%7D' }}">
Expand Down
Loading