mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 22:33:35 +02:00
Compare commits
42 Commits
v5.0.2
..
7b6415ccd0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b6415ccd0 | |||
| a5d726b678 | |||
| 93223caa56 | |||
| d0572ce138 | |||
| b0d1fc589d | |||
| 5a4f124199 | |||
| c3ceb7d0c7 | |||
| 16d1208344 | |||
| 1cebc84c2d | |||
| 1f79660add | |||
| 37bdf60a1c | |||
| cc7acfe485 | |||
| 7bfb2a6185 | |||
| ca56ca5216 | |||
| b49ba062d5 | |||
| 09699847a2 | |||
| 545d0c2784 | |||
| b795af3b1b | |||
| a40cc6bc0e | |||
| 4d3d0105b7 | |||
| e90e4f70fc | |||
| d112582857 | |||
| 9f3c4e1e82 | |||
| 71f39366c2 | |||
| bfd5b75677 | |||
| 411b9954b5 | |||
| db70017cb2 | |||
| 86adaedbbd | |||
| 92c7677c6e | |||
| 872f0a50c2 | |||
| 668809bc75 | |||
| 8d8f45c843 | |||
| 086b3e9107 | |||
| 43fe26f324 | |||
| 865c1e5113 | |||
| 19eed8d581 | |||
| ad1850b110 | |||
| 585de259a2 | |||
| 953664dce0 | |||
| 1dd959848c | |||
| 047753c836 | |||
| b63626a94d |
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
.idea/*
|
.idea/*
|
||||||
.idea/codeStyleSettings.xml
|
.idea/codeStyleSettings.xml
|
||||||
.temp/*
|
.temp/*
|
||||||
composer.lock
|
/composer.lock
|
||||||
/vendor/
|
/vendor/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -35,6 +35,7 @@ We cannot thank our sponsors enough for their incredible support in funding Pest
|
|||||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||||
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
|
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
|
||||||
- **[Bento](https://bentonow.com/?ref=nunomaduro)**
|
- **[Bento](https://bentonow.com/?ref=nunomaduro)**
|
||||||
- **[Redberry](https://redberry.international/laravel-development/)**
|
- **[Pixel](https://wearepixel.com.au/?ref=nunomaduro)**
|
||||||
|
- **[Redberry](https://redberry.international/laravel-development/?ref=nunomaduro)**
|
||||||
|
|
||||||
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
|
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
|
||||||
|
|||||||
+6
-4
@@ -23,7 +23,7 @@
|
|||||||
"nunomaduro/termwind": "^2.4.0",
|
"nunomaduro/termwind": "^2.4.0",
|
||||||
"pestphp/pest-plugin": "^5.0.0",
|
"pestphp/pest-plugin": "^5.0.0",
|
||||||
"pestphp/pest-plugin-arch": "^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",
|
"pestphp/pest-plugin-profanity": "^5.0.0",
|
||||||
"phpunit/phpunit": "^13.2.6",
|
"phpunit/phpunit": "^13.2.6",
|
||||||
"symfony/process": "^8.1.0"
|
"symfony/process": "^8.1.0"
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
|
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
|
||||||
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
|
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
|
||||||
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
||||||
|
"Tests\\Fixtures\\Tia\\": "tests/Fixtures/Tia",
|
||||||
"Tests\\": "tests/PHPUnit/"
|
"Tests\\": "tests/PHPUnit/"
|
||||||
},
|
},
|
||||||
"classmap": [
|
"classmap": [
|
||||||
@@ -58,12 +59,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"laravel/pao": "^1.1.3",
|
|
||||||
"pestphp/pest-dev-tools": "^5.0.0",
|
"pestphp/pest-dev-tools": "^5.0.0",
|
||||||
"pestphp/pest-plugin-browser": "^5.0.0",
|
"pestphp/pest-plugin-browser": "^5.0.0",
|
||||||
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
||||||
"pestphp/pest-plugin-rector": "^5.0.0",
|
"pestphp/pest-plugin-rector": "^5.0.2",
|
||||||
"pestphp/pest-plugin-type-coverage": "^5.0.0",
|
"pestphp/pest-plugin-type-coverage": "^5.0.2",
|
||||||
"psy/psysh": "^0.12.24"
|
"psy/psysh": "^0.12.24"
|
||||||
},
|
},
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
|
"process-timeout": 0,
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"pestphp/pest-plugin": true
|
"pestphp/pest-plugin": true
|
||||||
}
|
}
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
||||||
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
||||||
"test:integration": "php bin/pest --group=integration -v",
|
"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",
|
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
||||||
"test": [
|
"test": [
|
||||||
"@test:lint",
|
"@test:lint",
|
||||||
|
|||||||
@@ -90,11 +90,11 @@ final class TestSuiteLoader
|
|||||||
(static function () use ($suiteClassFile) {
|
(static function () use ($suiteClassFile) {
|
||||||
try {
|
try {
|
||||||
include_once $suiteClassFile;
|
include_once $suiteClassFile;
|
||||||
|
|
||||||
|
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Panic::with($e);
|
Panic::with($e);
|
||||||
}
|
}
|
||||||
|
|
||||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$loadedClasses = array_values(
|
$loadedClasses = array_values(
|
||||||
|
|||||||
+2
-1
@@ -18,8 +18,9 @@
|
|||||||
<directory suffix=".php">./tests</directory>
|
<directory suffix=".php">./tests</directory>
|
||||||
<directory suffix=".php">./tests-external</directory>
|
<directory suffix=".php">./tests-external</directory>
|
||||||
<exclude>./tests/.snapshots</exclude>
|
<exclude>./tests/.snapshots</exclude>
|
||||||
<exclude>./tests/.tests</exclude>
|
|
||||||
<exclude>./tests/Fixtures/Inheritance</exclude>
|
<exclude>./tests/Fixtures/Inheritance</exclude>
|
||||||
|
<exclude>./tests/Fixtures/Suites</exclude>
|
||||||
|
<exclude>./tests/Fixtures/Tia</exclude>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<source>
|
<source>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ return RectorConfig::configure()
|
|||||||
->withSkip([
|
->withSkip([
|
||||||
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
||||||
__DIR__.'/tests/Fixtures/Arch',
|
__DIR__.'/tests/Fixtures/Arch',
|
||||||
|
__DIR__.'/tests/Fixtures/Suites',
|
||||||
ReturnNeverTypeRector::class,
|
ReturnNeverTypeRector::class,
|
||||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
||||||
NarrowObjectReturnTypeRector::class,
|
NarrowObjectReturnTypeRector::class,
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ final readonly class BootSubscribers implements Bootstrapper
|
|||||||
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
|
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
|
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnRisky::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,
|
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -280,12 +280,14 @@ trait Testable
|
|||||||
|
|
||||||
/** @var Tia $tia */
|
/** @var Tia $tia */
|
||||||
$tia = Container::getInstance()->get(Tia::class);
|
$tia = Container::getInstance()->get(Tia::class);
|
||||||
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
|
$status = $tia->getStatus(self::$__filename, $this->valueObjectForEvents()->id());
|
||||||
$replay = ReplayType::fromStatus($status);
|
$replay = ReplayType::fromStatus($status);
|
||||||
|
|
||||||
if ($replay !== ReplayType::None) {
|
if ($replay !== ReplayType::None) {
|
||||||
assert($status !== null);
|
assert($status !== null);
|
||||||
|
|
||||||
|
$this->__replay = $replay;
|
||||||
|
|
||||||
match ($replay) {
|
match ($replay) {
|
||||||
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
||||||
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
||||||
@@ -319,7 +321,7 @@ trait Testable
|
|||||||
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
||||||
{
|
{
|
||||||
$this->__replay = $replay;
|
$this->__replay = $replay;
|
||||||
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
|
$this->__replayAssertions = $tia->getAssertionCount($this->valueObjectForEvents()->id());
|
||||||
$this->__ran = true;
|
$this->__ran = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class AfterAllAlreadyExist extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class AfterAllWithinDescribe extends InvalidArgumentException implements E
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class AfterBeforeTestFunction extends InvalidArgumentException implements
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
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.'].');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class BeforeAllAlreadyExist extends InvalidArgumentException implements Ex
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class BeforeAllWithinDescribe extends InvalidArgumentException implements
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class DatasetAlreadyExists extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $name, string $scope)
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class DatasetDoesNotExist extends InvalidArgumentException implements Exce
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $name)
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class FileOrFolderNotFound extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,6 @@ final class MissingDependency extends InvalidArgumentException implements Except
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $feature, string $dependency)
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class TestAlreadyExist extends InvalidArgumentException implements Excepti
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $fileName, string $description)
|
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)
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class TestDescriptionMissing extends InvalidArgumentException implements E
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $fileName)
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use Pest\Concerns;
|
|||||||
use Pest\Contracts\HasPrintableTestCaseName;
|
use Pest\Contracts\HasPrintableTestCaseName;
|
||||||
use Pest\Evaluators\Attributes;
|
use Pest\Evaluators\Attributes;
|
||||||
use Pest\Exceptions\DatasetMissing;
|
use Pest\Exceptions\DatasetMissing;
|
||||||
|
use Pest\Exceptions\InvalidTestClassName;
|
||||||
use Pest\Exceptions\ShouldNotHappen;
|
use Pest\Exceptions\ShouldNotHappen;
|
||||||
use Pest\Exceptions\TestAlreadyExist;
|
use Pest\Exceptions\TestAlreadyExist;
|
||||||
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
||||||
@@ -138,6 +139,16 @@ final class TestCaseFactory
|
|||||||
|
|
||||||
if (trim($className) === '') {
|
if (trim($className) === '') {
|
||||||
$className = 'InvalidTestName'.Str::random();
|
$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 = [
|
$this->attributes = [
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ namespace Pest;
|
|||||||
|
|
||||||
function version(): string
|
function version(): string
|
||||||
{
|
{
|
||||||
return '5.0.2';
|
return '5.0.4';
|
||||||
}
|
}
|
||||||
|
|
||||||
function testDirectory(string $file = ''): string
|
function testDirectory(string $file = ''): string
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use ParaTest\RunnerInterface;
|
|||||||
use ParaTest\WrapperRunner\MissingResultsException;
|
use ParaTest\WrapperRunner\MissingResultsException;
|
||||||
use ParaTest\WrapperRunner\SuiteLoader;
|
use ParaTest\WrapperRunner\SuiteLoader;
|
||||||
use ParaTest\WrapperRunner\WrapperWorker;
|
use ParaTest\WrapperRunner\WrapperWorker;
|
||||||
|
use Pest\Plugins\Tia;
|
||||||
use Pest\Result;
|
use Pest\Result;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Event\Facade as EventFacade;
|
use PHPUnit\Event\Facade as EventFacade;
|
||||||
@@ -155,6 +156,7 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
/** @var array<int, non-empty-string> $parameters */
|
/** @var array<int, non-empty-string> $parameters */
|
||||||
$parameters = $this->handleLaravelHerd($parameters);
|
$parameters = $this->handleLaravelHerd($parameters);
|
||||||
|
$parameters = $this->handleTia($parameters);
|
||||||
|
|
||||||
$parameters[] = $wrapper;
|
$parameters[] = $wrapper;
|
||||||
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
|
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
|
||||||
@@ -202,6 +204,19 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
return $parameters;
|
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
|
private function startWorkers(): void
|
||||||
{
|
{
|
||||||
for ($token = 1; $token <= $this->options->processes; $token++) {
|
for ($token = 1; $token <= $this->options->processes; $token++) {
|
||||||
|
|||||||
+481
-57
@@ -7,13 +7,20 @@ namespace Pest\Plugins;
|
|||||||
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
|
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
|
||||||
use Pest\Contracts\Plugins\AddsOutput;
|
use Pest\Contracts\Plugins\AddsOutput;
|
||||||
use Pest\Contracts\Plugins\HandlesArguments;
|
use Pest\Contracts\Plugins\HandlesArguments;
|
||||||
|
use Pest\Contracts\Plugins\HandlesOriginalArguments;
|
||||||
use Pest\Contracts\Plugins\Terminable;
|
use Pest\Contracts\Plugins\Terminable;
|
||||||
|
use Pest\Exceptions\InvalidOption;
|
||||||
|
use Pest\Exceptions\MissingDependency;
|
||||||
use Pest\Exceptions\NoAffectedTestsFound;
|
use Pest\Exceptions\NoAffectedTestsFound;
|
||||||
|
use Pest\Exceptions\TiaRequiresCommit;
|
||||||
|
use Pest\Exceptions\TiaRequiresDefaultBranch;
|
||||||
|
use Pest\Exceptions\TiaRequiresRemote;
|
||||||
use Pest\Exceptions\TiaRequiresRepositoryRoot;
|
use Pest\Exceptions\TiaRequiresRepositoryRoot;
|
||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
use Pest\Plugins\Concerns\HandleArguments;
|
use Pest\Plugins\Concerns\HandleArguments;
|
||||||
use Pest\Plugins\Tia\BaselineSync;
|
use Pest\Plugins\Tia\BaselineSync;
|
||||||
use Pest\Plugins\Tia\ChangedFiles;
|
use Pest\Plugins\Tia\ChangedFiles;
|
||||||
|
use Pest\Plugins\Tia\CiDefaultBranch;
|
||||||
use Pest\Plugins\Tia\Contracts\State;
|
use Pest\Plugins\Tia\Contracts\State;
|
||||||
use Pest\Plugins\Tia\CoverageCollector;
|
use Pest\Plugins\Tia\CoverageCollector;
|
||||||
use Pest\Plugins\Tia\Fingerprint;
|
use Pest\Plugins\Tia\Fingerprint;
|
||||||
@@ -26,17 +33,18 @@ use Pest\Plugins\Tia\Storage;
|
|||||||
use Pest\Plugins\Tia\TableExtractor;
|
use Pest\Plugins\Tia\TableExtractor;
|
||||||
use Pest\Plugins\Tia\WatchPatterns;
|
use Pest\Plugins\Tia\WatchPatterns;
|
||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
|
use Pest\Support\Git;
|
||||||
use Pest\Support\View;
|
use Pest\Support\View;
|
||||||
use Pest\TestCaseFilters\TiaTestCaseFilter;
|
use Pest\TestCaseFilters\TiaTestCaseFilter;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||||
|
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class Tia implements AddsOutput, HandlesArguments, Terminable
|
final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArguments, Terminable
|
||||||
{
|
{
|
||||||
use HandleArguments;
|
use HandleArguments;
|
||||||
|
|
||||||
@@ -56,6 +64,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
private const string BASELINE_PATH_OPTION = '--baseline';
|
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_TIA = 'PEST_TIA';
|
||||||
|
|
||||||
private const string ENV_FILTERED = 'PEST_TIA_FILTERED';
|
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 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 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.
|
* 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',
|
'--include-path', '--whitelist',
|
||||||
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
|
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
|
||||||
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
|
'--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',
|
'--coverage-filter', '--path-coverage',
|
||||||
'--repeat', '--retry-times', '--memory-limit', '--seed',
|
'--repeat', '--retry-times', '--memory-limit', '--seed',
|
||||||
'--compact', '--ci-build-id', '--min',
|
'--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 $graphWritten = false;
|
||||||
|
|
||||||
private bool $replayRan = false;
|
private bool $replayRan = false;
|
||||||
@@ -120,9 +156,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
/** @var array<string, int> */
|
/** @var array<string, int> */
|
||||||
private array $cachedAssertionsByTestId = [];
|
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 ?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> */
|
/** @var array<string, true> */
|
||||||
private array $affectedFiles = [];
|
private array $affectedFiles = [];
|
||||||
@@ -138,10 +186,23 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
private bool $baselineFetchAttemptedForDrift = false;
|
private bool $baselineFetchAttemptedForDrift = false;
|
||||||
|
|
||||||
private bool $freshRebuild = false;
|
|
||||||
|
|
||||||
private bool $filteredMode = 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 $driftLabel = null;
|
||||||
|
|
||||||
private ?string $driftDetails = null;
|
private ?string $driftDetails = null;
|
||||||
@@ -185,11 +246,54 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return null;
|
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
|
private function saveGraph(Graph $graph): bool
|
||||||
{
|
{
|
||||||
|
if ($this->detachedHead) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$json = $graph->encode();
|
$json = $graph->encode();
|
||||||
|
|
||||||
if ($json === null) {
|
if ($json === null) {
|
||||||
@@ -227,6 +331,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return ! self::argumentPresent('--ci', $arguments);
|
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
|
* @param array<int, string> $arguments
|
||||||
*/
|
*/
|
||||||
@@ -303,8 +413,18 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->replayedCount++;
|
$this->replayedCount++;
|
||||||
|
$this->cachedStatusByTestId[$testId] = [
|
||||||
|
'status' => $result->asInt(),
|
||||||
|
'message' => $result->message(),
|
||||||
|
];
|
||||||
$assertions = $this->replayGraph->getAssertions($this->branch, $testId);
|
$assertions = $this->replayGraph->getAssertions($this->branch, $testId);
|
||||||
$this->cachedAssertionsByTestId[$testId] = $assertions ?? 0;
|
$this->cachedAssertionsByTestId[$testId] = $assertions ?? 0;
|
||||||
|
|
||||||
|
$time = $this->replayGraph->getTime($this->branch, $testId);
|
||||||
|
|
||||||
|
if ($time !== null) {
|
||||||
|
$this->cachedTimeByTestId[$testId] = $time;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->executedCount++;
|
$this->executedCount++;
|
||||||
}
|
}
|
||||||
@@ -317,6 +437,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $this->cachedAssertionsByTestId[$testId] ?? 0;
|
return $this->cachedAssertionsByTestId[$testId] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
public function handleOriginalArguments(array $arguments): void
|
||||||
|
{
|
||||||
|
$this->originalArguments = $arguments;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
@@ -339,9 +467,20 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$cliEnabled = $this->hasArgument(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA);
|
$cliEnabled = $this->hasArgument(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA);
|
||||||
$alwaysEnabled = $watchPatterns->isEnabled()
|
$alwaysEnabled = $watchPatterns->isEnabled()
|
||||||
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
|
&& (! $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);
|
$enabled = ! $disabled && ($cliEnabled || $alwaysEnabled);
|
||||||
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered())
|
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered())
|
||||||
&& ! $this->hasExplicitPathArgument($arguments)
|
&& ! $hasExplicitPath
|
||||||
&& ! $this->coverageReportActive();
|
&& ! $this->coverageReportActive();
|
||||||
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
|
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
|
||||||
$this->forceRefetch = $this->hasArgument(self::REFETCH_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);
|
$arguments = $this->popArgument(self::BASELINED_OPTION, $arguments);
|
||||||
|
|
||||||
if ($disabled) {
|
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->forceRefetch = false;
|
||||||
$this->filteredMode = 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;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
|
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
|
||||||
$this->freshRebuild = $forceRebuild;
|
|
||||||
|
|
||||||
if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
|
if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
|
||||||
|
$this->requestWorkerResults();
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,10 +545,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive)) {
|
if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive || $this->flushesWorkerResults)) {
|
||||||
$this->flushWorkerReplay();
|
$this->flushWorkerReplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->writesSuppressed || $this->resultsOnlyWrites || $this->hasUnfinishedTest()) {
|
||||||
|
$this->recorder->reset();
|
||||||
|
$this->coverageCollector->reset();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$recorder = $this->recorder;
|
$recorder = $this->recorder;
|
||||||
|
|
||||||
if (! $this->recordingActive && ! $recorder->isActive()) {
|
if (! $this->recordingActive && ! $recorder->isActive()) {
|
||||||
@@ -453,15 +617,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$this->branch,
|
$this->branch,
|
||||||
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
||||||
);
|
);
|
||||||
$graph->replaceEdges($perTest);
|
$graph->replaceEdges($perTest, keepExisting: $this->piggybackCoverage);
|
||||||
$graph->replaceTestTables($perTestTables);
|
$graph->replaceTestTables($perTestTables);
|
||||||
$graph->replaceTestInertiaComponents($perTestInertia);
|
$graph->replaceTestInertiaComponents($perTestInertia);
|
||||||
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
||||||
|
|
||||||
if ($this->freshRebuild) {
|
|
||||||
$graph->pruneMissingTests();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->seedResultsInto($graph);
|
$this->seedResultsInto($graph);
|
||||||
|
|
||||||
if (! $this->saveGraph($graph)) {
|
if (! $this->saveGraph($graph)) {
|
||||||
@@ -481,13 +641,27 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $exitCode;
|
return $exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Only::isEnabled() || $this->stoppedEarly() || $this->hasUnfinishedTest()) {
|
||||||
|
$this->resultsOnlyWrites = true;
|
||||||
|
}
|
||||||
|
|
||||||
$this->reportMissingWorkerDrivers();
|
$this->reportMissingWorkerDrivers();
|
||||||
|
|
||||||
if (Parallel::isEnabled()) {
|
if (Parallel::isEnabled()) {
|
||||||
$this->mergeWorkerReplayPartials();
|
$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();
|
$this->bumpRecordedSha();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,15 +721,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $exitCode;
|
return $exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
$graph->replaceEdges($finalised);
|
$graph->replaceEdges($finalised, keepExisting: $this->piggybackCoverage);
|
||||||
$graph->replaceTestTables($finalisedTables);
|
$graph->replaceTestTables($finalisedTables);
|
||||||
$graph->replaceTestInertiaComponents($finalisedInertia);
|
$graph->replaceTestInertiaComponents($finalisedInertia);
|
||||||
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
||||||
|
|
||||||
if ($this->freshRebuild) {
|
|
||||||
$graph->pruneMissingTests();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->saveGraph($graph)) {
|
if (! $this->saveGraph($graph)) {
|
||||||
$this->renderBadge('ERROR', 'Could not write the dependency 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);
|
return $this->reconcileFingerprint($rebuilt, $current);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->state->delete(self::KEY_GRAPH);
|
$this->deleteState(self::KEY_GRAPH);
|
||||||
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
$this->deleteState(self::KEY_COVERAGE_CACHE);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -615,7 +785,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$graph->clearResults($this->branch);
|
$graph->clearResults($this->branch);
|
||||||
$graph->setFingerprint($current);
|
$graph->setFingerprint($current);
|
||||||
$this->saveGraph($graph);
|
$this->saveGraph($graph);
|
||||||
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
$this->deleteState(self::KEY_COVERAGE_CACHE);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $graph;
|
return $graph;
|
||||||
@@ -635,12 +805,28 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
Panic::with(new TiaRequiresRepositoryRoot($subdirectoryPrefix));
|
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);
|
$fingerprint = Fingerprint::compute($projectRoot);
|
||||||
$this->startFingerprint = $fingerprint;
|
$this->startFingerprint = $fingerprint;
|
||||||
|
|
||||||
if ($forceRebuild) {
|
if ($forceRebuild && ! $this->detachedHead) {
|
||||||
Storage::purge($projectRoot);
|
Storage::purge($projectRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,6 +844,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
&& $changedFiles->since($branchSha) === null) {
|
&& $changedFiles->since($branchSha) === null) {
|
||||||
$this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
|
$this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
|
||||||
$graph = null;
|
$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, '');
|
$this->state->write(self::KEY_COVERAGE_MARKER, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
|
if (! $graph instanceof Graph && $this->piggybackCoverage) {
|
||||||
if ($graph instanceof Graph && $this->driftLabel === null) {
|
$this->emitCoverageScopedRecordSkipped();
|
||||||
$this->freshGraphReason = 'recording coverage baseline';
|
|
||||||
|
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);
|
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
|
private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array
|
||||||
{
|
{
|
||||||
$this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? 'main';
|
$this->resolveBranch($projectRoot);
|
||||||
|
|
||||||
if ($replayingGlobal) {
|
if ($replayingGlobal) {
|
||||||
$this->installWorkerReplay($projectRoot);
|
$this->installWorkerReplay($projectRoot);
|
||||||
@@ -833,7 +1028,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
$affectedFromChanges = $changed === [] ? [] : $graph->affected($changed);
|
$affectedFromChanges = $changed === [] ? [] : $graph->testFilesOnDisk($graph->affected($changed));
|
||||||
$rerunFromCache = [];
|
$rerunFromCache = [];
|
||||||
|
|
||||||
if ($this->filteredMode && $graph->hasUnlocatedTestsToRerun($this->branch)) {
|
if ($this->filteredMode && $graph->hasUnlocatedTestsToRerun($this->branch)) {
|
||||||
@@ -875,7 +1070,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
if (! Parallel::isEnabled()) {
|
if (! Parallel::isEnabled()) {
|
||||||
if ($canRefreshReplayEdges) {
|
if ($canRefreshReplayEdges) {
|
||||||
|
if ($this->piggybackCoverage) {
|
||||||
|
$this->recorder->activateLinkTracking();
|
||||||
|
} else {
|
||||||
$this->recorder->activate();
|
$this->recorder->activate();
|
||||||
|
}
|
||||||
|
|
||||||
$this->recordingActive = true;
|
$this->recordingActive = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,6 +1094,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
if ($canRefreshReplayEdges) {
|
if ($canRefreshReplayEdges) {
|
||||||
Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
|
Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
|
||||||
|
|
||||||
|
if ($this->piggybackCoverage) {
|
||||||
|
Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->filteredMode) {
|
if ($this->filteredMode) {
|
||||||
@@ -1035,6 +1239,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$recorder->activate();
|
$recorder->activate();
|
||||||
$this->recordingActive = true;
|
$this->recordingActive = true;
|
||||||
|
|
||||||
|
if ($this->driftLabel !== null || $this->freshGraphReason !== null) {
|
||||||
|
$this->output->writeln('');
|
||||||
|
$this->renderFreshGraph();
|
||||||
|
|
||||||
|
return $arguments;
|
||||||
|
}
|
||||||
|
|
||||||
$this->renderChild('Running in TIA mode.');
|
$this->renderChild('Running in TIA mode.');
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
@@ -1042,15 +1253,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
private function renderFreshGraph(): void
|
private function renderFreshGraph(): void
|
||||||
{
|
{
|
||||||
|
if ($this->driftLabel === null && $this->freshGraphReason !== null) {
|
||||||
|
$headline = sprintf('Experimental TIA mode enabled / %s.', $this->freshGraphReason);
|
||||||
|
} else {
|
||||||
$headline = 'Experimental TIA mode enabled / fresh graph';
|
$headline = 'Experimental TIA mode enabled / fresh graph';
|
||||||
|
|
||||||
if ($this->driftLabel !== null) {
|
if ($this->driftLabel !== null) {
|
||||||
$headline .= sprintf(' (%s changed)', $this->driftLabel);
|
$headline .= sprintf(' (%s changed)', $this->driftLabel);
|
||||||
} elseif ($this->freshGraphReason !== null) {
|
|
||||||
$headline .= sprintf(' (%s)', $this->freshGraphReason);
|
|
||||||
} else {
|
} else {
|
||||||
$headline .= '.';
|
$headline .= '.';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->renderChild($headline);
|
$this->renderChild($headline);
|
||||||
|
|
||||||
@@ -1065,7 +1278,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
{
|
{
|
||||||
$this->output->writeln('');
|
$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.');
|
$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
|
private function purgeWorkerPartials(): void
|
||||||
{
|
{
|
||||||
foreach ($this->collectWorkerEdgesPartials() as $key) {
|
foreach ($this->collectWorkerEdgesPartials() as $key) {
|
||||||
@@ -1136,11 +1372,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($results as $testId => $result) {
|
||||||
|
$results[$testId] = $this->replayedAsRecorded($testId, $result);
|
||||||
|
}
|
||||||
|
|
||||||
$json = json_encode([
|
$json = json_encode([
|
||||||
'results' => $results,
|
'results' => $results,
|
||||||
'replayed' => $this->replayedCount,
|
'replayed' => $this->replayedCount,
|
||||||
'affected' => $this->affectedCount,
|
'affected' => $this->affectedCount,
|
||||||
'executed' => $this->executedCount,
|
'executed' => $this->executedCount,
|
||||||
|
'truncated' => $this->stoppedEarly() || $collector->hasUnfinishedTest(),
|
||||||
], JSON_UNESCAPED_SLASHES);
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
if ($json === false) {
|
if ($json === false) {
|
||||||
@@ -1177,6 +1418,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (($decoded['truncated'] ?? false) === true) {
|
||||||
|
$this->resultsOnlyWrites = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($decoded['replayed']) && is_int($decoded['replayed'])) {
|
if (isset($decoded['replayed']) && is_int($decoded['replayed'])) {
|
||||||
$this->replayedCount += $decoded['replayed'];
|
$this->replayedCount += $decoded['replayed'];
|
||||||
}
|
}
|
||||||
@@ -1400,6 +1645,29 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $coverage;
|
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
|
private function seedResultsInto(Graph $graph): void
|
||||||
{
|
{
|
||||||
/** @var ResultCollector $collector */
|
/** @var ResultCollector $collector */
|
||||||
@@ -1419,6 +1687,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$touchedFiles[$file] = true;
|
$touchedFiles[$file] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$result = $this->replayedAsRecorded($testId, $result);
|
||||||
|
|
||||||
$graph->setResult(
|
$graph->setResult(
|
||||||
$this->branch,
|
$this->branch,
|
||||||
$testId,
|
$testId,
|
||||||
@@ -1432,11 +1702,34 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
$graph->markKnownTestFiles(array_keys($touchedFiles));
|
$graph->markKnownTestFiles(array_keys($touchedFiles));
|
||||||
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
||||||
|
$this->reclaim($graph);
|
||||||
|
|
||||||
$collector->reset();
|
$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 */
|
/** @var ResultCollector $collector */
|
||||||
$collector = Container::getInstance()->get(ResultCollector::class);
|
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||||
@@ -1455,8 +1748,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->resolveBranch($projectRoot);
|
||||||
|
} catch (MissingDependency) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$graph->setFallbackBranch($this->fallbackBranch);
|
||||||
|
|
||||||
$touchedFiles = [];
|
$touchedFiles = [];
|
||||||
|
|
||||||
|
$recordsEdges = $complete && ($markKnownTestFiles || $this->recordingActive);
|
||||||
|
|
||||||
foreach ($results as $testId => $result) {
|
foreach ($results as $testId => $result) {
|
||||||
$file = $result['file'] ?? null;
|
$file = $result['file'] ?? null;
|
||||||
|
|
||||||
@@ -1468,6 +1770,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$touchedFiles[$file] = true;
|
$touchedFiles[$file] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $recordsEdges && (! is_string($file) || ! $graph->knowsTest($file))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->replayedAsRecorded($testId, $result);
|
||||||
|
|
||||||
$graph->setResult(
|
$graph->setResult(
|
||||||
$this->branch,
|
$this->branch,
|
||||||
$testId,
|
$testId,
|
||||||
@@ -1483,7 +1791,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$graph->markKnownTestFiles(array_keys($touchedFiles));
|
$graph->markKnownTestFiles(array_keys($touchedFiles));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($complete) {
|
||||||
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
||||||
|
$this->reclaim($graph);
|
||||||
|
}
|
||||||
|
|
||||||
$this->saveGraph($graph);
|
$this->saveGraph($graph);
|
||||||
$collector->reset();
|
$collector->reset();
|
||||||
@@ -1521,6 +1832,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function coverageReportActive(): bool
|
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);
|
$coverage = Container::getInstance()->get(Coverage::class);
|
||||||
assert($coverage instanceof Coverage);
|
assert($coverage instanceof Coverage);
|
||||||
@@ -1528,6 +1848,105 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $coverage->coverage;
|
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
|
* @param array<int, string> $arguments
|
||||||
*/
|
*/
|
||||||
@@ -1549,11 +1968,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
if (str_starts_with($arg, '-')) {
|
if (str_starts_with($arg, '-')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($index > 0) {
|
|
||||||
$previous = $arguments[$index - 1] ?? '';
|
if ($index === 0) {
|
||||||
if (in_array($previous, self::VALUE_TAKING_FLAGS, true)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$previous = $arguments[$index - 1] ?? '';
|
||||||
|
|
||||||
|
if (in_array($previous, self::VALUE_TAKING_FLAGS, true)) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$candidate = $this->resolveArgumentPath($arg, $projectRoot);
|
$candidate = $this->resolveArgumentPath($arg, $projectRoot);
|
||||||
@@ -1562,14 +1985,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
foreach ($testPaths as $testPath) {
|
||||||
if ($candidate === $testPath || str_starts_with($candidate, $testPath.DIRECTORY_SEPARATOR)) {
|
if ($candidate === $testPath || str_starts_with($candidate, $testPath.DIRECTORY_SEPARATOR)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return array_all($testPaths, fn (string $testPath): bool => ! str_starts_with($testPath, $candidate.DIRECTORY_SEPARATOR));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveArgumentPath(string $arg, string $projectRoot): ?string
|
private function resolveArgumentPath(string $arg, string $projectRoot): ?string
|
||||||
@@ -1701,16 +2136,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private function gitSubdirectoryPrefix(string $projectRoot): ?string
|
private function gitSubdirectoryPrefix(string $projectRoot): ?string
|
||||||
{
|
{
|
||||||
$process = new Process(['git', 'rev-parse', '--show-prefix'], $projectRoot);
|
return new Git($projectRoot)->subdirectoryPrefix();
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prefix = trim($process->getOutput());
|
|
||||||
|
|
||||||
return $prefix === '' ? null : rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function composerLockDelta(string $projectRoot, string $sha): string
|
private function composerLockDelta(string $projectRoot, string $sha): string
|
||||||
@@ -1720,15 +2146,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot);
|
$baseline = new Git($projectRoot)->show($sha, 'composer.lock');
|
||||||
$process->setTimeout(5.0);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if ($baseline === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$oldVersions = $this->lockVersions($process->getOutput());
|
$oldVersions = $this->lockVersions($baseline);
|
||||||
$newVersions = $this->lockVersions($current);
|
$newVersions = $this->lockVersions($current);
|
||||||
|
|
||||||
if ($oldVersions === [] && $newVersions === []) {
|
if ($oldVersions === [] && $newVersions === []) {
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use Pest\Exceptions\MissingDependency;
|
use Pest\Exceptions\MissingDependency;
|
||||||
use Symfony\Component\Process\Process;
|
use Pest\Support\Git;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class ChangedFiles
|
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.
|
* @param array<int, string> $files project-relative paths.
|
||||||
@@ -155,15 +160,7 @@ final readonly class ChangedFiles
|
|||||||
|
|
||||||
private function contentAtSha(string $sha, string $path): ?string
|
private function contentAtSha(string $sha, string $path): ?string
|
||||||
{
|
{
|
||||||
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
|
return $this->git->show($sha, $path);
|
||||||
$process->setTimeout(5.0);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $process->getOutput();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,21 +173,17 @@ final readonly class ChangedFiles
|
|||||||
return $candidates;
|
return $candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
$process = new Process(
|
$result = $this->git->result(
|
||||||
['git', 'check-ignore', '--no-index', '-z', '--stdin'],
|
['check-ignore', '--no-index', '-z', '--stdin'],
|
||||||
$this->projectRoot,
|
implode("\x00", array_keys($candidates)),
|
||||||
);
|
);
|
||||||
$process->setTimeout(5.0);
|
|
||||||
$process->setInput(implode("\x00", array_keys($candidates)));
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
$exitCode = $process->getExitCode();
|
// `check-ignore` exits 1 when nothing matched — that is not a failure.
|
||||||
|
if ($result['exitCode'] !== 0 && $result['exitCode'] !== 1) {
|
||||||
if ($exitCode !== 0 && $exitCode !== 1) {
|
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
}
|
}
|
||||||
|
|
||||||
$output = $process->getOutput();
|
$output = $result['output'];
|
||||||
|
|
||||||
if ($output === '') {
|
if ($output === '') {
|
||||||
return $candidates;
|
return $candidates;
|
||||||
@@ -207,27 +200,109 @@ final readonly class ChangedFiles
|
|||||||
|
|
||||||
public function currentBranch(): ?string
|
public function currentBranch(): ?string
|
||||||
{
|
{
|
||||||
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
|
$output = $this->git->raw(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if ($output === null) {
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
}
|
}
|
||||||
|
|
||||||
$branch = trim($process->getOutput());
|
$branch = trim($output);
|
||||||
|
|
||||||
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
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
|
private function shaIsReachable(string $sha): bool
|
||||||
{
|
{
|
||||||
$process = new Process(
|
return $this->git->succeeds(['merge-base', '--is-ancestor', $sha, 'HEAD']);
|
||||||
['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
|
|
||||||
$this->projectRoot,
|
|
||||||
);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
return $process->getExitCode() === 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,17 +310,13 @@ final readonly class ChangedFiles
|
|||||||
*/
|
*/
|
||||||
private function diffSinceSha(string $sha): array
|
private function diffSinceSha(string $sha): array
|
||||||
{
|
{
|
||||||
$process = new Process(
|
$output = $this->scan()->raw(['diff', '--name-only', '--no-renames', $sha.'..HEAD']);
|
||||||
['git', 'diff', '--name-only', $sha.'..HEAD'],
|
|
||||||
$this->projectRoot,
|
|
||||||
);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if ($output === null) {
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
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
|
private function workingTreeChanges(): array
|
||||||
{
|
{
|
||||||
$process = new Process(
|
$output = $this->scan()->raw(['status', '--porcelain', '-z', '--untracked-files=all']);
|
||||||
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
|
|
||||||
$this->projectRoot,
|
|
||||||
);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if ($output === null) {
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
}
|
}
|
||||||
|
|
||||||
$output = $process->getOutput();
|
|
||||||
|
|
||||||
if ($output === '') {
|
if ($output === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -302,14 +367,13 @@ final readonly class ChangedFiles
|
|||||||
|
|
||||||
public function currentSha(): ?string
|
public function currentSha(): ?string
|
||||||
{
|
{
|
||||||
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
|
$output = $this->git->raw(['rev-parse', 'HEAD']);
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if ($output === null) {
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
}
|
}
|
||||||
|
|
||||||
$sha = trim($process->getOutput());
|
$sha = trim($output);
|
||||||
|
|
||||||
return $sha === '' ? null : $sha;
|
return $sha === '' ? null : $sha;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,18 @@ final class Configuration
|
|||||||
return $this;
|
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
|
* @param array<string, string> $patterns glob → project-relative test dir
|
||||||
* @return $this
|
* @return $this
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -29,7 +29,9 @@ enum ReplayType
|
|||||||
$status->isRisky() => self::Risky,
|
$status->isRisky() => self::Risky,
|
||||||
$status->isSkipped() => self::Skipped,
|
$status->isSkipped() => self::Skipped,
|
||||||
$status->isIncomplete() => self::Incomplete,
|
$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
@@ -43,11 +43,14 @@ final class Graph
|
|||||||
* @var array<string, array{
|
* @var array<string, array{
|
||||||
* sha: ?string,
|
* sha: ?string,
|
||||||
* tree: array<string, string>,
|
* tree: array<string, string>,
|
||||||
|
* complete?: bool,
|
||||||
* results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
|
* results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
|
||||||
* }>
|
* }>
|
||||||
*/
|
*/
|
||||||
private array $baselines = [];
|
private array $baselines = [];
|
||||||
|
|
||||||
|
private string $fallbackBranch = 'main';
|
||||||
|
|
||||||
private readonly string $projectRoot;
|
private readonly string $projectRoot;
|
||||||
|
|
||||||
/** @var array<string, true>|null */
|
/** @var array<string, true>|null */
|
||||||
@@ -116,6 +119,24 @@ final class Graph
|
|||||||
return array_keys($affectedSet);
|
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
|
* @param array<int, string> $changedFiles
|
||||||
* @return array{0: list<string>, 1: list<string>}
|
* @return array{0: list<string>, 1: list<string>}
|
||||||
@@ -576,7 +597,12 @@ final class Graph
|
|||||||
return $this->fingerprint;
|
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);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
|
|
||||||
@@ -611,7 +637,7 @@ final class Graph
|
|||||||
$this->baselines[$branch]['results'][$testId] = $entry;
|
$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);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
|
|
||||||
@@ -622,7 +648,18 @@ final class Graph
|
|||||||
return $baseline['results'][$testId]['assertions'];
|
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);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
|
|
||||||
@@ -642,14 +679,14 @@ final class Graph
|
|||||||
6 => TestStatus::warning($r['message']),
|
6 => TestStatus::warning($r['message']),
|
||||||
7 => TestStatus::failure($r['message']),
|
7 => TestStatus::failure($r['message']),
|
||||||
8 => TestStatus::error($r['message']),
|
8 => TestStatus::error($r['message']),
|
||||||
default => TestStatus::unknown(),
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @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);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
$files = [];
|
$files = [];
|
||||||
@@ -669,7 +706,7 @@ final class Graph
|
|||||||
|
|
||||||
$rel = $this->relative($file);
|
$rel = $this->relative($file);
|
||||||
|
|
||||||
if ($rel !== null) {
|
if ($rel !== null && is_file($this->projectRoot.'/'.$rel)) {
|
||||||
$files[$rel] = true;
|
$files[$rel] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -677,7 +714,7 @@ final class Graph
|
|||||||
return array_keys($files);
|
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);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
|
|
||||||
@@ -688,7 +725,11 @@ final class Graph
|
|||||||
|
|
||||||
$file = $result['file'] ?? null;
|
$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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,6 +753,10 @@ final class Graph
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($testStatus->isUnknown()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$configuration = Registry::get();
|
$configuration = Registry::get();
|
||||||
|
|
||||||
if ($testStatus->isRisky()) {
|
if ($testStatus->isRisky()) {
|
||||||
@@ -779,25 +824,70 @@ final class Graph
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @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 $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])) {
|
$fallbackBranch ??= $this->fallbackBranch;
|
||||||
return $this->baselines[$branch];
|
|
||||||
|
$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])) {
|
if ($fallback === null) {
|
||||||
return $this->baselines[$fallbackBranch];
|
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
|
private function ensureBaseline(string $branch): void
|
||||||
@@ -809,8 +899,9 @@ final class Graph
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array<int, string>> $testToFiles
|
* @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) {
|
foreach ($testToFiles as $testFile => $sources) {
|
||||||
$testRel = $this->relative($testFile);
|
$testRel = $this->relative($testFile);
|
||||||
@@ -819,6 +910,10 @@ final class Graph
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($keepExisting && ($this->edges[$testRel] ?? []) !== []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$this->edges[$testRel] = [];
|
$this->edges[$testRel] = [];
|
||||||
|
|
||||||
foreach ($sources as $source) {
|
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
|
* 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).
|
* 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
|
public static function decode(string $json, string $projectRoot): ?self
|
||||||
{
|
{
|
||||||
$data = json_decode($json, true);
|
$data = json_decode($json, true);
|
||||||
@@ -1432,10 +1599,10 @@ final class Graph
|
|||||||
|
|
||||||
$graph = new self($projectRoot);
|
$graph = new self($projectRoot);
|
||||||
$graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : [];
|
$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->fileIds = array_flip($graph->files);
|
||||||
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
$graph->edges = self::decodeEdges($data['edges'] ?? null);
|
||||||
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
$graph->baselines = self::decodeBaselines($data['baselines'] ?? null);
|
||||||
|
|
||||||
$graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
|
$graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
|
||||||
$graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
|
$graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
|
||||||
@@ -1444,6 +1611,153 @@ final class Graph
|
|||||||
return $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>>
|
* @return array<string, list<string>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -360,7 +360,9 @@ final class Recorder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$lineKeys = array_keys($lines);
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ final class ResultCollector
|
|||||||
*/
|
*/
|
||||||
private array $results = [];
|
private array $results = [];
|
||||||
|
|
||||||
|
/** @var array<string, true> */
|
||||||
|
private array $triggered = [];
|
||||||
|
|
||||||
private ?string $currentTestId = null;
|
private ?string $currentTestId = null;
|
||||||
|
|
||||||
private ?string $currentTestFile = null;
|
private ?string $currentTestFile = null;
|
||||||
@@ -35,9 +38,30 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($this->triggered[$this->currentTestId])) {
|
||||||
|
$this->refreshTime();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->record(TestStatus::success());
|
$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
|
public function testFailed(string $message): void
|
||||||
{
|
{
|
||||||
if ($this->currentTestId === null) {
|
if ($this->currentTestId === null) {
|
||||||
@@ -91,6 +115,11 @@ final class ResultCollector
|
|||||||
return $this->results;
|
return $this->results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasUnfinishedTest(): bool
|
||||||
|
{
|
||||||
|
return $this->currentTestId !== null;
|
||||||
|
}
|
||||||
|
|
||||||
public function recordAssertions(string $testId, int $assertions): void
|
public function recordAssertions(string $testId, int $assertions): void
|
||||||
{
|
{
|
||||||
if (isset($this->results[$testId])) {
|
if (isset($this->results[$testId])) {
|
||||||
@@ -111,6 +140,7 @@ final class ResultCollector
|
|||||||
public function reset(): void
|
public function reset(): void
|
||||||
{
|
{
|
||||||
$this->results = [];
|
$this->results = [];
|
||||||
|
$this->triggered = [];
|
||||||
$this->currentTestId = null;
|
$this->currentTestId = null;
|
||||||
$this->currentTestFile = null;
|
$this->currentTestFile = null;
|
||||||
$this->startTime = null;
|
$this->startTime = null;
|
||||||
@@ -123,6 +153,38 @@ final class ResultCollector
|
|||||||
$this->startTime = null;
|
$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
|
private function record(TestStatus $status): void
|
||||||
{
|
{
|
||||||
if ($this->currentTestId === null) {
|
if ($this->currentTestId === null) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ final class TableExtractor
|
|||||||
$tables[strtolower($name)] = true;
|
$tables[strtolower($name)] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$out = array_keys($tables);
|
$out = array_map(strval(...), array_keys($tables));
|
||||||
sort($out);
|
sort($out);
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
@@ -112,7 +112,7 @@ final class TableExtractor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$out = array_keys($tables);
|
$out = array_map(strval(...), array_keys($tables));
|
||||||
sort($out);
|
sort($out);
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ final class WatchPatterns
|
|||||||
|
|
||||||
private bool $baselined = false;
|
private bool $baselined = false;
|
||||||
|
|
||||||
|
private ?string $defaultBranch = null;
|
||||||
|
|
||||||
public function useDefaults(string $projectRoot): void
|
public function useDefaults(string $projectRoot): void
|
||||||
{
|
{
|
||||||
$testPath = TestSuite::getInstance()->testPath;
|
$testPath = TestSuite::getInstance()->testPath;
|
||||||
@@ -177,6 +179,16 @@ final class WatchPatterns
|
|||||||
return $this->baselined;
|
return $this->baselined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setDefaultBranch(string $branch): void
|
||||||
|
{
|
||||||
|
$this->defaultBranch = $branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultBranch(): ?string
|
||||||
|
{
|
||||||
|
return $this->defaultBranch;
|
||||||
|
}
|
||||||
|
|
||||||
public function reset(): void
|
public function reset(): void
|
||||||
{
|
{
|
||||||
$this->patterns = [];
|
$this->patterns = [];
|
||||||
@@ -185,6 +197,7 @@ final class WatchPatterns
|
|||||||
$this->locally = false;
|
$this->locally = false;
|
||||||
$this->filtered = false;
|
$this->filtered = false;
|
||||||
$this->baselined = false;
|
$this->baselined = false;
|
||||||
|
$this->defaultBranch = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function keyMatches(string $key, string $file): bool
|
private function keyMatches(string $key, string $file): bool
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements Finishe
|
|||||||
|
|
||||||
if ($test instanceof TestMethod) {
|
if ($test instanceof TestMethod) {
|
||||||
$this->collector->recordAssertions(
|
$this->collector->recordAssertions(
|
||||||
$test->className().'::'.$test->methodName(),
|
$test->id(),
|
||||||
$event->numberOfAssertionsPerformed(),
|
$event->numberOfAssertionsPerformed(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ final readonly class EnsureTiaResultsAreCollected implements PreparationStartedS
|
|||||||
$test = $event->test();
|
$test = $event->test();
|
||||||
|
|
||||||
if ($test instanceof TestMethod) {
|
if ($test instanceof TestMethod) {
|
||||||
$this->collector->testPrepared($test->className().'::'.$test->methodName(), $test->file());
|
$this->collector->testPrepared($test->id(), $test->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), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,33 @@ final class Str
|
|||||||
|
|
||||||
private const string PREFIX = '__pest_evaluable_';
|
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
|
* Create a (unsecure & non-cryptographically safe) random alpha-numeric
|
||||||
* string value.
|
* string value.
|
||||||
@@ -64,6 +91,35 @@ final class Str
|
|||||||
return (string) preg_replace('/[^a-zA-Z0-9_\x80-\xff]/', '_', $code);
|
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.
|
* 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\MissingDependency;
|
||||||
use Pest\Exceptions\NoDirtyTestsFound;
|
use Pest\Exceptions\NoDirtyTestsFound;
|
||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
|
use Pest\Support\Git;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
|
|
||||||
final class GitDirtyTestCaseFilter implements TestCaseFilter
|
final class GitDirtyTestCaseFilter implements TestCaseFilter
|
||||||
{
|
{
|
||||||
@@ -52,14 +52,13 @@ final class GitDirtyTestCaseFilter implements TestCaseFilter
|
|||||||
*/
|
*/
|
||||||
private function loadChangedFiles(): void
|
private function loadChangedFiles(): void
|
||||||
{
|
{
|
||||||
$process = new Process(['git', 'status', '--short', '--', '*.php']);
|
$status = new Git(timeout: 60.0)->raw(['status', '--short', '--', '*.php']);
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if ($status === null) {
|
||||||
throw new MissingDependency('Filter by dirty files', 'git');
|
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));
|
assert(is_array($output));
|
||||||
|
|
||||||
$dirtyFiles = [];
|
$dirtyFiles = [];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
Pest Testing Framework 5.0.2.
|
Pest Testing Framework 5.0.4.
|
||||||
|
|
||||||
USAGE: pest <file> [options]
|
USAGE: pest <file> [options]
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
|
|
||||||
Pest Testing Framework 5.0.2.
|
Pest Testing Framework 5.0.4.
|
||||||
|
|
||||||
|
|||||||
@@ -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[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[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/.tests/Failure.php:6' type='comparisonFailure' actual='true' expected='false' 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[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[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[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[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[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[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/.tests/Failure.php:18' 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[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[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/.tests/Failure.php:22' 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[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[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[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[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']
|
||||||
|
|
||||||
[90mTests:[39m [31;1m3 failed[39;22m[90m,[39m[39m [39m[33;1m1 risky[39;22m[90m,[39m[39m [39m[36;1m2 todos[39;22m[90m,[39m[39m [39m[33;1m1 skipped[39;22m[90m,[39m[39m [39m[32;1m1 passed[39;22m[90m (3 assertions)[39m
|
[90mTests:[39m [31;1m3 failed[39;22m[90m,[39m[39m [39m[33;1m1 risky[39;22m[90m,[39m[39m [39m[36;1m2 todos[39;22m[90m,[39m[39m [39m[33;1m1 skipped[39;22m[90m,[39m[39m [39m[32;1m1 passed[39;22m[90m (3 assertions)[39m
|
||||||
[90mDuration:[39m [39m1.00s[39m
|
[90mDuration:[39m [39m1.00s[39m
|
||||||
|
|||||||
@@ -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[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[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[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[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/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' 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[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234']
|
||||||
##teamcity[testSuiteFinished name='can pass with dataset' 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[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/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' 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[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='`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']
|
||||||
|
|
||||||
[90mTests:[39m [32;1m4 passed[39;22m[90m (4 assertions)[39m
|
[90mTests:[39m [32;1m4 passed[39;22m[90m (4 assertions)[39m
|
||||||
[90mDuration:[39m [39m1.00s[39m
|
[90mDuration:[39m [39m1.00s[39m
|
||||||
|
|||||||
@@ -1,17 +1,4 @@
|
|||||||
WARN Tests\Fixtures\CollisionTest
|
|
||||||
- error
|
|
||||||
- success
|
|
||||||
|
|
||||||
PASS Tests\Fixtures\DirectoryWithTests\ExampleTest
|
PASS Tests\Fixtures\DirectoryWithTests\ExampleTest
|
||||||
✓ it example 1
|
✓ it example 1
|
||||||
|
|
||||||
PASS Tests\Fixtures\ExampleTest
|
Tests: 1 passed (1 assertions)
|
||||||
✓ it example 2
|
|
||||||
|
|
||||||
WARN Tests\Fixtures\Inheritance\Base\ExampleTest
|
|
||||||
- example
|
|
||||||
|
|
||||||
PASS Tests\Fixtures\Inheritance\ExampleTest
|
|
||||||
✓ example
|
|
||||||
|
|
||||||
Tests: 3 skipped, 3 passed (3 assertions)
|
|
||||||
|
|||||||
@@ -1565,6 +1565,229 @@
|
|||||||
✓ it allows performing no expectations without being risky
|
✓ it allows performing no expectations without being risky
|
||||||
✓ a "describe" group of tests → 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
|
PASS Tests\Features\Ticket
|
||||||
✓ it may be associated with an ticket #1, #2
|
✓ it may be associated with an ticket #1, #2
|
||||||
✓ nested → it may be associated with an ticket #1, #4, #5, #6, #3
|
✓ nested → it may be associated with an ticket #1, #4, #5, #6, #3
|
||||||
@@ -1908,6 +2131,11 @@
|
|||||||
✓ activateLinkTracking() → it tracks linked sources across consecutive tests
|
✓ activateLinkTracking() → it tracks linked sources across consecutive tests
|
||||||
✓ activateLinkTracking() → it records nothing while inactive
|
✓ activateLinkTracking() → it records nothing while inactive
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\ResultKey
|
||||||
|
✓ it keys a result per dataset row rather than per method
|
||||||
|
✓ it records assertions against the same per-dataset key
|
||||||
|
✓ it leaves a test without a dataset keyed by class and method
|
||||||
|
|
||||||
PASS Tests\Unit\Plugins\Tia\TableExtractor
|
PASS Tests\Unit\Plugins\Tia\TableExtractor
|
||||||
✓ fromSql() → it extracts tables from plain DML
|
✓ fromSql() → it extracts tables from plain DML
|
||||||
✓ fromSql() → it extracts tables from joins
|
✓ fromSql() → it extracts tables from joins
|
||||||
@@ -1916,10 +2144,12 @@
|
|||||||
✓ fromSql() → it records the table, not the schema, for qualified identifiers
|
✓ fromSql() → it records the table, not the schema, for qualified identifiers
|
||||||
✓ fromSql() → it handles quoted identifiers
|
✓ fromSql() → it handles quoted identifiers
|
||||||
✓ fromSql() → it ignores schema metadata tables
|
✓ fromSql() → it ignores schema metadata tables
|
||||||
|
✓ fromSql() → it does not leak int keys for numeric identifiers
|
||||||
✓ fromSql() → it returns nothing for non-DML statements
|
✓ fromSql() → it returns nothing for non-DML statements
|
||||||
✓ fromMigrationSource() → it extracts tables from Schema builder calls
|
✓ fromMigrationSource() → it extracts tables from Schema builder calls
|
||||||
✓ fromMigrationSource() → it extracts tables from raw DDL statements
|
✓ fromMigrationSource() → it extracts tables from raw DDL statements
|
||||||
✓ fromMigrationSource() → it records the table, not the schema, in qualified DDL and DML
|
✓ 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
|
✓ fromMigrationSource() → it extracts tables from DB::table calls
|
||||||
|
|
||||||
PASS Tests\Unit\Plugins\Tia\TestPaths
|
PASS Tests\Unit\Plugins\Tia\TestPaths
|
||||||
@@ -2153,6 +2383,7 @@
|
|||||||
✓ a parallel test can extend another test with same name
|
✓ a parallel test can extend another test with same name
|
||||||
✓ parallel reports invalid datasets as failures
|
✓ parallel reports invalid datasets as failures
|
||||||
✓ parallel can have multiple exclude-groups
|
✓ parallel can have multiple exclude-groups
|
||||||
|
✓ parallel can have multiple groups
|
||||||
|
|
||||||
PASS Tests\Visual\ParallelNestedDatasets
|
PASS Tests\Visual\ParallelNestedDatasets
|
||||||
✓ parallel loads nested datasets from nested directories
|
✓ parallel loads nested datasets from nested directories
|
||||||
@@ -2186,4 +2417,4 @@
|
|||||||
✓ pass with dataset with ('my-datas-set-value')
|
✓ pass with dataset with ('my-datas-set-value')
|
||||||
✓ within describe → 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, 1542 passed (3375 assertions)
|
Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1743 passed (3953 assertions)
|
||||||
@@ -18,27 +18,27 @@ $run = function (string $target): array {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test('reports missing datasets as errors for a single file run', function () use ($run): void {
|
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'])
|
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('FAILED')
|
||||||
->toContain('Tests: 1 failed')
|
->toContain('Tests: 1 failed')
|
||||||
->and($result['code'])->not->toBe(0);
|
->and($result['code'])->not->toBe(0);
|
||||||
})->skipOnWindows();
|
})->skipOnWindows();
|
||||||
|
|
||||||
test('reports missing datasets as errors alongside passing tests', function () use ($run): void {
|
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'])
|
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 passed')
|
||||||
->toContain('1 failed')
|
->toContain('1 failed')
|
||||||
->and($result['code'])->not->toBe(0);
|
->and($result['code'])->not->toBe(0);
|
||||||
})->skipOnWindows();
|
})->skipOnWindows();
|
||||||
|
|
||||||
test('reports dataset closure exceptions as errors', function () use ($run): void {
|
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'])
|
expect($result['output'])
|
||||||
->toContain('boom from dataset')
|
->toContain('boom from dataset')
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ beforeEach(function (): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws exception if dataset does not exist', 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 {
|
it('throws exception if dataset already exist', function (): void {
|
||||||
DatasetsRepository::set('second', [[]], __DIR__);
|
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 {
|
it('sets closures', function (): void {
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ it('works as higher order test')
|
|||||||
|
|
||||||
it('fails after exhausting all retries', function (): void {
|
it('fails after exhausting all retries', function (): void {
|
||||||
$process = new Process(
|
$process = new Process(
|
||||||
['php', 'bin/pest', 'tests/.tests/FlakyFailure.php'],
|
['php', 'bin/pest', 'tests/Fixtures/Suites/FlakyFailure.php'],
|
||||||
dirname(__DIR__, 2),
|
dirname(__DIR__, 2),
|
||||||
['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true', 'PAO_DISABLE' => '1'],
|
['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true', 'PAO_DISABLE' => '1'],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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');
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user