*/ 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( '', $laneId, ); } function spec439RefreshLane(string $laneId, string $artifactDirectory): int { return spec439RefreshLaneResult($laneId, $artifactDirectory)[0]; } /** * @return array{int, array} */ 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( '', $index, $index, $duration, $index, $index, $index, $index, $index, $duration, ); } file_put_contents($junitPath, ''.implode('', $testcases).''); $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, ''); file_put_contents($failurePath, ''); file_put_contents($errorPath, ''); file_put_contents($skippedPath, ''); $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( '<%1$s message="nope"/>', $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, ''); } $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', ); });