Compare commits

..

37 Commits

Author SHA1 Message Date
nuno maduro 7b6415ccd0 fix 2026-08-07 03:15:22 +01:00
nuno maduro a5d726b678 release: 5.0.4 2026-08-07 03:00:25 +01:00
nuno maduro 93223caa56 chore: fixes ci 2026-08-07 03:00:19 +01:00
nuno maduro d0572ce138 wip 2026-08-07 02:26:45 +01:00
nuno maduro b0d1fc589d refactor 2026-08-07 02:12:24 +01:00
nuno maduro 5a4f124199 wip 2026-08-07 02:06:28 +01:00
nuno maduro c3ceb7d0c7 qwd 2026-08-07 02:00:58 +01:00
nuno maduro 16d1208344 wip 2026-08-07 01:58:41 +01:00
nuno maduro 1cebc84c2d wip 2026-08-07 01:55:51 +01:00
nuno maduro 1f79660add wip 2026-08-07 01:55:34 +01:00
nuno maduro 37bdf60a1c wip 2026-08-07 01:41:32 +01:00
nuno maduro cc7acfe485 wdq 2026-08-07 01:11:53 +01:00
nuno maduro 7bfb2a6185 chore: upgrade snapshots 2026-08-06 22:24:26 +01:00
nuno maduro ca56ca5216 dw 2026-08-06 22:19:47 +01:00
nuno maduro b49ba062d5 wq 2026-08-06 22:05:15 +01:00
nuno maduro 09699847a2 wqd 2026-08-06 20:53:09 +01:00
nuno maduro 545d0c2784 wip 2026-08-06 20:48:22 +01:00
nuno maduro b795af3b1b wip 2026-08-06 17:52:04 +01:00
nuno maduro a40cc6bc0e more tests 2026-08-06 16:35:03 +01:00
nuno maduro 4d3d0105b7 chore: style 2026-08-06 16:02:36 +01:00
nuno maduro e90e4f70fc wip 2026-08-05 20:20:35 +01:00
nuno maduro d112582857 wip 2026-08-05 19:58:57 +01:00
nuno maduro 9f3c4e1e82 fix 2026-08-05 18:51:49 +01:00
nuno maduro 71f39366c2 wip 2026-08-05 18:21:34 +01:00
nuno maduro bfd5b75677 fix 2026-08-05 16:49:35 +01:00
nuno maduro 411b9954b5 wip 2026-08-05 16:18:03 +01:00
nuno maduro db70017cb2 wip 2026-08-05 03:30:50 +01:00
nuno maduro 86adaedbbd fix: tia filtered 2026-08-04 23:30:03 +01:00
nuno maduro 92c7677c6e fix: don't allow invalid class names 2026-08-04 20:46:21 +01:00
nuno maduro 872f0a50c2 fix(tia): replayable scenarios running afterEach 2026-08-04 20:19:12 +01:00
nuno maduro 668809bc75 chore: adds missing test 2026-08-04 19:28:58 +01:00
nuno maduro 8d8f45c843 chore: bump deps 2026-08-04 19:28:52 +01:00
nuno maduro 086b3e9107 chore: ai instructions 2026-08-04 19:01:16 +01:00
nuno maduro 43fe26f324 chore: bumps deps 2026-08-04 19:01:08 +01:00
nuno maduro 865c1e5113 ci: snapshots 2026-08-04 18:51:04 +01:00
stephanbouman 19eed8d581 Fixed typos (#1819)
* Fixed typo in message

* Fixed typo in message
2026-08-03 11:29:03 -04:00
Danny de Wit ad1850b110 fix: keep TableExtractor table names as strings (#1794)
PHP coerces numeric-string array keys to ints, so collecting table names in
$tables[$name] and returning array_keys() can yield ints. That breaks the
declared list<string> and throws a TypeError in Recorder::linkTable(string).

Standard SQL like substring(x FROM 1 FOR 10) is enough to trigger it, since
the numeric operand matches the FROM pattern.

Closes #1793

Co-authored-by: Danny de Wit <d.dewit@fenceweb.com>
2026-08-02 22:50:47 -04:00
119 changed files with 5893 additions and 234 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
.idea/*
.idea/codeStyleSettings.xml
.temp/*
composer.lock
/composer.lock
/vendor/
coverage.xml
.phpunit.result.cache
+34
View File
@@ -0,0 +1,34 @@
# CLAUDE.md
**Do not edit this file.** Agents must never add, remove, or reword anything here. If a change seems needed, say so and let a human do it.
## Ask before testing
When asked to review code or build a feature, do not run the test suite and do not write new tests. Make the change, report it, then ask the user whether tests should be added — describing the tests you have in mind — and wait for the user to confirm.
Two reasons this matters here: the suite takes minutes, and `tests/.snapshots/success.txt` plus the tally in `tests/Visual/Parallel.php` encode the whole suite's result, so a single added test breaks both.
Once the user confirms:
```bash
composer test:unit # fast, excludes the visual group
composer test:integration # visual and snapshot tests
composer test # everything CI runs, in CI's order
composer update:snapshots # only when a test was added or removed
```
## TIA scenario tests
`tests/Features/Tia/*` scaffold a throwaway git project, run a real `pest` subprocess against it, and diff the TIA graph it wrote. They exist because TIA's contract is about what a run *writes* — replay, branch keys, and the COMPLETE / RESULTS-ONLY / HARD-SUPPRESSED tiers are invisible to ordinary assertions, and every case used to be measured by hand against a playground app.
Add one whenever a change touches branch resolution, replay, filtered mode, or the write tiers. How:
- `Project::make('master')` scaffolds; `seed('master')` writes a graph and sentinels every cached result (`time=9.999`, `assertions=42`) so any rewrite shows up.
- `$project->pest('--tia', …)` runs it; `$project->delta()` compares against that snapshot. `writtenCount()` is the discriminator — `0` means "replayed", not "wrote the same values". `mutateGraph()` bends one entry; overlays in `tests/Fixtures/Tia/overlays/<name>/` supply a different `tests/Pest.php`.
- Keep expectations driver-independent: a cold recording run needs pcov/xdebug and behaves differently without one. Seed a graph instead of recording one.
Run them by file (a directory argument finds nothing) or by `--filter`:
```bash
php bin/pest tests/Features/Tia/PartialRunWriteTier.php
```
+6 -4
View File
@@ -23,7 +23,7 @@
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^5.0.0",
"pestphp/pest-plugin-arch": "^5.0.0",
"pestphp/pest-plugin-mutate": "^5.0.0",
"pestphp/pest-plugin-mutate": "^5.0.1",
"pestphp/pest-plugin-profanity": "^5.0.0",
"phpunit/phpunit": "^13.2.6",
"symfony/process": "^8.1.0"
@@ -48,6 +48,7 @@
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
"Tests\\Fixtures\\Tia\\": "tests/Fixtures/Tia",
"Tests\\": "tests/PHPUnit/"
},
"classmap": [
@@ -58,12 +59,11 @@
]
},
"require-dev": {
"laravel/pao": "^1.1.3",
"pestphp/pest-dev-tools": "^5.0.0",
"pestphp/pest-plugin-browser": "^5.0.0",
"pestphp/pest-plugin-phpstan": "^5.0.0",
"pestphp/pest-plugin-rector": "^5.0.0",
"pestphp/pest-plugin-type-coverage": "^5.0.0",
"pestphp/pest-plugin-rector": "^5.0.2",
"pestphp/pest-plugin-type-coverage": "^5.0.2",
"psy/psysh": "^0.12.24"
},
"minimum-stability": "dev",
@@ -71,6 +71,7 @@
"config": {
"sort-packages": true,
"preferred-install": "dist",
"process-timeout": 0,
"allow-plugins": {
"pestphp/pest-plugin": true
}
@@ -94,6 +95,7 @@
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
"test:integration": "php bin/pest --group=integration -v",
"test:tia": "php bin/pest --group=tia -v",
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
"test": [
"@test:lint",
+2 -2
View File
@@ -90,11 +90,11 @@ final class TestSuiteLoader
(static function () use ($suiteClassFile) {
try {
include_once $suiteClassFile;
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
} catch (Throwable $e) {
Panic::with($e);
}
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
})();
$loadedClasses = array_values(
+2 -1
View File
@@ -18,8 +18,9 @@
<directory suffix=".php">./tests</directory>
<directory suffix=".php">./tests-external</directory>
<exclude>./tests/.snapshots</exclude>
<exclude>./tests/.tests</exclude>
<exclude>./tests/Fixtures/Inheritance</exclude>
<exclude>./tests/Fixtures/Suites</exclude>
<exclude>./tests/Fixtures/Tia</exclude>
</testsuite>
</testsuites>
<source>
+5
View File
@@ -0,0 +1,5 @@
{
"exclude": [
"tests/Fixtures/Suites"
]
}
+1
View File
@@ -28,6 +28,7 @@ return RectorConfig::configure()
->withSkip([
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
__DIR__.'/tests/Fixtures/Arch',
__DIR__.'/tests/Fixtures/Suites',
ReturnNeverTypeRector::class,
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
NarrowObjectReturnTypeRector::class,
+6
View File
@@ -35,6 +35,12 @@ final readonly class BootSubscribers implements Bootstrapper
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
Subscribers\EnsureTiaResultIsRecordedOnNoticeTriggered::class,
Subscribers\EnsureTiaResultIsRecordedOnPhpNoticeTriggered::class,
Subscribers\EnsureTiaResultIsRecordedOnDeprecationTriggered::class,
Subscribers\EnsureTiaResultIsRecordedOnPhpDeprecationTriggered::class,
Subscribers\EnsureTiaResultIsRecordedOnWarningTriggered::class,
Subscribers\EnsureTiaResultIsRecordedOnPhpWarningTriggered::class,
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
];
+2
View File
@@ -286,6 +286,8 @@ trait Testable
if ($replay !== ReplayType::None) {
assert($status !== null);
$this->__replay = $replay;
match ($replay) {
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
ReplayType::Skipped => $this->markTestSkipped($status->message()),
+1 -1
View File
@@ -19,6 +19,6 @@ final class AfterAllAlreadyExist extends InvalidArgumentException implements Exc
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The afterAll already exists in the filename `%s`.', $filename));
parent::__construct(sprintf('The afterAll already exists in the filename [%s].', $filename));
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ final class AfterAllWithinDescribe extends InvalidArgumentException implements E
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename));
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename [%s].', $filename));
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ final class AfterBeforeTestFunction extends InvalidArgumentException implements
*/
public function __construct(string $filename)
{
parent::__construct('After method cannot be used with before the [test|it] functions in the filename `['.$filename.']`.');
parent::__construct('After method cannot be used with before the [test|it] functions in the filename ['.$filename.'].');
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ final class BeforeAllAlreadyExist extends InvalidArgumentException implements Ex
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename));
parent::__construct(sprintf('The beforeAll already exists in the filename [%s].', $filename));
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ final class BeforeAllWithinDescribe extends InvalidArgumentException implements
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename));
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename [%s].', $filename));
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ final class DatasetAlreadyExists extends InvalidArgumentException implements Exc
*/
public function __construct(string $name, string $scope)
{
parent::__construct(sprintf('A dataset with the name `%s` already exists in scope [%s].', $name, $scope));
parent::__construct(sprintf('A dataset with the name [%s] already exists in scope [%s].', $name, $scope));
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ final class DatasetDoesNotExist extends InvalidArgumentException implements Exce
*/
public function __construct(string $name)
{
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
parent::__construct(sprintf("A dataset with the name [%s] does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ final class FileOrFolderNotFound extends InvalidArgumentException implements Exc
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The file or folder with the name `%s` could not be found.', $filename));
parent::__construct(sprintf('The file or folder with the name [%s] could not be found.', $filename));
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidTestClassName extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance for the given class name.
*/
public static function fromClassName(string $filename, string $className): self
{
return new self(sprintf(
'The test file [%s] would create the class [%s], which is not a valid PHP class name. Please rename the test file.',
$filename,
$className,
));
}
/**
* Creates a new Exception instance for the given namespace.
*/
public static function fromNamespace(string $filename, string $namespace, string $part): self
{
return new self(sprintf(
'The test file [%s] would create the namespace [%s], which is not a valid PHP namespace, as [%s] may not be used as a namespace name. Please rename the folder in question.',
$filename,
$namespace,
$part,
));
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ final class MissingDependency extends InvalidArgumentException implements Except
*/
public function __construct(string $feature, string $dependency)
{
parent::__construct(sprintf('The feature "%s" requires "%s".', $feature, $dependency));
parent::__construct(sprintf('The feature [%s] requires [%s].', $feature, $dependency));
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ final class TestAlreadyExist extends InvalidArgumentException implements Excepti
*/
public function __construct(string $fileName, string $description)
{
parent::__construct(sprintf('A test with the description `%s` already exists in the filename `%s`.', $description, $fileName));
parent::__construct(sprintf('A test with the description [%s] already exists in the filename [%s].', $description, $fileName));
}
}
@@ -19,6 +19,6 @@ final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implem
*/
public function __construct(string $testCaseClass)
{
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass));
parent::__construct(sprintf('The class [%s] was not found.', $testCaseClass));
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ final class TestDescriptionMissing extends InvalidArgumentException implements E
*/
public function __construct(string $fileName)
{
parent::__construct(sprintf('Test description is missing in the filename `%s`.', $fileName));
parent::__construct(sprintf('Test description is missing in the filename [%s].', $fileName));
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class TiaRequiresCommit extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct()
{
parent::__construct(
'Tia mode requires a repository with at least one commit, so the baseline it records can be anchored to a revision.',
);
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires at least one commit.',
'',
' A baseline is anchored to the revision it was recorded at, and this repository',
' has none yet, so there is nothing to record against and nothing to compare a',
' later run to.',
'',
' Commit once, then run again:',
'',
' <fg=yellow>git add . && git commit -m "Initial commit"</>',
'',
' Runs without <fg=yellow>--tia</> are unaffected.',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class TiaRequiresDefaultBranch extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct()
{
parent::__construct(
'Tia mode could not determine the default branch every other branch falls back to reading.',
);
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode could not determine the default branch.',
'',
' It is the branch whose baseline every other branch falls back to reading, and',
' nothing in this checkout names it: the repository has a remote, but no',
' <fg=yellow>origin/HEAD</>, and no CI provider stated it either. Guessing would re-run the',
' whole suite on every new branch while reporting it as a cache hit.',
'',
' Name the branch in <fg=yellow>tests/Pest.php</>:',
'',
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
'',
' Or let git answer, once per clone:',
'',
' <fg=yellow>git remote set-head origin --auto</>',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class TiaRequiresRemote extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct()
{
parent::__construct(
'Tia mode requires a repository with a remote, so the default branch every other branch falls back to reading can be resolved.',
);
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires a repository with a remote.',
'',
' Without one there is no way to tell which branch is the default — the branch',
' whose baseline every other branch falls back to reading — so the first run on',
' each new branch would silently re-run the whole suite.',
'',
' Add a remote, or name the branch yourself in <fg=yellow>tests/Pest.php</>:',
'',
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}
+11
View File
@@ -9,6 +9,7 @@ use Pest\Concerns;
use Pest\Contracts\HasPrintableTestCaseName;
use Pest\Evaluators\Attributes;
use Pest\Exceptions\DatasetMissing;
use Pest\Exceptions\InvalidTestClassName;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Exceptions\TestAlreadyExist;
use Pest\Exceptions\TestClosureMustNotBeStatic;
@@ -138,6 +139,16 @@ final class TestCaseFactory
if (trim($className) === '') {
$className = 'InvalidTestName'.Str::random();
} elseif (! Str::isValidClassName($className)) {
throw InvalidTestClassName::fromClassName($this->filename, $className);
}
if ($this->namespace === null) {
foreach ($partsFQN as $partFQN) {
if (! Str::isValidIdentifier($partFQN)) {
throw InvalidTestClassName::fromNamespace($this->filename, $namespace, $partFQN);
}
}
}
$this->attributes = [
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Pest;
function version(): string
{
return '5.0.3';
return '5.0.4';
}
function testDirectory(string $file = ''): string
@@ -14,6 +14,7 @@ use ParaTest\RunnerInterface;
use ParaTest\WrapperRunner\MissingResultsException;
use ParaTest\WrapperRunner\SuiteLoader;
use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Plugins\Tia;
use Pest\Result;
use Pest\TestSuite;
use PHPUnit\Event\Facade as EventFacade;
@@ -155,6 +156,7 @@ final class WrapperRunner implements RunnerInterface
/** @var array<int, non-empty-string> $parameters */
$parameters = $this->handleLaravelHerd($parameters);
$parameters = $this->handleTia($parameters);
$parameters[] = $wrapper;
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
@@ -202,6 +204,19 @@ final class WrapperRunner implements RunnerInterface
return $parameters;
}
/**
* @param array<int, non-empty-string> $parameters
* @return array<int, non-empty-string>
*/
private function handleTia(array $parameters): array
{
if (! Tia::recordsEdgesInWorkers()) {
return $parameters;
}
return array_merge($parameters, ['-d', 'pcov.directory='.TestSuite::getInstance()->rootPath]);
}
private function startWorkers(): void
{
for ($token = 1; $token <= $this->options->processes; $token++) {
+492 -68
View File
@@ -7,13 +7,20 @@ namespace Pest\Plugins;
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\HandlesOriginalArguments;
use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\InvalidOption;
use Pest\Exceptions\MissingDependency;
use Pest\Exceptions\NoAffectedTestsFound;
use Pest\Exceptions\TiaRequiresCommit;
use Pest\Exceptions\TiaRequiresDefaultBranch;
use Pest\Exceptions\TiaRequiresRemote;
use Pest\Exceptions\TiaRequiresRepositoryRoot;
use Pest\Panic;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Tia\BaselineSync;
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\CiDefaultBranch;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Plugins\Tia\CoverageCollector;
use Pest\Plugins\Tia\Fingerprint;
@@ -26,17 +33,18 @@ use Pest\Plugins\Tia\Storage;
use Pest\Plugins\Tia\TableExtractor;
use Pest\Plugins\Tia\WatchPatterns;
use Pest\Support\Container;
use Pest\Support\Git;
use Pest\Support\View;
use Pest\TestCaseFilters\TiaTestCaseFilter;
use Pest\TestSuite;
use PHPUnit\Framework\TestStatus\TestStatus;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final class Tia implements AddsOutput, HandlesArguments, Terminable
final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArguments, Terminable
{
use HandleArguments;
@@ -56,6 +64,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string BASELINE_PATH_OPTION = '--baseline';
private const string ENV_MUTATION_TESTING = 'PEST_MUTATION_TESTING';
private const string ENV_TIA = 'PEST_TIA';
private const string ENV_FILTERED = 'PEST_TIA_FILTERED';
@@ -86,8 +96,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string FILTERED_GLOBAL = 'TIA_FILTERED';
private const string WORKER_RESULTS_GLOBAL = 'TIA_WORKER_RESULTS';
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
private const string FALLBACK_BRANCH_GLOBAL = 'TIA_FALLBACK_BRANCH';
private const string DEFAULT_BRANCH = 'main';
/**
* PHPUnit/Pest CLI flags whose subsequent argument is a value, not a path.
*
@@ -101,12 +117,32 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
'--include-path', '--whitelist',
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
'--coverage-html', '--coverage-php', '--coverage-text', '--coverage-xml',
'--coverage-html', '--coverage-openclover', '--coverage-php',
'--coverage-text', '--coverage-xml',
'--coverage-filter', '--path-coverage',
'--repeat', '--retry-times', '--memory-limit', '--seed',
'--compact', '--ci-build-id', '--min',
];
/** @var list<string> */
private const array COVERAGE_REPORT_FLAGS = [
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
'--coverage-html', '--coverage-openclover', '--coverage-php',
'--coverage-text', '--coverage-xml',
];
/** @var list<string> */
private const array PARTIAL_SELECTION_FLAGS = [
'--filter', '--exclude-filter', '--group', '--exclude-group',
'--covers', '--uses', '--testsuite', '--exclude-testsuite', '--test-suffix',
'--dirty', '--todo', '--todos', '--flaky', '--notes',
'--assignee', '--issue', '--ticket', '--pr', '--pull-request',
];
private const array UNSUPPORTED_OPTIONS = [
'--covers', '--uses', '--random-order-seed',
];
private bool $graphWritten = false;
private bool $replayRan = false;
@@ -120,9 +156,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** @var array<string, int> */
private array $cachedAssertionsByTestId = [];
/** @var array<string, array{status: int, message: string}> */
private array $cachedStatusByTestId = [];
/** @var array<string, float> */
private array $cachedTimeByTestId = [];
private ?Graph $replayGraph = null;
private string $branch = 'main';
private string $branch = self::DEFAULT_BRANCH;
private string $fallbackBranch = self::DEFAULT_BRANCH;
private bool $fallbackBranchResolved = false;
private bool $branchResolved = false;
/** @var array<string, true> */
private array $affectedFiles = [];
@@ -138,10 +186,23 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private bool $baselineFetchAttemptedForDrift = false;
private bool $freshRebuild = false;
private bool $filteredMode = false;
private bool $writesSuppressed = false;
private bool $resultsOnlyWrites = false;
private bool $flushesWorkerResults = false;
private bool $unreadableGraphReported = false;
private bool $detachedHead = false;
private bool $graphUnreachable = false;
/** @var array<int, string> */
private array $originalArguments = [];
private ?string $driftLabel = null;
private ?string $driftDetails = null;
@@ -185,11 +246,54 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return null;
}
return Graph::decode($json, $projectRoot);
$graph = Graph::decode($json, $projectRoot);
if (! $graph instanceof Graph) {
$this->discardUnreadableGraph();
return null;
}
$graph->setFallbackBranch($this->fallbackBranch);
return $graph;
}
private function discardUnreadableGraph(): void
{
if (Parallel::isWorker()) {
return;
}
if (! $this->deleteState(self::KEY_GRAPH)) {
return;
}
if ($this->unreadableGraphReported) {
return;
}
$this->unreadableGraphReported = true;
$this->output->writeln('');
$this->renderBadge('WARN', 'The dependency graph could not be read — it will be rebuilt.');
}
private function deleteState(string $key): bool
{
if ($this->detachedHead) {
return false;
}
return $this->state->delete($key);
}
private function saveGraph(Graph $graph): bool
{
if ($this->detachedHead) {
return true;
}
$json = $graph->encode();
if ($json === null) {
@@ -227,6 +331,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return ! self::argumentPresent('--ci', $arguments);
}
public static function recordsEdgesInWorkers(): bool
{
return (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'
&& (string) Parallel::getGlobal(self::PIGGYBACK_COVERAGE_GLOBAL) !== '1';
}
/**
* @param array<int, string> $arguments
*/
@@ -303,8 +413,18 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
$this->replayedCount++;
$this->cachedStatusByTestId[$testId] = [
'status' => $result->asInt(),
'message' => $result->message(),
];
$assertions = $this->replayGraph->getAssertions($this->branch, $testId);
$this->cachedAssertionsByTestId[$testId] = $assertions ?? 0;
$time = $this->replayGraph->getTime($this->branch, $testId);
if ($time !== null) {
$this->cachedTimeByTestId[$testId] = $time;
}
} else {
$this->executedCount++;
}
@@ -317,6 +437,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $this->cachedAssertionsByTestId[$testId] ?? 0;
}
/**
* {@inheritDoc}
*/
public function handleOriginalArguments(array $arguments): void
{
$this->originalArguments = $arguments;
}
/**
* {@inheritDoc}
*/
@@ -339,9 +467,20 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$cliEnabled = $this->hasArgument(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA);
$alwaysEnabled = $watchPatterns->isEnabled()
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
if (! $isWorker && ! $disabled && ($cliEnabled || $alwaysEnabled)) {
$this->guardUnsupportedOptions($arguments);
}
$hasExplicitPath = $this->hasExplicitPathArgument($arguments);
$partial = ! $isWorker && ($hasExplicitPath || $this->hasPartialSelection($arguments));
$disabled = $disabled || $partial;
if (getenv(self::ENV_MUTATION_TESTING) !== false) {
$this->writesSuppressed = true;
}
$enabled = ! $disabled && ($cliEnabled || $alwaysEnabled);
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered())
&& ! $this->hasExplicitPathArgument($arguments)
&& ! $hasExplicitPath
&& ! $this->coverageReportActive();
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
@@ -355,17 +494,35 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$arguments = $this->popArgument(self::BASELINED_OPTION, $arguments);
if ($disabled) {
$this->requestWorkerResults();
if ($partial) {
$this->resultsOnlyWrites = true;
if ($cliEnabled || $freshRequested || $this->forceRefetch || $this->filteredMode) {
$this->output->writeln('');
$this->renderChild('TIA does not apply to partial runs — running the selected tests directly.');
}
}
$this->forceRefetch = false;
$this->filteredMode = false;
$this->freshRebuild = false;
return $arguments;
}
if ($isWorker && (string) Parallel::getGlobal(self::WORKER_RESULTS_GLOBAL) === '1') {
$this->flushesWorkerResults = true;
$this->resultsOnlyWrites = true;
return $arguments;
}
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
$this->freshRebuild = $forceRebuild;
if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
$this->requestWorkerResults();
return $arguments;
}
@@ -388,10 +545,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive)) {
if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive || $this->flushesWorkerResults)) {
$this->flushWorkerReplay();
}
if ($this->writesSuppressed || $this->resultsOnlyWrites || $this->hasUnfinishedTest()) {
$this->recorder->reset();
$this->coverageCollector->reset();
return;
}
$recorder = $this->recorder;
if (! $this->recordingActive && ! $recorder->isActive()) {
@@ -453,15 +617,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->branch,
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
);
$graph->replaceEdges($perTest);
$graph->replaceEdges($perTest, keepExisting: $this->piggybackCoverage);
$graph->replaceTestTables($perTestTables);
$graph->replaceTestInertiaComponents($perTestInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
if ($this->freshRebuild) {
$graph->pruneMissingTests();
}
$this->seedResultsInto($graph);
if (! $this->saveGraph($graph)) {
@@ -481,13 +641,27 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $exitCode;
}
if (Only::isEnabled() || $this->stoppedEarly() || $this->hasUnfinishedTest()) {
$this->resultsOnlyWrites = true;
}
$this->reportMissingWorkerDrivers();
if (Parallel::isEnabled()) {
$this->mergeWorkerReplayPartials();
}
if ($this->replayRan) {
if ($this->writesSuppressed) {
return $exitCode;
}
if ($this->resultsOnlyWrites) {
$this->snapshotTestResults(complete: false);
return $exitCode;
}
if ($this->replayRan || $this->graphUnreachable) {
$this->bumpRecordedSha();
}
@@ -547,15 +721,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $exitCode;
}
$graph->replaceEdges($finalised);
$graph->replaceEdges($finalised, keepExisting: $this->piggybackCoverage);
$graph->replaceTestTables($finalisedTables);
$graph->replaceTestInertiaComponents($finalisedInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
if ($this->freshRebuild) {
$graph->pruneMissingTests();
}
if (! $this->saveGraph($graph)) {
$this->renderBadge('ERROR', 'Could not write the dependency graph.');
@@ -598,8 +768,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $this->reconcileFingerprint($rebuilt, $current);
}
$this->state->delete(self::KEY_GRAPH);
$this->state->delete(self::KEY_COVERAGE_CACHE);
$this->deleteState(self::KEY_GRAPH);
$this->deleteState(self::KEY_COVERAGE_CACHE);
return null;
}
@@ -615,7 +785,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->clearResults($this->branch);
$graph->setFingerprint($current);
$this->saveGraph($graph);
$this->state->delete(self::KEY_COVERAGE_CACHE);
$this->deleteState(self::KEY_COVERAGE_CACHE);
}
return $graph;
@@ -635,12 +805,28 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
Panic::with(new TiaRequiresRepositoryRoot($subdirectoryPrefix));
}
$this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? 'main';
try {
$this->resolveBranch($projectRoot);
} catch (MissingDependency $missingGit) {
$repository = new ChangedFiles($projectRoot);
if ($repository->isRepository() && ! $repository->hasCommits()) {
Panic::with(new TiaRequiresCommit);
}
throw $missingGit;
}
if (! $this->fallbackBranchResolved) {
Panic::with(new ChangedFiles($projectRoot)->hasRemote()
? new TiaRequiresDefaultBranch
: new TiaRequiresRemote);
}
$fingerprint = Fingerprint::compute($projectRoot);
$this->startFingerprint = $fingerprint;
if ($forceRebuild) {
if ($forceRebuild && ! $this->detachedHead) {
Storage::purge($projectRoot);
}
@@ -658,6 +844,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
&& $changedFiles->since($branchSha) === null) {
$this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
$graph = null;
$this->graphUnreachable = true;
}
}
@@ -673,13 +860,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
}
if ($this->piggybackCoverage) {
$coverageCacheOwned = $this->piggybackCoverage && $this->pestCoverageActive();
if ($coverageCacheOwned) {
$this->state->write(self::KEY_COVERAGE_MARKER, '');
}
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
if ($graph instanceof Graph && $this->driftLabel === null) {
$this->freshGraphReason = 'recording coverage baseline';
if (! $graph instanceof Graph && $this->piggybackCoverage) {
$this->emitCoverageScopedRecordSkipped();
return $arguments;
}
if ($coverageCacheOwned && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
if ($this->driftLabel === null) {
$this->freshGraphReason = 'recording a coverage baseline';
}
return $this->enterRecordMode($arguments);
@@ -698,7 +893,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array
{
$this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? 'main';
$this->resolveBranch($projectRoot);
if ($replayingGlobal) {
$this->installWorkerReplay($projectRoot);
@@ -833,7 +1028,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $arguments;
}
$affectedFromChanges = $changed === [] ? [] : $graph->affected($changed);
$affectedFromChanges = $changed === [] ? [] : $graph->testFilesOnDisk($graph->affected($changed));
$rerunFromCache = [];
if ($this->filteredMode && $graph->hasUnlocatedTestsToRerun($this->branch)) {
@@ -875,7 +1070,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (! Parallel::isEnabled()) {
if ($canRefreshReplayEdges) {
$this->recorder->activate();
if ($this->piggybackCoverage) {
$this->recorder->activateLinkTracking();
} else {
$this->recorder->activate();
}
$this->recordingActive = true;
}
@@ -894,6 +1094,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if ($canRefreshReplayEdges) {
Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
if ($this->piggybackCoverage) {
Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1');
}
}
if ($this->filteredMode) {
@@ -1035,6 +1239,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$recorder->activate();
$this->recordingActive = true;
if ($this->driftLabel !== null || $this->freshGraphReason !== null) {
$this->output->writeln('');
$this->renderFreshGraph();
return $arguments;
}
$this->renderChild('Running in TIA mode.');
return $arguments;
@@ -1042,14 +1253,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function renderFreshGraph(): void
{
$headline = 'Experimental TIA mode enabled / fresh graph';
if ($this->driftLabel !== null) {
$headline .= sprintf(' (%s changed)', $this->driftLabel);
} elseif ($this->freshGraphReason !== null) {
$headline .= sprintf(' (%s)', $this->freshGraphReason);
if ($this->driftLabel === null && $this->freshGraphReason !== null) {
$headline = sprintf('Experimental TIA mode enabled / %s.', $this->freshGraphReason);
} else {
$headline .= '.';
$headline = 'Experimental TIA mode enabled / fresh graph';
if ($this->driftLabel !== null) {
$headline .= sprintf(' (%s changed)', $this->driftLabel);
} else {
$headline .= '.';
}
}
$this->renderChild($headline);
@@ -1065,7 +1278,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
{
$this->output->writeln('');
$this->renderChild('Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug.');
$this->renderChild('Running in TIA mode, however TIA is skipped as it needs ext-pcov or Xdebug.');
}
private function emitCoverageScopedRecordSkipped(): void
{
$this->output->writeln('');
$this->renderChild('Running in TIA mode, however TIA is skipped as an active coverage report narrows the edges it could record.');
$this->renderChild('Record the baseline with a plain --tia run first; coverage runs then reuse it.');
}
/**
@@ -1115,6 +1336,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.');
}
private function requestWorkerResults(): void
{
if (Parallel::isWorker() || ! Parallel::isEnabled() || $this->writesSuppressed) {
return;
}
if ($this->state->read(self::KEY_GRAPH) === null) {
return;
}
$this->purgeWorkerPartials();
Parallel::setGlobal(self::WORKER_RESULTS_GLOBAL, '1');
}
private function purgeWorkerPartials(): void
{
foreach ($this->collectWorkerEdgesPartials() as $key) {
@@ -1136,11 +1372,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
foreach ($results as $testId => $result) {
$results[$testId] = $this->replayedAsRecorded($testId, $result);
}
$json = json_encode([
'results' => $results,
'replayed' => $this->replayedCount,
'affected' => $this->affectedCount,
'executed' => $this->executedCount,
'truncated' => $this->stoppedEarly() || $collector->hasUnfinishedTest(),
], JSON_UNESCAPED_SLASHES);
if ($json === false) {
@@ -1177,6 +1418,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
continue;
}
if (($decoded['truncated'] ?? false) === true) {
$this->resultsOnlyWrites = true;
}
if (isset($decoded['replayed']) && is_int($decoded['replayed'])) {
$this->replayedCount += $decoded['replayed'];
}
@@ -1400,6 +1645,29 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $coverage;
}
private function resultTime(string $testId, float $time): float
{
return $this->cachedTimeByTestId[$testId] ?? $time;
}
/**
* @param array{status: int, message: string, time: float, assertions: int, file?: string} $result
* @return array{status: int, message: string, time: float, assertions: int, file?: string}
*/
private function replayedAsRecorded(string $testId, array $result): array
{
$result['time'] = $this->resultTime($testId, $result['time']);
$cached = $this->cachedStatusByTestId[$testId] ?? null;
if ($cached !== null) {
$result['status'] = $cached['status'];
$result['message'] = $cached['message'];
}
return $result;
}
private function seedResultsInto(Graph $graph): void
{
/** @var ResultCollector $collector */
@@ -1419,6 +1687,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$touchedFiles[$file] = true;
}
$result = $this->replayedAsRecorded($testId, $result);
$graph->setResult(
$this->branch,
$testId,
@@ -1432,11 +1702,34 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->markKnownTestFiles(array_keys($touchedFiles));
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
$this->reclaim($graph);
$collector->reset();
}
private function snapshotTestResults(bool $markKnownTestFiles = false): void
private function reclaim(Graph $graph): void
{
if ($this->branch !== $this->fallbackBranch) {
$graph->markBaselineComplete($this->branch);
}
$graph->pruneMissingTests();
$graph->pruneResultsForMissingFiles($this->branch);
$branches = new ChangedFiles(TestSuite::getInstance()->rootPath)->branchNames();
if ($branches === null) {
return;
}
if (! in_array($this->fallbackBranch, $branches, true)) {
return;
}
$graph->pruneMissingBranches([...$branches, $this->branch, $this->fallbackBranch]);
}
private function snapshotTestResults(bool $markKnownTestFiles = false, bool $complete = true): void
{
/** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class);
@@ -1455,8 +1748,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
try {
$this->resolveBranch($projectRoot);
} catch (MissingDependency) {
}
$graph->setFallbackBranch($this->fallbackBranch);
$touchedFiles = [];
$recordsEdges = $complete && ($markKnownTestFiles || $this->recordingActive);
foreach ($results as $testId => $result) {
$file = $result['file'] ?? null;
@@ -1468,6 +1770,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$touchedFiles[$file] = true;
}
if (! $recordsEdges && (! is_string($file) || ! $graph->knowsTest($file))) {
continue;
}
$result = $this->replayedAsRecorded($testId, $result);
$graph->setResult(
$this->branch,
$testId,
@@ -1483,7 +1791,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->markKnownTestFiles(array_keys($touchedFiles));
}
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
if ($complete) {
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
$this->reclaim($graph);
}
$this->saveGraph($graph);
$collector->reset();
@@ -1521,6 +1832,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
private function coverageReportActive(): bool
{
if ($this->pestCoverageActive()) {
return true;
}
return array_any(self::COVERAGE_REPORT_FLAGS, fn (string $flag): bool => $this->hasArgument($flag, $this->originalArguments));
}
private function pestCoverageActive(): bool
{
$coverage = Container::getInstance()->get(Coverage::class);
assert($coverage instanceof Coverage);
@@ -1528,6 +1848,105 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $coverage->coverage;
}
/**
* @param array<int, string> $arguments
*/
private function guardUnsupportedOptions(array $arguments): void
{
foreach (self::UNSUPPORTED_OPTIONS as $option) {
if (! $this->hasArgument($option, $arguments) && ! $this->hasArgument($option, $this->originalArguments)) {
continue;
}
Panic::with(new InvalidOption(sprintf(
'The [%s] option cannot be combined with [%s].',
$option,
self::OPTION,
)));
}
}
/**
* @param array<int, string> $arguments
*/
private function hasPartialSelection(array $arguments): bool
{
foreach (self::PARTIAL_SELECTION_FLAGS as $flag) {
if ($this->hasArgument($flag, $arguments)) {
return true;
}
if ($this->hasArgument($flag, $this->originalArguments)) {
return true;
}
}
return false;
}
private function stoppedEarly(): bool
{
return TestResultFacade::shouldStop();
}
private function hasUnfinishedTest(): bool
{
$collector = Container::getInstance()->get(ResultCollector::class);
assert($collector instanceof ResultCollector);
return $collector->hasUnfinishedTest();
}
private function resolveBranch(string $projectRoot): void
{
if ($this->branchResolved) {
return;
}
$this->branchResolved = true;
$changedFiles = new ChangedFiles($projectRoot);
$resolved = $this->resolveFallbackBranch($changedFiles);
$this->fallbackBranchResolved = $resolved !== null;
$this->fallbackBranch = $resolved ?? self::DEFAULT_BRANCH;
Parallel::setGlobal(self::FALLBACK_BRANCH_GLOBAL, $this->fallbackBranch);
$currentBranch = $changedFiles->currentBranch();
$this->detachedHead = $currentBranch === null;
$this->branch = $currentBranch ?? $this->fallbackBranch;
}
private function resolveFallbackBranch(ChangedFiles $changedFiles): ?string
{
$inherited = Parallel::getGlobal(self::FALLBACK_BRANCH_GLOBAL);
if (is_string($inherited) && $inherited !== '') {
return $inherited;
}
return $this->watchPatterns->defaultBranch()
?? CiDefaultBranch::detect()
?? $changedFiles->defaultBranch()
?? $this->soleRecordedBranch();
}
private function soleRecordedBranch(): ?string
{
$json = $this->state->read(self::KEY_GRAPH);
if ($json === null) {
return null;
}
$branches = Graph::branchesIn($json);
return count($branches) === 1 ? $branches[0] : null;
}
/**
* @param array<int, string> $arguments
*/
@@ -1549,11 +1968,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (str_starts_with($arg, '-')) {
continue;
}
if ($index > 0) {
$previous = $arguments[$index - 1] ?? '';
if (in_array($previous, self::VALUE_TAKING_FLAGS, true)) {
continue;
}
if ($index === 0) {
continue;
}
$previous = $arguments[$index - 1] ?? '';
if (in_array($previous, self::VALUE_TAKING_FLAGS, true)) {
continue;
}
$candidate = $this->resolveArgumentPath($arg, $projectRoot);
@@ -1562,16 +1985,28 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
continue;
}
foreach ($testPaths as $testPath) {
if ($candidate === $testPath || str_starts_with($candidate, $testPath.DIRECTORY_SEPARATOR)) {
return true;
}
if ($this->narrowsSuite($candidate, $testPaths)) {
return true;
}
}
return false;
}
/**
* @param array<int, string> $testPaths
*/
private function narrowsSuite(string $candidate, array $testPaths): bool
{
foreach ($testPaths as $testPath) {
if ($candidate === $testPath || str_starts_with($candidate, $testPath.DIRECTORY_SEPARATOR)) {
return true;
}
}
return array_all($testPaths, fn (string $testPath): bool => ! str_starts_with($testPath, $candidate.DIRECTORY_SEPARATOR));
}
private function resolveArgumentPath(string $arg, string $projectRoot): ?string
{
$candidates = [$arg, rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.ltrim($arg, DIRECTORY_SEPARATOR)];
@@ -1701,16 +2136,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private function gitSubdirectoryPrefix(string $projectRoot): ?string
{
$process = new Process(['git', 'rev-parse', '--show-prefix'], $projectRoot);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$prefix = trim($process->getOutput());
return $prefix === '' ? null : rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/');
return new Git($projectRoot)->subdirectoryPrefix();
}
private function composerLockDelta(string $projectRoot, string $sha): string
@@ -1720,15 +2146,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return '';
}
$process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot);
$process->setTimeout(5.0);
$process->run();
$baseline = new Git($projectRoot)->show($sha, 'composer.lock');
if (! $process->isSuccessful()) {
if ($baseline === null) {
return '';
}
$oldVersions = $this->lockVersions($process->getOutput());
$oldVersions = $this->lockVersions($baseline);
$newVersions = $this->lockVersions($current);
if ($oldVersions === [] && $newVersions === []) {
+115 -51
View File
@@ -5,14 +5,19 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Exceptions\MissingDependency;
use Symfony\Component\Process\Process;
use Pest\Support\Git;
/**
* @internal
*/
final readonly class ChangedFiles
{
public function __construct(private string $projectRoot) {}
private Git $git;
public function __construct(private string $projectRoot)
{
$this->git = new Git($projectRoot);
}
/**
* @param array<int, string> $files project-relative paths.
@@ -155,15 +160,7 @@ final readonly class ChangedFiles
private function contentAtSha(string $sha, string $path): ?string
{
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
$process->setTimeout(5.0);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
return $process->getOutput();
return $this->git->show($sha, $path);
}
/**
@@ -176,21 +173,17 @@ final readonly class ChangedFiles
return $candidates;
}
$process = new Process(
['git', 'check-ignore', '--no-index', '-z', '--stdin'],
$this->projectRoot,
$result = $this->git->result(
['check-ignore', '--no-index', '-z', '--stdin'],
implode("\x00", array_keys($candidates)),
);
$process->setTimeout(5.0);
$process->setInput(implode("\x00", array_keys($candidates)));
$process->run();
$exitCode = $process->getExitCode();
if ($exitCode !== 0 && $exitCode !== 1) {
// `check-ignore` exits 1 when nothing matched — that is not a failure.
if ($result['exitCode'] !== 0 && $result['exitCode'] !== 1) {
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
$output = $result['output'];
if ($output === '') {
return $candidates;
@@ -207,27 +200,109 @@ final readonly class ChangedFiles
public function currentBranch(): ?string
{
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
$process->run();
$output = $this->git->raw(['rev-parse', '--abbrev-ref', 'HEAD']);
if (! $process->isSuccessful()) {
if ($output === null) {
throw new MissingDependency('Tia mode', 'git');
}
$branch = trim($process->getOutput());
$branch = trim($output);
return $branch === '' || $branch === 'HEAD' ? null : $branch;
}
public function defaultBranch(): ?string
{
$head = $this->git->output(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
if ($head !== null) {
$branch = preg_replace('#^origin/#', '', $head);
if (is_string($branch) && $branch !== '') {
return $branch;
}
}
$configured = $this->git->output(['config', '--get', 'init.defaultBranch']);
if ($configured === null) {
return null;
}
$exists = $this->git->hasRef('refs/heads/'.$configured)
|| $this->git->hasRef('refs/remotes/origin/'.$configured);
return $exists ? $configured : null;
}
/**
* @return list<string>|null
*/
public function branchNames(): ?array
{
$output = $this->git->raw(['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']);
if ($output === null) {
return null;
}
$names = [];
foreach ($this->splitLines($output) as $ref) {
if (str_starts_with($ref, 'refs/heads/')) {
$names[substr($ref, strlen('refs/heads/'))] = true;
continue;
}
if (! str_starts_with($ref, 'refs/remotes/')) {
continue;
}
$tail = substr($ref, strlen('refs/remotes/'));
$slash = strpos($tail, '/');
if ($slash === false) {
continue;
}
$branch = substr($tail, $slash + 1);
if ($branch !== '' && $branch !== 'HEAD') {
$names[$branch] = true;
}
}
return array_keys($names);
}
public function hasRemote(): bool
{
return $this->git->hasRemote();
}
public function isRepository(): bool
{
return $this->git->isRepository();
}
public function hasCommits(): bool
{
return $this->git->hasCommits();
}
/**
* Working-tree scans get a longer leash than metadata queries — on a large
* repository with a cold cache, `status` and `diff` are not instant.
*/
private function scan(): Git
{
return $this->git->withTimeout(60.0);
}
private function shaIsReachable(string $sha): bool
{
$process = new Process(
['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
$this->projectRoot,
);
$process->run();
return $process->getExitCode() === 0;
return $this->git->succeeds(['merge-base', '--is-ancestor', $sha, 'HEAD']);
}
/**
@@ -235,17 +310,13 @@ final readonly class ChangedFiles
*/
private function diffSinceSha(string $sha): array
{
$process = new Process(
['git', 'diff', '--name-only', $sha.'..HEAD'],
$this->projectRoot,
);
$process->run();
$output = $this->scan()->raw(['diff', '--name-only', '--no-renames', $sha.'..HEAD']);
if (! $process->isSuccessful()) {
if ($output === null) {
throw new MissingDependency('Tia mode', 'git');
}
return $this->splitLines($process->getOutput());
return $this->splitLines($output);
}
/**
@@ -253,18 +324,12 @@ final readonly class ChangedFiles
*/
private function workingTreeChanges(): array
{
$process = new Process(
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
$this->projectRoot,
);
$process->run();
$output = $this->scan()->raw(['status', '--porcelain', '-z', '--untracked-files=all']);
if (! $process->isSuccessful()) {
if ($output === null) {
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
if ($output === '') {
return [];
}
@@ -302,14 +367,13 @@ final readonly class ChangedFiles
public function currentSha(): ?string
{
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
$process->run();
$output = $this->git->raw(['rev-parse', 'HEAD']);
if (! $process->isSuccessful()) {
if ($output === null) {
throw new MissingDependency('Tia mode', 'git');
}
$sha = trim($process->getOutput());
$sha = trim($output);
return $sha === '' ? null : $sha;
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\Ci;
/**
* @internal
*/
final class CiDefaultBranch
{
/**
* @var array<int, class-string<Ci>>
*/
private const array CIS = [
Cis\GitLab::class,
Cis\GitHub::class,
];
public static function detect(): ?string
{
foreach (self::CIS as $class) {
$branch = (new $class)->defaultBranch();
if ($branch !== null) {
return $branch;
}
}
return null;
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Cis\Concerns;
/**
* @internal
*/
trait ReadsEnvironment
{
private function environment(string $name): ?string
{
$value = getenv($name);
if (! is_string($value)) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Cis;
use Pest\Plugins\Tia\Cis\Concerns\ReadsEnvironment;
use Pest\Plugins\Tia\Contracts\Ci;
/**
* @internal
*/
final readonly class GitHub implements Ci
{
use ReadsEnvironment;
public function defaultBranch(): ?string
{
$path = $this->environment('GITHUB_EVENT_PATH');
if ($path === null || ! is_file($path) || ! is_readable($path)) {
return null;
}
$contents = @file_get_contents($path);
if ($contents === false) {
return null;
}
$payload = json_decode($contents, true);
if (! is_array($payload) || ! is_array($payload['repository'] ?? null)) {
return null;
}
$branch = $payload['repository']['default_branch'] ?? null;
return is_string($branch) && $branch !== '' ? $branch : null;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Cis;
use Pest\Plugins\Tia\Cis\Concerns\ReadsEnvironment;
use Pest\Plugins\Tia\Contracts\Ci;
/**
* @internal
*/
final readonly class GitLab implements Ci
{
use ReadsEnvironment;
public function defaultBranch(): ?string
{
return $this->environment('CI_DEFAULT_BRANCH');
}
}
+12
View File
@@ -60,6 +60,18 @@ final class Configuration
return $this;
}
/**
* @return $this
*/
public function defaultBranch(string $branch): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->setDefaultBranch($branch);
return $this;
}
/**
* @param array<string, string> $patterns glob → project-relative test dir
* @return $this
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts;
/**
* @internal
*/
interface Ci
{
/**
* The default branch advertised by this CI, or `null` when the run
* does not happen on it — or when it exposes no such information.
*/
public function defaultBranch(): ?string;
}
+3 -1
View File
@@ -29,7 +29,9 @@ enum ReplayType
$status->isRisky() => self::Risky,
$status->isSkipped() => self::Skipped,
$status->isIncomplete() => self::Incomplete,
default => self::Failure,
$status->isNotice(), $status->isDeprecation(), $status->isWarning() => self::Pass,
$status->isFailure(), $status->isError() => self::Failure,
default => self::None,
};
}
}
+334 -20
View File
@@ -43,11 +43,14 @@ final class Graph
* @var array<string, array{
* sha: ?string,
* tree: array<string, string>,
* complete?: bool,
* results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
* }>
*/
private array $baselines = [];
private string $fallbackBranch = 'main';
private readonly string $projectRoot;
/** @var array<string, true>|null */
@@ -116,6 +119,24 @@ final class Graph
return array_keys($affectedSet);
}
/**
* @param array<int, string> $testFiles Project-relative paths.
* @return list<string>
*/
public function testFilesOnDisk(array $testFiles): array
{
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$onDisk = [];
foreach ($testFiles as $testFile) {
if (is_file($root.$testFile)) {
$onDisk[] = $testFile;
}
}
return $onDisk;
}
/**
* @param array<int, string> $changedFiles
* @return array{0: list<string>, 1: list<string>}
@@ -576,7 +597,12 @@ final class Graph
return $this->fingerprint;
}
public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string
public function setFallbackBranch(string $branch): void
{
$this->fallbackBranch = $branch;
}
public function recordedAtSha(string $branch, ?string $fallbackBranch = null): ?string
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
@@ -611,7 +637,7 @@ final class Graph
$this->baselines[$branch]['results'][$testId] = $entry;
}
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
public function getAssertions(string $branch, string $testId, ?string $fallbackBranch = null): ?int
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
@@ -622,7 +648,18 @@ final class Graph
return $baseline['results'][$testId]['assertions'];
}
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
public function getTime(string $branch, string $testId, ?string $fallbackBranch = null): ?float
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
if (! isset($baseline['results'][$testId]['time'])) {
return null;
}
return $baseline['results'][$testId]['time'];
}
public function getResult(string $branch, string $testId, ?string $fallbackBranch = null): ?TestStatus
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
@@ -642,14 +679,14 @@ final class Graph
6 => TestStatus::warning($r['message']),
7 => TestStatus::failure($r['message']),
8 => TestStatus::error($r['message']),
default => TestStatus::unknown(),
default => null,
};
}
/**
* @return array<int, string>
*/
public function testFilesToRerun(string $branch, string $fallbackBranch = 'main'): array
public function testFilesToRerun(string $branch, ?string $fallbackBranch = null): array
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
$files = [];
@@ -669,7 +706,7 @@ final class Graph
$rel = $this->relative($file);
if ($rel !== null) {
if ($rel !== null && is_file($this->projectRoot.'/'.$rel)) {
$files[$rel] = true;
}
}
@@ -677,7 +714,7 @@ final class Graph
return array_keys($files);
}
public function hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main'): bool
public function hasUnlocatedTestsToRerun(string $branch, ?string $fallbackBranch = null): bool
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
@@ -688,7 +725,11 @@ final class Graph
$file = $result['file'] ?? null;
if ($file === null || $file === '' || $this->relative($file) === null) {
if ($file === null || $file === '') {
return true;
}
if ($this->relative($file) === null) {
return true;
}
}
@@ -712,6 +753,10 @@ final class Graph
return true;
}
if ($testStatus->isUnknown()) {
return true;
}
$configuration = Registry::get();
if ($testStatus->isRisky()) {
@@ -779,25 +824,70 @@ final class Graph
/**
* @return array<string, string>
*/
public function lastRunTree(string $branch, string $fallbackBranch = 'main'): array
public function lastRunTree(string $branch, ?string $fallbackBranch = null): array
{
return $this->baselineFor($branch, $fallbackBranch)['tree'];
}
/**
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}
* @return array{sha: ?string, tree: array<string, string>, complete?: bool, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}
*/
private function baselineFor(string $branch, string $fallbackBranch): array
private function baselineFor(string $branch, ?string $fallbackBranch): array
{
if (isset($this->baselines[$branch])) {
return $this->baselines[$branch];
$fallbackBranch ??= $this->fallbackBranch;
$fallback = $branch !== $fallbackBranch ? ($this->baselines[$fallbackBranch] ?? null) : null;
$own = $this->baselines[$branch] ?? null;
if ($own === null) {
return $fallback ?? ['sha' => null, 'tree' => [], 'results' => []];
}
if ($branch !== $fallbackBranch && isset($this->baselines[$fallbackBranch])) {
return $this->baselines[$fallbackBranch];
if ($fallback === null) {
return $own;
}
return ['sha' => null, 'tree' => [], 'results' => []];
$under = ($own['complete'] ?? false) === true
? $this->withoutFilesCoveredBy($fallback['results'], $own['results'])
: $fallback['results'];
return [
'sha' => $own['sha'] ?? $fallback['sha'],
'tree' => $own['tree'] !== [] ? $own['tree'] : $fallback['tree'],
'results' => array_replace($under, $own['results']),
];
}
/**
* @param array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}> $results
* @param array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}> $authoritative
* @return array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
*/
private function withoutFilesCoveredBy(array $results, array $authoritative): array
{
$covered = [];
foreach ($authoritative as $entry) {
$file = $entry['file'] ?? null;
if (is_string($file) && $file !== '') {
$covered[$file] = true;
}
}
if ($covered === []) {
return $results;
}
foreach ($results as $testId => $entry) {
$file = $entry['file'] ?? null;
if (is_string($file) && isset($covered[$file])) {
unset($results[$testId]);
}
}
return $results;
}
private function ensureBaseline(string $branch): void
@@ -809,8 +899,9 @@ final class Graph
/**
* @param array<string, array<int, string>> $testToFiles
* @param bool $keepExisting Leave already-recorded edge sets alone.
*/
public function replaceEdges(array $testToFiles): void
public function replaceEdges(array $testToFiles, bool $keepExisting = false): void
{
foreach ($testToFiles as $testFile => $sources) {
$testRel = $this->relative($testFile);
@@ -819,6 +910,10 @@ final class Graph
continue;
}
if ($keepExisting && ($this->edges[$testRel] ?? []) !== []) {
continue;
}
$this->edges[$testRel] = [];
foreach ($sources as $source) {
@@ -1377,6 +1472,56 @@ final class Graph
}
}
public function markBaselineComplete(string $branch): void
{
if (isset($this->baselines[$branch])) {
$this->baselines[$branch]['complete'] = true;
}
}
public function pruneResultsForMissingFiles(string $branch): void
{
if (! isset($this->baselines[$branch]['results'])) {
return;
}
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
foreach ($this->baselines[$branch]['results'] as $testId => $result) {
$file = $result['file'] ?? null;
if (! is_string($file)) {
continue;
}
if ($file === '') {
continue;
}
$rel = $this->relative($file);
if ($rel === null) {
continue;
}
if (is_file($root.$rel)) {
continue;
}
unset($this->baselines[$branch]['results'][$testId]);
}
}
/**
* @param array<int, string> $keep
*/
public function pruneMissingBranches(array $keep): void
{
$survivors = array_fill_keys($keep, true);
foreach (array_keys($this->baselines) as $branch) {
if (! isset($survivors[$branch])) {
unset($this->baselines[$branch]);
}
}
}
/**
* Prune baseline result entries whose test files were just executed but whose
* test IDs are no longer present (e.g. the test method was removed or renamed).
@@ -1422,6 +1567,28 @@ final class Graph
}
}
/**
* @return list<string>
*/
public static function branchesIn(string $json): array
{
$data = json_decode($json, true);
if (! is_array($data) || ! is_array($data['baselines'] ?? null)) {
return [];
}
$branches = [];
foreach (array_keys($data['baselines']) as $branch) {
if (is_string($branch) && $branch !== '') {
$branches[] = $branch;
}
}
return $branches;
}
public static function decode(string $json, string $projectRoot): ?self
{
$data = json_decode($json, true);
@@ -1432,10 +1599,10 @@ final class Graph
$graph = new self($projectRoot);
$graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : [];
$graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : [];
$graph->files = self::decodeFiles($data['files'] ?? null);
$graph->fileIds = array_flip($graph->files);
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
$graph->edges = self::decodeEdges($data['edges'] ?? null);
$graph->baselines = self::decodeBaselines($data['baselines'] ?? null);
$graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
$graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
@@ -1444,6 +1611,153 @@ final class Graph
return $graph;
}
/**
* @return array<int, string>
*/
private static function decodeFiles(mixed $section): array
{
if (! is_array($section)) {
return [];
}
$files = [];
foreach ($section as $path) {
if (is_string($path) && $path !== '') {
$files[] = $path;
}
}
return $files;
}
/**
* @return array<string, array<int, int>>
*/
private static function decodeEdges(mixed $section): array
{
if (! is_array($section)) {
return [];
}
$edges = [];
foreach ($section as $key => $ids) {
$testFile = (string) $key;
if ($testFile === '') {
continue;
}
if (! is_array($ids)) {
continue;
}
$clean = [];
foreach ($ids as $id) {
if (is_int($id)) {
$clean[] = $id;
}
}
$edges[$testFile] = $clean;
}
return $edges;
}
/**
* @return array<string, array{sha: ?string, tree: array<string, string>, complete?: bool, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}>
*/
private static function decodeBaselines(mixed $section): array
{
if (! is_array($section)) {
return [];
}
$baselines = [];
foreach ($section as $key => $baseline) {
$branch = (string) $key;
if ($branch === '') {
continue;
}
if (! is_array($baseline)) {
continue;
}
$sha = $baseline['sha'] ?? null;
$tree = [];
if (is_array($baseline['tree'] ?? null)) {
foreach ($baseline['tree'] as $path => $hash) {
if (is_string($path) && is_string($hash)) {
$tree[$path] = $hash;
}
}
}
$baselines[$branch] = [
'sha' => is_string($sha) ? $sha : null,
'tree' => $tree,
'results' => self::decodeResults($baseline['results'] ?? null),
];
if (($baseline['complete'] ?? null) === true) {
$baselines[$branch]['complete'] = true;
}
}
return $baselines;
}
/**
* @return array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
*/
private static function decodeResults(mixed $section): array
{
if (! is_array($section)) {
return [];
}
$results = [];
foreach ($section as $key => $entry) {
$testId = (string) $key;
if ($testId === '') {
continue;
}
if (! is_array($entry)) {
continue;
}
if (! is_int($entry['status'] ?? null)) {
continue;
}
$time = $entry['time'] ?? null;
$result = [
'status' => $entry['status'],
'message' => is_string($entry['message'] ?? null) ? $entry['message'] : '',
'time' => is_int($time) || is_float($time) ? (float) $time : 0.0,
];
if (is_int($entry['assertions'] ?? null)) {
$result['assertions'] = $entry['assertions'];
}
if (is_string($entry['file'] ?? null) && $entry['file'] !== '') {
$result['file'] = $entry['file'];
}
$results[$testId] = $result;
}
return $results;
}
/**
* @return array<string, list<string>>
*/
+3 -1
View File
@@ -360,7 +360,9 @@ final class Recorder
}
$lineKeys = array_keys($lines);
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
$reportsUnexecutedLines = count($covered) < count($lines);
if ($reportsUnexecutedLines && $lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
continue;
}
+62
View File
@@ -16,6 +16,9 @@ final class ResultCollector
*/
private array $results = [];
/** @var array<string, true> */
private array $triggered = [];
private ?string $currentTestId = null;
private ?string $currentTestFile = null;
@@ -35,9 +38,30 @@ final class ResultCollector
return;
}
if (isset($this->triggered[$this->currentTestId])) {
$this->refreshTime();
return;
}
$this->record(TestStatus::success());
}
public function testTriggeredNotice(string $message): void
{
$this->recordIssue(TestStatus::notice($message));
}
public function testTriggeredDeprecation(string $message): void
{
$this->recordIssue(TestStatus::deprecation($message));
}
public function testTriggeredWarning(string $message): void
{
$this->recordIssue(TestStatus::warning($message));
}
public function testFailed(string $message): void
{
if ($this->currentTestId === null) {
@@ -91,6 +115,11 @@ final class ResultCollector
return $this->results;
}
public function hasUnfinishedTest(): bool
{
return $this->currentTestId !== null;
}
public function recordAssertions(string $testId, int $assertions): void
{
if (isset($this->results[$testId])) {
@@ -111,6 +140,7 @@ final class ResultCollector
public function reset(): void
{
$this->results = [];
$this->triggered = [];
$this->currentTestId = null;
$this->currentTestFile = null;
$this->startTime = null;
@@ -123,6 +153,38 @@ final class ResultCollector
$this->startTime = null;
}
private function recordIssue(TestStatus $status): void
{
if ($this->currentTestId === null) {
return;
}
$existing = $this->results[$this->currentTestId]['status'] ?? null;
if (is_int($existing) && $existing >= $status->asInt()) {
return;
}
$this->triggered[$this->currentTestId] = true;
$this->record($status);
}
private function refreshTime(): void
{
if ($this->currentTestId === null) {
return;
}
if (! isset($this->results[$this->currentTestId])) {
return;
}
if ($this->startTime === null) {
return;
}
$this->results[$this->currentTestId]['time'] = round(microtime(true) - $this->startTime, 3);
}
private function record(TestStatus $status): void
{
if ($this->currentTestId === null) {
+2 -2
View File
@@ -56,7 +56,7 @@ final class TableExtractor
$tables[strtolower($name)] = true;
}
$out = array_keys($tables);
$out = array_map(strval(...), array_keys($tables));
sort($out);
return $out;
@@ -112,7 +112,7 @@ final class TableExtractor
}
}
$out = array_keys($tables);
$out = array_map(strval(...), array_keys($tables));
sort($out);
return $out;
+13
View File
@@ -44,6 +44,8 @@ final class WatchPatterns
private bool $baselined = false;
private ?string $defaultBranch = null;
public function useDefaults(string $projectRoot): void
{
$testPath = TestSuite::getInstance()->testPath;
@@ -177,6 +179,16 @@ final class WatchPatterns
return $this->baselined;
}
public function setDefaultBranch(string $branch): void
{
$this->defaultBranch = $branch;
}
public function defaultBranch(): ?string
{
return $this->defaultBranch;
}
public function reset(): void
{
$this->patterns = [];
@@ -185,6 +197,7 @@ final class WatchPatterns
$this->locally = false;
$this->filtered = false;
$this->baselined = false;
$this->defaultBranch = null;
}
private function keyMatches(string $key, string $file): bool
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\DeprecationTriggered;
use PHPUnit\Event\Test\DeprecationTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnDeprecationTriggered implements DeprecationTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(DeprecationTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredDeprecation($event->message());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\NoticeTriggered;
use PHPUnit\Event\Test\NoticeTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnNoticeTriggered implements NoticeTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(NoticeTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredNotice($event->message());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\PhpDeprecationTriggered;
use PHPUnit\Event\Test\PhpDeprecationTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnPhpDeprecationTriggered implements PhpDeprecationTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(PhpDeprecationTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredDeprecation($event->message());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\PhpNoticeTriggered;
use PHPUnit\Event\Test\PhpNoticeTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnPhpNoticeTriggered implements PhpNoticeTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(PhpNoticeTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredNotice($event->message());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\PhpWarningTriggered;
use PHPUnit\Event\Test\PhpWarningTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnPhpWarningTriggered implements PhpWarningTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(PhpWarningTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredWarning($event->message());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\WarningTriggered;
use PHPUnit\Event\Test\WarningTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnWarningTriggered implements WarningTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(WarningTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredWarning($event->message());
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final readonly class Git
{
private const float TIMEOUT = 5.0;
public function __construct(
private ?string $directory = null,
private float $timeout = self::TIMEOUT,
) {}
public function withTimeout(float $timeout): self
{
return new self($this->directory, $timeout);
}
/**
* @param array<int, string> $arguments
*/
public function raw(array $arguments): ?string
{
$result = $this->result($arguments);
return $result['exitCode'] === 0 ? $result['output'] : null;
}
/**
* @param array<int, string> $arguments
*/
public function output(array $arguments): ?string
{
$output = $this->raw($arguments);
if ($output === null) {
return null;
}
$output = trim($output);
return $output === '' ? null : $output;
}
/**
* @param array<int, string> $arguments
*/
public function succeeds(array $arguments): bool
{
return $this->result($arguments)['exitCode'] === 0;
}
/**
* @param array<int, string> $arguments
* @return array{exitCode: int, output: string}
*/
public function result(array $arguments, ?string $input = null): array
{
$process = new Process(['git', ...$arguments], $this->directory);
$process->setTimeout($this->timeout);
if ($input !== null) {
$process->setInput($input);
}
$process->run();
return [
'exitCode' => $process->getExitCode() ?? 1,
'output' => $process->getOutput(),
];
}
public function isRepository(): bool
{
return $this->succeeds(['rev-parse', '--git-dir']);
}
public function hasCommits(): bool
{
return $this->succeeds(['rev-parse', '--verify', '--quiet', 'HEAD']);
}
public function hasRemote(): bool
{
return $this->output(['remote']) !== null;
}
public function hasRef(string $ref): bool
{
return $this->output(['rev-parse', '--verify', '--quiet', $ref]) !== null;
}
public function show(string $sha, string $path): ?string
{
return $this->raw(['show', $sha.':'.$path]);
}
public function subdirectoryPrefix(): ?string
{
$prefix = $this->output(['rev-parse', '--show-prefix']);
if ($prefix === null) {
return null;
}
return rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/');
}
}
+56
View File
@@ -17,6 +17,33 @@ final class Str
private const string PREFIX = '__pest_evaluable_';
/**
* The list of names PHP reserves, and therefore refuses, as class names.
*
* @see https://github.com/php/php-src/blob/master/Zend/zend_compile.c
*
* @var array<int, string>
*/
private const array RESERVED_CLASS_NAMES = [
'array',
'bool',
'callable',
'false',
'float',
'int',
'iterable',
'mixed',
'never',
'null',
'object',
'parent',
'self',
'static',
'string',
'true',
'void',
];
/**
* Create a (unsecure & non-cryptographically safe) random alpha-numeric
* string value.
@@ -64,6 +91,35 @@ final class Str
return (string) preg_replace('/[^a-zA-Z0-9_\x80-\xff]/', '_', $code);
}
/**
* Determine if the given name is a valid PHP identifier, and therefore may
* be used as a single namespace name.
*/
public static function isValidIdentifier(string $name): bool
{
return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $name) === 1;
}
/**
* Determine if the given name may be declared as a class name by an `eval`.
*/
public static function isValidClassName(string $name): bool
{
if (! self::isValidIdentifier($name)) {
return false;
}
if (in_array(strtolower($name), self::RESERVED_CLASS_NAMES, true)) {
return false;
}
$tokens = token_get_all(sprintf('<?php %s;', $name));
// Anything the lexer sees as a keyword, like `list` or `match`, may not
// be used as a class name.
return is_array($tokens[1] ?? null) && $tokens[1][0] === T_STRING;
}
/**
* Get the portion of a string before the last occurrence of a given value.
*/
@@ -8,8 +8,8 @@ use Pest\Contracts\TestCaseFilter;
use Pest\Exceptions\MissingDependency;
use Pest\Exceptions\NoDirtyTestsFound;
use Pest\Panic;
use Pest\Support\Git;
use Pest\TestSuite;
use Symfony\Component\Process\Process;
final class GitDirtyTestCaseFilter implements TestCaseFilter
{
@@ -52,14 +52,13 @@ final class GitDirtyTestCaseFilter implements TestCaseFilter
*/
private function loadChangedFiles(): void
{
$process = new Process(['git', 'status', '--short', '--', '*.php']);
$process->run();
$status = new Git(timeout: 60.0)->raw(['status', '--short', '--', '*.php']);
if (! $process->isSuccessful()) {
if ($status === null) {
throw new MissingDependency('Filter by dirty files', 'git');
}
$output = preg_split('/\R+/', $process->getOutput(), flags: PREG_SPLIT_NO_EMPTY);
$output = preg_split('/\R+/', $status, flags: PREG_SPLIT_NO_EMPTY);
assert(is_array($output));
$dirtyFiles = [];
@@ -1,5 +1,5 @@
Pest Testing Framework 5.0.3.
Pest Testing Framework 5.0.4.
USAGE: pest <file> [options]
@@ -1,3 +1,3 @@
Pest Testing Framework 5.0.3.
Pest Testing Framework 5.0.4.
+13 -13
View File
@@ -1,27 +1,27 @@
##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='pest_qn://tests/.tests/Failure.php' flowId='1234']
##teamcity[testSuiteStarted name='Tests/Fixtures/Suites/Failure' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php' flowId='1234']
##teamcity[testCount count='8' flowId='1234']
##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234']
##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/.tests/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234']
##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can fail with comparison' flowId='1234']
##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/Fixtures/Suites/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234']
##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234']
##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234']
##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can be ignored because of no assertions' flowId='1234']
##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234']
##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234']
##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234']
##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can be ignored because it is skipped' flowId='1234']
##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234']
##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234']
##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234']
##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18' flowId='1234']
##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can fail' flowId='1234']
##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/Fixtures/Suites/Failure.php:18' flowId='1234']
##teamcity[testFinished name='it can fail' duration='100000' flowId='1234']
##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/.tests/Failure.php::it throws exception' flowId='1234']
##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/.tests/Failure.php:22' flowId='1234']
##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it throws exception' flowId='1234']
##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/Fixtures/Suites/Failure.php:22' flowId='1234']
##teamcity[testFinished name='it throws exception' duration='100000' flowId='1234']
##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234']
##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it is not done yet' flowId='1234']
##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234']
##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234']
##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::build this one.' flowId='1234']
##teamcity[testFinished name='build this one.' duration='100000' flowId='1234']
##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/.tests/Failure.php::it is passing' flowId='1234']
##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it is passing' flowId='1234']
##teamcity[testFinished name='it is passing' duration='100000' flowId='1234']
##teamcity[testSuiteFinished name='Tests/tests/Failure' flowId='1234']
##teamcity[testSuiteFinished name='Tests/Fixtures/Suites/Failure' flowId='1234']
Tests: 3 failed, 1 risky, 2 todos, 1 skipped, 1 passed (3 assertions)
Duration: 1.00s
+8 -8
View File
@@ -1,18 +1,18 @@
##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234']
##teamcity[testSuiteStarted name='Tests/Fixtures/Suites/SuccessOnly' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php' flowId='1234']
##teamcity[testCount count='4' flowId='1234']
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234']
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::it can pass with comparison' flowId='1234']
##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234']
##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234']
##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::can also pass' flowId='1234']
##teamcity[testFinished name='can also pass' duration='100000' flowId='1234']
##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset' flowId='1234']
##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::can pass with dataset' flowId='1234']
##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
##teamcity[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234']
##teamcity[testSuiteFinished name='can pass with dataset' flowId='1234']
##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
##teamcity[testFinished name='`block` → can pass with dataset in describe block with data set "(1)"' duration='100000' flowId='1234']
##teamcity[testSuiteFinished name='`block` → can pass with dataset in describe block' flowId='1234']
##teamcity[testSuiteFinished name='Tests/tests/SuccessOnly' flowId='1234']
##teamcity[testSuiteFinished name='Tests/Fixtures/Suites/SuccessOnly' flowId='1234']
Tests: 4 passed (4 assertions)
Duration: 1.00s
+1 -14
View File
@@ -1,17 +1,4 @@
WARN Tests\Fixtures\CollisionTest
- error
- success
PASS Tests\Fixtures\DirectoryWithTests\ExampleTest
✓ it example 1
PASS Tests\Fixtures\ExampleTest
✓ it example 2
WARN Tests\Fixtures\Inheritance\Base\ExampleTest
- example
PASS Tests\Fixtures\Inheritance\ExampleTest
✓ example
Tests: 3 skipped, 3 passed (3 assertions)
Tests: 1 passed (1 assertions)
+227 -1
View File
@@ -1565,6 +1565,229 @@
✓ it allows performing no expectations without being risky
✓ a "describe" group of tests → it allows performing no expectations without being risky
PASS Tests\Features\Tia
✓ it does not run user hooks when replaying cached skipped and incomplete results
PASS Tests\Features\Tia\BranchShapes
✓ a branch name git allows is a branch key TIA can hold with dataset "slashes"
✓ a branch name git allows is a branch key TIA can hold with dataset "dots"
✓ a branch name git allows is a branch key TIA can hold with dataset "unicode"
✓ a branch name git allows is a branch key TIA can hold with dataset "digits"
✓ a branch name git allows is a branch key TIA can hold with dataset "underscores"
✓ a branch name git allows is a branch key TIA can hold with dataset "very long"
✓ a branch differing from the default only in case gets its own key
✓ a branch that only lives on the remote keeps its baseline
✓ a branch checked out in a worktree keeps its baseline
✓ deleting many branches reclaims every one of their baselines with dataset "sequential"
✓ deleting many branches reclaims every one of their baselines with dataset "parallel"
✓ a narrowed run does not reclaim anything
✓ a detached HEAD does not reclaim anything either
✓ the default branch baseline survives every branch that comes and goes
✓ a project below the git repository root refuses to run and writes nothing with dataset "sequential"
✓ a project below the git repository root refuses to run and writes nothing with dataset "parallel"
✓ a repository with no commits says so, and leaves plain runs alone
✓ a directory with no repository at all still asks for git
PASS Tests\Features\Tia\CompleteRunWriteTier
✓ a complete run prunes a deleted test with dataset "sequential"
✓ a complete run prunes a deleted test with dataset "parallel"
✓ a complete run records nothing for a test file the graph does not know
✓ a partial run records nothing for a test file the graph does not know
✓ a truncated run does not prune with dataset "sequential"
✓ a truncated run does not prune with dataset "parallel"
✓ a green bail run is complete
✓ --no-tia refreshes results without enabling tia with dataset "sequential"
✓ --no-tia refreshes results without enabling tia with dataset "parallel"
✓ a plain run refreshes the results it executed with dataset "sequential"
✓ a plain run refreshes the results it executed with dataset "parallel"
✓ a parallel replay keeps the recorded time of tests that did not run
✓ a run that never enables tia creates no graph with dataset "plain"
✓ a run that never enables tia creates no graph with dataset "filtered"
✓ a run that never enables tia creates no graph with dataset "parallel filtered"
✓ a test edit narrows to the affected file and replays the rest
✓ a parallel run merges worker results into the parent baseline
PASS Tests\Features\Tia\CoveragePiggyback
✓ a coverage report does not found a dependency graph with dataset "pest coverage"
✓ a coverage report does not found a dependency graph with dataset "phpunit coverage report"
✓ a coverage report does not found a dependency graph with dataset "parallel"
✓ a plain run after a coverage run records the whole project scope
✓ a coverage report leaves the edges of an existing graph alone
PASS Tests\Features\Tia\DefaultBranchReplay
✓ replays the default branch baseline on a new branch
✓ replays whatever the default branch is called with ('main')
✓ replays whatever the default branch is called with ('master')
✓ replays whatever the default branch is called with ('trunk')
✓ replays whatever the default branch is called with ('develop')
✓ replays on a second new branch too
✓ writes nothing on a second run on the same branch
✓ replays on a branch whose name contains slashes
✓ replays again once back on the default branch
✓ replays on a new branch when tia is enabled by configuration
✓ a narrowed run on a new branch does not cost the fallback with dataset "sequential"
✓ a narrowed run on a new branch does not cost the fallback with dataset "parallel"
✓ replays inside a worktree on a new branch
PASS Tests\Features\Tia\DefaultBranchResolution
✓ a declared default branch beats autodetection
✓ a declared default branch that does not exist degrades to a full run
✓ a renamed default branch replays and writes under its new name
✓ the CI provider names the default branch where the checkout cannot
✓ GitLab names the default branch through its own variable
✓ a lone recorded baseline names the default branch
✓ a default branch nothing can name is refused rather than guessed
✓ an init.defaultBranch naming a branch that exists is still trusted
✓ a repository with no remote is refused rather than silently re-run
✓ a remote-less repository holding one baseline is not refused
✓ a declared default branch stands in for a missing remote
✓ tia still requires git
✓ a plain run outside a repository creates no baseline
✓ the default branch is resolved once per run, not once per test
PASS Tests\Features\Tia\DefaultBranchWriteTier
✓ narrows to the affected tests on a new branch
✓ filtered mode reads the fallback too
✓ filtered mode finds nothing to do on a clean green feature branch
✓ filtered mode falls back to a full replay when a cached failure cannot be located
✓ filtered mode finds nothing to do on the default branch itself
✓ a detached HEAD replays without minting a branch key
✓ a detached HEAD does not write into the default branch baseline with dataset "sequential"
✓ a detached HEAD does not write into the default branch baseline with dataset "parallel"
✓ the branch that ran gets its own key and the default branch keeps its baseline
✓ the fallback reaches parallel workers
PASS Tests\Features\Tia\FilteredMode
✓ re-runs a cached failure on a clean tree
✓ an explicit path turns filtered mode off
✓ filtered mode runs the whole suite when there is no baseline
✓ filtered mode finds nothing to do in parallel either
✓ a corrupt graph is reported and does not crash the run
✓ --parallel --retry is refused and leaves the graph alone
PASS Tests\Features\Tia\HostileState
✓ a graph mangled beyond use still lets the suite run with dataset "empty"
✓ a graph mangled beyond use still lets the suite run with dataset "truncated"
✓ a graph mangled beyond use still lets the suite run with dataset "not json"
✓ a graph mangled beyond use still lets the suite run with dataset "json scalar"
✓ a graph mangled beyond use still lets the suite run with dataset "json list"
✓ a graph mangled beyond use still lets the suite run with dataset "json null"
✓ a graph mangled beyond use still lets the suite run with dataset "empty object"
✓ a graph mangled beyond use still lets the suite run with dataset "nul bytes"
✓ a graph whose shape is wrong everywhere is repaired rather than trusted with dataset "sequential"
✓ a graph whose shape is wrong everywhere is repaired rather than trusted with dataset "parallel"
✓ a cached status this build cannot interpret is re-run, not replayed with dataset "below the range"
✓ a cached status this build cannot interpret is re-run, not replayed with dataset "one past the range"
✓ a cached status this build cannot interpret is re-run, not replayed with dataset "far past the range"
✓ a cached status this build cannot interpret is re-run, not replayed with dataset "huge"
✓ a cached status with no replay of its own does not fail the run with dataset "notice"
✓ a cached status with no replay of its own does not fail the run with dataset "deprecation"
✓ a cached status with no replay of its own does not fail the run with dataset "warning"
✓ a cached skip or todo replays with its message intact with dataset "skipped"
✓ a cached skip or todo replays with its message intact with dataset "incomplete"
✓ a cached failure with a multi-line message re-runs rather than replaying the text
✓ a result pointing outside the project is not addressable and widens the run
✓ an edge pointing at a file id that does not exist is ignored
✓ a graph from a schema this build does not know is rebuilt, not read
✓ graph.json being a directory does not stop the run
✓ a state dir it cannot write to still replays
PASS Tests\Features\Tia\IssueStatuses
✓ a triggered issue is recorded as itself, not as a pass with dataset "sequential" / dataset "deprecation"
✓ a triggered issue is recorded as itself, not as a pass with dataset "sequential" / dataset "notice"
✓ a triggered issue is recorded as itself, not as a pass with dataset "sequential" / dataset "warning"
✓ a triggered issue is recorded as itself, not as a pass with dataset "parallel" / dataset "deprecation"
✓ a triggered issue is recorded as itself, not as a pass with dataset "parallel" / dataset "notice"
✓ a triggered issue is recorded as itself, not as a pass with dataset "parallel" / dataset "warning"
✓ a cached deprecation still fails the run that asked to fail on one with dataset "sequential"
✓ a cached deprecation still fails the run that asked to fail on one with dataset "parallel"
✓ replaying a cached issue does not downgrade it to a pass with dataset "sequential"
✓ replaying a cached issue does not downgrade it to a pass with dataset "parallel"
✓ a failure outranks an issue triggered on the way to it
✓ a skip outranks an issue triggered on the way to it
✓ a suppressed issue is not recorded
PASS Tests\Features\Tia\PartialRunWriteTier
✓ a filtered run rewrites only the test that ran
✓ a filtered run under --tia announces that tia does not apply
✓ a test suffix narrows the tier even though every test runs
✓ a dirty run narrows to the uncommitted test edit
✓ filtered mode yields to an explicit filter
✓ an env flag narrows exactly like the option it mirrors with ('PEST_TIA')
✓ an env flag narrows exactly like the option it mirrors with ('PEST_TIA_FILTERED')
✓ a partial run does not purge the graph even with --fresh
✓ --no-tia does not stop a partial run from refreshing its own entry
✓ two partial runs each keep the other entry
✓ a shard is a partial run
✓ a parallel partial run records the test that ran, like a sequential one
PASS Tests\Features\Tia\RemoteBaseline
✓ a published baseline is fetched instead of recorded locally
✓ a fetched baseline that will not decode is discarded rather than trusted
✓ a fetched baseline recorded against another tree is not used
✓ an artifact without a graph in it fails loudly
✓ a baseline that cannot be authenticated for fails loudly
✓ a workflow or artifact that is not there fails loudly
✓ a network failure warns and lets the suite run with dataset "querying the runs"
✓ a network failure warns and lets the suite run with dataset "downloading the artifact"
✓ no published baseline yet starts a cooldown, and a corrupt cooldown does not break the run
PASS Tests\Features\Tia\SelectionPaths
✓ a committed rename selects the tests that depended on the old path with dataset "sequential"
✓ a committed rename selects the tests that depended on the old path with dataset "parallel"
✓ an affected test file that is gone does not strand a filtered run with dataset "sequential"
✓ an affected test file that is gone does not strand a filtered run with dataset "parallel"
✓ a plain run reclaims the edge of a test file that is gone
✓ a changed view selects the test that rendered it
✓ a changed partial selects the test that rendered its ancestor with dataset "direct @include"
✓ a changed partial selects the test that rendered its ancestor with dataset "transitive @include"
✓ a changed partial selects the test that rendered its ancestor with dataset "x- component"
✓ a changed partial selects the test that rendered its ancestor with dataset "include cycle"
✓ a changed Inertia page selects the test that rendered its component
✓ a changed shared JS module selects the tests of the pages that import it
✓ a changed frontend runtime file selects every Inertia test
PASS Tests\Features\Tia\StateReclamation
✓ a detached HEAD does not purge the graph on structural drift with dataset "sequential"
✓ a detached HEAD does not purge the graph on structural drift with dataset "parallel"
✓ a detached HEAD does not purge the graph with --fresh either with dataset "sequential"
✓ a detached HEAD does not purge the graph with --fresh either with dataset "parallel"
✓ a detached HEAD leaves an unreadable graph for a checkout that can rebuild it
✓ a cached failure whose test file was deleted stops widening later runs with dataset "sequential"
✓ a cached failure whose test file was deleted stops widening later runs with dataset "parallel"
✓ a complete run reclaims the entry and the edge of a deleted test file with dataset "sequential"
✓ a complete run reclaims the entry and the edge of a deleted test file with dataset "parallel"
✓ a pruned result does not come back from the fallback with dataset "sequential"
✓ a pruned result does not come back from the fallback with dataset "parallel"
✓ the fallback still reaches a branch that has never run a test file
✓ a branch that git no longer knows loses its baseline
✓ an unknown cached status is re-run rather than replayed as a failure with dataset "unknown"
✓ an unknown cached status is re-run rather than replayed as a failure with dataset "future"
✓ an unknown cached status is re-run rather than replayed as a failure with dataset "garbage"
✓ a cached notice, deprecation or warning does not replay as a failure with dataset "notice"
✓ a cached notice, deprecation or warning does not replay as a failure with dataset "deprecation"
✓ a cached notice, deprecation or warning does not replay as a failure with dataset "warning"
✓ a malformed baseline entry cannot break the run with dataset "sequential"
✓ a malformed baseline entry cannot break the run with dataset "parallel"
✓ a run torn down mid-file does not prune the tests it never reached with dataset "sequential"
✓ a run torn down mid-file does not prune the tests it never reached with dataset "parallel"
✓ a fatal error mid-file is a test error, not a truncation with dataset "sequential"
✓ a fatal error mid-file is a test error, not a truncation with dataset "parallel"
✓ a green complete run leaves the graph exactly as it found it with dataset "bail"
✓ a green complete run leaves the graph exactly as it found it with dataset "stop-on-failure"
✓ a green complete run leaves the graph exactly as it found it with dataset "compact"
✓ a green complete run leaves the graph exactly as it found it with dataset "parallel bail"
✓ a green complete run leaves the graph exactly as it found it with dataset "parallel one process"
✓ a green complete run leaves the graph exactly as it found it with dataset "parallel more processes than files"
✓ --tia --no-tia is a plain run that still refreshes what it executed with dataset "sequential"
✓ --tia --no-tia is a plain run that still refreshes what it executed with dataset "parallel"
✓ --fresh on a partial run neither purges nor prunes with dataset "sequential"
✓ --fresh on a partial run neither purges nor prunes with dataset "parallel"
✓ a second green run on a feature branch writes nothing at all with dataset "sequential"
✓ a second green run on a feature branch writes nothing at all with dataset "parallel"
✓ a graph whose recorded commit is gone is re-anchored, not warned about forever with dataset "sequential"
✓ a graph whose recorded commit is gone is re-anchored, not warned about forever with dataset "parallel"
PASS Tests\Features\Ticket
✓ it may be associated with an ticket #1, #2
✓ nested → it may be associated with an ticket #1, #4, #5, #6, #3
@@ -1921,10 +2144,12 @@
✓ fromSql() → it records the table, not the schema, for qualified identifiers
✓ fromSql() → it handles quoted identifiers
✓ fromSql() → it ignores schema metadata tables
✓ fromSql() → it does not leak int keys for numeric identifiers
✓ fromSql() → it returns nothing for non-DML statements
✓ fromMigrationSource() → it extracts tables from Schema builder calls
✓ fromMigrationSource() → it extracts tables from raw DDL statements
✓ fromMigrationSource() → it records the table, not the schema, in qualified DDL and DML
✓ fromMigrationSource() → it does not leak int keys for numeric table names
✓ fromMigrationSource() → it extracts tables from DB::table calls
PASS Tests\Unit\Plugins\Tia\TestPaths
@@ -2158,6 +2383,7 @@
✓ a parallel test can extend another test with same name
✓ parallel reports invalid datasets as failures
✓ parallel can have multiple exclude-groups
✓ parallel can have multiple groups
PASS Tests\Visual\ParallelNestedDatasets
✓ parallel loads nested datasets from nested directories
@@ -2191,4 +2417,4 @@
✓ pass with dataset with ('my-datas-set-value')
✓ within describe → pass with dataset with ('my-datas-set-value')
Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1545 passed (3378 assertions)
Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1743 passed (3953 assertions)
+5 -5
View File
@@ -18,27 +18,27 @@ $run = function (string $target): array {
};
test('reports missing datasets as errors for a single file run', function () use ($run): void {
$result = $run('tests/.tests/IssueOnly.php');
$result = $run('tests/Fixtures/Suites/IssueOnly.php');
expect($result['output'])
->toContain("A dataset with the name `missing` does not exist. You can create it using `dataset('missing', ['a', 'b']);`.")
->toContain("A dataset with the name [missing] does not exist. You can create it using `dataset('missing', ['a', 'b']);`.")
->toContain('FAILED')
->toContain('Tests: 1 failed')
->and($result['code'])->not->toBe(0);
})->skipOnWindows();
test('reports missing datasets as errors alongside passing tests', function () use ($run): void {
$result = $run('tests/.tests/IssueWithPassing.php');
$result = $run('tests/Fixtures/Suites/IssueWithPassing.php');
expect($result['output'])
->toContain("A dataset with the name `missing` does not exist. You can create it using `dataset('missing', ['a', 'b']);`.")
->toContain("A dataset with the name [missing] does not exist. You can create it using `dataset('missing', ['a', 'b']);`.")
->toContain('1 passed')
->toContain('1 failed')
->and($result['code'])->not->toBe(0);
})->skipOnWindows();
test('reports dataset closure exceptions as errors', function () use ($run): void {
$result = $run('tests/.tests/DatasetClosureThrows.php');
$result = $run('tests/Fixtures/Suites/DatasetClosureThrows.php');
expect($result['output'])
->toContain('boom from dataset')
+2 -2
View File
@@ -10,12 +10,12 @@ beforeEach(function (): void {
});
it('throws exception if dataset does not exist', function (): void {
expect(fn () => DatasetsRepository::resolve(['first'], __FILE__))->toThrow(DatasetDoesNotExist::class, "A dataset with the name `first` does not exist. You can create it using `dataset('first', ['a', 'b']);`.");
expect(fn () => DatasetsRepository::resolve(['first'], __FILE__))->toThrow(DatasetDoesNotExist::class, "A dataset with the name [first] does not exist. You can create it using `dataset('first', ['a', 'b']);`.");
});
it('throws exception if dataset already exist', function (): void {
DatasetsRepository::set('second', [[]], __DIR__);
expect(fn () => DatasetsRepository::set('second', [[]], __DIR__))->toThrow(DatasetAlreadyExists::class, 'A dataset with the name `second` already exists in scope ['.__DIR__.'].');
expect(fn () => DatasetsRepository::set('second', [[]], __DIR__))->toThrow(DatasetAlreadyExists::class, 'A dataset with the name [second] already exists in scope ['.__DIR__.'].');
});
it('sets closures', function (): void {
+1 -1
View File
@@ -142,7 +142,7 @@ it('works as higher order test')
it('fails after exhausting all retries', function (): void {
$process = new Process(
['php', 'bin/pest', 'tests/.tests/FlakyFailure.php'],
['php', 'bin/pest', 'tests/Fixtures/Suites/FlakyFailure.php'],
dirname(__DIR__, 2),
['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true', 'PAO_DISABLE' => '1'],
);
+84
View File
@@ -0,0 +1,84 @@
<?php
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\FileState;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage;
use Pest\Support\Str;
use Symfony\Component\Process\Process;
it('does not run user hooks when replaying cached skipped and incomplete results', function (): void {
$projectRoot = dirname(__DIR__, 2);
$home = sys_get_temp_dir().'/pest-tia-'.bin2hex(random_bytes(8));
$fixture = 'tests/Fixtures/Suites/TiaReplayHooks.php';
mkdir($home, 0755, true);
try {
$changedFiles = new ChangedFiles($projectRoot);
$branch = $changedFiles->currentBranch() ?? 'main';
$sha = $changedFiles->currentSha();
$id = fn (string $description): string => 'P\Tests\Fixtures\Suites\TiaReplayHooks::'.Str::evaluable($description);
$graph = new Graph($projectRoot);
$graph->setFingerprint(Fingerprint::compute($projectRoot));
$graph->setRecordedAtSha($branch, $sha);
// Hashes the working tree as it stands, so the replay sees nothing as changed.
$graph->setLastRunTree($branch, $changedFiles->snapshotTree($changedFiles->since($sha) ?? []));
$graph->markKnownTestFiles([$fixture]);
$graph->setResult($branch, $id('replayed pass'), 0, '', 0.01, 1, $fixture);
$graph->setResult($branch, $id('replayed skip'), 1, 'cached skip', 0.01, 0, $fixture);
$graph->setResult($branch, $id('replayed incomplete'), 2, 'cached incomplete', 0.01, 0, $fixture);
$json = $graph->encode();
expect($json)->not->toBeNull();
$originalHome = getenv('HOME');
putenv('HOME='.$home);
try {
$storage = new FileState(Storage::tempDir($projectRoot));
} finally {
putenv($originalHome === false ? 'HOME' : 'HOME='.$originalHome);
}
expect($storage->write(Tia::KEY_GRAPH, (string) $json))->toBeTrue();
$process = new Process(
['php', 'bin/pest', '--configuration', 'tests/Fixtures/Suites/TiaReplayHooks.xml', '--tia'],
$projectRoot,
[
'COLLISION_PRINTER' => 'DefaultPrinter',
'COLLISION_IGNORE_DURATION' => 'true',
'PARATEST' => 0,
'PAO_DISABLE' => '1',
'HOME' => $home,
],
);
$process->run();
$output = removeAnsiEscapeSequences($process->getOutput().$process->getErrorOutput());
// Both hooks throw, so the run stays green only if neither one ran.
expect($output)->toContain('3 replayed')
->and($output)->not->toContain('must not run for replayed tests')
->and($output)->toContain('1 incomplete, 1 skipped, 1 passed')
->and($process->getExitCode())->toBe(0);
} finally {
$paths = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($home, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($paths as $path) {
$path->isDir() ? @rmdir($path->getPathname()) : @unlink($path->getPathname());
}
@rmdir($home);
}
})->skipOnWindows();
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
test('a branch name git allows is a branch key TIA can hold', function (string $branch): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo($branch, new: true);
$result = $project->pest('--tia');
$delta = $project->delta();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe())
->and($project->branchKeys())->toBe(['master', $branch])
->and($delta->baselineUntouched('master'))->toBeTrue($delta->summary());
})->with([
'slashes' => 'feature/deep/nesting',
'dots' => 'release.1.2.x',
'unicode' => 'feature-café-日本',
'digits' => '12345',
'underscores' => 'feature_x_y',
'very long' => 'feature-'.str_repeat('x', 180),
])->skipOnWindows();
test('a branch differing from the default only in case gets its own key', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('MASTER-2', new: true);
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($project->branchKeys())->toBe(['master', 'MASTER-2']);
})->skipOnWindows();
test('a branch that only lives on the remote keeps its baseline', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('remote-only', new: true);
$project->pest('--tia');
$project->git()->switchTo('master');
$project->git()->run(['update-ref', 'refs/remotes/origin/remote-only', 'HEAD']);
$project->git()->run(['branch', '-D', 'remote-only']);
$project->pest('--tia');
expect($project->branchKeys())->toBe(['master', 'remote-only']);
})->skipOnWindows();
test('a branch checked out in a worktree keeps its baseline', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('wt-branch', new: true);
$project->pest('--tia');
$project->git()->switchTo('master');
$project->git()->run(['worktree', 'add', '--quiet', $project->path().'-wt', 'wt-branch']);
$project->pest('--tia');
$project->git()->run(['worktree', 'remove', '--force', $project->path().'-wt']);
expect($project->branchKeys())->toBe(['master', 'wt-branch']);
})->skipOnWindows();
test('deleting many branches reclaims every one of their baselines', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
foreach (range(1, 4) as $index) {
$project->git()->switchTo('feature-'.$index, new: true);
$project->pest('--tia', ...$arguments);
}
expect($project->branchKeys())->toHaveCount(5);
$project->git()->switchTo('master');
foreach (range(1, 4) as $index) {
$project->git()->run(['branch', '-D', 'feature-'.$index]);
}
$project->pest('--tia', ...$arguments);
expect($project->branchKeys())->toBe(['master']);
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a narrowed run does not reclaim anything', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest('--tia');
$project->git()->switchTo('master');
$project->git()->run(['branch', '-D', 'feature-x']);
$project->snapshot();
$project->pest('--tia', '--filter=adds two numbers');
$delta = $project->delta();
expect($project->branchKeys())->toBe(['master', 'feature-x'])
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('a detached HEAD does not reclaim anything either', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest('--tia');
$project->git()->switchTo('master');
$project->git()->run(['branch', '-D', 'feature-x']);
$project->git()->detach();
$project->snapshot();
$project->pest('--tia');
$delta = $project->delta();
expect($project->branchKeys())->toBe(['master', 'feature-x'])
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->skipOnWindows();
test('the default branch baseline survives every branch that comes and goes', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest('--tia');
$project->git()->switchTo('master');
$project->git()->run(['branch', '-D', 'feature-x']);
$project->snapshot();
$result = $project->pest('--tia');
$delta = $project->delta();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($delta->writtenCount())->toBe(0, $delta->summary())
->and($delta->added())->toBe(0, $delta->summary())
->and($delta->removed())->toBe(0, $delta->summary())
->and($project->graph()['baselines']['master']['results'])->toHaveCount(Project::TOTAL_TESTS);
})->skipOnWindows();
test('a project below the git repository root refuses to run and writes nothing', function (array $arguments): void {
$project = Project::make('master');
$nested = $project->nested();
$result = $project->pestIn($nested, '--tia', ...$arguments);
expect($result->exitCode)->toBe(1, $result->describe())
->and($result->output)->toContain('Tia mode requires the git repository root')
->and($project->path('.home/.pest'))->not->toBeDirectory()
->and($nested.DIRECTORY_SEPARATOR.'.pest')->not->toBeDirectory();
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a repository with no commits says so, and leaves plain runs alone', function (): void {
$project = Project::withoutGit();
$project->git()->run(['init', '--quiet']);
$project->git()->run(['checkout', '--quiet', '-b', 'master']);
$project->git()->addOrigin();
$tia = $project->pest('--tia');
expect($tia->exitCode)->toBe(1, $tia->describe())
->and($tia->output)->toContain('Tia mode requires at least one commit')
->and($tia->output)->not->toContain('requires [git]')
->and($project->graphExists())->toBeFalse();
$plain = $project->pest();
expect($plain->exitCode)->toBe(0, $plain->describe())
->and($plain->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->skipOnWindows();
test('a directory with no repository at all still asks for git', function (): void {
$project = Project::withoutGit();
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(1, $result->describe())
->and($result->output)->toContain('requires [git]')
->and($project->graphExists())->toBeFalse();
})->skipOnWindows();
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
test('a complete run prunes a deleted test', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/GreeterTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Greeter;
test('greets a person', function (): void {
expect((new Greeter)->greet('Nuno'))->toBe('Hello, Nuno!');
});
PHP);
$result = $project->pest(...$arguments);
$delta = $project->delta();
expect($result->tally())->toContain('5 passed')
->and($delta->removed())->toBe(1, $delta->summary())
->and($delta->added())->toBe(0, $delta->summary())
->and($delta->structureMoved())->toBeFalse($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a complete run records nothing for a test file the graph does not know', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/BrandNewTest.php', <<<'PHP'
<?php
declare(strict_types=1);
test('brand new thing', function (): void {
expect(true)->toBeTrue();
});
PHP);
$result = $project->pest();
$delta = $project->delta();
expect($result->tally())->toContain('7 passed')
->and($delta->added())->toBe(0, $delta->summary())
->and($delta->removed())->toBe(0, $delta->summary())
->and($delta->writtenCount())->toBe(Project::TOTAL_TESTS, $delta->summary());
})->skipOnWindows();
test('a partial run records nothing for a test file the graph does not know', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/BrandNewTest.php', <<<'PHP'
<?php
declare(strict_types=1);
test('brand new thing', function (): void {
expect(true)->toBeTrue();
});
PHP);
$result = $project->pest('--filter=brand new thing');
$delta = $project->delta();
expect($result->tally())->toContain('1 passed')
->and($delta->added())->toBe(0, $delta->summary())
->and($delta->writtenCount())->toBe(0, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('a truncated run does not prune', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/GreeterTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Greeter;
test('greets a person', function (): void {
expect((new Greeter)->greet('Nuno'))->toBe('Goodbye, Nuno!');
});
test('greets the world', function (): void {
expect((new Greeter)->greet('world'))->toBe('Hello, world!');
});
PHP);
$result = $project->pest('--bail', ...$arguments);
$delta = $project->delta();
expect($result->exitCode)->toBe(1, $result->describe())
->and($result->tally())->toContain('1 failed')
->and($delta->removed())->toBe(0, $delta->summary())
->and($delta->structureMoved())->toBeFalse($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a green bail run is complete', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--bail');
$delta = $project->delta();
expect($result->exitCode)->toBe(0, $result->describe())
->and($delta->writtenCount())->toBe(Project::TOTAL_TESTS, $delta->summary())
->and($delta->removed())->toBe(0, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('--no-tia refreshes results without enabling tia', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--no-tia', ...$arguments);
$delta = $project->delta();
expect($result->output)->not->toContain('Experimental TIA mode enabled')
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($delta->writtenCount())->toBe(Project::TOTAL_TESTS, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a plain run refreshes the results it executed', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest(...$arguments);
$delta = $project->delta();
expect($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($delta->writtenCount())->toBe(Project::TOTAL_TESTS, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a parallel replay keeps the recorded time of tests that did not run', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->pest('--tia', '--parallel', '--processes=2');
$delta = $project->delta();
expect($delta->writtenCount())->toBe(0, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('a run that never enables tia creates no graph', function (array $arguments): void {
$project = Project::make('master');
$result = $project->pest(...$arguments);
expect($result->exitCode)->toBe(0, $result->describe())
->and($project->graphExists())->toBeFalse();
})->with([
'plain' => [[]],
'filtered' => [['--filter=adds two numbers']],
'parallel filtered' => [['--parallel', '--processes=2', '--filter=adds two numbers']],
])->skipOnWindows();
test('a test edit narrows to the affected file and replays the rest', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/GreeterTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Greeter;
test('greets a person', function (): void {
expect((new Greeter)->greet('Nuno'))->toBe('Hello, Nuno!');
expect((new Greeter)->greet('Nuno'))->toBeString();
});
test('greets the world', function (): void {
expect((new Greeter)->greet('world'))->toBe('Hello, world!');
});
PHP);
$result = $project->pest('--tia');
$delta = $project->delta();
expect($result->affected())->toBe(2, $result->describe())
->and($result->replayed())->toBe(4, $result->describe())
->and($delta->writtenCount())->toBe(2, $delta->summary())
->and($delta->edgesMoved())->toBeFalse($delta->summary())
->and($delta->removed())->toBe(0, $delta->summary());
})->skipOnWindows();
test('a parallel run merges worker results into the parent baseline', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/GreeterTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Greeter;
test('greets a person', function (): void {
expect((new Greeter)->greet('Nuno'))->toBe('Hello, Nuno!');
expect((new Greeter)->greet('Nuno'))->toBeString();
});
test('greets the world', function (): void {
expect((new Greeter)->greet('world'))->toBe('Hello, world!');
});
PHP);
$result = $project->pest('--tia', '--parallel', '--processes=2');
$delta = $project->delta();
expect($result->affected())->toBe(2, $result->describe())
->and($result->replayed())->toBe(4, $result->describe())
->and($delta->writtenCount())->toBe(2, $delta->summary())
->and($delta->edgesMoved())->toBeFalse($delta->summary())
->and($delta->removed())->toBe(0, $delta->summary());
})->skipOnWindows();
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
test('a coverage report does not found a dependency graph', function (array $arguments): void {
$project = Project::make('master');
$project->pest('--tia', ...$arguments);
expect($project->graphExists())->toBeFalse();
})->with([
'pest coverage' => [['--coverage']],
'phpunit coverage report' => [['--coverage-text']],
'parallel' => [['--coverage', '--parallel', '--processes=2']],
])->skipOnWindows();
test('a plain run after a coverage run records the whole project scope', function (): void {
$project = Project::make('master');
$project->pest('--tia', '--coverage');
$project->pest('--tia');
$graph = $project->graph();
if ($graph === null) {
expect($project->graphExists())->toBeFalse();
return;
}
expect(array_keys($graph['edges']))->toEqualCanonicalizing(array_keys(Project::EDGES))
->and($graph['files'])->toContain('tests/Unit/CalculatorTest.php')
->and($graph['files'])->toContain('app/Calculator.php');
})->skipOnWindows();
test('a coverage report leaves the edges of an existing graph alone', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->pest('--tia', '--coverage');
$delta = $project->delta();
expect($delta->edgesMoved())->toBeFalse($delta->summary())
->and($delta->filesMoved())->toBeFalse($delta->summary())
->and($delta->removed())->toBe(0, $delta->summary())
->and($delta->added())->toBe(0, $delta->summary());
})->skipOnWindows();
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
test('replays the default branch baseline on a new branch', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe())
->and($result->exitCode)->toBe(0);
})->skipOnWindows();
test('replays whatever the default branch is called', function (string $defaultBranch): void {
$project = Project::make($defaultBranch);
$project->seed($defaultBranch);
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe());
})->with(['main', 'master', 'trunk', 'develop'])->skipOnWindows();
test('replays on a second new branch too', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest('--tia');
$project->git()->switchTo('master');
$project->git()->switchTo('feature-y', new: true);
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe());
})->skipOnWindows();
test('writes nothing on a second run on the same branch', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest('--tia');
$project->snapshot();
$result = $project->pest('--tia');
$delta = $project->delta();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($delta->writtenCount())->toBe(0, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('replays on a branch whose name contains slashes', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature/x/y', new: true);
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe())
->and($project->branchKeys())->toContain('feature/x/y');
})->skipOnWindows();
test('replays again once back on the default branch', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest('--tia');
$project->git()->switchTo('master');
$project->snapshot();
$result = $project->pest('--tia');
$delta = $project->delta();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe())
->and($delta->writtenCount())->toBe(0, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('replays on a new branch when tia is enabled by configuration', function (): void {
$project = Project::make('master', overlay: 'always-enabled');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe())
->and($project->branchKeys())->toBe(['master', 'feature-x']);
})->skipOnWindows();
test('a narrowed run on a new branch does not cost the fallback', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest(...$arguments);
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe());
})->with([
'sequential' => [['--filter=adds two numbers']],
'parallel' => [['--parallel', '--processes=2', '--filter=adds two numbers']],
])->skipOnWindows();
test('replays inside a worktree on a new branch', function (): void {
$project = Project::make('master');
$worktree = $project->worktree('feature-worktree');
$project->seedFor($worktree, 'master');
$result = $project->pestIn($worktree, '--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe());
})->skipOnWindows();
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
use Symfony\Component\Process\ExecutableFinder;
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
test('a declared default branch beats autodetection', function (): void {
$project = Project::make('develop', overlay: 'configured-default-branch');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe());
})->skipOnWindows();
test('a declared default branch that does not exist degrades to a full run', function (): void {
$project = Project::make('master', overlay: 'unknown-default-branch');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia');
expect($result->uncached())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->exitCode)->toBe(0, $result->describe())
->and($project->branchKeys())->toBe(['master', 'feature-x']);
})->skipOnWindows();
test('a renamed default branch replays and writes under its new name', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->rename('master', 'main');
$result = $project->pest('--tia');
$delta = $project->delta();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe())
->and($project->branchKeys())->toBe(['master', 'main'])
->and($delta->baselineUntouched('master'))->toBeTrue($delta->summary())
->and($delta->writtenCount())->toBe(0, $delta->summary());
})->skipOnWindows();
test('the CI provider names the default branch where the checkout cannot', function (): void {
$project = Project::make('master');
$project->git()->unsetOriginHead();
$project->seed('master');
$project->addBaseline('legacy');
$project->git()->switchTo('feature-x', new: true);
$project->write('.home/event.json', (string) json_encode([
'repository' => ['default_branch' => 'master'],
]));
$result = $project->pestWithEnvironment($project->path(), [
'GITHUB_EVENT_PATH' => $project->path('.home/event.json'),
], '--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe());
})->skipOnWindows();
test('GitLab names the default branch through its own variable', function (): void {
$project = Project::make('master');
$project->git()->unsetOriginHead();
$project->seed('master');
$project->addBaseline('legacy');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pestWithEnvironment($project->path(), [
'CI_DEFAULT_BRANCH' => 'master',
], '--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe());
})->skipOnWindows();
test('a lone recorded baseline names the default branch', function (): void {
$project = Project::make('master');
$project->git()->unsetOriginHead();
$project->git()->config('init.defaultBranch', 'main');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe());
})->skipOnWindows();
test('a default branch nothing can name is refused rather than guessed', function (): void {
$project = Project::make('master');
$project->git()->unsetOriginHead();
$project->git()->config('init.defaultBranch', 'main');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia');
expect($result->output)->toContain('Tia mode could not determine the default branch.')
->and($result->output)->toContain('git remote set-head origin --auto')
->and($result->exitCode)->toBe(1, $result->describe())
->and($project->graphExists())->toBeFalse();
})->skipOnWindows();
test('an init.defaultBranch naming a branch that exists is still trusted', function (): void {
$project = Project::make('master');
$project->git()->unsetOriginHead();
$project->git()->config('init.defaultBranch', 'master');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->not->toContain('could not determine the default branch')
->and($project->branchKeys())->not->toContain('master');
})->skipOnWindows();
test('a repository with no remote is refused rather than silently re-run', function (): void {
$project = Project::make('master');
$project->git()->removeOrigin();
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia');
expect($result->output)->toContain('Tia mode requires a repository with a remote.')
->and($result->exitCode)->toBe(1, $result->describe());
})->skipOnWindows();
test('a remote-less repository holding one baseline is not refused', function (): void {
$project = Project::make('master');
$project->git()->removeOrigin();
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe());
})->skipOnWindows();
test('a declared default branch stands in for a missing remote', function (): void {
$project = Project::make('master', overlay: 'configured-default-branch');
$project->git()->removeOrigin();
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe());
})->skipOnWindows();
test('tia still requires git', function (): void {
$project = Project::withoutGit();
$result = $project->pest('--tia');
expect($result->output)->toContain('The feature [Tia mode] requires [git].')
->and($result->exitCode)->not->toBe(0);
})->skipOnWindows();
test('a plain run outside a repository creates no baseline', function (): void {
$project = Project::withoutGit();
$result = $project->pest();
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($project->graphExists())->toBeFalse()
->and($project->graphDir())->not->toBeDirectory();
})->skipOnWindows();
test('the default branch is resolved once per run, not once per test', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$git = (new ExecutableFinder)->find('git');
expect($git)->not->toBeNull();
$log = $project->path('git-calls.log');
$project->write('shim/git', implode("\n", [
'#!/bin/sh',
'echo "$@" >> '.escapeshellarg($log),
'exec '.escapeshellarg((string) $git).' "$@"',
'',
]));
chmod($project->path('shim/git'), 0755);
$result = $project->pestWithEnvironment($project->path(), [
'PATH' => $project->path('shim').':'.getenv('PATH'),
], '--tia');
$calls = file_exists($log) ? explode("\n", trim((string) file_get_contents($log))) : [];
$resolutions = array_filter($calls, fn (string $call): bool => str_contains($call, 'symbolic-ref')
|| str_contains($call, 'init.defaultBranch'));
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($calls)->not->toBeEmpty('the git shim was never reached')
->and($resolutions)->toHaveCount(1, implode("\n", $calls));
})->skipOnWindows();
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
test('narrows to the affected tests on a new branch', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->write('tests/Unit/GreeterTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Greeter;
test('greets a person', function (): void {
expect((new Greeter)->greet('Nuno'))->toBe('Hello, Nuno!');
expect((new Greeter)->greet('Nuno'))->toBeString();
});
test('greets the world', function (): void {
expect((new Greeter)->greet('world'))->toBe('Hello, world!');
});
PHP);
$result = $project->pest('--tia');
$delta = $project->delta();
expect($result->affected())->toBe(2, $result->describe())
->and($result->replayed())->toBe(4, $result->describe())
->and($result->exitCode)->toBe(0, $result->describe())
->and($project->branchKeys())->toBe(['master', 'feature-x'])
->and($delta->baselineUntouched('master'))->toBeTrue($delta->summary())
->and($delta->edgesMoved())->toBeFalse($delta->summary());
})->skipOnWindows();
test('filtered mode reads the fallback too', function (): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia', '--filtered');
expect($result->output)->toContain('from 1 previously unsuccessful test')
->and($result->output)->not->toContain('No affected tests found')
->and($result->tally())->toContain('2 passed');
})->skipOnWindows();
test('filtered mode finds nothing to do on a clean green feature branch', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia', '--filtered');
$delta = $project->delta();
expect($result->output)->toContain('No affected tests found')
->and($result->exitCode)->toBe(0, $result->describe())
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->skipOnWindows();
test('filtered mode falls back to a full replay when a cached failure cannot be located', function (): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
$project->mutateGraph(function (array $graph): array {
$testId = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$testId]['file'] = '/build/agent/tests/Unit/DeletedTest.php';
return $graph;
});
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia', '--filtered');
expect($result->output)->toContain('Some cached tests due a re-run could not be located on disk.')
->and($result->output)->toContain('Running the full suite with replay instead of a filtered run.')
->and($result->output)->not->toContain('No affected tests found');
})->skipOnWindows();
test('filtered mode finds nothing to do on the default branch itself', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--filtered');
$delta = $project->delta();
expect($result->output)->toContain('No affected tests found')
->and($result->exitCode)->toBe(0, $result->describe())
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->skipOnWindows();
test('a detached HEAD replays without minting a branch key', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->detach();
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe())
->and($project->branchKeys())->toBe(['master']);
})->skipOnWindows();
test('a detached HEAD does not write into the default branch baseline', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->detach();
$project->pest(...$arguments);
$delta = $project->delta();
expect($delta->baselineUntouched('master'))->toBeTrue($delta->summary())
->and($project->branchKeys())->toBe(['master']);
})->with([
'sequential' => [['--filter=adds two numbers']],
'parallel' => [['--parallel', '--processes=2', '--filter=adds two numbers']],
])->skipOnWindows();
test('the branch that ran gets its own key and the default branch keeps its baseline', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest('--tia');
$delta = $project->delta();
expect($project->branchKeys())->toBe(['master', 'feature-x'])
->and($delta->baselineUntouched('master'))->toBeTrue($delta->summary())
->and($delta->writtenCount())->toBe(0, $delta->summary());
})->skipOnWindows();
test('the fallback reaches parallel workers', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$result = $project->pest('--tia', '--parallel', '--processes=2');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe());
})->skipOnWindows();
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
test('re-runs a cached failure on a clean tree', function (): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
$result = $project->pest('--tia', '--filtered');
$delta = $project->delta();
expect($result->output)->toContain('from 1 previously unsuccessful test')
->and($result->affected())->toBe(2, $result->describe())
->and($result->tally())->toContain('2 passed')
->and($delta->writtenCount())->toBe(2, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('an explicit path turns filtered mode off', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--filtered', 'tests/Unit');
$delta = $project->delta();
expect($result->output)->toContain('TIA does not apply to partial runs')
->and($result->output)->not->toContain('No affected tests found')
->and($result->tally())->toContain('4 passed')
->and($delta->writtenCount())->toBe(4, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('filtered mode runs the whole suite when there is no baseline', function (): void {
$project = Project::make('master');
$result = $project->pest('--tia', '--filtered');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($result->output)->not->toContain('No affected tests found');
})->skipOnWindows();
test('filtered mode finds nothing to do in parallel either', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--filtered', '--parallel', '--processes=2');
$delta = $project->delta();
expect($result->output)->toContain('No affected tests found')
->and($result->exitCode)->toBe(0, $result->describe())
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->skipOnWindows();
test('a corrupt graph is reported and does not crash the run', function (): void {
$project = Project::make('master');
$project->seed('master');
$graph = $project->graphDir().'/graph.json';
file_put_contents($graph, '{not json');
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($result->output)->toContain('The dependency graph could not be read')
->and(is_file($graph) ? file_get_contents($graph) : null)->not->toBe('{not json');
})->skipOnWindows();
test('--parallel --retry is refused and leaves the graph alone', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--parallel', '--retry');
$delta = $project->delta();
expect($result->exitCode)->not->toBe(0)
->and($result->output)->toContain('--retry')
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->skipOnWindows();
+221
View File
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
test('a graph mangled beyond use still lets the suite run', function (string $contents): void {
$project = Project::make('master');
$project->seed('master');
file_put_contents($project->graphDir().'/graph.json', $contents);
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->with([
'empty' => '',
'truncated' => '{"schema":1,"files":["app/Calculator.php"],"edg',
'not json' => '{not json',
'json scalar' => '"just a string"',
'json list' => '[1,2,3]',
'json null' => 'null',
'empty object' => '{}',
'nul bytes' => "\0\0\0\0",
])->skipOnWindows();
test('a graph whose shape is wrong everywhere is repaired rather than trusted', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id] = 'nope';
$graph['baselines']['master']['results'][7] = ['status' => 0, 'message' => '', 'time' => 0.1];
$graph['baselines']['master']['tree'] = 'nope';
$graph['baselines']['master']['sha'] = 42;
$graph['baselines'][''] = ['sha' => null, 'tree' => [], 'results' => []];
$graph['baselines']['broken'] = 'nope';
$graph['edges']['tests/Unit/GreeterTest.php'] = 'nope';
$graph['edges'][''] = [0];
$graph['files'][] = ['nested'];
return $graph;
});
$result = $project->pest('--tia', ...$arguments);
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->not->toContain('TypeError')
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($project->branchKeys())->toBe(['master']);
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a cached status this build cannot interpret is re-run, not replayed', function (int $status): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($status): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = $status;
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($result->uncached())->toBe(1, $result->describe());
})->with([
'below the range' => -1,
'one past the range' => 9,
'far past the range' => 99,
'huge' => PHP_INT_MAX,
])->skipOnWindows();
test('a cached status with no replay of its own does not fail the run', function (int $status): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($status): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = $status;
$graph['baselines']['master']['results'][$id]['message'] = 'cached detail';
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->with(['notice' => 3, 'deprecation' => 4, 'warning' => 6])->skipOnWindows();
test('a cached skip or todo replays with its message intact', function (int $status, string $tally): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($status): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = $status;
$graph['baselines']['master']['results'][$id]['message'] = 'a recorded reason';
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain($tally)
->and($result->output)->toContain('a recorded reason')
->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe());
})->with([
'skipped' => [1, '1 skipped'],
'incomplete' => [2, '1 incomplete'],
])->skipOnWindows();
test('a cached failure with a multi-line message re-runs rather than replaying the text', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = 7;
$graph['baselines']['master']['results'][$id]['message'] = "line one\nline two\nline three";
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->uncached())->toBe(1, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->skipOnWindows();
test('a result pointing outside the project is not addressable and widens the run', function (): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
$project->mutateGraph(function (array $graph): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['file'] = '/build/agent/tests/Unit/CalculatorTest.php';
return $graph;
});
$result = $project->pest('--tia', '--filtered');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->toContain('could not be located on disk')
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->skipOnWindows();
test('an edge pointing at a file id that does not exist is ignored', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$graph['edges']['tests/Unit/CalculatorTest.php'] = [0, 999, -5];
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe());
})->skipOnWindows();
test('a graph from a schema this build does not know is rebuilt, not read', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$graph['schema'] = 2;
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($result->replayed())->toBe(0, $result->describe());
})->skipOnWindows();
test('graph.json being a directory does not stop the run', function (): void {
$project = Project::make('master');
$project->seed('master');
unlink($project->graphDir().'/graph.json');
mkdir($project->graphDir().'/graph.json');
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->skipOnWindows();
test('a state dir it cannot write to still replays', function (): void {
$project = Project::make('master');
$project->seed('master');
chmod($project->graphDir(), 0500);
try {
$result = $project->pest('--tia');
} finally {
chmod($project->graphDir(), 0700);
}
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe());
})->skipOnWindows();
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
function tiaTriggering(string $call): string
{
return <<<PHP
<?php
declare(strict_types=1);
use Fixture\App\Calculator;
test('adds two numbers', function (): void {
{$call}
expect((new Calculator)->add(1, 2))->toBe(3);
});
test('subtracts two numbers', function (): void {
expect((new Calculator)->subtract(3, 1))->toBe(2);
});
PHP;
}
test('a triggered issue is recorded as itself, not as a pass', function (array $arguments, string $call, int $status): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', tiaTriggering($call));
$project->git()->commit('trigger an issue');
$project->pest('--tia', ...$arguments);
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
expect($project->graph()['baselines']['master']['results'][$id]['status'])->toBe($status);
})->with(Project::SEQUENTIAL_AND_PARALLEL)->with([
'deprecation' => ["trigger_error('legacy adder', E_USER_DEPRECATED);", 4],
'notice' => ["trigger_error('a notice', E_USER_NOTICE);", 3],
'warning' => ["trigger_error('a warning', E_USER_WARNING);", 6],
])->skipOnWindows();
test('a cached deprecation still fails the run that asked to fail on one', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', tiaTriggering("trigger_error('legacy adder', E_USER_DEPRECATED);"));
$project->git()->commit('trigger a deprecation');
$project->pest('--tia', ...$arguments);
$result = $project->pest('--tia', '--fail-on-deprecation', ...$arguments);
expect($result->exitCode)->toBe(1, $result->describe())
->and($result->tally())->toContain('1 deprecated')
->and($result->uncached())->toBe(1, $result->describe());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('replaying a cached issue does not downgrade it to a pass', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', tiaTriggering("trigger_error('legacy adder', E_USER_DEPRECATED);"));
$project->git()->commit('trigger a deprecation');
$project->pest('--tia', ...$arguments);
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
foreach (range(1, 3) as $ignored) {
$project->pest('--tia', ...$arguments);
expect($project->graph()['baselines']['master']['results'][$id]['status'])->toBe(4);
}
$result = $project->pest('--tia', '--fail-on-deprecation', ...$arguments);
expect($result->exitCode)->toBe(1, $result->describe());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a failure outranks an issue triggered on the way to it', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', str_replace(
'toBe(3)',
'toBe(999)',
tiaTriggering("trigger_error('legacy adder', E_USER_DEPRECATED);"),
));
$project->git()->commit('an issue then a failure');
$result = $project->pest('--tia');
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
expect($result->exitCode)->not->toBe(0)
->and($project->graph()['baselines']['master']['results'][$id]['status'])->toBe(7);
})->skipOnWindows();
test('a skip outranks an issue triggered on the way to it', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', str_replace(
'expect((new Calculator)->add(1, 2))->toBe(3);',
"\$this->markTestSkipped('not today');",
tiaTriggering("trigger_error('legacy adder', E_USER_DEPRECATED);"),
));
$project->git()->commit('an issue then a skip');
$project->pest('--tia');
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
expect($project->graph()['baselines']['master']['results'][$id]['status'])->toBe(1);
})->skipOnWindows();
test('a suppressed issue is not recorded', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', tiaTriggering("@trigger_error('quiet', E_USER_DEPRECATED);"));
$project->git()->commit('a suppressed deprecation');
$project->pest('--tia');
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
expect($project->graph()['baselines']['master']['results'][$id]['status'])->toBe(0);
})->skipOnWindows();
+170
View File
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
test('a filtered run rewrites only the test that ran', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--filter=adds two numbers');
$delta = $project->delta();
expect($result->tally())->toContain('1 passed')
->and($result->output)->not->toContain('TIA does not apply to partial runs')
->and($delta->writtenCount())->toBe(1, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('a filtered run under --tia announces that tia does not apply', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--filter=adds two numbers');
$delta = $project->delta();
expect($result->output)->toContain('TIA does not apply to partial runs')
->and($delta->writtenCount())->toBe(1, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('a test suffix narrows the tier even though every test runs', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--test-suffix=Test.php');
$delta = $project->delta();
expect($result->output)->toContain('TIA does not apply to partial runs')
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($delta->writtenCount())->toBe(Project::TOTAL_TESTS, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('a dirty run narrows to the uncommitted test edit', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/GreeterTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Greeter;
test('greets a person', function (): void {
expect((new Greeter)->greet('Nuno'))->toBe('Hello, Nuno!');
});
test('greets the world', function (): void {
expect((new Greeter)->greet('world'))->toBe('Hello, world!');
});
test('greets again', function (): void {
expect((new Greeter)->greet('again'))->toBe('Hello, again!');
});
PHP);
$result = $project->pest('--tia', '--dirty');
$delta = $project->delta();
expect($result->output)->toContain('TIA does not apply to partial runs')
->and($result->tally())->toContain('3 passed')
->and($delta->removed())->toBe(0, $delta->summary())
->and($delta->structureMoved())->toBeFalse($delta->summary());
})->skipOnWindows();
test('filtered mode yields to an explicit filter', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--filtered', '--filter=adds two numbers');
$delta = $project->delta();
expect($result->output)->toContain('TIA does not apply to partial runs')
->and($result->tally())->toContain('1 passed')
->and($delta->writtenCount())->toBe(1, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('an env flag narrows exactly like the option it mirrors', function (string $variable): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pestWithEnvironment($project->path(), [
$variable => '1',
], '--filter=adds two numbers');
$delta = $project->delta();
expect($result->output)->toContain('TIA does not apply to partial runs')
->and($delta->writtenCount())->toBe(1, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->with(['PEST_TIA', 'PEST_TIA_FILTERED'])->skipOnWindows();
test('a partial run does not purge the graph even with --fresh', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--fresh', '--filter=adds two numbers');
$delta = $project->delta();
expect($result->output)->toContain('TIA does not apply to partial runs')
->and($delta->writtenCount())->toBe(1, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('--no-tia does not stop a partial run from refreshing its own entry', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--no-tia', '--filter=adds two numbers');
$delta = $project->delta();
expect($result->output)->not->toContain('TIA does not apply to partial runs')
->and($delta->writtenCount())->toBe(1, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('two partial runs each keep the other entry', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->pest('--filter=adds two numbers');
$project->pest('--filter=greets a person');
$delta = $project->delta();
expect($delta->writtenCount())->toBe(2, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('a shard is a partial run', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--shard=1/2');
$delta = $project->delta();
expect($result->output)->toContain('TIA does not apply to partial runs')
->and($delta->removed())->toBe(0, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('a parallel partial run records the test that ran, like a sequential one', function (): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--parallel', '--processes=2', '--filter=adds two numbers');
$delta = $project->delta();
expect($result->output)->toContain('TIA does not apply to partial runs')
->and($result->tally())->toContain('1 passed')
->and($delta->writtenCount())->toBe(1, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
/**
* @param callable(array<string, mixed>): array<string, mixed>|null $mutator
* @return array{0: Project, 1: array<string, string>}
*/
function tiaPublishedBaseline(string $mode = 'ok', ?callable $mutator = null): array
{
$project = Project::make('master');
$project->seed('master');
$payload = $project->detachGraph();
if ($mutator !== null) {
/** @var array<string, mixed> $decoded */
$decoded = json_decode($payload, true);
$payload = (string) json_encode($mutator($decoded), JSON_UNESCAPED_SLASHES);
}
return [$project, $project->gh($mode, $payload)];
}
test('a published baseline is fetched instead of recorded locally', function (): void {
[$project, $environment] = tiaPublishedBaseline();
$result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->toContain('Downloading TIA baseline')
->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($project->graphExists())->toBeTrue();
})->skipOnWindows();
test('a fetched baseline that will not decode is discarded rather than trusted', function (): void {
[$project, $environment] = tiaPublishedBaseline('corrupt');
$result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->toContain('The dependency graph could not be read')
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($result->replayed())->toBe(0, $result->describe());
})->skipOnWindows();
test('a fetched baseline recorded against another tree is not used', function (): void {
[$project, $environment] = tiaPublishedBaseline('ok', function (array $graph): array {
$graph['fingerprint']['structural']['composer_lock'] = 'a-lockfile-this-project-never-had';
return $graph;
});
$result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($result->replayed())->toBe(0, $result->describe());
})->skipOnWindows();
test('an artifact without a graph in it fails loudly', function (): void {
[$project, $environment] = tiaPublishedBaseline('missing-asset');
$result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');
expect($result->exitCode)->toBe(1, $result->describe())
->and($result->output)->toContain('the artifact is missing expected files')
->and($project->graphExists())->toBeFalse();
})->skipOnWindows();
test('a baseline that cannot be authenticated for fails loudly', function (): void {
[$project, $environment] = tiaPublishedBaseline('unauthenticated');
$result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');
expect($result->exitCode)->toBe(1, $result->describe())
->and($result->output)->toContain('is not authenticated')
->and($project->graphExists())->toBeFalse();
})->skipOnWindows();
test('a workflow or artifact that is not there fails loudly', function (): void {
[$project, $environment] = tiaPublishedBaseline('list-404');
$result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');
expect($result->exitCode)->toBe(1, $result->describe())
->and($result->output)->toContain('not found in repo')
->and($project->graphExists())->toBeFalse();
})->skipOnWindows();
test('a network failure warns and lets the suite run', function (string $mode): void {
[$project, $environment] = tiaPublishedBaseline($mode);
$result = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->toContain('network error')
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->with([
'querying the runs' => ['list-network'],
'downloading the artifact' => ['download-network'],
])->skipOnWindows();
test('no published baseline yet starts a cooldown, and a corrupt cooldown does not break the run', function (): void {
[$project, $environment] = tiaPublishedBaseline('no-runs');
$discardGraph = function () use ($project): void {
if ($project->graphExists()) {
$project->detachGraph();
}
};
$first = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');
expect($first->exitCode)->toBe(0, $first->describe())
->and($first->output)->toContain('No baseline published yet')
->and($project->graphDir().DIRECTORY_SEPARATOR.'fetch-cooldown.json')->toBeFile();
$discardGraph();
$second = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');
expect($second->exitCode)->toBe(0, $second->describe())
->and($second->output)->toContain('next auto-retry in');
file_put_contents($project->graphDir().DIRECTORY_SEPARATOR.'fetch-cooldown.json', 'not json{');
$discardGraph();
$third = $project->pestWithEnvironment($project->path(), $environment, '--tia', '--baselined');
expect($third->exitCode)->toBe(0, $third->describe())
->and($third->output)->toContain('No baseline published yet')
->and($third->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->skipOnWindows();
+218
View File
@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
function tiaSeedWithView(Project $project, string $view): void
{
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($view): array {
$id = count($graph['files']);
$graph['files'][$id] = $view;
$graph['edges']['tests/Unit/GreeterTest.php'][] = $id;
return $graph;
});
}
/**
* @param array<int, string> $components
* @param array<string, array<int, string>> $jsFileToComponents
*/
function tiaSeedWithInertia(Project $project, array $components, array $jsFileToComponents = []): void
{
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($components, $jsFileToComponents): array {
$graph['test_inertia_components'] = ['tests/Unit/GreeterTest.php' => $components];
$graph['js_file_to_components'] = $jsFileToComponents;
return $graph;
});
}
test('a committed rename selects the tests that depended on the old path', function (array $arguments): void {
$project = Project::make('master');
$project->write('resources/views/greeting.blade.php', "<p>Hello</p>\n");
$project->git()->commit('add view');
tiaSeedWithView($project, 'resources/views/greeting.blade.php');
$project->git()->run(['mv', 'resources/views/greeting.blade.php', 'resources/views/hello.blade.php']);
$project->git()->commit('move the view');
$project->snapshot();
$result = $project->pest('--tia', ...$arguments);
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->affected())->toBe(2, $result->describe())
->and($result->replayed())->toBe(4, $result->describe());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('an affected test file that is gone does not strand a filtered run', function (array $arguments): void {
$project = Project::make('master');
$project->write('resources/views/page.blade.php', "<p>one</p>\n");
$project->git()->commit('add view');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$id = count($graph['files']);
$graph['files'][$id] = 'resources/views/page.blade.php';
$graph['edges']['tests/Unit/GhostTest.php'] = [$id];
return $graph;
});
$project->write('resources/views/page.blade.php', "<p>two</p>\n");
$project->snapshot();
$result = $project->pest('--tia', '--filtered', ...$arguments);
$delta = $project->delta();
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->toContain('No affected tests found')
->and($result->output)->not->toContain('tests/Unit/GhostTest.php')
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a plain run reclaims the edge of a test file that is gone', function (): void {
$project = Project::make('master');
$project->write('resources/views/page.blade.php', "<p>one</p>\n");
$project->git()->commit('add view');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$id = count($graph['files']);
$graph['files'][$id] = 'resources/views/page.blade.php';
$graph['edges']['tests/Unit/GhostTest.php'] = [$id];
$graph['baselines']['master']['results']['P\\Tests\\Unit\\GhostTest::ghostly'] = [
'status' => 0,
'message' => '',
'time' => 9.999,
'assertions' => 42,
'file' => 'tests/Unit/GhostTest.php',
];
return $graph;
});
$project->write('resources/views/page.blade.php', "<p>two</p>\n");
$project->snapshot();
$result = $project->pest('--tia');
$delta = $project->delta();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($delta->removed())->toBe(1, $delta->summary())
->and($project->graph()['edges'])->not->toHaveKey('tests/Unit/GhostTest.php');
})->skipOnWindows();
test('a changed view selects the test that rendered it', function (): void {
$project = Project::make('master');
$project->write('resources/views/page.blade.php', "<p>one</p>\n");
$project->git()->commit('add view');
tiaSeedWithView($project, 'resources/views/page.blade.php');
$project->write('resources/views/page.blade.php', "<p>two</p>\n");
$project->snapshot();
$result = $project->pest('--tia');
expect($result->affected())->toBe(2, $result->describe())
->and($result->replayed())->toBe(4, $result->describe());
})->skipOnWindows();
test('a changed partial selects the test that rendered its ancestor', function (array $views, string $changed): void {
$project = Project::make('master');
foreach ($views as $path => $contents) {
$project->write($path, $contents);
}
$project->git()->commit('add views');
tiaSeedWithView($project, 'resources/views/page.blade.php');
$project->write($changed, $views[$changed]."<span>edited</span>\n");
$project->snapshot();
$result = $project->pest('--tia');
expect($result->affected())->toBe(2, $result->describe())
->and($result->replayed())->toBe(4, $result->describe());
})->with([
'direct @include' => [[
'resources/views/page.blade.php' => "@include('partials.nav')\n",
'resources/views/partials/nav.blade.php' => "<nav>one</nav>\n",
], 'resources/views/partials/nav.blade.php'],
'transitive @include' => [[
'resources/views/page.blade.php' => "@include('partials.wrapper')\n",
'resources/views/partials/wrapper.blade.php' => "@include('partials.nav')\n",
'resources/views/partials/nav.blade.php' => "<nav>one</nav>\n",
], 'resources/views/partials/nav.blade.php'],
'x- component' => [[
'resources/views/page.blade.php' => "<x-card>hi</x-card>\n",
'resources/views/components/card.blade.php' => "<div>one</div>\n",
], 'resources/views/components/card.blade.php'],
'include cycle' => [[
'resources/views/page.blade.php' => "@include('partials.a')\n",
'resources/views/partials/a.blade.php' => "@include('partials.b')\n",
'resources/views/partials/b.blade.php' => "@include('partials.a')\n",
], 'resources/views/partials/b.blade.php'],
])->skipOnWindows();
test('a changed Inertia page selects the test that rendered its component', function (): void {
$project = Project::make('master');
$project->write('resources/js/Pages/Foo.vue', "<template>one</template>\n");
$project->git()->commit('add page');
tiaSeedWithInertia($project, ['Foo']);
$project->write('resources/js/Pages/Foo.vue', "<template>two</template>\n");
$project->snapshot();
$result = $project->pest('--tia');
expect($result->affected())->toBe(2, $result->describe())
->and($result->replayed())->toBe(4, $result->describe());
})->skipOnWindows();
test('a changed shared JS module selects the tests of the pages that import it', function (): void {
$project = Project::make('master');
$project->write('resources/js/Pages/Foo.vue', "<template>one</template>\n");
$project->write('resources/js/Shared/Nav.vue', "<template>nav</template>\n");
$project->git()->commit('add pages');
tiaSeedWithInertia($project, ['Foo'], ['resources/js/Shared/Nav.vue' => ['Foo']]);
$project->write('resources/js/Shared/Nav.vue', "<template>nav two</template>\n");
$project->snapshot();
$result = $project->pest('--tia');
expect($result->affected())->toBe(2, $result->describe())
->and($result->replayed())->toBe(4, $result->describe());
})->skipOnWindows();
test('a changed frontend runtime file selects every Inertia test', function (): void {
$project = Project::make('master');
$project->write('resources/js/app.js', "console.log(1)\n");
$project->git()->commit('add runtime');
tiaSeedWithInertia($project, ['Foo']);
$project->write('resources/js/app.js', "console.log(2)\n");
$project->snapshot();
$result = $project->pest('--tia');
expect($result->affected())->toBe(2, $result->describe())
->and($result->replayed())->toBe(4, $result->describe());
})->skipOnWindows();
+360
View File
@@ -0,0 +1,360 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
test('a detached HEAD does not purge the graph on structural drift', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->detach();
$project->write('composer.lock', (string) json_encode([
'content-hash' => 'drifted',
'packages' => [],
'packages-dev' => [],
]));
$result = $project->pest('--tia', ...$arguments);
$delta = $project->delta();
expect($result->exitCode)->toBe(0, $result->describe())
->and($project->graphExists())->toBeTrue('the detached run deleted graph.json')
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a detached HEAD does not purge the graph with --fresh either', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->detach();
$result = $project->pest('--tia', '--fresh', ...$arguments);
$delta = $project->delta();
expect($result->exitCode)->toBe(0, $result->describe())
->and($project->graphExists())->toBeTrue('the detached --fresh run deleted graph.json')
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a detached HEAD leaves an unreadable graph for a checkout that can rebuild it', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->detach();
file_put_contents($project->graphDir().'/graph.json', '{not json');
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and(file_get_contents($project->graphDir().'/graph.json'))->toBe('{not json');
})->skipOnWindows();
test('a cached failure whose test file was deleted stops widening later runs', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
unlink($project->path('tests/Unit/CalculatorTest.php'));
$project->git()->commit('drop CalculatorTest');
$project->pest('--tia', '--filtered', ...$arguments);
$project->snapshot();
$second = $project->pest('--tia', '--filtered', ...$arguments);
$delta = $project->delta();
expect($second->output)->not->toContain('could not be located on disk')
->and($second->output)->toContain('No affected tests found')
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a complete run reclaims the entry and the edge of a deleted test file', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
unlink($project->path('tests/Unit/CalculatorTest.php'));
$project->git()->commit('drop CalculatorTest');
$project->pest('--tia', ...$arguments);
$graph = $project->graph();
expect($graph['edges'] ?? [])->not->toHaveKey('tests/Unit/CalculatorTest.php')
->and(array_column($graph['baselines']['master']['results'] ?? [], 'file'))
->not->toContain('tests/Unit/CalculatorTest.php');
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a pruned result does not come back from the fallback', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
$project->git()->switchTo('feature-x', new: true);
$project->write('tests/Unit/CalculatorTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Calculator;
test('adds two numbers, renamed', function (): void {
expect((new Calculator)->add(1, 2))->toBe(3);
});
test('subtracts two numbers', function (): void {
expect((new Calculator)->subtract(3, 1))->toBe(2);
});
PHP);
$project->git()->commit('rename the test on the branch');
$project->pest('--tia', '--filtered', ...$arguments);
$second = $project->pest('--tia', '--filtered', ...$arguments);
expect($second->output)->not->toContain('previously unsuccessful')
->and($second->output)->toContain('No affected tests found');
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('the fallback still reaches a branch that has never run a test file', function (): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
$project->git()->switchTo('feature-x', new: true);
$project->pest('--filter=greets a person');
$result = $project->pest('--tia', '--filtered');
expect($result->output)->toContain('previously unsuccessful')
->and($result->affected())->toBe(2, $result->describe());
})->skipOnWindows();
test('a branch that git no longer knows loses its baseline', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest('--tia');
expect($project->branchKeys())->toBe(['master', 'feature-x']);
$project->git()->switchTo('master');
$project->git()->run(['branch', '-D', 'feature-x']);
$project->pest('--tia');
expect($project->branchKeys())->toBe(['master']);
})->skipOnWindows();
test('an unknown cached status is re-run rather than replayed as a failure', function (int $status): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($status): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = $status;
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($result->uncached())->toBe(1, $result->describe());
})->with(['unknown' => -1, 'future' => 9, 'garbage' => 99])->skipOnWindows();
test('a cached notice, deprecation or warning does not replay as a failure', function (int $status): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($status): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = $status;
$graph['baselines']['master']['results'][$id]['message'] = 'cached detail';
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->with(['notice' => 3, 'deprecation' => 4, 'warning' => 6])->skipOnWindows();
test('a malformed baseline entry cannot break the run', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id] = 'nope';
$graph['baselines']['master']['tree'] = 'nope';
$graph['edges']['tests/Unit/GreeterTest.php'] = 'nope';
return $graph;
});
$result = $project->pest('--tia', ...$arguments);
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->not->toContain('TypeError')
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a run torn down mid-file does not prune the tests it never reached', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Calculator;
test('adds two numbers', function (): void {
expect((new Calculator)->add(1, 2))->toBe(3);
});
test('subtracts two numbers', function (): void {
exit(0);
});
PHP);
$project->git()->commit('a test that kills its own process');
$project->pest('--tia', ...$arguments);
$delta = $project->delta();
expect($delta->removed())->toBe(0, $delta->summary())
->and($delta->shaMoved())->toBeFalse($delta->summary())
->and($delta->structureMoved())->toBeFalse($delta->summary())
->and(array_keys($project->graph()['baselines']['master']['results']))
->toContain(Project::testId('tests/Unit/CalculatorTest.php', 'subtracts two numbers'));
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a fatal error mid-file is a test error, not a truncation', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Calculator;
test('adds two numbers', function (): void {
expect((new Calculator)->add(1, 2))->toBe(3);
});
test('subtracts two numbers', function (): void {
undefined_function_here();
});
PHP);
$project->git()->commit('a test that fatals');
$result = $project->pest('--tia', ...$arguments);
$delta = $project->delta();
expect($result->exitCode)->not->toBe(0)
->and($result->tally())->toContain('1 failed')
->and($delta->removed())->toBe(0, $delta->summary())
->and($delta->structureMoved())->toBeFalse($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a green complete run leaves the graph exactly as it found it', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', ...$arguments);
$delta = $project->delta();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->with([
'bail' => [['--bail']],
'stop-on-failure' => [['--stop-on-failure']],
'compact' => [['--compact']],
'parallel bail' => [['--parallel', '--processes=2', '--bail']],
'parallel one process' => [['--parallel', '--processes=1']],
'parallel more processes than files' => [['--parallel', '--processes=8']],
])->skipOnWindows();
test('--tia --no-tia is a plain run that still refreshes what it executed', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--no-tia', ...$arguments);
$delta = $project->delta();
expect($result->replayed())->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($delta->writtenCount())->toBe(Project::TOTAL_TESTS, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('--fresh on a partial run neither purges nor prunes', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--fresh', '--filter=adds two numbers', ...$arguments);
$delta = $project->delta();
expect($result->exitCode)->toBe(0, $result->describe())
->and($project->graphExists())->toBeTrue()
->and($delta->writtenCount())->toBe(1, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a second green run on a feature branch writes nothing at all', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest('--tia', ...$arguments);
$project->snapshot();
$result = $project->pest('--tia', ...$arguments);
$delta = $project->delta();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a graph whose recorded commit is gone is re-anchored, not warned about forever', function (array $arguments): void {
$project = Project::make('master');
$project->git()->commit('second');
$project->seed('master');
$recordedSha = $project->graph()['baselines']['master']['sha'];
$project->git()->run(['reset', '--quiet', '--hard', 'HEAD~1']);
$project->snapshot();
$first = $project->pest('--tia', ...$arguments);
expect($first->exitCode)->toBe(0, $first->describe())
->and($first->output)->toContain('no longer reachable')
->and($first->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($project->graph()['baselines']['master']['sha'])->not->toBe($recordedSha)
->and($project->graph()['baselines']['master']['sha'])->toBe($project->git()->sha());
$project->snapshot();
$second = $project->pest('--tia', ...$arguments);
$delta = $project->delta();
expect($second->output)->not->toContain('no longer reachable')
->and($second->replayed())->toBe(Project::TOTAL_TESTS, $second->describe())
->and($delta->writtenCount())->toBe(0, $delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
@@ -0,0 +1,5 @@
<?php
// The folder name creates the namespace segment `2fa`, which starts with a number.
it('never runs')->assertTrue(true);
@@ -0,0 +1,5 @@
<?php
// The file name creates the class `list`, which is a PHP keyword.
it('never runs')->assertTrue(true);
@@ -0,0 +1,5 @@
<?php
// The file name creates the class `int`, which is a name PHP reserves.
it('never runs')->assertTrue(true);
@@ -0,0 +1,5 @@
<?php
// The file name creates the class `2fa`, which starts with a number.
it('never runs')->assertTrue(true);
@@ -0,0 +1,5 @@
<?php
it('belongs to group one', function () {
expect(true)->toBeTrue();
})->group('one');
@@ -0,0 +1,5 @@
<?php
it('belongs to group three', function () {
expect(true)->toBeTrue();
})->group('three');
@@ -0,0 +1,5 @@
<?php
it('belongs to group two', function () {
expect(true)->toBeTrue();
})->group('two');
+24
View File
@@ -0,0 +1,24 @@
<?php
// Only ever run through `tests/Features/Tia.php`, against a seeded TIA graph.
// Both hooks throw, so a replayed test that wrongly runs one fails the run.
beforeEach(function (): void {
throw new RuntimeException('The beforeEach hook must not run for replayed tests.');
});
afterEach(function (): void {
throw new RuntimeException('The afterEach hook must not run for replayed tests.');
});
test('replayed pass', function (): void {
expect(true)->toBeTrue();
});
test('replayed skip', function (): void {
expect(true)->toBeTrue();
});
test('replayed incomplete', function (): void {
expect(true)->toBeTrue();
});
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Runs TiaReplayHooks.php as a suite of its own, so `tests/Features/Tia.php` can
replay it without narrowing the run to a path. TIA declines to replay a
narrowed run, and a single-file invocation is exactly that.
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
bootstrap="../../../vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="default">
<file>TiaReplayHooks.php</file>
</testsuite>
</testsuites>
</phpunit>
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Tests\Fixtures\Tia;
use RuntimeException;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final readonly class GitRepo
{
/**
* @var array<string, string>
*/
public const array ENV = [
'GIT_CONFIG_GLOBAL' => '/dev/null',
'GIT_CONFIG_SYSTEM' => '/dev/null',
'GIT_CONFIG_NOSYSTEM' => '1',
'GIT_AUTHOR_NAME' => 'Pest Fixture',
'GIT_AUTHOR_EMAIL' => 'fixture@pestphp.io',
'GIT_COMMITTER_NAME' => 'Pest Fixture',
'GIT_COMMITTER_EMAIL' => 'fixture@pestphp.io',
];
public function __construct(public string $path) {}
public function init(string $branch = 'master'): void
{
$this->run(['init', '--quiet']);
$this->run(['checkout', '--quiet', '-b', $branch]);
$this->commit('Initial commit');
}
public function commit(string $message): void
{
$this->run(['add', '-A']);
$this->run(['commit', '--quiet', '--allow-empty', '-m', $message]);
}
public function switchTo(string $branch, bool $new = false): void
{
$this->run($new ? ['checkout', '--quiet', '-b', $branch] : ['checkout', '--quiet', $branch]);
}
public function rename(string $from, string $to): void
{
$this->run(['branch', '-m', $from, $to]);
}
public function detach(): void
{
$this->run(['checkout', '--quiet', '--detach']);
}
public function addOrigin(string $url = 'git@github.com:pestphp/tia-fixture.git'): void
{
$this->run(['remote', 'add', 'origin', $url]);
}
public function removeOrigin(): void
{
$this->run(['remote', 'remove', 'origin']);
}
public function setOriginHead(string $branch): void
{
$this->run(['update-ref', 'refs/remotes/origin/'.$branch, 'HEAD']);
$this->run(['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/'.$branch]);
}
public function unsetOriginHead(): void
{
$this->run(['symbolic-ref', '--delete', 'refs/remotes/origin/HEAD']);
}
public function config(string $key, string $value): void
{
$this->run(['config', '--local', $key, $value]);
}
public function worktree(string $path, string $branch): string
{
$this->run(['worktree', 'add', '--quiet', '-b', $branch, $path]);
return $path;
}
public function sha(): string
{
return $this->output(['rev-parse', 'HEAD']);
}
public function currentBranch(): string
{
return $this->output(['rev-parse', '--abbrev-ref', 'HEAD']);
}
/**
* @return array<int, string>
*/
public function branchNames(): array
{
$names = explode("\n", $this->output(['for-each-ref', '--format=%(refname:short)', 'refs/heads']));
return array_values(array_filter($names, fn (string $name): bool => $name !== ''));
}
/**
* @param array<int, string> $arguments
*/
public function output(array $arguments): string
{
return trim($this->process($arguments, mustSucceed: true)->getOutput());
}
/**
* @param array<int, string> $arguments
*/
public function run(array $arguments): void
{
$this->process($arguments, mustSucceed: true);
}
/**
* @param array<int, string> $arguments
*/
private function process(array $arguments, bool $mustSucceed): Process
{
$process = new Process(['git', ...$arguments], $this->path, self::ENV);
$process->setTimeout(120.0);
$process->run();
if ($mustSucceed && ! $process->isSuccessful()) {
throw new RuntimeException(sprintf(
"git %s failed in [%s]:\n%s",
implode(' ', $arguments),
$this->path,
$process->getErrorOutput().$process->getOutput(),
));
}
return $process;
}
}
+293
View File
@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace Tests\Fixtures\Tia;
/**
* @internal
*/
final readonly class GraphDelta
{
/**
* @param array<string, mixed>|null $before
* @param array<string, mixed>|null $after
*/
public function __construct(
private ?array $before,
private ?array $after,
) {}
public function graphWasCreated(): bool
{
return $this->before === null && $this->after !== null;
}
public function graphWasDeleted(): bool
{
return $this->before !== null && $this->after === null;
}
public function writtenCount(): int
{
$written = 0;
foreach ($this->branchKeys() as $branch) {
$before = $this->results($this->before, $branch);
$after = $this->results($this->after, $branch);
foreach ($before as $testId => $entry) {
if (! isset($after[$testId])) {
continue;
}
foreach (['status', 'time', 'assertions', 'message'] as $field) {
if (($entry[$field] ?? null) !== ($after[$testId][$field] ?? null)) {
$written++;
break;
}
}
}
}
return $written;
}
public function added(): int
{
$added = 0;
foreach ($this->branchKeys() as $branch) {
$added += count(array_diff(
array_keys($this->results($this->after, $branch)),
array_keys($this->results($this->before, $branch)),
));
}
return $added;
}
public function removed(): int
{
$removed = 0;
foreach ($this->branchKeys() as $branch) {
$removed += count(array_diff(
array_keys($this->results($this->before, $branch)),
array_keys($this->results($this->after, $branch)),
));
}
return $removed;
}
/**
* @return array<int, string>
*/
public function branchKeys(): array
{
return array_map(strval(...), array_keys($this->baselines($this->after)));
}
/**
* @return array<int, string>
*/
public function branchKeysBefore(): array
{
return array_map(strval(...), array_keys($this->baselines($this->before)));
}
public function branchKeysMoved(): bool
{
return $this->branchKeysBefore() !== $this->branchKeys();
}
public function baselineUntouched(string $branch): bool
{
return ($this->baselines($this->before)[$branch] ?? null)
=== ($this->baselines($this->after)[$branch] ?? null);
}
public function shaMoved(): bool
{
return array_any($this->branchKeys(), fn (string $branch) => $this->baselineField($branch, 'sha', $this->before) !== $this->baselineField($branch, 'sha', $this->after));
}
public function treeMoved(): bool
{
return array_any($this->branchKeys(), fn (string $branch) => $this->baselineField($branch, 'tree', $this->before) !== $this->baselineField($branch, 'tree', $this->after));
}
public function edgesMoved(): bool
{
return $this->edgeSets($this->before) !== $this->edgeSets($this->after);
}
public function filesMoved(): bool
{
return $this->section($this->before, 'files') !== $this->section($this->after, 'files');
}
public function fingerprintMoved(): bool
{
return $this->section($this->before, 'fingerprint') !== $this->section($this->after, 'fingerprint');
}
public function structureMoved(): bool
{
if ($this->edgesMoved()) {
return true;
}
if ($this->filesMoved()) {
return true;
}
if ($this->fingerprintMoved()) {
return true;
}
return $this->branchKeysMoved();
}
public function isHardSuppressed(): bool
{
return $this->before === $this->after;
}
public function isResultsOnly(): bool
{
return ! $this->graphWasCreated()
&& ! $this->graphWasDeleted()
&& ! $this->structureMoved()
&& ! $this->shaMoved()
&& ! $this->treeMoved()
&& $this->removed() === 0
&& $this->added() === 0;
}
public function summary(): string
{
if ($this->graphWasCreated()) {
return 'graph created';
}
if ($this->graphWasDeleted()) {
return 'graph deleted';
}
$moved = [];
foreach (['edges', 'files', 'fingerprint'] as $section) {
if ($this->{$section.'Moved'}()) {
$moved[] = $section;
}
}
if ($this->branchKeysMoved()) {
$moved[] = sprintf(
'branchkeys(%s->%s)',
implode('|', $this->branchKeysBefore()),
implode('|', $this->branchKeys()),
);
}
return sprintf(
'w=%d +%d -%d %s%s%s',
$this->writtenCount(),
$this->added(),
$this->removed(),
$moved === [] ? 'struct:ok' : 'STRUCT:'.implode(',', $moved),
$this->shaMoved() ? ' sha:changed' : '',
$this->treeMoved() ? ' tree:changed' : '',
);
}
/**
* @param array<string, mixed>|null $graph
* @return array<string, array<int, string>>
*/
private function edgeSets(?array $graph): array
{
$files = $this->section($graph, 'files');
$sets = [];
foreach ($this->section($graph, 'edges') as $test => $ids) {
if (! is_string($test)) {
continue;
}
if (! is_array($ids)) {
continue;
}
$paths = array_map(
fn (mixed $id): string => is_int($id) && isset($files[$id]) && is_string($files[$id])
? $files[$id]
: '?'.json_encode($id),
$ids,
);
sort($paths);
$sets[$test] = $paths;
}
ksort($sets);
return $sets;
}
/**
* @param array<string, mixed>|null $graph
* @return array<mixed>
*/
private function section(?array $graph, string $key): array
{
$section = $graph[$key] ?? null;
return is_array($section) ? $section : [];
}
/**
* @param array<string, mixed>|null $graph
* @return array<string, mixed>
*/
private function baselines(?array $graph): array
{
$baselines = [];
foreach ($this->section($graph, 'baselines') as $branch => $baseline) {
$baselines[(string) $branch] = $baseline;
}
return $baselines;
}
/**
* @param array<string, mixed>|null $graph
* @return array<string, array<string, mixed>>
*/
private function results(?array $graph, string $branch): array
{
$results = $this->baselines($graph)[$branch]['results'] ?? null;
if (! is_array($results)) {
return [];
}
$entries = [];
foreach ($results as $testId => $entry) {
if (is_string($testId) && is_array($entry)) {
$entries[$testId] = $entry;
}
}
return $entries;
}
/**
* @param array<string, mixed>|null $graph
*/
private function baselineField(string $branch, string $field, ?array $graph): mixed
{
return $this->baselines($graph)[$branch][$field] ?? null;
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Tests\Fixtures\Tia;
/**
* @internal
*/
final readonly class PestResult
{
public string $output;
/**
* @param array<int, string> $arguments
*/
public function __construct(
public array $arguments,
string $output,
public int $exitCode,
) {
$this->output = (string) preg_replace([
'#\x1b[[][^A-Za-z]*[A-Za-z]#',
'#\x1b\]8;[^\x1b\x07]*(?:\x1b\\\\|\x07)#',
], '', $output);
}
public function replayed(): int
{
return $this->recapFragment('replayed');
}
public function uncached(): int
{
return $this->recapFragment('uncached');
}
public function affected(): int
{
return $this->recapFragment('affected');
}
public function tally(): string
{
if (preg_match('/^\s*Tests:\s+(.+)$/m', $this->output, $matches) !== 1) {
return '';
}
return trim($matches[1]);
}
public function contains(string $needle): bool
{
return str_contains($this->output, $needle);
}
public function describe(): string
{
return sprintf(
"pest %s exited %d:\n%s",
implode(' ', $this->arguments),
$this->exitCode,
$this->output,
);
}
private function recapFragment(string $label): int
{
if (preg_match('/(\d+) '.preg_quote($label, '/').'/', $this->tally(), $matches) !== 1) {
return 0;
}
return (int) $matches[1];
}
}
+569
View File
@@ -0,0 +1,569 @@
<?php
declare(strict_types=1);
namespace Tests\Fixtures\Tia;
use FilesystemIterator;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\FileState;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage;
use Pest\Support\Str;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final class Project
{
/**
* @var array<string, array<int, string>>
*/
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<string, array<int, string>>
*/
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<string, array<int, array<int, string>>>
*/
public const array SEQUENTIAL_AND_PARALLEL = [
'sequential' => [[]],
'parallel' => [['--parallel', '--processes=2']],
];
/**
* @var array<int, self>
*/
private static array $created = [];
private ?GitRepo $repo = null;
/**
* @var array<string, mixed>|null
*/
private ?array $snapshot = null;
/**
* @var array<int, string>
*/
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 nested(string $directory = 'nested'): string
{
$path = $this->path($directory);
if (! is_dir($path) && ! @mkdir($path, 0755, true) && ! is_dir($path)) {
throw new RuntimeException(sprintf('Unable to create [%s].', $path));
}
self::copy(__DIR__.'/app', $path);
$this->scaffoldVendor($path);
return $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<string, string> $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',
'XDEBUG_MODE' => 'coverage',
'HOME' => $this->home(),
'GITHUB_EVENT_PATH' => '',
'CI_DEFAULT_BRANCH' => '',
'GITHUB_ACTIONS' => '',
'GITLAB_CI' => '',
'CIRCLECI' => '',
...$environment,
],
);
$process->setTimeout(180.0);
$process->run();
return new PestResult(
array_values($arguments),
$process->getOutput().$process->getErrorOutput(),
(int) $process->getExitCode(),
);
}
/**
* @param array<int, string> $failing
*/
public function seed(string $branch, bool $sentinel = true, array $failing = []): void
{
$this->seedFor($this->path, $branch, $sentinel, $failing);
}
/**
* @param array<int, string> $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 function detachGraph(): string
{
$json = $this->state()->read(Tia::KEY_GRAPH);
if ($json === null) {
throw new RuntimeException('There is no graph to detach.');
}
if (! $this->state()->delete(Tia::KEY_GRAPH)) {
throw new RuntimeException('Unable to remove the detached graph.');
}
$this->snapshot();
return $json;
}
/**
* @return array<string, string>
*/
public function gh(string $mode = 'ok', string $payload = '{}'): array
{
self::mirror(__DIR__.'/stubs/gh', $this->path('stub/gh'));
chmod($this->path('stub/gh'), 0755);
$this->write('payload/graph.json', $payload);
return [
'PATH' => $this->path('stub').PATH_SEPARATOR.getenv('PATH'),
'GH_STUB_MODE' => $mode,
'GH_STUB_PAYLOAD' => $this->path('payload/graph.json'),
];
}
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;
});
}
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<string, mixed>): array<string, mixed> $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<string, mixed>|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<int, string>
*/
public function branchKeys(): array
{
$baselines = $this->graph()['baselines'] ?? [];
return is_array($baselines) ? array_map(strval(...), 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(
"<?php\n\n\$loader = require %s;\n\$loader->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);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Fixture\App;
final class Calculator
{
public function add(int $a, int $b): int
{
return $a + $b;
}
public function subtract(int $a, int $b): int
{
return $a - $b;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Fixture\App;
final class Greeter
{
public function greet(string $name): string
{
return sprintf('Hello, %s!', $name);
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "pest/tia-fixture",
"description": "Throwaway project the TIA scenario tests scaffold into a temp directory.",
"license": "MIT",
"require": {},
"config": {
"lock": true
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"_readme": [
"Hand-written: the fixture project installs nothing. It exists so the TIA",
"fingerprint has a composer.lock to hash, the way a real project does."
],
"content-hash": "0000000000000000000000000000000000",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

Some files were not shown because too many files have changed in this diff Show More