## Summary - implement the canonical shared fixture profile model with minimal, standard, and full semantics plus temporary legacy alias resolution - slim default factory behavior for operation runs, backup sets, provider connections, and provider credentials while keeping explicit heavy opt-in states - migrate the first console, navigation, RBAC, and drift caller packs to explicit lean helpers and wire lane comparison reporting into the existing Spec 206 seams - reconcile spec 207 docs, contracts, quickstart guidance, and task tracking with the implemented behavior ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CreateUserWithTenantProfilesTest.php tests/Unit/Factories/TenantFactoryTest.php tests/Unit/Factories/OperationRunFactoryTest.php tests/Unit/Factories/BackupSetFactoryTest.php tests/Unit/Factories/ProviderConnectionFactoryTest.php tests/Unit/Factories/ProviderCredentialFactoryTest.php tests/Feature/Guards/FixtureCostProfilesGuardTest.php tests/Feature/Guards/FixtureLaneImpactBudgetTest.php tests/Feature/Guards/TestLaneArtifactsContractTest.php tests/Feature/Console/ReconcileOperationRunsCommandTest.php tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php tests/Feature/Spec080WorkspaceManagedTenantAdminMigrationTest.php tests/Feature/BaselineDriftEngine/FindingFidelityTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `./scripts/platform-test-lane fast-feedback` - `./scripts/platform-test-lane confidence` - `./scripts/platform-test-report fast-feedback` - `./scripts/platform-test-report confidence` ## Lane outcome - `fast-feedback`: 136.400761s vs 176.73623s baseline, status `improved` - `confidence`: 394.5669s vs 394.383441s baseline, status `stable` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #240
703 lines
28 KiB
PHP
703 lines
28 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',
|
|
];
|
|
|
|
private const COMPARISON_BASELINES = [
|
|
'shared-test-fixture-slimming' => [
|
|
'fast-feedback' => [
|
|
'laneId' => 'fast-feedback',
|
|
'finishedAt' => '2026-04-16T13:11:57+00:00',
|
|
'wallClockSeconds' => 176.73623,
|
|
'budgetThresholdSeconds' => 200,
|
|
'targetImprovementPercent' => 10,
|
|
'maxRegressionPercent' => 5,
|
|
],
|
|
'confidence' => [
|
|
'laneId' => 'confidence',
|
|
'finishedAt' => '2026-04-16T13:11:57+00:00',
|
|
'wallClockSeconds' => 394.383441,
|
|
'budgetThresholdSeconds' => 450,
|
|
'targetImprovementPercent' => 10,
|
|
'maxRegressionPercent' => 5,
|
|
],
|
|
],
|
|
];
|
|
|
|
/**
|
|
* @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 array<string, float|int|string>|null
|
|
*/
|
|
public static function comparisonBaseline(string $comparisonProfile, string $laneId): ?array
|
|
{
|
|
$profileBaselines = self::COMPARISON_BASELINES[$comparisonProfile] ?? null;
|
|
|
|
if (! is_array($profileBaselines)) {
|
|
return null;
|
|
}
|
|
|
|
$baseline = $profileBaselines[$laneId] ?? null;
|
|
|
|
return is_array($baseline) ? $baseline : null;
|
|
}
|
|
|
|
/**
|
|
* @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, ?string $comparisonProfile = null): 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'],
|
|
comparisonProfile: $comparisonProfile,
|
|
);
|
|
|
|
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;
|
|
}
|
|
} |