sprintf('%s/%s-latest.junit.xml', $directory, $laneId), 'summary' => sprintf('%s/%s-latest.summary.md', $directory, $laneId), 'budget' => sprintf('%s/%s-latest.budget.json', $directory, $laneId), 'report' => sprintf('%s/%s-latest.report.json', $directory, $laneId), 'profile' => sprintf('%s/%s-latest.profile.txt', $directory, $laneId), ]; } /** * @return array{slowestEntries: list, durationsByFile: array} */ public static function parseJUnit(string $filePath, string $laneId): array { if (! is_file($filePath)) { return [ 'slowestEntries' => [], 'durationsByFile' => [], ]; } $useInternalErrors = libxml_use_internal_errors(true); $xml = simplexml_load_file($filePath); libxml_clear_errors(); libxml_use_internal_errors($useInternalErrors); if (! $xml instanceof SimpleXMLElement) { return [ 'slowestEntries' => [], 'durationsByFile' => [], ]; } $slowestEntries = []; $durationsByFile = []; foreach ($xml->xpath('//testcase') ?: [] as $testcase) { $rawSubject = trim((string) ($testcase['file'] ?? '')); $subject = $rawSubject !== '' ? $rawSubject : trim((string) ($testcase['name'] ?? 'unknown-testcase')); $duration = (float) ($testcase['time'] ?? 0.0); $normalizedFile = explode('::', $subject)[0]; $slowestEntries[] = [ 'subject' => $subject, 'durationSeconds' => round($duration, 6), 'laneId' => $laneId, ]; $durationsByFile[$normalizedFile] = round(($durationsByFile[$normalizedFile] ?? 0.0) + $duration, 6); } usort($slowestEntries, static fn (array $left, array $right): int => $right['durationSeconds'] <=> $left['durationSeconds']); return [ 'slowestEntries' => array_slice($slowestEntries, 0, 10), 'durationsByFile' => $durationsByFile, ]; } /** * @param list $slowestEntries * @param array $durationsByFile * @return array */ public static function buildReport( string $laneId, float $wallClockSeconds, array $slowestEntries, array $durationsByFile, ?string $artifactDirectory = null, ?string $comparisonProfile = null, ): array { $lane = TestLaneManifest::lane($laneId); $budget = TestLaneBudget::fromArray($lane['budget']); $budgetEvaluation = $budget->evaluate($wallClockSeconds); $artifactPaths = self::artifactPaths($laneId, $artifactDirectory); $artifacts = []; foreach ($lane['artifacts'] as $artifactMode) { $relativePath = match ($artifactMode) { 'summary' => $artifactPaths['summary'], 'junit-xml' => $artifactPaths['junit'], 'profile-top' => $artifactPaths['profile'], 'budget-report' => $artifactPaths['budget'], default => null, }; if (! is_string($relativePath)) { continue; } $artifacts[] = [ 'artifactMode' => $artifactMode, 'relativePath' => $relativePath, 'machineReadable' => in_array($artifactMode, ['junit-xml', 'budget-report'], true), ]; } $report = [ 'laneId' => $laneId, 'finishedAt' => gmdate('c'), 'wallClockSeconds' => round($wallClockSeconds, 6), 'budgetThresholdSeconds' => $budget->thresholdSeconds, 'budgetBaselineSource' => $budget->baselineSource, 'budgetEnforcement' => $budget->enforcement, 'budgetLifecycleState' => $budget->lifecycleState, 'budgetStatus' => $budgetEvaluation['budgetStatus'], 'slowestEntries' => array_values($slowestEntries), 'familyBudgetEvaluations' => TestLaneBudget::evaluateFamilyBudgets(TestLaneManifest::familyBudgets(), $durationsByFile), 'artifacts' => $artifacts, ]; if ($budget->baselineDeltaTargetPercent !== null) { $report['baselineDeltaTargetPercent'] = $budget->baselineDeltaTargetPercent; } $comparison = self::buildSharedFixtureSlimmingComparison($laneId, $wallClockSeconds, $comparisonProfile); if ($comparison !== null) { $report['sharedFixtureSlimmingComparison'] = $comparison; } return $report; } /** * @param array $report * @return array{summary: string, budget: string, report: string, profile: string} */ public static function writeArtifacts( string $laneId, array $report, ?string $profileOutput = null, ?string $artifactDirectory = null, ): array { $artifactPaths = self::artifactPaths($laneId, $artifactDirectory); self::ensureDirectory(dirname(TestLaneManifest::absolutePath($artifactPaths['summary']))); file_put_contents( TestLaneManifest::absolutePath($artifactPaths['summary']), self::buildSummaryMarkdown($report), ); file_put_contents( TestLaneManifest::absolutePath($artifactPaths['budget']), json_encode([ 'laneId' => $report['laneId'], 'wallClockSeconds' => $report['wallClockSeconds'], 'budgetThresholdSeconds' => $report['budgetThresholdSeconds'], 'budgetBaselineSource' => $report['budgetBaselineSource'], 'budgetEnforcement' => $report['budgetEnforcement'], 'budgetLifecycleState' => $report['budgetLifecycleState'], 'budgetStatus' => $report['budgetStatus'], 'familyBudgetEvaluations' => $report['familyBudgetEvaluations'], 'sharedFixtureSlimmingComparison' => $report['sharedFixtureSlimmingComparison'] ?? null, ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), ); file_put_contents( TestLaneManifest::absolutePath($artifactPaths['report']), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), ); if (is_string($profileOutput) && trim($profileOutput) !== '') { file_put_contents(TestLaneManifest::absolutePath($artifactPaths['profile']), $profileOutput); } return $artifactPaths; } /** * @return array */ public static function finalizeLane( string $laneId, float $wallClockSeconds, string $capturedOutput = '', ?string $comparisonProfile = null, ): array { $artifactPaths = self::artifactPaths($laneId); $parsed = self::parseJUnit(TestLaneManifest::absolutePath($artifactPaths['junit']), $laneId); $report = self::buildReport( laneId: $laneId, wallClockSeconds: $wallClockSeconds, slowestEntries: $parsed['slowestEntries'], durationsByFile: $parsed['durationsByFile'], comparisonProfile: $comparisonProfile, ); self::writeArtifacts($laneId, $report, $capturedOutput); return $report; } /** * @param array $report */ private static function buildSummaryMarkdown(array $report): string { $lines = [ '# Test Lane Summary', '', sprintf('- Lane: %s', $report['laneId']), sprintf('- Finished: %s', $report['finishedAt']), sprintf('- Wall clock: %.2f seconds', (float) $report['wallClockSeconds']), sprintf('- Budget: %d seconds (%s)', (int) $report['budgetThresholdSeconds'], $report['budgetStatus']), ]; if (isset($report['sharedFixtureSlimmingComparison']) && is_array($report['sharedFixtureSlimmingComparison'])) { $comparison = $report['sharedFixtureSlimmingComparison']; $lines[] = sprintf( '- Shared fixture slimming baseline: %.2f seconds (%s, %+0.2f%%)', (float) $comparison['baselineSeconds'], (string) $comparison['status'], (float) $comparison['deltaPercent'], ); } $lines[] = ''; $lines[] = '## Slowest entries'; foreach ($report['slowestEntries'] as $entry) { $lines[] = sprintf('- %s (%.2fs)', $entry['subject'], (float) $entry['durationSeconds']); } return implode(PHP_EOL, $lines).PHP_EOL; } /** * @return array|null */ private static function buildSharedFixtureSlimmingComparison( string $laneId, float $wallClockSeconds, ?string $comparisonProfile, ): ?array { if ($comparisonProfile !== 'shared-test-fixture-slimming') { return null; } $baseline = TestLaneManifest::comparisonBaseline($comparisonProfile, $laneId); if (! is_array($baseline)) { return null; } $baselineSeconds = (float) $baseline['wallClockSeconds']; $deltaSeconds = round($wallClockSeconds - $baselineSeconds, 6); $deltaPercent = $baselineSeconds > 0 ? round(($deltaSeconds / $baselineSeconds) * 100, 6) : 0.0; $targetImprovementPercent = (int) ($baseline['targetImprovementPercent'] ?? 10); $maxRegressionPercent = (int) ($baseline['maxRegressionPercent'] ?? 5); $status = 'stable'; if ($deltaPercent <= -$targetImprovementPercent) { $status = 'improved'; } elseif ($deltaPercent > $maxRegressionPercent) { $status = 'regressed'; } return [ 'comparisonProfile' => $comparisonProfile, 'baselineFinishedAt' => (string) $baseline['finishedAt'], 'baselineSeconds' => $baselineSeconds, 'deltaSeconds' => $deltaSeconds, 'deltaPercent' => $deltaPercent, 'targetImprovementPercent' => $targetImprovementPercent, 'maxRegressionPercent' => $maxRegressionPercent, 'status' => $status, ]; } private static function ensureDirectory(string $directory): void { if (is_dir($directory)) { return; } mkdir($directory, 0777, true); } }