mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 10:52:14 +02:00
wip
This commit is contained in:
@ -20,6 +20,7 @@ final class BaselineFetchFailed extends RuntimeException implements ExceptionInt
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $headline,
|
private readonly string $headline,
|
||||||
private readonly string $hint,
|
private readonly string $hint,
|
||||||
|
private readonly bool $hasAnchor = false,
|
||||||
) {
|
) {
|
||||||
parent::__construct($headline);
|
parent::__construct($headline);
|
||||||
}
|
}
|
||||||
@ -28,16 +29,26 @@ final class BaselineFetchFailed extends RuntimeException implements ExceptionInt
|
|||||||
{
|
{
|
||||||
View::renderUsing($output);
|
View::renderUsing($output);
|
||||||
|
|
||||||
|
if (! $this->hasAnchor) {
|
||||||
View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
|
View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
|
||||||
View::render('components.two-column-detail', ['left' => $this->hint, 'right' => '']);
|
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
||||||
View::render('components.two-column-detail', [
|
$output->writeln('');
|
||||||
'left' => 'Bypass with --fresh to record locally and skip the baseline fetch.',
|
|
||||||
'right' => '',
|
return;
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
$this->renderChild($output, $this->headline);
|
||||||
|
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
||||||
|
$output->writeln('');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function exitCode(): int
|
public function exitCode(): int
|
||||||
{
|
{
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function renderChild(OutputInterface $output, string $text): void
|
||||||
|
{
|
||||||
|
$output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,6 +90,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
/** @var array<string, true> */
|
/** @var array<string, true> */
|
||||||
private array $affectedFiles = [];
|
private array $affectedFiles = [];
|
||||||
|
|
||||||
|
/** @var array{structural: array<string, mixed>, environmental: array<string, mixed>}|null */
|
||||||
|
private ?array $startFingerprint = null;
|
||||||
|
|
||||||
private function workerEdgesKey(string $token): string
|
private function workerEdgesKey(string $token): string
|
||||||
{
|
{
|
||||||
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
|
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
|
||||||
@ -126,9 +129,19 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
View::render('components.badge', ['type' => $type, 'content' => $content]);
|
View::render('components.badge', ['type' => $type, 'content' => $content]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderDetail(string $left, string $right = ''): void
|
private function renderChild(string $text): void
|
||||||
{
|
{
|
||||||
View::render('components.two-column-detail', ['left' => $left, 'right' => $right]);
|
$this->output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
|
||||||
|
*/
|
||||||
|
private function structuralFingerprintShifted(array $current): bool
|
||||||
|
{
|
||||||
|
assert($this->startFingerprint !== null);
|
||||||
|
|
||||||
|
return ! Fingerprint::structuralMatches($this->startFingerprint, $current);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadGraph(string $projectRoot): ?Graph
|
private function loadGraph(string $projectRoot): ?Graph
|
||||||
@ -318,8 +331,19 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$changedFiles = new ChangedFiles($projectRoot);
|
$changedFiles = new ChangedFiles($projectRoot);
|
||||||
$currentSha = $changedFiles->currentSha();
|
$currentSha = $changedFiles->currentSha();
|
||||||
|
|
||||||
|
$currentFingerprint = Fingerprint::compute($projectRoot);
|
||||||
|
|
||||||
|
if ($this->structuralFingerprintShifted($currentFingerprint)) {
|
||||||
|
$this->renderBadge('WARN', 'Project files changed during the run — discarding recorded edges.');
|
||||||
|
$this->renderChild('Re-run --tia after your edits settle to record a fresh dependency graph.');
|
||||||
|
$recorder->reset();
|
||||||
|
$this->coverageCollector->reset();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
|
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
|
||||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
$graph->setFingerprint($currentFingerprint);
|
||||||
$graph->setRecordedAtSha($this->branch, $currentSha);
|
$graph->setRecordedAtSha($this->branch, $currentSha);
|
||||||
$graph->setLastRunTree(
|
$graph->setLastRunTree(
|
||||||
$this->branch,
|
$this->branch,
|
||||||
@ -343,12 +367,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->renderBadge('INFO', sprintf(
|
|
||||||
'Recorded the dependency graph (%d test file%s).',
|
|
||||||
count($perTest),
|
|
||||||
count($perTest) === 1 ? '' : 's',
|
|
||||||
));
|
|
||||||
|
|
||||||
$recorder->reset();
|
$recorder->reset();
|
||||||
$this->coverageCollector->reset();
|
$this->coverageCollector->reset();
|
||||||
}
|
}
|
||||||
@ -389,8 +407,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$changedFiles = new ChangedFiles($projectRoot);
|
$changedFiles = new ChangedFiles($projectRoot);
|
||||||
$currentSha = $changedFiles->currentSha();
|
$currentSha = $changedFiles->currentSha();
|
||||||
|
|
||||||
|
$currentFingerprint = Fingerprint::compute($projectRoot);
|
||||||
|
|
||||||
|
if ($this->structuralFingerprintShifted($currentFingerprint)) {
|
||||||
|
$this->renderBadge('WARN', 'Project files changed during the run — discarding recorded edges.');
|
||||||
|
$this->renderChild('Re-run --tia after your edits settle to record a fresh dependency graph.');
|
||||||
|
|
||||||
|
foreach ($partialKeys as $key) {
|
||||||
|
$this->state->delete($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
|
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
|
||||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
$graph->setFingerprint($currentFingerprint);
|
||||||
$graph->setRecordedAtSha($this->branch, $currentSha);
|
$graph->setRecordedAtSha($this->branch, $currentSha);
|
||||||
$graph->setLastRunTree(
|
$graph->setLastRunTree(
|
||||||
$this->branch,
|
$this->branch,
|
||||||
@ -467,7 +498,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->renderBadge('ERROR', 'Recorded zero edges — coverage driver likely missing.');
|
$this->renderBadge('ERROR', 'Recorded zero edges — coverage driver likely missing.');
|
||||||
$this->renderDetail('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.');
|
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.');
|
||||||
|
|
||||||
return $exitCode;
|
return $exitCode;
|
||||||
}
|
}
|
||||||
@ -487,14 +518,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $exitCode;
|
return $exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->renderBadge('INFO', sprintf(
|
|
||||||
'Recorded the dependency graph (%d test file%s, %d worker partial%s).',
|
|
||||||
count($finalised),
|
|
||||||
count($finalised) === 1 ? '' : 's',
|
|
||||||
count($partialKeys),
|
|
||||||
count($partialKeys) === 1 ? '' : 's',
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->snapshotTestResults();
|
$this->snapshotTestResults();
|
||||||
|
|
||||||
return $exitCode;
|
return $exitCode;
|
||||||
@ -523,7 +546,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$branchSha,
|
$branchSha,
|
||||||
);
|
);
|
||||||
if ($summary !== '') {
|
if ($summary !== '') {
|
||||||
$this->renderDetail($summary);
|
$this->renderChild($summary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -567,12 +590,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
||||||
|
|
||||||
$fingerprint = Fingerprint::compute($projectRoot);
|
$fingerprint = Fingerprint::compute($projectRoot);
|
||||||
|
$this->startFingerprint = $fingerprint;
|
||||||
|
|
||||||
if ($forceRebuild) {
|
if ($forceRebuild) {
|
||||||
Storage::purge($projectRoot);
|
Storage::purge($projectRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
$graph = $forceRebuild ? null : $this->loadGraph($projectRoot);
|
$graph = ($forceRebuild || $this->forceRefetch) ? null : $this->loadGraph($projectRoot);
|
||||||
|
|
||||||
if ($graph instanceof Graph) {
|
if ($graph instanceof Graph) {
|
||||||
$graph = $this->reconcileFingerprint($graph, $fingerprint);
|
$graph = $this->reconcileFingerprint($graph, $fingerprint);
|
||||||
@ -759,8 +783,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
if ($hasProjectPhpSourceChanges && ! $coverageAvailable) {
|
if ($hasProjectPhpSourceChanges && ! $coverageAvailable) {
|
||||||
$this->renderBadge('WARN', 'Detected PHP source changes but no coverage driver is available.');
|
$this->renderBadge('WARN', 'Detected PHP source changes but no coverage driver is available.');
|
||||||
$this->renderDetail('Running the full suite to avoid using a stale dependency graph.');
|
$this->renderChild('Running the full suite to avoid using a stale dependency graph.');
|
||||||
$this->renderDetail('Install / enable pcov or xdebug (mode: coverage) so edges can be safely refreshed after PHP refactors.');
|
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) so edges can be safely refreshed after PHP refactors.');
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
@ -836,6 +860,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $failedFromCache, array $affected): void
|
private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $failedFromCache, array $affected): void
|
||||||
{
|
{
|
||||||
|
$this->output->writeln('');
|
||||||
|
$this->renderChild('TIA mode enabled.');
|
||||||
|
|
||||||
if ($affected === []) {
|
if ($affected === []) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -936,11 +963,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1');
|
Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->renderBadge('INFO', $this->piggybackCoverage
|
$this->output->writeln('');
|
||||||
? 'Recording dependency graph in parallel via --coverage (first run) — '.
|
$this->renderChild('TIA mode enabled / fresh graph.');
|
||||||
'subsequent --tia runs will only re-execute affected tests.'
|
|
||||||
: 'Recording dependency graph in parallel (first run) — '.
|
|
||||||
'subsequent --tia runs will only re-execute affected tests.');
|
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
@ -948,8 +972,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
if ($this->piggybackCoverage) {
|
if ($this->piggybackCoverage) {
|
||||||
$this->recordingActive = true;
|
$this->recordingActive = true;
|
||||||
|
|
||||||
$this->renderBadge('INFO', 'Recording dependency graph via --coverage (first run) — '.
|
$this->output->writeln('');
|
||||||
'subsequent --tia runs will only re-execute affected tests.');
|
$this->renderChild('TIA mode enabled / fresh graph.');
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
@ -957,11 +981,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$recorder->activate();
|
$recorder->activate();
|
||||||
$this->recordingActive = true;
|
$this->recordingActive = true;
|
||||||
|
|
||||||
$this->renderBadge('INFO', sprintf(
|
$this->renderChild('Running in TIA mode.');
|
||||||
'Recording dependency graph via %s (first run) — '.
|
|
||||||
'subsequent --tia runs will only re-execute affected tests.',
|
|
||||||
$recorder->driver(),
|
|
||||||
));
|
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
@ -969,8 +989,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
private function emitCoverageDriverMissing(): void
|
private function emitCoverageDriverMissing(): void
|
||||||
{
|
{
|
||||||
$this->renderBadge('WARN', 'No coverage driver is available — skipped.');
|
$this->renderBadge('WARN', 'No coverage driver is available — skipped.');
|
||||||
$this->renderDetail('Needs ext-pcov or Xdebug with coverage mode enabled to record the dependency graph.');
|
$this->renderChild('Needs ext-pcov or Xdebug with coverage mode enabled to record the dependency graph.');
|
||||||
$this->renderDetail('Install or enable one and rerun with --tia.');
|
$this->renderChild('Install or enable one and rerun with --tia.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1017,7 +1037,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
'%d worker(s) had no coverage driver — their per-test edges and results were dropped.',
|
'%d worker(s) had no coverage driver — their per-test edges and results were dropped.',
|
||||||
count($keys),
|
count($keys),
|
||||||
));
|
));
|
||||||
$this->renderDetail('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.');
|
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function purgeWorkerPartials(): void
|
private function purgeWorkerPartials(): void
|
||||||
@ -1404,7 +1424,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||||
$this->baselineFetchAttemptedForDrift = true;
|
$this->baselineFetchAttemptedForDrift = true;
|
||||||
|
|
||||||
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
|
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,12 +42,12 @@ final readonly class BaselineSync
|
|||||||
View::render('components.badge', ['type' => $type, 'content' => $content]);
|
View::render('components.badge', ['type' => $type, 'content' => $content]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderDetail(string $left, string $right = ''): void
|
private function renderChild(string $text): void
|
||||||
{
|
{
|
||||||
View::render('components.two-column-detail', ['left' => $left, 'right' => $right]);
|
$this->output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function fetchIfAvailable(string $projectRoot, bool $force = false): bool
|
public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
|
||||||
{
|
{
|
||||||
$repo = $this->detectGitHubRepo($projectRoot);
|
$repo = $this->detectGitHubRepo($projectRoot);
|
||||||
|
|
||||||
@ -65,7 +65,7 @@ final readonly class BaselineSync
|
|||||||
}
|
}
|
||||||
|
|
||||||
$failureKind = null;
|
$failureKind = null;
|
||||||
$payload = $this->download($repo, $projectRoot, $failureKind);
|
$payload = $this->download($repo, $projectRoot, $failureKind, $hasAnchor);
|
||||||
|
|
||||||
if ($payload === null) {
|
if ($payload === null) {
|
||||||
if ($failureKind === 'no-runs' || $failureKind === null) {
|
if ($failureKind === 'no-runs' || $failureKind === null) {
|
||||||
@ -151,8 +151,8 @@ final readonly class BaselineSync
|
|||||||
: $this->genericWorkflowYaml();
|
: $this->genericWorkflowYaml();
|
||||||
|
|
||||||
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
|
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
|
||||||
$this->renderDetail('To share the baseline with your team, add this workflow to the repo:');
|
$this->renderChild('To share the baseline with your team, add this workflow to the repo:');
|
||||||
$this->renderDetail('.github/workflows/tia-baseline.yml');
|
$this->renderChild('.github/workflows/tia-baseline.yml');
|
||||||
|
|
||||||
$indentedYaml = array_map(
|
$indentedYaml = array_map(
|
||||||
static fn (string $line): string => ' '.$line,
|
static fn (string $line): string => ' '.$line,
|
||||||
@ -161,8 +161,8 @@ final readonly class BaselineSync
|
|||||||
|
|
||||||
$this->output->writeln(['', ...$indentedYaml, '']);
|
$this->output->writeln(['', ...$indentedYaml, '']);
|
||||||
|
|
||||||
$this->renderDetail(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo));
|
$this->renderChild(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo));
|
||||||
$this->renderDetail('Details: https://pestphp.com/docs/tia/ci');
|
$this->renderChild('Details: https://pestphp.com/docs/tia/ci');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isCi(): bool
|
private function isCi(): bool
|
||||||
@ -285,7 +285,7 @@ YAML;
|
|||||||
*
|
*
|
||||||
* @return array{graph: string, coverage: ?string}|null
|
* @return array{graph: string, coverage: ?string}|null
|
||||||
*/
|
*/
|
||||||
private function download(string $repo, string $projectRoot, ?string &$failureKind = null): ?array
|
private function download(string $repo, string $projectRoot, ?string &$failureKind = null, bool $hasAnchor = false): ?array
|
||||||
{
|
{
|
||||||
$failureKind = null;
|
$failureKind = null;
|
||||||
|
|
||||||
@ -293,6 +293,7 @@ YAML;
|
|||||||
Panic::with(new BaselineFetchFailed(
|
Panic::with(new BaselineFetchFailed(
|
||||||
'GitHub CLI (gh) not found — cannot fetch baseline.',
|
'GitHub CLI (gh) not found — cannot fetch baseline.',
|
||||||
'Install it from https://cli.github.com.',
|
'Install it from https://cli.github.com.',
|
||||||
|
$hasAnchor,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -300,6 +301,7 @@ YAML;
|
|||||||
Panic::with(new BaselineFetchFailed(
|
Panic::with(new BaselineFetchFailed(
|
||||||
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
|
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
|
||||||
'Run `gh auth login` and retry.',
|
'Run `gh auth login` and retry.',
|
||||||
|
$hasAnchor,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,7 +313,8 @@ YAML;
|
|||||||
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
||||||
Panic::with(new BaselineFetchFailed(
|
Panic::with(new BaselineFetchFailed(
|
||||||
sprintf('Failed to query baseline runs — %s', $listError['message']),
|
sprintf('Failed to query baseline runs — %s', $listError['message']),
|
||||||
'Check the workflow file name (tia-baseline.yml), artifact name (pest-tia-baseline), and your gh token scope.',
|
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
|
||||||
|
$hasAnchor,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -388,7 +391,8 @@ YAML;
|
|||||||
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
||||||
Panic::with(new BaselineFetchFailed(
|
Panic::with(new BaselineFetchFailed(
|
||||||
sprintf('Baseline download failed — %s', $diagnosis['message']),
|
sprintf('Baseline download failed — %s', $diagnosis['message']),
|
||||||
'Check the workflow file name (tia-baseline.yml), artifact name (pest-tia-baseline), and your gh token scope.',
|
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
|
||||||
|
$hasAnchor,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -408,6 +412,7 @@ YAML;
|
|||||||
Panic::with(new BaselineFetchFailed(
|
Panic::with(new BaselineFetchFailed(
|
||||||
'Baseline downloaded but the artifact is missing expected files (graph.json).',
|
'Baseline downloaded but the artifact is missing expected files (graph.json).',
|
||||||
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
|
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
|
||||||
|
$hasAnchor,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -316,6 +316,23 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A changed file inside the configured test suites is itself the unit
|
||||||
|
// of work — always run it (new untracked tests, edited tests, renames).
|
||||||
|
$testPaths = TestPaths::fromProjectRoot($this->projectRoot);
|
||||||
|
|
||||||
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
|
if (isset($affectedSet[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! $testPaths->isTestFile($rel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_file($this->projectRoot.'/'.$rel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$affectedSet[$rel] = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
|
// Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
|
||||||
$staticallyHandledBlade = [];
|
$staticallyHandledBlade = [];
|
||||||
foreach ($nonMigrationPaths as $rel) {
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
|
|||||||
170
src/Plugins/Tia/TestPaths.php
Normal file
170
src/Plugins/Tia/TestPaths.php
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Pest\TestSuite;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the set of project-relative paths that are considered test files,
|
||||||
|
* driven by phpunit.xml's <testsuites>. Falls back to the runtime TestSuite
|
||||||
|
* configuration when no config file is present.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final readonly class TestPaths
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $directories Project-relative directory prefixes (no trailing slash).
|
||||||
|
* @param list<string> $files Project-relative file paths.
|
||||||
|
* @param list<string> $suffixes Filename suffixes (e.g. '.php').
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private array $directories,
|
||||||
|
private array $files,
|
||||||
|
private array $suffixes,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromProjectRoot(string $projectRoot): self
|
||||||
|
{
|
||||||
|
$configPath = self::configPath($projectRoot);
|
||||||
|
|
||||||
|
$directories = [];
|
||||||
|
$files = [];
|
||||||
|
$suffixes = ['.php'];
|
||||||
|
|
||||||
|
if ($configPath !== null) {
|
||||||
|
$xml = @simplexml_load_file($configPath);
|
||||||
|
|
||||||
|
if ($xml !== false) {
|
||||||
|
$configDir = dirname($configPath);
|
||||||
|
|
||||||
|
foreach ($xml->xpath('testsuites/testsuite/directory') ?: [] as $node) {
|
||||||
|
$rel = self::toRelative((string) $node, $configDir, $projectRoot);
|
||||||
|
|
||||||
|
if ($rel !== null) {
|
||||||
|
$directories[] = $rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
$suffix = (string) ($node['suffix'] ?? '');
|
||||||
|
if ($suffix !== '' && ! in_array($suffix, $suffixes, true)) {
|
||||||
|
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($xml->xpath('testsuites/testsuite/file') ?: [] as $node) {
|
||||||
|
$rel = self::toRelative((string) $node, $configDir, $projectRoot);
|
||||||
|
|
||||||
|
if ($rel !== null) {
|
||||||
|
$files[] = $rel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($directories === [] && $files === []) {
|
||||||
|
$fallback = self::testSuiteFallback($projectRoot);
|
||||||
|
|
||||||
|
if ($fallback !== null) {
|
||||||
|
$directories[] = $fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
array_values(array_unique($directories)),
|
||||||
|
array_values(array_unique($files)),
|
||||||
|
array_values(array_unique($suffixes)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTestFile(string $relativePath): bool
|
||||||
|
{
|
||||||
|
if (in_array($relativePath, $this->files, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matchesSuffix = false;
|
||||||
|
foreach ($this->suffixes as $suffix) {
|
||||||
|
if (str_ends_with($relativePath, $suffix)) {
|
||||||
|
$matchesSuffix = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $matchesSuffix) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->directories as $dir) {
|
||||||
|
if ($dir === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_starts_with($relativePath, $dir.'/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function configPath(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) {
|
||||||
|
$candidate = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
||||||
|
|
||||||
|
if (is_file($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function toRelative(string $value, string $configDir, string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isAbsolute = $value[0] === '/' || $value[0] === DIRECTORY_SEPARATOR
|
||||||
|
|| (strlen($value) >= 2 && $value[1] === ':');
|
||||||
|
|
||||||
|
$combined = $isAbsolute ? $value : $configDir.DIRECTORY_SEPARATOR.$value;
|
||||||
|
|
||||||
|
$real = @realpath($combined);
|
||||||
|
$resolved = $real === false ? $combined : $real;
|
||||||
|
|
||||||
|
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
|
||||||
|
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
|
||||||
|
|
||||||
|
if (! str_starts_with($resolved.'/', $root)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(substr($resolved, strlen($root)), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function testSuiteFallback(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$testPath = TestSuite::getInstance()->testPath;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$real = @realpath($testPath);
|
||||||
|
$resolved = $real === false ? $testPath : $real;
|
||||||
|
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
|
||||||
|
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
|
||||||
|
|
||||||
|
if (! str_starts_with($resolved.'/', $root)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(substr($resolved, strlen($root)), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user