821 lines
36 KiB
PHP
821 lines
36 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Tests\Support\TestLaneManifest;
|
|
use Tests\Support\TestLaneReport;
|
|
|
|
function spec439ReportArtifactDirectory(string $suffix): string
|
|
{
|
|
return 'storage/logs/spec439-test-lanes/'.$suffix.'-'.bin2hex(random_bytes(6));
|
|
}
|
|
|
|
function spec439WriteJUnit(string $laneId, string $artifactDirectory, string $xml): string
|
|
{
|
|
$path = TestLaneManifest::absolutePath(TestLaneReport::artifactPaths($laneId, $artifactDirectory)['junit']);
|
|
|
|
if (! is_dir(dirname($path))) {
|
|
mkdir(dirname($path), 0777, true);
|
|
}
|
|
|
|
file_put_contents($path, $xml);
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
function spec439ReadLaneReport(string $laneId, string $artifactDirectory): array
|
|
{
|
|
$path = TestLaneManifest::absolutePath(TestLaneReport::artifactPaths($laneId, $artifactDirectory)['report']);
|
|
|
|
return json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
function spec439RemoveReportArtifacts(string $artifactDirectory): void
|
|
{
|
|
$directory = TestLaneManifest::absolutePath($artifactDirectory);
|
|
|
|
if (! is_dir($directory)) {
|
|
return;
|
|
}
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
|
|
RecursiveIteratorIterator::CHILD_FIRST,
|
|
);
|
|
|
|
foreach ($iterator as $item) {
|
|
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
|
}
|
|
|
|
rmdir($directory);
|
|
}
|
|
|
|
function spec439PassingJUnit(string $laneId = 'junit'): string
|
|
{
|
|
return sprintf(
|
|
'<?xml version="1.0" encoding="UTF-8"?><testsuites tests="1" failures="0" errors="0" skipped="0"><testsuite name="%s" tests="1" failures="0" errors="0" skipped="0"><testcase name="passes" file="tests/Unit/Support/TestLaneReportTest.php" time="0.01"/></testsuite></testsuites>',
|
|
$laneId,
|
|
);
|
|
}
|
|
|
|
function spec439RefreshLane(string $laneId, string $artifactDirectory): int
|
|
{
|
|
return spec439RefreshLaneResult($laneId, $artifactDirectory)[0];
|
|
}
|
|
|
|
/**
|
|
* @return array{int, array<string, mixed>}
|
|
*/
|
|
function spec439RefreshLaneResult(string $laneId, string $artifactDirectory): array
|
|
{
|
|
ob_start();
|
|
|
|
try {
|
|
$exitCode = TestLaneManifest::renderLatestReport($laneId, null, $artifactDirectory);
|
|
$output = (string) ob_get_contents();
|
|
|
|
return [
|
|
$exitCode,
|
|
json_decode($output, true, 512, JSON_THROW_ON_ERROR),
|
|
];
|
|
} finally {
|
|
ob_end_clean();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @template TResult
|
|
*
|
|
* @param callable(): TResult $callback
|
|
* @return TResult
|
|
*/
|
|
function spec439WithoutCiContext(callable $callback): mixed
|
|
{
|
|
$originalTriggerClass = getenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
$originalWorkflowId = getenv('TENANTATLAS_CI_WORKFLOW_ID');
|
|
|
|
try {
|
|
putenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
putenv('TENANTATLAS_CI_WORKFLOW_ID');
|
|
|
|
return $callback();
|
|
} finally {
|
|
$originalTriggerClass === false
|
|
? putenv('TENANTATLAS_CI_TRIGGER_CLASS')
|
|
: putenv('TENANTATLAS_CI_TRIGGER_CLASS='.$originalTriggerClass);
|
|
$originalWorkflowId === false
|
|
? putenv('TENANTATLAS_CI_WORKFLOW_ID')
|
|
: putenv('TENANTATLAS_CI_WORKFLOW_ID='.$originalWorkflowId);
|
|
}
|
|
}
|
|
|
|
it('extracts at least the top ten slowest entries from a junit artifact', function (): void {
|
|
$junitPath = sys_get_temp_dir().'/tenantatlas-test-lane-junit.xml';
|
|
|
|
$testcases = [];
|
|
|
|
for ($index = 1; $index <= 12; $index++) {
|
|
$duration = number_format(0.5 + ($index / 10), 6, '.', '');
|
|
$testcases[] = sprintf(
|
|
'<testsuite name="Suite%d" file="tests/Feature/Fake/Fake%dTest.php" tests="1" assertions="1" errors="0" failures="0" skipped="0" time="%s"><testcase name="fake %d" file="tests/Feature/Fake/Fake%dTest.php::fake %d" class="Tests\\Feature\\Fake\\Fake%dTest" classname="Tests.Feature.Fake.Fake%dTest" assertions="1" time="%s"/></testsuite>',
|
|
$index,
|
|
$index,
|
|
$duration,
|
|
$index,
|
|
$index,
|
|
$index,
|
|
$index,
|
|
$index,
|
|
$duration,
|
|
);
|
|
}
|
|
|
|
file_put_contents($junitPath, '<?xml version="1.0" encoding="UTF-8"?><testsuites>'.implode('', $testcases).'</testsuites>');
|
|
|
|
$parsed = TestLaneReport::parseJUnit($junitPath, 'junit');
|
|
|
|
expect($parsed['slowestEntries'])->toHaveCount(10)
|
|
->and($parsed['slowestEntries'][0]['durationSeconds'])->toBeGreaterThanOrEqual($parsed['slowestEntries'][9]['durationSeconds'])
|
|
->and($parsed['durationsByFile'])->toHaveCount(12);
|
|
});
|
|
|
|
it('builds a report payload and writes the summary plus budget artifacts under a target directory', function (): void {
|
|
$artifactDirectory = 'storage/logs/test-lanes-test';
|
|
$report = TestLaneReport::buildReport(
|
|
laneId: 'junit',
|
|
wallClockSeconds: 24.5,
|
|
slowestEntries: array_map(
|
|
static fn (int $index): array => [
|
|
'subject' => sprintf('tests/Feature/Fake/Fake%dTest.php', $index),
|
|
'durationSeconds' => 1 + ($index / 10),
|
|
'laneId' => 'junit',
|
|
],
|
|
range(1, 10),
|
|
),
|
|
durationsByFile: [
|
|
'tests/Feature/OpsUx/OperateHubShellTest.php' => 9.8,
|
|
'tests/Feature/Guards/ActionSurfaceContractTest.php' => 4.4,
|
|
],
|
|
);
|
|
|
|
$written = TestLaneReport::writeArtifacts('junit', $report, null, $artifactDirectory);
|
|
|
|
expect($report)->toHaveKeys([
|
|
'laneId',
|
|
'finishedAt',
|
|
'wallClockSeconds',
|
|
'budgetThresholdSeconds',
|
|
'budgetBaselineSource',
|
|
'budgetEnforcement',
|
|
'budgetLifecycleState',
|
|
'budgetStatus',
|
|
'slowestEntries',
|
|
'familyBudgetEvaluations',
|
|
'artifacts',
|
|
])
|
|
->and($report['slowestEntries'])->toHaveCount(10)
|
|
->and($written['summary'])->toStartWith($artifactDirectory.'/')
|
|
->and($written['budget'])->toStartWith($artifactDirectory.'/')
|
|
->and(file_exists(base_path($written['summary'])))->toBeTrue()
|
|
->and(file_exists(base_path($written['budget'])))->toBeTrue();
|
|
});
|
|
|
|
it('[EF-001] persists a failed execution and report refresh cannot replace it with success', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('failed-refresh');
|
|
|
|
try {
|
|
spec439WriteJUnit('junit', $artifactDirectory, spec439PassingJUnit());
|
|
|
|
$finalized = TestLaneReport::finalizeLane(
|
|
laneId: 'junit',
|
|
wallClockSeconds: 2.5,
|
|
capturedOutput: 'synthetic failure',
|
|
exitCode: 7,
|
|
artifactDirectory: $artifactDirectory,
|
|
);
|
|
$storedBeforeRefresh = spec439ReadLaneReport('junit', $artifactDirectory);
|
|
$refreshExit = spec439RefreshLane('junit', $artifactDirectory);
|
|
$storedAfterRefresh = spec439ReadLaneReport('junit', $artifactDirectory);
|
|
|
|
expect($finalized['execution']['exitCode'] ?? null)->toBe(7)
|
|
->and($finalized['finalResult']['status'] ?? null)->toBe('failed')
|
|
->and($storedBeforeRefresh['execution']['exitCode'] ?? null)->toBe(7)
|
|
->and($refreshExit)->toBe(7)
|
|
->and($storedAfterRefresh['execution']['exitCode'] ?? null)->toBe(7)
|
|
->and($storedAfterRefresh['finalResult']['status'] ?? null)->toBe('failed')
|
|
->and($storedAfterRefresh['ciSummary']['finalExitCode'] ?? null)->toBe(7);
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
});
|
|
|
|
it('[EF-002] keeps a valid passing execution and evidence result successful through refresh', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('passing-refresh');
|
|
|
|
try {
|
|
spec439WriteJUnit('junit', $artifactDirectory, spec439PassingJUnit());
|
|
|
|
[$finalized, $refreshExit, $stored] = spec439WithoutCiContext(function () use ($artifactDirectory): array {
|
|
$finalized = TestLaneReport::finalizeLane(
|
|
laneId: 'junit',
|
|
wallClockSeconds: 1.5,
|
|
exitCode: 0,
|
|
artifactDirectory: $artifactDirectory,
|
|
);
|
|
$refreshExit = spec439RefreshLane('junit', $artifactDirectory);
|
|
$stored = spec439ReadLaneReport('junit', $artifactDirectory);
|
|
|
|
return [$finalized, $refreshExit, $stored];
|
|
});
|
|
|
|
expect($finalized['execution']['status'] ?? null)->toBe('passed')
|
|
->and($finalized['evidence']['status'] ?? null)->toBe('passed')
|
|
->and($finalized['finalResult']['status'] ?? null)->toBe('passed')
|
|
->and($refreshExit)->toBe(0)
|
|
->and($stored['finalResult']['exitCode'] ?? null)->toBe(0);
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
});
|
|
|
|
it('refuses to replace an unresolved failed report claim with a successful refresh', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('failed-claim-contradiction');
|
|
|
|
try {
|
|
spec439WriteJUnit('junit', $artifactDirectory, spec439PassingJUnit());
|
|
|
|
[$refreshExit, $refreshed] = spec439WithoutCiContext(function () use ($artifactDirectory): array {
|
|
TestLaneReport::finalizeLane(
|
|
laneId: 'junit',
|
|
wallClockSeconds: 1.5,
|
|
exitCode: 0,
|
|
artifactDirectory: $artifactDirectory,
|
|
);
|
|
|
|
$reportPath = TestLaneManifest::absolutePath(
|
|
TestLaneReport::artifactPaths('junit', $artifactDirectory)['report'],
|
|
);
|
|
$stored = spec439ReadLaneReport('junit', $artifactDirectory);
|
|
$stored['finalResult']['status'] = 'failed';
|
|
$stored['finalResult']['exitCode'] = 1;
|
|
$stored['ciSummary']['finalStatus'] = 'failed';
|
|
$stored['ciSummary']['finalExitCode'] = 1;
|
|
file_put_contents(
|
|
$reportPath,
|
|
json_encode($stored, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
|
);
|
|
|
|
$refreshExit = spec439RefreshLane('junit', $artifactDirectory);
|
|
$refreshed = spec439ReadLaneReport('junit', $artifactDirectory);
|
|
|
|
return [$refreshExit, $refreshed];
|
|
});
|
|
|
|
expect($refreshExit)->toBe(1)
|
|
->and($refreshed['execution']['exitCode'] ?? null)->toBe(0)
|
|
->and($refreshed['evidence']['status'] ?? null)->toBe('passed')
|
|
->and($refreshed['finalResult']['status'] ?? null)->toBe('failed')
|
|
->and($refreshed['finalResult']['failureContextIds'] ?? [])->toContain(
|
|
'report-claim-contradiction',
|
|
);
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
});
|
|
|
|
it('keeps unresolved workflow entry points blocking through finalization and contextless refresh', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('wrapper-failure');
|
|
$originalTriggerClass = getenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
$originalWorkflowId = getenv('TENANTATLAS_CI_WORKFLOW_ID');
|
|
|
|
try {
|
|
putenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
putenv('TENANTATLAS_CI_WORKFLOW_ID=unknown-spec439-workflow');
|
|
spec439WriteJUnit('fast-feedback', $artifactDirectory, spec439PassingJUnit('fast-feedback'));
|
|
|
|
$finalized = TestLaneReport::finalizeLane(
|
|
laneId: 'fast-feedback',
|
|
wallClockSeconds: 1.0,
|
|
exitCode: 0,
|
|
artifactDirectory: $artifactDirectory,
|
|
);
|
|
putenv('TENANTATLAS_CI_WORKFLOW_ID');
|
|
$refreshExit = spec439RefreshLane('fast-feedback', $artifactDirectory);
|
|
$refreshed = spec439ReadLaneReport('fast-feedback', $artifactDirectory);
|
|
|
|
expect($finalized['ciContext']['entryPointResolved'] ?? null)->toBeFalse()
|
|
->and($finalized['ciSummary']['primaryFailureClassId'] ?? null)->toBe('wrapper-failure')
|
|
->and($finalized['finalResult']['exitCode'] ?? null)->toBe(1)
|
|
->and($finalized['finalResult']['failureContextIds'] ?? [])->toContain('wrapper-failure')
|
|
->and($refreshExit)->toBe(1)
|
|
->and($refreshed['finalResult']['exitCode'] ?? null)->toBe(1)
|
|
->and($refreshed['finalResult']['failureContextIds'] ?? [])->toContain('wrapper-failure');
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
$originalTriggerClass === false
|
|
? putenv('TENANTATLAS_CI_TRIGGER_CLASS')
|
|
: putenv('TENANTATLAS_CI_TRIGGER_CLASS='.$originalTriggerClass);
|
|
$originalWorkflowId === false
|
|
? putenv('TENANTATLAS_CI_WORKFLOW_ID')
|
|
: putenv('TENANTATLAS_CI_WORKFLOW_ID='.$originalWorkflowId);
|
|
}
|
|
});
|
|
|
|
it('keeps a known workflow bound to the wrong lane on the wrapper-failure final-result path', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('wrong-lane-wrapper-failure');
|
|
$originalTriggerClass = getenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
$originalWorkflowId = getenv('TENANTATLAS_CI_WORKFLOW_ID');
|
|
|
|
try {
|
|
putenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
putenv('TENANTATLAS_CI_WORKFLOW_ID=pr-fast-feedback');
|
|
spec439WriteJUnit('confidence', $artifactDirectory, spec439PassingJUnit('confidence'));
|
|
|
|
$finalized = TestLaneReport::finalizeLane(
|
|
laneId: 'confidence',
|
|
wallClockSeconds: 1.0,
|
|
exitCode: 0,
|
|
artifactDirectory: $artifactDirectory,
|
|
);
|
|
putenv('TENANTATLAS_CI_WORKFLOW_ID');
|
|
$refreshExit = spec439RefreshLane('confidence', $artifactDirectory);
|
|
$refreshed = spec439ReadLaneReport('confidence', $artifactDirectory);
|
|
|
|
expect($finalized['ciContext']['entryPointResolved'] ?? null)->toBeTrue()
|
|
->and($finalized['ciContext']['workflowLaneMatched'] ?? null)->toBeFalse()
|
|
->and($finalized['finalResult']['exitCode'] ?? null)->toBe(1)
|
|
->and($finalized['finalResult']['failureContextIds'] ?? [])->toContain('wrapper-failure')
|
|
->and($refreshExit)->toBe(1)
|
|
->and($refreshed['finalResult']['failureContextIds'] ?? [])->toContain('wrapper-failure');
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
$originalTriggerClass === false
|
|
? putenv('TENANTATLAS_CI_TRIGGER_CLASS')
|
|
: putenv('TENANTATLAS_CI_TRIGGER_CLASS='.$originalTriggerClass);
|
|
$originalWorkflowId === false
|
|
? putenv('TENANTATLAS_CI_WORKFLOW_ID')
|
|
: putenv('TENANTATLAS_CI_WORKFLOW_ID='.$originalWorkflowId);
|
|
}
|
|
});
|
|
|
|
it('keeps an explicitly invalid current workflow blocking while supplementing only its stored trigger', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('invalid-current-workflow');
|
|
$originalTriggerClass = getenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
$originalWorkflowId = getenv('TENANTATLAS_CI_WORKFLOW_ID');
|
|
|
|
try {
|
|
putenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
putenv('TENANTATLAS_CI_WORKFLOW_ID=pr-fast-feedback');
|
|
spec439WriteJUnit('fast-feedback', $artifactDirectory, spec439PassingJUnit('fast-feedback'));
|
|
|
|
TestLaneReport::finalizeLane(
|
|
laneId: 'fast-feedback',
|
|
wallClockSeconds: 1.0,
|
|
exitCode: 0,
|
|
artifactDirectory: $artifactDirectory,
|
|
);
|
|
|
|
putenv('TENANTATLAS_CI_WORKFLOW_ID=unknown-current-workflow');
|
|
[$refreshExit, $refreshed] = spec439RefreshLaneResult('fast-feedback', $artifactDirectory);
|
|
|
|
expect($refreshExit)->toBe(1)
|
|
->and($refreshed['ciContext']['workflowId'] ?? null)->toBe('unknown-current-workflow')
|
|
->and($refreshed['ciContext']['triggerClass'] ?? null)->toBe('pull-request')
|
|
->and($refreshed['ciContext']['entryPointResolved'] ?? null)->toBeFalse()
|
|
->and($refreshed['ciContext']['workflowLaneMatched'] ?? null)->toBeFalse()
|
|
->and($refreshed['finalResult']['failureContextIds'] ?? [])->toContain('wrapper-failure');
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
$originalTriggerClass === false
|
|
? putenv('TENANTATLAS_CI_TRIGGER_CLASS')
|
|
: putenv('TENANTATLAS_CI_TRIGGER_CLASS='.$originalTriggerClass);
|
|
$originalWorkflowId === false
|
|
? putenv('TENANTATLAS_CI_WORKFLOW_ID')
|
|
: putenv('TENANTATLAS_CI_WORKFLOW_ID='.$originalWorkflowId);
|
|
}
|
|
});
|
|
|
|
it('propagates canonical blocking and advisory budgets through finalization and refresh', function (): void {
|
|
$originalTriggerClass = getenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
$originalWorkflowId = getenv('TENANTATLAS_CI_WORKFLOW_ID');
|
|
$fixtures = [
|
|
[
|
|
'laneId' => 'fast-feedback',
|
|
'triggerClass' => 'pull-request',
|
|
'wallClockSeconds' => 216.0,
|
|
'expectedExitCode' => 1,
|
|
'expectedBlockingStatus' => 'blocking',
|
|
'expectedFinalStatus' => 'failed',
|
|
'expectedContextKey' => 'failureContextIds',
|
|
'expectedContextId' => 'blocking-budget-breach',
|
|
],
|
|
[
|
|
'laneId' => 'confidence',
|
|
'triggerClass' => 'mainline-push',
|
|
'wallClockSeconds' => 481.0,
|
|
'expectedExitCode' => 0,
|
|
'expectedBlockingStatus' => 'non-blocking-warning',
|
|
'expectedFinalStatus' => 'passed',
|
|
'expectedContextKey' => 'advisoryContextIds',
|
|
'expectedContextId' => 'budget-advisory-warning',
|
|
],
|
|
];
|
|
|
|
try {
|
|
putenv('TENANTATLAS_CI_WORKFLOW_ID');
|
|
|
|
foreach ($fixtures as $fixture) {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('budget-'.$fixture['laneId']);
|
|
|
|
try {
|
|
putenv('TENANTATLAS_CI_TRIGGER_CLASS='.$fixture['triggerClass']);
|
|
spec439WriteJUnit(
|
|
$fixture['laneId'],
|
|
$artifactDirectory,
|
|
spec439PassingJUnit($fixture['laneId']),
|
|
);
|
|
|
|
$finalized = TestLaneReport::finalizeLane(
|
|
laneId: $fixture['laneId'],
|
|
wallClockSeconds: $fixture['wallClockSeconds'],
|
|
exitCode: 0,
|
|
artifactDirectory: $artifactDirectory,
|
|
);
|
|
putenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
$refreshExit = spec439RefreshLane($fixture['laneId'], $artifactDirectory);
|
|
$refreshed = spec439ReadLaneReport($fixture['laneId'], $artifactDirectory);
|
|
$summaryPath = TestLaneManifest::absolutePath(
|
|
TestLaneReport::artifactPaths($fixture['laneId'], $artifactDirectory)['summary'],
|
|
);
|
|
$summary = (string) file_get_contents($summaryPath);
|
|
|
|
expect($finalized['ciBudgetEvaluation']['budgetStatus'] ?? null)->toBe('over-budget')
|
|
->and($finalized['finalResult']['exitCode'] ?? null)->toBe($fixture['expectedExitCode'])
|
|
->and($finalized['finalResult']['blockingStatus'] ?? null)->toBe($fixture['expectedBlockingStatus'])
|
|
->and($finalized['finalResult'][$fixture['expectedContextKey']] ?? [])->toContain(
|
|
$fixture['expectedContextId'],
|
|
)
|
|
->and($finalized['ciSummary']['finalExitCode'] ?? null)->toBe($fixture['expectedExitCode'])
|
|
->and($refreshExit)->toBe($fixture['expectedExitCode'])
|
|
->and($refreshed['ciBudgetEvaluation']['budgetStatus'] ?? null)->toBe('over-budget')
|
|
->and($refreshed['finalResult']['exitCode'] ?? null)->toBe($fixture['expectedExitCode'])
|
|
->and($refreshed['finalResult']['blockingStatus'] ?? null)->toBe($fixture['expectedBlockingStatus'])
|
|
->and($refreshed['finalResult'][$fixture['expectedContextKey']] ?? [])->toContain(
|
|
$fixture['expectedContextId'],
|
|
)
|
|
->and($refreshed['finalResult']['failureContextIds'] ?? [])->not->toContain(
|
|
'report-claim-contradiction',
|
|
)
|
|
->and($summary)->toContain(sprintf(
|
|
'- CI outcome: %s / %s',
|
|
$fixture['expectedFinalStatus'],
|
|
$fixture['expectedBlockingStatus'],
|
|
));
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
}
|
|
} finally {
|
|
$originalTriggerClass === false
|
|
? putenv('TENANTATLAS_CI_TRIGGER_CLASS')
|
|
: putenv('TENANTATLAS_CI_TRIGGER_CLASS='.$originalTriggerClass);
|
|
$originalWorkflowId === false
|
|
? putenv('TENANTATLAS_CI_WORKFLOW_ID')
|
|
: putenv('TENANTATLAS_CI_WORKFLOW_ID='.$originalWorkflowId);
|
|
}
|
|
});
|
|
|
|
it('classifies JUnit existence, validity, counts, failures, errors, and skipped evidence', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('junit-evidence');
|
|
|
|
try {
|
|
$missingPath = TestLaneManifest::absolutePath($artifactDirectory.'/missing.xml');
|
|
$malformedPath = TestLaneManifest::absolutePath($artifactDirectory.'/malformed.xml');
|
|
$failurePath = TestLaneManifest::absolutePath($artifactDirectory.'/failure.xml');
|
|
$errorPath = TestLaneManifest::absolutePath($artifactDirectory.'/error.xml');
|
|
$skippedPath = TestLaneManifest::absolutePath($artifactDirectory.'/skipped.xml');
|
|
mkdir(dirname($malformedPath), 0777, true);
|
|
file_put_contents($malformedPath, '<testsuites><broken>');
|
|
file_put_contents($failurePath, '<?xml version="1.0"?><testsuites><testsuite name="failure"><testcase name="fails" file="failure.php" time="0.1"><failure message="nope"/></testcase></testsuite></testsuites>');
|
|
file_put_contents($errorPath, '<?xml version="1.0"?><testsuites><testsuite name="error"><testcase name="errors" file="error.php" time="0.1"><error message="boom"/></testcase></testsuite></testsuites>');
|
|
file_put_contents($skippedPath, '<?xml version="1.0"?><testsuites><testsuite name="skipped"><testcase name="skips" file="skipped.php" time="0"><skipped/></testcase></testsuite></testsuites>');
|
|
|
|
$missing = TestLaneReport::parseJUnit($missingPath, 'junit')['evidence'];
|
|
$malformed = TestLaneReport::parseJUnit($malformedPath, 'junit')['evidence'];
|
|
$failure = TestLaneReport::parseJUnit($failurePath, 'junit')['evidence'];
|
|
$error = TestLaneReport::parseJUnit($errorPath, 'junit')['evidence'];
|
|
$skipped = TestLaneReport::parseJUnit($skippedPath, 'junit')['evidence'];
|
|
|
|
expect($missing)->toMatchArray(['exists' => false, 'parseValid' => false, 'status' => 'missing'])
|
|
->and($malformed)->toMatchArray(['exists' => true, 'parseValid' => false, 'status' => 'malformed'])
|
|
->and($failure)->toMatchArray(['testCount' => 1, 'failureCount' => 1, 'errorCount' => 0, 'status' => 'failed'])
|
|
->and($error)->toMatchArray(['testCount' => 1, 'failureCount' => 0, 'errorCount' => 1, 'status' => 'error-containing'])
|
|
->and($skipped)->toMatchArray(['testCount' => 1, 'skippedCount' => 1, 'status' => 'skipped'])
|
|
->and($failure['path'])->toBe($failurePath);
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
});
|
|
|
|
it('[EF-005] makes passed execution plus failing or error JUnit contradictions visible and non-zero', function (): void {
|
|
foreach ([
|
|
'failure' => ['status' => 'failed', 'contextId' => 'junit-failures'],
|
|
'error' => ['status' => 'error-containing', 'contextId' => 'junit-errors'],
|
|
] as $element => $expected) {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('junit-'.$element.'-contradiction');
|
|
|
|
try {
|
|
spec439WriteJUnit(
|
|
'junit',
|
|
$artifactDirectory,
|
|
sprintf(
|
|
'<?xml version="1.0"?><testsuites><testsuite name="%1$s"><testcase name="fails" file="%1$s.php" time="0.1"><%1$s message="nope"/></testcase></testsuite></testsuites>',
|
|
$element,
|
|
),
|
|
);
|
|
|
|
$finalized = TestLaneReport::finalizeLane(
|
|
laneId: 'junit',
|
|
wallClockSeconds: 1.0,
|
|
exitCode: 0,
|
|
artifactDirectory: $artifactDirectory,
|
|
);
|
|
|
|
expect($finalized['evidence']['status'] ?? null)->toBe($expected['status'])
|
|
->and($finalized['finalResult']['exitCode'] ?? null)->toBe(1)
|
|
->and($finalized['finalResult']['failureContextIds'] ?? [])->toContain(
|
|
$expected['contextId'],
|
|
'evidence-contradiction',
|
|
);
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('[EF-006] fails closed for missing and malformed required JUnit evidence', function (): void {
|
|
foreach (['missing', 'malformed'] as $fixture) {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('required-'.$fixture);
|
|
|
|
try {
|
|
if ($fixture === 'malformed') {
|
|
spec439WriteJUnit('junit', $artifactDirectory, '<testsuites><broken>');
|
|
}
|
|
|
|
$finalized = TestLaneReport::finalizeLane(
|
|
laneId: 'junit',
|
|
wallClockSeconds: 1.0,
|
|
exitCode: 0,
|
|
artifactDirectory: $artifactDirectory,
|
|
);
|
|
|
|
expect($finalized['finalResult']['exitCode'] ?? null)->toBe(1)
|
|
->and($finalized['finalResult']['failureContextIds'] ?? [])->toContain(
|
|
$fixture === 'missing' ? 'evidence-missing' : 'evidence-malformed',
|
|
);
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('fails closed when report refresh starts from malformed report evidence', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('malformed-report');
|
|
|
|
try {
|
|
spec439WriteJUnit('junit', $artifactDirectory, spec439PassingJUnit());
|
|
$reportPath = TestLaneManifest::absolutePath(TestLaneReport::artifactPaths('junit', $artifactDirectory)['report']);
|
|
file_put_contents($reportPath, '{not-json');
|
|
|
|
$refreshExit = spec439RefreshLane('junit', $artifactDirectory);
|
|
$stored = spec439ReadLaneReport('junit', $artifactDirectory);
|
|
|
|
expect($refreshExit)->toBe(1)
|
|
->and($stored['reportInput']['status'] ?? null)->toBe('malformed')
|
|
->and($stored['finalResult']['failureContextIds'] ?? [])->toContain('report-input-malformed');
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
});
|
|
|
|
it('classifies scalar and list report roots as malformed while retaining a minimal legacy object diagnostically', function (): void {
|
|
foreach ([
|
|
'scalar' => ['json' => '"legacy-pass"', 'expectedStatus' => 'malformed'],
|
|
'list' => ['json' => '[{"laneId":"junit"}]', 'expectedStatus' => 'malformed'],
|
|
'legacy-object' => ['json' => '{"laneId":"junit","wallClockSeconds":1.0}', 'expectedStatus' => 'valid'],
|
|
] as $fixture => $expectation) {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('report-shape-'.$fixture);
|
|
|
|
try {
|
|
spec439WriteJUnit('junit', $artifactDirectory, spec439PassingJUnit());
|
|
$reportPath = TestLaneManifest::absolutePath(TestLaneReport::artifactPaths('junit', $artifactDirectory)['report']);
|
|
file_put_contents($reportPath, $expectation['json']);
|
|
|
|
[$refreshExit, $refreshed] = spec439WithoutCiContext(
|
|
fn (): array => spec439RefreshLaneResult('junit', $artifactDirectory),
|
|
);
|
|
|
|
expect($refreshed['reportInput']['status'] ?? null)->toBe($expectation['expectedStatus'])
|
|
->and($refreshExit)->toBe(1)
|
|
->and($refreshed['finalResult']['status'] ?? null)->toBe('failed');
|
|
|
|
if ($expectation['expectedStatus'] === 'malformed') {
|
|
expect($refreshed['finalResult']['failureContextIds'] ?? [])->toContain('report-input-malformed');
|
|
}
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('returns current failure truth when the required report artifact cannot be written', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('report-write-failure');
|
|
$originalTriggerClass = getenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
$originalWorkflowId = getenv('TENANTATLAS_CI_WORKFLOW_ID');
|
|
|
|
try {
|
|
putenv('TENANTATLAS_CI_TRIGGER_CLASS');
|
|
putenv('TENANTATLAS_CI_WORKFLOW_ID=unknown-write-workflow');
|
|
spec439WriteJUnit('junit', $artifactDirectory, spec439PassingJUnit());
|
|
$reportPath = TestLaneManifest::absolutePath(TestLaneReport::artifactPaths('junit', $artifactDirectory)['report']);
|
|
mkdir($reportPath, 0777, true);
|
|
|
|
$finalized = TestLaneReport::finalizeLane(
|
|
laneId: 'junit',
|
|
wallClockSeconds: 1.0,
|
|
exitCode: 0,
|
|
artifactDirectory: $artifactDirectory,
|
|
);
|
|
|
|
expect($finalized['reportGeneration']['status'] ?? null)->toBe('failed')
|
|
->and($finalized['reportGeneration']['errorClass'] ?? null)->toBe(RuntimeException::class)
|
|
->and($finalized['finalResult']['exitCode'] ?? null)->toBe(1)
|
|
->and($finalized['finalResult']['failureContextIds'] ?? [])->toContain(
|
|
'report-generation-failure',
|
|
'artifact-publication-failure',
|
|
'wrapper-failure',
|
|
)
|
|
->and($finalized['ciSummary']['primaryFailureClassId'] ?? null)->toBe('wrapper-failure');
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
$originalTriggerClass === false
|
|
? putenv('TENANTATLAS_CI_TRIGGER_CLASS')
|
|
: putenv('TENANTATLAS_CI_TRIGGER_CLASS='.$originalTriggerClass);
|
|
$originalWorkflowId === false
|
|
? putenv('TENANTATLAS_CI_WORKFLOW_ID')
|
|
: putenv('TENANTATLAS_CI_WORKFLOW_ID='.$originalWorkflowId);
|
|
}
|
|
});
|
|
|
|
it('keeps report-generation and artifact-publication failures visible when refresh cannot write a required artifact', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('refresh-write-failure');
|
|
|
|
try {
|
|
spec439WriteJUnit('junit', $artifactDirectory, spec439PassingJUnit());
|
|
spec439WithoutCiContext(fn (): array => TestLaneReport::finalizeLane(
|
|
laneId: 'junit',
|
|
wallClockSeconds: 1.0,
|
|
exitCode: 0,
|
|
artifactDirectory: $artifactDirectory,
|
|
));
|
|
|
|
$summaryPath = TestLaneManifest::absolutePath(
|
|
TestLaneReport::artifactPaths('junit', $artifactDirectory)['summary'],
|
|
);
|
|
$reportPath = TestLaneManifest::absolutePath(
|
|
TestLaneReport::artifactPaths('junit', $artifactDirectory)['report'],
|
|
);
|
|
unlink($summaryPath);
|
|
mkdir($summaryPath);
|
|
|
|
[$refreshExit, $refreshed] = spec439WithoutCiContext(
|
|
fn (): array => spec439RefreshLaneResult('junit', $artifactDirectory),
|
|
);
|
|
|
|
expect($refreshExit)->toBe(1)
|
|
->and($refreshed['reportGeneration']['status'] ?? null)->toBe('failed')
|
|
->and($refreshed['artifactPublication']['complete'] ?? null)->toBeFalse()
|
|
->and($refreshed['finalResult']['failureContextIds'] ?? [])->toContain(
|
|
'report-generation-failure',
|
|
'artifact-publication-failure',
|
|
)
|
|
->and($refreshed['ciSummary']['artifactStatus'] ?? null)->toBe('incomplete')
|
|
->and(is_file($reportPath))->toBeFalse();
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
});
|
|
|
|
it('keeps the persisted report fail-closed when publication stops after the provisional report write', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('provisional-report-write-failure');
|
|
|
|
try {
|
|
spec439WriteJUnit('junit', $artifactDirectory, spec439PassingJUnit());
|
|
$artifactPaths = TestLaneReport::artifactPaths('junit', $artifactDirectory);
|
|
$trendHistoryPath = TestLaneManifest::absolutePath($artifactPaths['trendHistory']);
|
|
mkdir($trendHistoryPath, 0777, true);
|
|
|
|
$finalized = spec439WithoutCiContext(fn (): array => TestLaneReport::finalizeLane(
|
|
laneId: 'junit',
|
|
wallClockSeconds: 1.0,
|
|
exitCode: 0,
|
|
artifactDirectory: $artifactDirectory,
|
|
));
|
|
$persisted = spec439ReadLaneReport('junit', $artifactDirectory);
|
|
|
|
expect($finalized['reportGeneration']['status'] ?? null)->toBe('failed')
|
|
->and($finalized['finalResult']['status'] ?? null)->toBe('failed')
|
|
->and($persisted['reportGeneration']['status'] ?? null)->toBe('failed')
|
|
->and($persisted['finalResult']['status'] ?? null)->toBe('failed')
|
|
->and($persisted['finalResult']['failureContextIds'] ?? [])->toContain(
|
|
'report-generation-failure',
|
|
'artifact-publication-failure',
|
|
);
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
});
|
|
|
|
it('rolls every result artifact back to provisional failure truth when a final write fails', function (): void {
|
|
$artifactDirectory = spec439ReportArtifactDirectory('final-result-rollback');
|
|
$absoluteDirectory = TestLaneManifest::absolutePath($artifactDirectory);
|
|
$artifactContents = [
|
|
$absoluteDirectory.'/summary.md' => [
|
|
'provisional' => 'summary failed',
|
|
'final' => 'summary passed',
|
|
],
|
|
$absoluteDirectory.'/budget.json' => [
|
|
'provisional' => '{"finalResult":"failed"}',
|
|
'final' => '{"finalResult":"passed"}',
|
|
],
|
|
$absoluteDirectory.'/report.json' => [
|
|
'provisional' => '{"finalResult":"failed"}',
|
|
'final' => '{"finalResult":"passed"}',
|
|
],
|
|
];
|
|
|
|
try {
|
|
mkdir($absoluteDirectory, 0777, true);
|
|
$commit = new ReflectionMethod(TestLaneReport::class, 'commitFinalResultArtifacts');
|
|
$commit->setAccessible(true);
|
|
|
|
foreach ([1, 2, 3] as $failingWrite) {
|
|
foreach ($artifactContents as $path => $contents) {
|
|
file_put_contents($path, $contents['provisional']);
|
|
}
|
|
|
|
$writeCount = 0;
|
|
$writer = static function (string $path, string $contents) use (&$writeCount, $failingWrite): void {
|
|
$writeCount++;
|
|
|
|
if ($writeCount === $failingWrite) {
|
|
throw new RuntimeException('synthetic final publication failure');
|
|
}
|
|
|
|
file_put_contents($path, $contents);
|
|
};
|
|
|
|
expect(static fn () => $commit->invoke(null, $artifactContents, $writer))
|
|
->toThrow(RuntimeException::class, 'synthetic final publication failure');
|
|
|
|
foreach ($artifactContents as $path => $contents) {
|
|
expect((string) file_get_contents($path))->toBe($contents['provisional']);
|
|
}
|
|
}
|
|
} finally {
|
|
spec439RemoveReportArtifacts($artifactDirectory);
|
|
}
|
|
});
|
|
|
|
it('[EF-007] preserves both test and report-generation failure contexts', function (): void {
|
|
$result = TestLaneReport::deriveFinalResult(
|
|
execution: TestLaneReport::executionResult(9),
|
|
evidence: [
|
|
'path' => '/tmp/passed.xml',
|
|
'exists' => true,
|
|
'parseValid' => true,
|
|
'testCount' => 1,
|
|
'failureCount' => 0,
|
|
'errorCount' => 0,
|
|
'skippedCount' => 0,
|
|
'status' => 'passed',
|
|
],
|
|
reportGeneration: [
|
|
'status' => 'failed',
|
|
'errorClass' => RuntimeException::class,
|
|
'message' => 'synthetic report write failure',
|
|
],
|
|
);
|
|
|
|
expect($result['status'])->toBe('failed')
|
|
->and($result['exitCode'])->toBe(9)
|
|
->and($result['failureContextIds'])->toContain(
|
|
'test-failure',
|
|
'report-generation-failure',
|
|
'evidence-contradiction',
|
|
);
|
|
});
|