> */ public const array EDGES = [ 'tests/Unit/CalculatorTest.php' => ['app/Calculator.php'], 'tests/Unit/GreeterTest.php' => ['app/Greeter.php'], 'tests/Feature/CoversCalculatorTest.php' => ['app/Calculator.php'], ]; /** * @var array> */ public const array TESTS = [ 'tests/Unit/CalculatorTest.php' => ['adds two numbers', 'subtracts two numbers'], 'tests/Unit/GreeterTest.php' => ['greets a person', 'greets the world'], 'tests/Feature/CoversCalculatorTest.php' => ['adds within a feature test', 'subtracts within a feature test'], ]; public const int TOTAL_TESTS = 6; /** * @var array */ private static array $created = []; private ?GitRepo $repo = null; /** * @var array|null */ private ?array $snapshot = null; /** * @var array */ private array $extraPaths = []; private string $graphRoot; private function __construct(public readonly string $path) { $this->graphRoot = $path; } public static function make(string $branch = 'master', ?string $overlay = null): self { $project = self::scaffold($overlay); $project->git()->init($branch); $project->git()->addOrigin(); $project->git()->setOriginHead($branch); return $project; } public static function destroyAll(): void { while (self::$created !== []) { array_pop(self::$created)->destroy(); } } public static function withoutGit(?string $overlay = null): self { return self::scaffold($overlay); } public function git(): GitRepo { return $this->repo ??= new GitRepo($this->path); } public function path(string $relative = ''): string { return $relative === '' ? $this->path : $this->path.DIRECTORY_SEPARATOR.$relative; } public function write(string $relative, string $contents): void { $path = $this->path($relative); $directory = dirname($path); if (! is_dir($directory) && ! @mkdir($directory, 0755, true) && ! is_dir($directory)) { throw new RuntimeException(sprintf('Unable to create [%s].', $directory)); } if (file_put_contents($path, $contents) === false) { throw new RuntimeException(sprintf('Unable to write [%s].', $path)); } } public function worktree(string $branch): string { $path = $this->path.'-worktree-'.preg_replace('/[^a-z0-9]+/i', '-', $branch); $this->git()->worktree($path, $branch); $this->scaffoldVendor($path); $this->extraPaths[] = $path; return $path; } public function pest(string ...$arguments): PestResult { return $this->pestIn($this->path, ...$arguments); } public function pestIn(string $directory, string ...$arguments): PestResult { return $this->pestWithEnvironment($directory, [], ...$arguments); } /** * @param array $environment */ public function pestWithEnvironment(string $directory, array $environment, string ...$arguments): PestResult { $process = new Process( [PHP_BINARY, $directory.'/vendor/pestphp/pest/bin/pest', ...$arguments], $directory, [ ...GitRepo::ENV, 'COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true', 'PARATEST' => '0', 'PAO_DISABLE' => '1', 'HOME' => $this->home(), 'GITHUB_EVENT_PATH' => '', 'CI_DEFAULT_BRANCH' => '', ...$environment, ], ); $process->setTimeout(180.0); $process->run(); return new PestResult( array_values($arguments), $process->getOutput().$process->getErrorOutput(), (int) $process->getExitCode(), ); } /** * @param array $failing */ public function seed(string $branch, bool $sentinel = true, array $failing = []): void { $this->seedFor($this->path, $branch, $sentinel, $failing); } /** * @param array $failing */ public function seedFor(string $root, string $branch, bool $sentinel = true, array $failing = []): void { $this->graphRoot = $root; $changedFiles = new ChangedFiles($root); $sha = new GitRepo($root)->sha(); $graph = new Graph($root); $graph->setFingerprint(Fingerprint::compute($root)); $graph->setRecordedAtSha($branch, $sha); $graph->setLastRunTree($branch, $changedFiles->snapshotTree($changedFiles->since($sha) ?? [])); $graph->markKnownTestFiles(array_keys(self::EDGES)); foreach (self::EDGES as $testFile => $sourceFiles) { $graph->link($testFile, $testFile); foreach ($sourceFiles as $sourceFile) { $graph->link($testFile, $sourceFile); } } foreach (self::TESTS as $testFile => $descriptions) { foreach ($descriptions as $description) { $failed = in_array($description, $failing, true); $graph->setResult( $branch, self::testId($testFile, $description), $failed ? 7 : 0, $failed ? 'cached failure' : '', 0.05, 1, $testFile, ); } } $json = $graph->encode(); if ($json === null) { throw new RuntimeException('Unable to encode the seeded graph.'); } if (! $this->state()->write(Tia::KEY_GRAPH, $json)) { throw new RuntimeException('Unable to persist the seeded graph.'); } $sentinel ? $this->sentinel() : $this->snapshot(); } public static function testId(string $testFile, string $description): string { $basename = basename($testFile, '.php'); $dotPosition = strpos($basename, '.'); if ($dotPosition !== false) { $basename = substr($basename, 0, $dotPosition); } $relative = dirname(ucfirst($testFile)).DIRECTORY_SEPARATOR.$basename; return 'P\\'.str_replace(DIRECTORY_SEPARATOR, '\\', $relative).'::'.Str::evaluable($description); } public function sentinel(): void { $this->mutateGraph(function (array $graph): array { foreach ($graph['baselines'] ?? [] as $branch => $baseline) { foreach (array_keys($baseline['results'] ?? []) as $testId) { $graph['baselines'][$branch]['results'][$testId]['time'] = 9.999; if ((int) ($baseline['results'][$testId]['assertions'] ?? 0) > 0) { $graph['baselines'][$branch]['results'][$testId]['assertions'] = 42; } } } return $graph; }); } /** * Adds a second, empty baseline key, so a lone recorded baseline can no * longer stand in for the default branch. */ public function addBaseline(string $branch): void { $this->mutateGraph(function (array $graph) use ($branch): array { $graph['baselines'][$branch] = ['sha' => null, 'tree' => [], 'results' => []]; return $graph; }); } /** * @param callable(array): array $callback */ public function mutateGraph(callable $callback): void { $graph = $this->graph(); if ($graph === null) { throw new RuntimeException('There is no graph to mutate.'); } $this->state()->write(Tia::KEY_GRAPH, (string) json_encode($callback($graph), JSON_UNESCAPED_SLASHES)); $this->snapshot(); } /** * @return array|null */ public function graph(): ?array { $json = $this->state()->read(Tia::KEY_GRAPH); if ($json === null) { return null; } $graph = json_decode($json, true); return is_array($graph) ? $graph : null; } /** * @return array */ public function branchKeys(): array { $baselines = $this->graph()['baselines'] ?? []; return is_array($baselines) ? array_keys($baselines) : []; } public function graphDir(): string { return $this->withHome(fn (): string => Storage::tempDir($this->graphRoot)); } public function graphExists(): bool { return is_file($this->graphDir().DIRECTORY_SEPARATOR.Tia::KEY_GRAPH); } public function snapshot(): void { $this->snapshot = $this->graph(); } public function delta(): GraphDelta { return new GraphDelta($this->snapshot, $this->graph()); } public function scaffoldVendor(string $directory): void { $pestRoot = dirname(__DIR__, 3); $pest = $directory.'/vendor/pestphp/pest'; foreach (['src', 'overrides', 'resources', 'stubs'] as $tree) { self::mirror($pestRoot.'/'.$tree, $pest.'/'.$tree); } foreach (['pest', 'worker.php'] as $binary) { self::mirror($pestRoot.'/bin/'.$binary, $pest.'/bin/'.$binary); } self::mirror($pestRoot.'/composer.json', $pest.'/composer.json'); file_put_contents($directory.'/vendor/autoload.php', sprintf( "addPsr4('Pest\\\\', __DIR__.'/pestphp/pest/src', true);\n\nreturn \$loader;\n", var_export($pestRoot.'/vendor/autoload.php', true), )); if (! is_dir($directory.'/vendor/bin') && ! @mkdir($directory.'/vendor/bin', 0755, true)) { throw new RuntimeException(sprintf('Unable to create [%s].', $directory.'/vendor/bin')); } self::mirror($pestRoot.'/vendor/pest-plugins.json', $directory.'/vendor/pest-plugins.json'); } public function destroy(): void { foreach ([...$this->extraPaths, $this->path] as $path) { $this->remove($path); } } private function home(): string { return $this->path.DIRECTORY_SEPARATOR.'.home'; } private function state(): FileState { return new FileState($this->graphDir()); } /** * @template TReturn * * @param callable(): TReturn $callback * @return TReturn */ private function withHome(callable $callback): mixed { $original = getenv('HOME'); putenv('HOME='.$this->home()); try { return $callback(); } finally { putenv($original === false ? 'HOME' : 'HOME='.$original); } } private static function scaffold(?string $overlay): self { $path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(8)); if (! @mkdir($path, 0755, true) && ! is_dir($path)) { throw new RuntimeException(sprintf('Unable to create [%s].', $path)); } $real = realpath($path); $project = new self($real === false ? $path : $real); self::$created[] = $project; self::copy(__DIR__.'/app', $project->path); if ($overlay !== null) { self::copy(__DIR__.'/overlays/'.$overlay, $project->path); } $project->write('.gitignore', implode("\n", ['/vendor/', '/.home/', '/.phpunit.cache/', ''])); $project->scaffoldVendor($project->path); @mkdir($project->home(), 0755, true); return $project; } private static function mirror(string $from, string $to): void { if (is_dir($from)) { foreach (self::contentsOf($from) as $path) { self::mirror($path->getPathname(), $to.DIRECTORY_SEPARATOR.substr($path->getPathname(), strlen($from) + 1)); } return; } $directory = dirname($to); if (! is_dir($directory) && ! @mkdir($directory, 0755, true) && ! is_dir($directory)) { throw new RuntimeException(sprintf('Unable to create [%s].', $directory)); } if (@link($from, $to) || @copy($from, $to)) { return; } throw new RuntimeException(sprintf('Unable to mirror [%s] into [%s].', $from, $to)); } /** * @return iterable<\SplFileInfo> */ private static function contentsOf(string $directory): iterable { $paths = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST, ); foreach ($paths as $path) { if (! $path->isDir()) { yield $path; } } } private static function copy(string $from, string $to): void { $paths = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($from, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST, ); foreach ($paths as $path) { $target = $to.DIRECTORY_SEPARATOR.substr($path->getPathname(), strlen($from) + 1); if ($path->isDir()) { if (! is_dir($target) && ! @mkdir($target, 0755, true) && ! is_dir($target)) { throw new RuntimeException(sprintf('Unable to create [%s].', $target)); } continue; } if (! is_dir(dirname($target)) && ! @mkdir(dirname($target), 0755, true) && ! is_dir(dirname($target))) { throw new RuntimeException(sprintf('Unable to create [%s].', dirname($target))); } if (! @copy($path->getPathname(), $target)) { throw new RuntimeException(sprintf('Unable to copy [%s].', $path->getPathname())); } } } private function remove(string $path): void { if (! is_dir($path)) { return; } $paths = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST, ); foreach ($paths as $entry) { $entry->isDir() && ! $entry->isLink() ? @rmdir($entry->getPathname()) : @unlink($entry->getPathname()); } @rmdir($path); } }