TenantAtlas/apps/platform/tests/Unit/Support/TestLaneReportTest.php
Ahmed Darrazi 8144a3be78
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 7m2s
feat: implement spec 439 branch and evidence truth
2026-07-11 21:13:42 +02:00

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',
);
});