mirror of
https://github.com/pestphp/pest.git
synced 2026-06-07 20:02:13 +02:00
wip
This commit is contained in:
@ -9,6 +9,7 @@ use Pest\Exceptions\BaselineFetchFailed;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Support\View;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
@ -46,6 +47,16 @@ final readonly class BaselineSync
|
||||
private OutputInterface $output,
|
||||
) {}
|
||||
|
||||
private function renderBadge(string $type, string $content): void
|
||||
{
|
||||
View::render('components.badge', ['type' => $type, 'content' => $content]);
|
||||
}
|
||||
|
||||
private function renderDetail(string $left, string $right = ''): void
|
||||
{
|
||||
View::render('components.two-column-detail', ['left' => $left, 'right' => $right]);
|
||||
}
|
||||
|
||||
public function fetchIfAvailable(string $projectRoot, bool $force = false): bool
|
||||
{
|
||||
$repo = $this->detectGitHubRepo($projectRoot);
|
||||
@ -55,9 +66,8 @@ final readonly class BaselineSync
|
||||
}
|
||||
|
||||
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. '
|
||||
.'Override with <fg=cyan>--refetch</>.',
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'TIA last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
|
||||
$this->formatDuration($remaining),
|
||||
));
|
||||
|
||||
@ -91,8 +101,8 @@ final readonly class BaselineSync
|
||||
|
||||
$this->clearCooldown();
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> baseline ready (%s).',
|
||||
$this->renderBadge('SUCCESS', sprintf(
|
||||
'TIA baseline ready (%s).',
|
||||
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
|
||||
));
|
||||
|
||||
@ -146,9 +156,7 @@ final readonly class BaselineSync
|
||||
private function emitPublishInstructions(string $repo): void
|
||||
{
|
||||
if ($this->isCi()) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> no baseline yet — this run will produce one.',
|
||||
);
|
||||
$this->renderBadge('INFO', 'TIA no baseline yet — this run will produce one.');
|
||||
|
||||
return;
|
||||
}
|
||||
@ -157,28 +165,21 @@ final readonly class BaselineSync
|
||||
? $this->laravelWorkflowYaml()
|
||||
: $this->genericWorkflowYaml();
|
||||
|
||||
$preamble = [
|
||||
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
|
||||
'',
|
||||
' To share the baseline with your team, add this workflow to the repo:',
|
||||
'',
|
||||
' <fg=cyan>.github/workflows/tia-baseline.yml</>',
|
||||
'',
|
||||
];
|
||||
$this->renderBadge('WARN', 'TIA 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');
|
||||
|
||||
// YAML stays as a raw indented block — Termwind would mangle the
|
||||
// verbatim whitespace.
|
||||
$indentedYaml = array_map(
|
||||
static fn (string $line): string => ' '.$line,
|
||||
explode("\n", $yaml),
|
||||
);
|
||||
|
||||
$trailer = [
|
||||
'',
|
||||
sprintf(' Commit, push, then run once: <fg=cyan>gh workflow run tia-baseline.yml -R %s</>', $repo),
|
||||
' Details: <fg=gray>https://pestphp.com/docs/tia/ci</>',
|
||||
'',
|
||||
];
|
||||
$this->output->writeln(['', ...$indentedYaml, '']);
|
||||
|
||||
$this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]);
|
||||
$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');
|
||||
}
|
||||
|
||||
// `CI=true` alone is ambiguous (users set it locally) — require a provider-specific env var.
|
||||
@ -337,8 +338,8 @@ YAML;
|
||||
|
||||
// Tier 2 — transient (network, rate-limit, unknown). Surface
|
||||
// the diagnostic but let the suite fall through to record mode.
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> failed to query baseline runs — %s',
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'TIA failed to query baseline runs — %s',
|
||||
$listError['message'],
|
||||
));
|
||||
|
||||
@ -362,8 +363,8 @@ YAML;
|
||||
// id as recently used and doesn't evict it later.
|
||||
@touch($runCacheDir);
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=cyan>TIA</> using cached baseline from <fg=white>%s</> (run %s).',
|
||||
$this->renderBadge('INFO', sprintf(
|
||||
'TIA using cached baseline from %s (run %s).',
|
||||
$repo,
|
||||
$runId,
|
||||
));
|
||||
@ -377,14 +378,14 @@ YAML;
|
||||
|
||||
$artifactSize = $this->artifactSize($repo, $runId);
|
||||
|
||||
$this->output->writeln($artifactSize !== null
|
||||
$this->renderBadge('INFO', $artifactSize !== null
|
||||
? sprintf(
|
||||
' <fg=cyan>TIA</> fetching baseline (%s) from <fg=white>%s</>…',
|
||||
'TIA fetching baseline (%s) from %s…',
|
||||
$this->formatSize($artifactSize),
|
||||
$repo,
|
||||
)
|
||||
: sprintf(
|
||||
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
|
||||
'TIA fetching baseline from %s…',
|
||||
$repo,
|
||||
));
|
||||
|
||||
@ -422,8 +423,8 @@ YAML;
|
||||
}
|
||||
|
||||
// Tier 2 — transient. Diagnostic + fall through to record mode.
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> baseline download failed — %s',
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'TIA baseline download failed — %s',
|
||||
$diagnosis['message'],
|
||||
));
|
||||
|
||||
@ -599,10 +600,12 @@ YAML;
|
||||
$candidates = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $root.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (! is_dir($path)) {
|
||||
|
||||
@ -18,7 +18,7 @@ final readonly class ChangedFiles
|
||||
* @param array<string, string> $lastRunTree path → content hash from last run.
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree, ?string $sha = null): array
|
||||
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
|
||||
{
|
||||
if ($lastRunTree === []) {
|
||||
return $files;
|
||||
|
||||
28
src/Plugins/Tia/Collectors.php
Normal file
28
src/Plugins/Tia/Collectors.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Edges\BladeEdges;
|
||||
use Pest\Plugins\Tia\Edges\InertiaEdges;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Collectors
|
||||
{
|
||||
/** @var list<class-string> */
|
||||
private const array COLLECTORS = [
|
||||
BladeEdges::class,
|
||||
TableTracker::class,
|
||||
InertiaEdges::class,
|
||||
];
|
||||
|
||||
public static function armAll(Recorder $recorder): void
|
||||
{
|
||||
foreach (self::COLLECTORS as $collector) {
|
||||
$collector::arm($recorder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
namespace Pest\Plugins\Tia\Edges;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -17,7 +17,7 @@ final readonly class AutoloadEdges
|
||||
$files = [];
|
||||
|
||||
foreach (get_included_files() as $file) {
|
||||
if (is_string($file) && $file !== '') {
|
||||
if ($file !== '') {
|
||||
$files[$file] = true;
|
||||
}
|
||||
}
|
||||
@ -80,7 +80,7 @@ final readonly class AutoloadEdges
|
||||
];
|
||||
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (str_starts_with($relative, $prefix)) {
|
||||
if (str_starts_with($relative, (string) $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
namespace Pest\Plugins\Tia\Edges;
|
||||
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
namespace Pest\Plugins\Tia\Edges;
|
||||
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -4,11 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Factories\TestCaseFactory;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\View;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -230,13 +231,13 @@ final class Graph
|
||||
if ($freshMap === null) {
|
||||
// Vite resolver unavailable — falling back to watch pattern; surface a line so the user
|
||||
// knows precision was downgraded rather than leaving the slower replay unexplained.
|
||||
$output = Container::getInstance()->get(OutputInterface::class);
|
||||
if ($output instanceof OutputInterface) {
|
||||
$output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
|
||||
View::render('components.badge', [
|
||||
'type' => 'WARN',
|
||||
'content' => sprintf(
|
||||
'TIA Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
|
||||
count($newJsFiles),
|
||||
));
|
||||
}
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
foreach ($newJsFiles as $rel) {
|
||||
$pages = $freshMap[$rel] ?? [];
|
||||
@ -538,8 +539,10 @@ final class Graph
|
||||
}
|
||||
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if (! is_string($file) || $file === '') {
|
||||
if (! is_string($file)) {
|
||||
continue;
|
||||
}
|
||||
if ($file === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -767,7 +770,7 @@ final class Graph
|
||||
];
|
||||
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (str_starts_with($rel, $prefix)) {
|
||||
if (str_starts_with($rel, (string) $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -805,7 +808,7 @@ final class Graph
|
||||
foreach ($repo->getFilenames() as $filename) {
|
||||
$factory = $repo->get($filename);
|
||||
|
||||
if ($factory === null) {
|
||||
if (! $factory instanceof TestCaseFactory) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -850,7 +853,10 @@ final class Graph
|
||||
if (! is_object($attribute)) {
|
||||
continue;
|
||||
}
|
||||
if (! property_exists($attribute, 'name') || $attribute->name !== Group::class) {
|
||||
if (! property_exists($attribute, 'name')) {
|
||||
continue;
|
||||
}
|
||||
if ($attribute->name !== Group::class) {
|
||||
continue;
|
||||
}
|
||||
if (! property_exists($attribute, 'arguments')) {
|
||||
@ -989,10 +995,12 @@ final class Graph
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file instanceof \SplFileInfo || ! $file->isFile()) {
|
||||
if (! $file instanceof \SplFileInfo) {
|
||||
continue;
|
||||
}
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->getPathname();
|
||||
if (! str_ends_with($path, '.blade.php')) {
|
||||
continue;
|
||||
|
||||
@ -42,7 +42,7 @@ final class JsModuleGraph
|
||||
*/
|
||||
public static function warmInBackground(string $projectRoot): void
|
||||
{
|
||||
if (self::$warmer !== null || self::$warmerCacheHit) {
|
||||
if (self::$warmer instanceof Process || self::$warmerCacheHit) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -62,7 +62,7 @@ final class JsModuleGraph
|
||||
|
||||
$process = self::buildNodeProcess($projectRoot);
|
||||
|
||||
if ($process === null) {
|
||||
if (! $process instanceof Process) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -164,7 +164,7 @@ final class JsModuleGraph
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$warmer !== null
|
||||
if (self::$warmer instanceof Process
|
||||
&& self::$warmerFingerprint === $fingerprint
|
||||
&& self::$warmerProjectRoot === $projectRoot) {
|
||||
$process = self::$warmer;
|
||||
@ -179,7 +179,7 @@ final class JsModuleGraph
|
||||
$process = null;
|
||||
}
|
||||
|
||||
if ($process !== null && $process->isSuccessful()) {
|
||||
if ($process instanceof Process && $process->isSuccessful()) {
|
||||
$result = self::parseNodeOutput($process->getOutput());
|
||||
|
||||
if ($result !== null) {
|
||||
@ -212,7 +212,7 @@ final class JsModuleGraph
|
||||
{
|
||||
$process = self::buildNodeProcess($projectRoot);
|
||||
|
||||
if ($process === null) {
|
||||
if (! $process instanceof Process) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -319,7 +319,7 @@ final class JsModuleGraph
|
||||
self::$warmerProjectRoot = null;
|
||||
self::$warmerCacheHit = false;
|
||||
|
||||
if ($process === null) {
|
||||
if (! $process instanceof Process) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -446,10 +446,12 @@ final class JsModuleGraph
|
||||
$out = [];
|
||||
|
||||
foreach ($graph as $key => $value) {
|
||||
if (! is_string($key) || ! is_array($value)) {
|
||||
if (! is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($value as $name) {
|
||||
@ -465,7 +467,7 @@ final class JsModuleGraph
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<string>> $graph
|
||||
* @param array<string, list<string>> $graph
|
||||
*/
|
||||
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
|
||||
{
|
||||
|
||||
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Edges\AutoloadEdges;
|
||||
use Pest\TestSuite;
|
||||
use ReflectionClass;
|
||||
|
||||
@ -169,7 +170,7 @@ final class Recorder
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
|
||||
|
||||
$coveredFiles = self::filesWithExecutedLines($data);
|
||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||
} else {
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \xdebug_get_code_coverage();
|
||||
@ -484,10 +485,15 @@ final class Recorder
|
||||
private function findAutoloadFile(string $className): ?string
|
||||
{
|
||||
foreach (spl_autoload_functions() as $loader) {
|
||||
if (! is_array($loader) || ! isset($loader[0]) || ! is_object($loader[0])) {
|
||||
if (! is_array($loader)) {
|
||||
continue;
|
||||
}
|
||||
if (! isset($loader[0])) {
|
||||
continue;
|
||||
}
|
||||
if (! is_object($loader[0])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! method_exists($loader[0], 'findFile')) {
|
||||
continue;
|
||||
}
|
||||
@ -678,15 +684,17 @@ final class Recorder
|
||||
* @param array<string, mixed> $data
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function filesWithExecutedLines(array $data): array
|
||||
private function filesWithExecutedLines(array $data): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($data as $file => $lines) {
|
||||
if (! is_string($file) || ! is_array($lines)) {
|
||||
if (! is_string($file)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($lines)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$covered = [];
|
||||
foreach ($lines as $line => $count) {
|
||||
if (is_int($count) && $count > 0) {
|
||||
|
||||
28
src/Plugins/Tia/Replay.php
Normal file
28
src/Plugins/Tia/Replay.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
enum Replay
|
||||
{
|
||||
case Pass;
|
||||
case Skipped;
|
||||
case Incomplete;
|
||||
case Failure;
|
||||
|
||||
public static function from(TestStatus $cached): self
|
||||
{
|
||||
return match (true) {
|
||||
$cached->isSuccess(), $cached->isRisky() => self::Pass,
|
||||
$cached->isSkipped() => self::Skipped,
|
||||
$cached->isIncomplete() => self::Incomplete,
|
||||
default => self::Failure,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@ namespace Pest\Plugins\Tia;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class SourceScope
|
||||
final readonly class SourceScope
|
||||
{
|
||||
/**
|
||||
* Top-level directory names always treated as out-of-scope. These
|
||||
@ -44,9 +44,8 @@ final class SourceScope
|
||||
* @param list<string> $excludes Absolute, normalised directory paths.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $projectRoot,
|
||||
private readonly array $includes,
|
||||
private readonly array $excludes,
|
||||
private array $includes,
|
||||
private array $excludes,
|
||||
) {}
|
||||
|
||||
public static function fromProjectRoot(string $projectRoot): self
|
||||
@ -94,13 +93,13 @@ final class SourceScope
|
||||
$candidate = self::normalise($candidate);
|
||||
|
||||
foreach ($this->excludes as $excluded) {
|
||||
if (self::startsWithDir($candidate, $excluded)) {
|
||||
if ($this->startsWithDir($candidate, $excluded)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->includes as $included) {
|
||||
if (self::startsWithDir($candidate, $included)) {
|
||||
if ($this->startsWithDir($candidate, $included)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -184,10 +183,12 @@ final class SourceScope
|
||||
$out = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($entry, self::TOP_LEVEL_NOISE, true)) {
|
||||
continue;
|
||||
}
|
||||
@ -223,7 +224,7 @@ final class SourceScope
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function resolveRelative(string $path, string $configDir): ?string
|
||||
private static function resolveRelative(string $path, string $configDir): string
|
||||
{
|
||||
$isAbsolute = $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || $path[0] === '/'
|
||||
|| (strlen($path) >= 2 && $path[1] === ':'));
|
||||
@ -246,7 +247,7 @@ final class SourceScope
|
||||
return rtrim($path, '/\\');
|
||||
}
|
||||
|
||||
private static function startsWithDir(string $candidate, string $dir): bool
|
||||
private function startsWithDir(string $candidate, string $dir): bool
|
||||
{
|
||||
if ($candidate === $dir) {
|
||||
return true;
|
||||
|
||||
@ -57,10 +57,12 @@ final class Storage
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (is_dir($path) && ! is_link($path)) {
|
||||
|
||||
Reference in New Issue
Block a user