TenantAtlas/apps/platform/tests/Support/TestLaneManifest.php
2026-04-16 15:57:39 +02:00

665 lines
27 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Support;
use InvalidArgumentException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use Symfony\Component\Process\Process;
final class TestLaneManifest
{
private const ARTIFACT_DIRECTORY = 'storage/logs/test-lanes';
private const FULL_SUITE_BASELINE_SECONDS = 2625;
private const COMMAND_REFS = [
'fast-feedback' => 'test',
'confidence' => 'test:confidence',
'browser' => 'test:browser',
'heavy-governance' => 'test:heavy',
'profiling' => 'test:profile',
'junit' => 'test:junit',
];
/**
* @return array<string, mixed>
*/
public static function manifest(): array
{
return [
'version' => 1,
'artifactDirectory' => self::artifactDirectory(),
'lanes' => [
[
'id' => 'fast-feedback',
'governanceClass' => 'fast',
'description' => 'Quick representative feedback for normal local edits.',
'intendedAudience' => 'Contributors working in the default authoring loop.',
'includedFamilies' => ['unit', 'core-feature-safety'],
'excludedFamilies' => ['browser', 'heavy-governance'],
'ownershipExpectations' => 'Run on normal edits and escalate to confidence or heavy-governance when the touched surface expands.',
'defaultEntryPoint' => true,
'parallelMode' => 'required',
'selectors' => [
'includeSuites' => ['Unit'],
'includePaths' => [
'tests/Feature/Auth',
'tests/Feature/Authorization',
'tests/Feature/EntraAdminRoles',
'tests/Feature/Findings',
'tests/Feature/Guards',
'tests/Feature/Monitoring',
'tests/Feature/Navigation',
'tests/Feature/Onboarding',
'tests/Feature/RequiredPermissions',
'tests/Feature/Tenants',
'tests/Feature/Workspaces',
],
'includeGroups' => ['fast-feedback'],
'includeFiles' => [
'tests/Feature/AdminConsentCallbackTest.php',
'tests/Feature/AdminNewRedirectTest.php',
],
'excludeSuites' => ['Browser'],
'excludePaths' => [
'tests/Architecture',
'tests/Browser',
'tests/Deprecation',
'tests/Feature/078',
'tests/Feature/090',
'tests/Feature/144',
'tests/Feature/OpsUx',
],
'excludeGroups' => ['browser', 'heavy-governance'],
'excludeFiles' => [
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
'tests/Feature/Guards/ActionSurfaceContractTest.php',
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
'tests/Feature/ProviderConnections/CredentialLeakGuardTest.php',
],
],
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
'budget' => [
'thresholdSeconds' => 200,
'baselineSource' => 'measured-current-suite',
'enforcement' => 'warn',
'lifecycleState' => 'documented',
'baselineDeltaTargetPercent' => 50,
'reviewCadence' => 'tighten after two stable runs',
],
'dbStrategy' => [
'connectionMode' => 'sqlite-memory',
'resetStrategy' => 'refresh-database',
'seedsPolicy' => 'restricted',
'schemaBaselineCandidate' => false,
],
],
[
'id' => 'confidence',
'governanceClass' => 'confidence',
'description' => 'Broader pre-merge validation for non-browser feature and integration work.',
'intendedAudience' => 'Contributors and reviewers preparing a higher-confidence run before merge.',
'includedFamilies' => ['unit', 'non-browser-feature-integration'],
'excludedFamilies' => ['browser', 'heavy-governance'],
'ownershipExpectations' => 'Run before merge when a change touches multiple feature surfaces or shared infrastructure.',
'defaultEntryPoint' => false,
'parallelMode' => 'required',
'selectors' => [
'includeSuites' => ['Unit'],
'includePaths' => ['tests/Feature'],
'includeGroups' => [],
'includeFiles' => [],
'excludeSuites' => ['Browser'],
'excludePaths' => [
'tests/Architecture',
'tests/Browser',
'tests/Deprecation',
'tests/Feature/078',
'tests/Feature/090',
'tests/Feature/144',
'tests/Feature/OpsUx',
],
'excludeGroups' => ['browser', 'heavy-governance'],
'excludeFiles' => [
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
'tests/Feature/Guards/ActionSurfaceContractTest.php',
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
'tests/Feature/ProviderConnections/CredentialLeakGuardTest.php',
],
],
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
'budget' => [
'thresholdSeconds' => 450,
'baselineSource' => 'measured-current-suite',
'enforcement' => 'warn',
'lifecycleState' => 'documented',
'reviewCadence' => 'tighten after two stable runs',
],
'dbStrategy' => [
'connectionMode' => 'sqlite-memory',
'resetStrategy' => 'refresh-database',
'seedsPolicy' => 'restricted',
'schemaBaselineCandidate' => false,
],
],
[
'id' => 'browser',
'governanceClass' => 'heavy',
'description' => 'Dedicated browser runtime lane for smoke and workflow validation.',
'intendedAudience' => 'Contributors validating browser-specific behavior and smoke coverage.',
'includedFamilies' => ['browser'],
'excludedFamilies' => ['fast-feedback', 'confidence'],
'ownershipExpectations' => 'Run when a change touches browser behavior or before promoting browser-sensitive work.',
'defaultEntryPoint' => false,
'parallelMode' => 'forbidden',
'selectors' => [
'includeSuites' => ['Browser'],
'includePaths' => ['tests/Browser'],
'includeGroups' => ['browser'],
'includeFiles' => [],
'excludeSuites' => [],
'excludePaths' => [],
'excludeGroups' => [],
'excludeFiles' => [],
],
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
'budget' => [
'thresholdSeconds' => 150,
'baselineSource' => 'measured-lane',
'enforcement' => 'warn',
'lifecycleState' => 'documented',
'reviewCadence' => 'tighten after two stable runs',
],
'dbStrategy' => [
'connectionMode' => 'sqlite-memory',
'resetStrategy' => 'refresh-database',
'seedsPolicy' => 'restricted',
'schemaBaselineCandidate' => false,
],
],
[
'id' => 'heavy-governance',
'governanceClass' => 'heavy',
'description' => 'Intentionally expensive governance scans, architecture guards, and high-fan-out operational checks.',
'intendedAudience' => 'Maintainers and reviewers validating the heaviest governance families on purpose.',
'includedFamilies' => ['architecture-governance', 'ops-ux', 'surface-scan'],
'excludedFamilies' => ['browser'],
'ownershipExpectations' => 'Run intentionally when touching governance scans, high-fan-out guards, or operational UX surfaces.',
'defaultEntryPoint' => false,
'parallelMode' => 'optional',
'selectors' => [
'includeSuites' => [],
'includePaths' => [
'tests/Architecture',
'tests/Deprecation',
'tests/Feature/078',
'tests/Feature/090',
'tests/Feature/144',
'tests/Feature/OpsUx',
],
'includeGroups' => ['heavy-governance'],
'includeFiles' => [
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
'tests/Feature/Guards/ActionSurfaceContractTest.php',
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
'tests/Feature/ProviderConnections/CredentialLeakGuardTest.php',
],
'excludeSuites' => ['Browser'],
'excludePaths' => [],
'excludeGroups' => ['browser'],
'excludeFiles' => [],
],
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
'budget' => [
'thresholdSeconds' => 120,
'baselineSource' => 'measured-lane',
'enforcement' => 'warn',
'lifecycleState' => 'documented',
'reviewCadence' => 'tighten after two stable runs',
],
'dbStrategy' => [
'connectionMode' => 'mixed',
'resetStrategy' => 'refresh-database',
'seedsPolicy' => 'restricted',
'schemaBaselineCandidate' => false,
],
],
[
'id' => 'profiling',
'governanceClass' => 'support',
'description' => 'Serial non-browser profiling support run that exposes the slowest tests and families.',
'intendedAudience' => 'Maintainers investigating slow-test drift before it becomes baseline behavior.',
'includedFamilies' => ['unit', 'non-browser-feature-integration', 'heavy-governance'],
'excludedFamilies' => ['browser'],
'ownershipExpectations' => 'Run intentionally when refreshing baselines or refining the heavy-governance split from evidence.',
'defaultEntryPoint' => false,
'parallelMode' => 'forbidden',
'selectors' => [
'includeSuites' => ['Unit'],
'includePaths' => ['tests/Feature'],
'includeGroups' => [],
'includeFiles' => [],
'excludeSuites' => ['Browser'],
'excludePaths' => ['tests/Browser'],
'excludeGroups' => ['browser'],
'excludeFiles' => [],
],
'artifacts' => ['summary', 'junit-xml', 'profile-top', 'budget-report'],
'budget' => [
'thresholdSeconds' => 3000,
'baselineSource' => 'measured-current-suite',
'enforcement' => 'warn',
'lifecycleState' => 'documented',
'reviewCadence' => 'tighten after two stable runs',
],
'dbStrategy' => [
'connectionMode' => 'sqlite-memory',
'resetStrategy' => 'refresh-database',
'seedsPolicy' => 'restricted',
'schemaBaselineCandidate' => false,
],
],
[
'id' => 'junit',
'governanceClass' => 'support',
'description' => 'Machine-readable report run for the broader confidence scope.',
'intendedAudience' => 'Contributors and reviewers who need durable artifacts instead of ad-hoc terminal output.',
'includedFamilies' => ['unit', 'non-browser-feature-integration'],
'excludedFamilies' => ['browser', 'heavy-governance'],
'ownershipExpectations' => 'Run when the latest broad report needs to be shared or attached to follow-up work.',
'defaultEntryPoint' => false,
'parallelMode' => 'required',
'selectors' => [
'includeSuites' => ['Unit'],
'includePaths' => ['tests/Feature'],
'includeGroups' => [],
'includeFiles' => [],
'excludeSuites' => ['Browser'],
'excludePaths' => [
'tests/Architecture',
'tests/Browser',
'tests/Deprecation',
'tests/Feature/078',
'tests/Feature/090',
'tests/Feature/144',
'tests/Feature/OpsUx',
],
'excludeGroups' => ['browser', 'heavy-governance'],
'excludeFiles' => [
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
'tests/Feature/Guards/ActionSurfaceContractTest.php',
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
'tests/Feature/ProviderConnections/CredentialLeakGuardTest.php',
],
],
'artifacts' => ['summary', 'junit-xml', 'budget-report'],
'budget' => [
'thresholdSeconds' => 450,
'baselineSource' => 'measured-lane',
'enforcement' => 'warn',
'lifecycleState' => 'documented',
'reviewCadence' => 'tighten after two stable runs',
],
'dbStrategy' => [
'connectionMode' => 'sqlite-memory',
'resetStrategy' => 'refresh-database',
'seedsPolicy' => 'restricted',
'schemaBaselineCandidate' => false,
],
],
],
'familyBudgets' => [
[
'familyId' => 'ops-ux-governance',
'selectorType' => 'path',
'selectors' => [
'tests/Feature/OpsUx',
'tests/Feature/Filament/Alerts/AlertsKpiHeaderTest.php',
'tests/Feature/Guards/ActionSurfaceContractTest.php',
'tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php',
'tests/Feature/ProviderConnections/CredentialLeakGuardTest.php',
],
'thresholdSeconds' => 120,
'baselineSource' => 'measured-current-suite',
'enforcement' => 'warn',
'lifecycleState' => 'documented',
'reviewCadence' => 'tighten after two stable runs',
],
[
'familyId' => 'browser-smoke',
'selectorType' => 'path',
'selectors' => ['tests/Browser'],
'thresholdSeconds' => 150,
'baselineSource' => 'measured-lane',
'enforcement' => 'warn',
'lifecycleState' => 'documented',
'reviewCadence' => 'tighten after two stable runs',
],
],
];
}
/**
* @return list<array<string, mixed>>
*/
public static function familyBudgets(): array
{
return self::manifest()['familyBudgets'];
}
/**
* @return array<string, mixed>
*/
public static function lane(string $id): array
{
foreach (self::manifest()['lanes'] as $lane) {
if ($lane['id'] === $id) {
return $lane;
}
}
throw new InvalidArgumentException(sprintf('Unknown test lane [%s].', $id));
}
public static function commandRef(string $laneId): string
{
if (! array_key_exists($laneId, self::COMMAND_REFS)) {
throw new InvalidArgumentException(sprintf('Unknown lane command reference [%s].', $laneId));
}
return self::COMMAND_REFS[$laneId];
}
public static function artifactDirectory(): string
{
return self::ARTIFACT_DIRECTORY;
}
public static function fullSuiteBaselineSeconds(): int
{
return self::FULL_SUITE_BASELINE_SECONDS;
}
/**
* @return list<string>
*/
public static function buildCommand(string $laneId): array
{
$lane = self::lane($laneId);
$command = ['php', 'artisan', 'test', '--without-tty', '--no-ansi'];
$commandSuites = self::commandSuites($lane);
if (! in_array('profile-top', $lane['artifacts'], true)) {
$command[] = '--compact';
}
if ($lane['parallelMode'] === 'required') {
$command[] = '--parallel';
}
if ($commandSuites !== []) {
$command[] = '--testsuite='.implode(',', $commandSuites);
}
$command[] = '--log-junit='.TestLaneReport::artifactPaths($laneId)['junit'];
if (in_array('profile-top', $lane['artifacts'], true)) {
$command[] = '--profile';
}
if ($lane['selectors']['includeGroups'] !== []) {
$command[] = '--group='.implode(',', $lane['selectors']['includeGroups']);
}
if ($lane['selectors']['excludeGroups'] !== []) {
$command[] = '--exclude-group='.implode(',', $lane['selectors']['excludeGroups']);
}
foreach (self::commandTargets($lane) as $target) {
$command[] = $target;
}
return $command;
}
public static function runLane(string $laneId): int
{
self::ensureArtifactDirectory();
$command = self::buildCommand($laneId);
$process = new Process($command, self::appRoot());
$process->setTimeout(null);
$capturedOutput = '';
$startedAt = microtime(true);
$process->run(function (string $type, string $buffer) use (&$capturedOutput): void {
echo $buffer;
$capturedOutput .= $buffer;
});
TestLaneReport::finalizeLane($laneId, microtime(true) - $startedAt, $capturedOutput);
return $process->getExitCode() ?? 1;
}
public static function renderLatestReport(string $laneId): int
{
$artifactPaths = TestLaneReport::artifactPaths($laneId);
$reportPath = self::absolutePath($artifactPaths['report']);
$wallClockSeconds = 0.0;
if (is_file($reportPath)) {
$existingReport = json_decode((string) file_get_contents($reportPath), true);
$wallClockSeconds = (float) ($existingReport['wallClockSeconds'] ?? 0.0);
}
$parsed = TestLaneReport::parseJUnit(self::absolutePath($artifactPaths['junit']), $laneId);
$profileOutputPath = self::absolutePath($artifactPaths['profile']);
$report = TestLaneReport::buildReport(
laneId: $laneId,
wallClockSeconds: $wallClockSeconds,
slowestEntries: $parsed['slowestEntries'],
durationsByFile: $parsed['durationsByFile'],
);
TestLaneReport::writeArtifacts(
laneId: $laneId,
report: $report,
profileOutput: is_file($profileOutputPath) ? (string) file_get_contents($profileOutputPath) : null,
);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
return 0;
}
/**
* @return list<string>
*/
public static function discoverFiles(string $laneId): array
{
$lane = self::lane($laneId);
$selectors = $lane['selectors'];
$files = [];
foreach (array_merge(self::suiteTargets($selectors['includeSuites']), $selectors['includePaths'], $selectors['includeFiles']) as $target) {
foreach (self::discoverTarget($target) as $resolvedFile) {
if (self::isExcluded($resolvedFile, $selectors)) {
continue;
}
$files[] = $resolvedFile;
}
}
$files = array_values(array_unique($files));
sort($files);
return $files;
}
public static function absolutePath(string $relativePath): string
{
return self::appRoot().DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, ltrim($relativePath, '/'));
}
private static function appRoot(): string
{
return dirname(__DIR__, 2);
}
private static function ensureArtifactDirectory(): void
{
$directory = self::absolutePath(self::artifactDirectory());
if (is_dir($directory)) {
return;
}
mkdir($directory, 0777, true);
}
/**
* @param list<string> $suites
* @return list<string>
*/
private static function suiteTargets(array $suites): array
{
$targets = [];
foreach ($suites as $suite) {
$targets = array_merge($targets, match ($suite) {
'Unit' => ['tests/Unit'],
'Feature' => ['tests/Feature'],
'Browser' => ['tests/Browser'],
'Pgsql' => ['tests/Feature/WorkspaceIsolation/WorkspaceIdForeignKeyConstraintTest.php'],
default => [],
});
}
return array_values(array_unique($targets));
}
/**
* @param array<string, mixed> $lane
* @return list<string>
*/
private static function commandTargets(array $lane): array
{
return [];
}
/**
* @param array<string, mixed> $lane
* @return list<string>
*/
private static function commandSuites(array $lane): array
{
$selectors = $lane['selectors'];
$suites = [];
foreach ($selectors['includeSuites'] as $suite) {
$suites[] = $suite;
}
foreach (array_merge($selectors['includePaths'], $selectors['includeFiles']) as $target) {
$suite = self::inferSuiteFromTarget($target);
if ($suite !== null) {
$suites[] = $suite;
}
}
$suites = array_values(array_unique(array_filter($suites, static fn (mixed $suite): bool => is_string($suite) && $suite !== '')));
if ($selectors['excludeSuites'] !== []) {
$suites = array_values(array_filter(
$suites,
static fn (string $suite): bool => ! in_array($suite, $selectors['excludeSuites'], true),
));
}
$suiteOrder = [
'Unit' => 10,
'Feature' => 20,
'Browser' => 30,
'Architecture' => 40,
'Deprecation' => 50,
'Pgsql' => 60,
];
usort($suites, static function (string $left, string $right) use ($suiteOrder): int {
return ($suiteOrder[$left] ?? 999) <=> ($suiteOrder[$right] ?? 999);
});
return $suites;
}
private static function inferSuiteFromTarget(string $target): ?string
{
return match (true) {
str_starts_with($target, 'tests/Unit') => 'Unit',
str_starts_with($target, 'tests/Feature') => 'Feature',
str_starts_with($target, 'tests/Browser') => 'Browser',
str_starts_with($target, 'tests/Architecture') => 'Architecture',
str_starts_with($target, 'tests/Deprecation') => 'Deprecation',
default => null,
};
}
/**
* @return list<string>
*/
private static function discoverTarget(string $target): array
{
$absoluteTarget = self::absolutePath($target);
if (is_file($absoluteTarget)) {
return [str_replace(DIRECTORY_SEPARATOR, '/', $target)];
}
if (! is_dir($absoluteTarget)) {
return [];
}
$files = [];
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($absoluteTarget));
/** @var SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile() || ! str_ends_with($file->getFilename(), 'Test.php')) {
continue;
}
$resolvedPath = str_replace(self::appRoot().DIRECTORY_SEPARATOR, '', $file->getPathname());
$files[] = str_replace(DIRECTORY_SEPARATOR, '/', $resolvedPath);
}
return $files;
}
/**
* @param array<string, mixed> $selectors
*/
private static function isExcluded(string $filePath, array $selectors): bool
{
if (in_array($filePath, $selectors['excludeFiles'], true)) {
return true;
}
foreach ($selectors['excludePaths'] as $excludedPath) {
if (str_starts_with($filePath, rtrim($excludedPath, '/'))) {
return true;
}
}
return false;
}
}