This commit is contained in:
nuno maduro
2026-05-01 23:59:25 +01:00
parent 21efbc3107
commit d0295f6168
5 changed files with 279 additions and 56 deletions

View File

@ -42,12 +42,12 @@ final readonly class BaselineSync
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);
@ -65,7 +65,7 @@ final readonly class BaselineSync
}
$failureKind = null;
$payload = $this->download($repo, $projectRoot, $failureKind);
$payload = $this->download($repo, $projectRoot, $failureKind, $hasAnchor);
if ($payload === null) {
if ($failureKind === 'no-runs' || $failureKind === null) {
@ -151,8 +151,8 @@ final readonly class BaselineSync
: $this->genericWorkflowYaml();
$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->renderDetail('.github/workflows/tia-baseline.yml');
$this->renderChild('To share the baseline with your team, add this workflow to the repo:');
$this->renderChild('.github/workflows/tia-baseline.yml');
$indentedYaml = array_map(
static fn (string $line): string => ' '.$line,
@ -161,8 +161,8 @@ final readonly class BaselineSync
$this->output->writeln(['', ...$indentedYaml, '']);
$this->renderDetail(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(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo));
$this->renderChild('Details: https://pestphp.com/docs/tia/ci');
}
private function isCi(): bool
@ -285,7 +285,7 @@ YAML;
*
* @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;
@ -293,6 +293,7 @@ YAML;
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) not found — cannot fetch baseline.',
'Install it from https://cli.github.com.',
$hasAnchor,
));
}
@ -300,6 +301,7 @@ YAML;
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
'Run `gh auth login` and retry.',
$hasAnchor,
));
}
@ -311,7 +313,8 @@ YAML;
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
Panic::with(new BaselineFetchFailed(
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)) {
Panic::with(new BaselineFetchFailed(
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(
'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.',
$hasAnchor,
));
}

View File

@ -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
$staticallyHandledBlade = [];
foreach ($nonMigrationPaths as $rel) {

View 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)), '/');
}
}