Compare commits

...

35 Commits

Author SHA1 Message Date
92e76eb5ab ci: runs ci only against stable 2026-06-01 06:32:29 +01:00
bd22f478b8 chore: fixes issues with contracts and symfony 8.1 2026-06-01 06:24:42 +01:00
eeaac34cf6 release: v4.7.1 2026-06-01 05:42:12 +01:00
b9b07d8983 chore: bump dependencies 2026-06-01 05:42:03 +01:00
6aa7d2f891 fix: better fatal exceptions reporting 2026-06-01 05:41:58 +01:00
1c21a7647a chore: fixes types 2026-05-13 12:20:00 +01:00
d649de1988 chore: add security policy 2026-05-12 02:48:25 +01:00
783ca4bcd6 chore(deps): limit dependabot to maintained branches (4.x + 5.x) 2026-05-12 02:34:08 +01:00
ba07497219 chore: enable Dependabot version updates for GitHub Actions (#1700) 2026-05-11 22:12:07 -03:00
1ca021dea6 chore: pin GitHub Actions to commit SHAs (#1695)
* chore: pin GitHub Actions to commit SHAs

* chore: pin GitHub Actions to commit SHAs
2026-05-12 02:08:47 +01:00
2fc75cfcf0 chore: updates snapshots 2026-05-03 13:09:32 -03:00
6cc48f63f8 chore: style 2026-05-03 13:06:24 -03:00
e0419d1328 release: v4.7.0 2026-05-03 12:46:24 -03:00
faa6988801 Merge pull request #1682 from pestphp/feat/tia
[4.x] TIA Engine
2026-05-03 16:27:58 +01:00
c12247fafd Revert "wip"
This reverts commit 1b168aba1c.
2026-05-03 11:45:39 -03:00
29b4452443 wip 2026-05-03 11:33:30 -03:00
1b168aba1c wip 2026-05-03 11:30:13 -03:00
6aabd977cd wip 2026-05-03 11:01:52 -03:00
a882543c53 wip 2026-05-03 10:57:28 -03:00
c250b9da4f wip 2026-05-03 10:37:17 -03:00
46bc3dc628 wip 2026-05-03 10:34:44 -03:00
d3ce498b8a wip 2026-05-03 10:31:47 -03:00
e1a4b98b71 wip 2026-05-03 10:16:10 -03:00
9afbcd5c18 wuip 2026-05-03 09:54:02 -03:00
75593b6454 wip 2026-05-03 13:37:27 +01:00
89590d6120 wip 2026-05-03 13:35:01 +01:00
fb0978c9bf wip 2026-05-03 13:26:48 +01:00
a3796daa42 wip 2026-05-03 13:24:10 +01:00
e3004db666 wip 2026-05-03 12:43:38 +01:00
99cc4e0146 wip 2026-05-02 19:33:09 +01:00
a47e6f8fef wip 2026-05-02 19:30:14 +01:00
536d79f765 wip 2026-05-02 19:20:55 +01:00
65c0fbc528 wip 2026-05-02 19:07:41 +01:00
9e4cf4b665 wip 2026-05-02 18:58:42 +01:00
fcf5c27914 chore: adds YouTube channel badge
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:56:04 -07:00
41 changed files with 546 additions and 351 deletions

13
.github/SECURITY.md vendored Normal file
View File

@ -0,0 +1,13 @@
# Security Policy
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
## Reporting a Vulnerability
If you discover a security vulnerability in Pest, please report it privately using one of the following channels:
1. **GitHub Private Vulnerability Reporting** (preferred) — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
2. **Email** — send the details to Nuno Maduro at **enunomaduro@gmail.com**.
All security vulnerabilities will be promptly addressed.

19
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,19 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
target-branch: "5.x"
groups:
github-actions:
patterns:
- "*"

View File

@ -24,14 +24,14 @@ jobs:
strategy:
fail-fast: true
matrix:
dependency-version: [prefer-lowest, prefer-stable]
dependency-version: [prefer-stable]
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2
with:
php-version: 8.3
tools: composer:v2
@ -44,7 +44,7 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}

View File

@ -35,10 +35,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2
with:
php-version: ${{ matrix.php }}
tools: composer:v2
@ -51,7 +51,7 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}

View File

@ -6,6 +6,7 @@
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
<a href="https://whyphp.dev"><img src="https://img.shields.io/badge/Why_PHP-in_2026-7A86E8?style=flat-square&labelColor=18181b" alt="Why PHP in 2026"></a>
<a href="https://youtube.com/@nunomaduro?sub_confirmation=1"><img alt="YouTube Channel Subscribers" src="https://img.shields.io/youtube/channel/subscribers/UCO_hYZF2gb_CyG5sA7ArlGg?style=flat&label=youtube&color=brightgreen"></a>
</p>
</p>

View File

@ -6,10 +6,22 @@ import { createRequire } from 'node:module'
import { resolve, relative, extname, sep, join } from 'node:path'
import { pathToFileURL } from 'node:url'
const PAGE_EXTENSIONS = new Set(['.vue', '.tsx', '.jsx', '.svelte'])
const PAGE_EXTENSIONS = new Set([
'.vue', '.svelte',
'.tsx', '.jsx',
'.ts', '.js',
'.mts', '.cts', '.mjs', '.cjs',
])
const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/')
const PAGE_DIR_CANDIDATES = [
'resources/js/Pages',
'resources/js/pages',
'assets/js/Pages',
'assets/js/pages',
'assets/Pages',
'assets/pages',
]
async function loadRolldown() {
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
@ -64,6 +76,22 @@ async function listPageFiles(pagesDir) {
return out
}
async function discoverPagesDir() {
const override = process.env.TIA_VITE_PAGES_DIR
if (override && override.length > 0) {
return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
}
for (const rel of PAGE_DIR_CANDIDATES) {
const abs = resolve(PROJECT_ROOT, rel)
if (!existsSync(abs)) continue
const files = await listPageFiles(abs)
if (files.length > 0) return abs
}
return null
}
function componentNameFor(pageAbs, pagesDir) {
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
const ext = extname(rel)
@ -79,7 +107,13 @@ function isLocalSpecifier(source, aliasKeys) {
}
async function main() {
const pagesDir = resolve(PROJECT_ROOT, PAGES_REL)
const pagesDir = await discoverPagesDir()
if (pagesDir === null) {
process.stdout.write('{}')
return
}
const pages = await listPageFiles(pagesDir)
if (pages.length === 0) {

View File

@ -26,12 +26,12 @@
"pestphp/pest-plugin-arch": "^4.0.2",
"pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.1",
"phpunit/phpunit": "^12.5.24",
"symfony/process": "^7.4.8|^8.0.8"
"phpunit/phpunit": "^12.5.28",
"symfony/process": "^7.4.13|^8.1.0"
},
"conflict": {
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.24",
"phpunit/phpunit": ">12.5.28",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@ -59,11 +59,11 @@
]
},
"require-dev": {
"mrpunyapal/peststan": "^0.2.9",
"mrpunyapal/peststan": "^0.2.10",
"pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.1",
"pestphp/pest-plugin-type-coverage": "^4.0.4",
"psy/psysh": "^0.12.22"
"psy/psysh": "^0.12.23"
},
"minimum-stability": "dev",
"prefer-stable": true,

View File

@ -26,8 +26,8 @@ final readonly class BootSubscribers implements Bootstrapper
Subscribers\EnsureKernelDumpIsFlushed::class,
Subscribers\EnsureTeamCityEnabled::class,
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
Subscribers\EnsureTiaCoverageIsRecorded::class,
Subscribers\EnsureTiaCoverageIsFlushed::class,
Subscribers\EnsureTiaStarts::class,
Subscribers\EnsureTiaEnds::class,
Subscribers\EnsureTiaResultsAreCollected::class,
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,

View File

@ -9,8 +9,8 @@ use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Collectors;
use Pest\Plugins\Tia\Enums\ReplayType;
use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\Replay;
use Pest\Preset;
use Pest\Support\ChainableClosure;
use Pest\Support\Container;
@ -85,7 +85,7 @@ trait Testable
* The active replay mode for this test, set in `setUp()` and checked
* in `__runTest()` / `tearDown()` to skip the body and after-each.
*/
private Replay $__replay = Replay::No;
private ReplayType $__replay = ReplayType::None;
/**
* The cached assertion count to replay, captured when entering replay mode.
@ -279,16 +279,16 @@ trait Testable
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
$replay = Replay::fromStatus($status);
$replay = ReplayType::fromStatus($status);
if ($replay !== Replay::No) {
if ($replay !== ReplayType::None) {
assert($status !== null);
match ($replay) {
Replay::Pass, Replay::Risky => $this->__beginReplay($replay, $tia),
Replay::Skipped => $this->markTestSkipped($status->message()),
Replay::Incomplete => $this->markTestIncomplete($status->message()),
Replay::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
ReplayType::Skipped => $this->markTestSkipped($status->message()),
ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
};
return;
@ -314,7 +314,7 @@ trait Testable
$this->__callClosure($beforeEach, $arguments);
}
private function __beginReplay(Replay $replay, Tia $tia): void
private function __beginReplay(ReplayType $replay, Tia $tia): void
{
$this->__replay = $replay;
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
@ -353,7 +353,7 @@ trait Testable
*/
protected function tearDown(...$arguments): void
{
if ($this->__replay !== Replay::No) {
if ($this->__replay !== ReplayType::None) {
TestSuite::getInstance()->test = null;
return;
@ -384,8 +384,8 @@ trait Testable
*/
private function __runTest(Closure $closure, ...$args): mixed
{
if ($this->__replay === Replay::Pass || $this->__replay === Replay::Risky) {
if ($this->__replay === Replay::Pass && $this->__replayAssertions === 0) {
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
$this->expectNotToPerformAssertions();
}

View File

@ -163,7 +163,7 @@ final class Kernel
$this->terminate();
if (is_array($error = error_get_last())) {
if (! in_array($error['type'], [E_ERROR, E_CORE_ERROR], true)) {
if (! in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
return;
}

View File

@ -68,6 +68,10 @@ final class KernelDump
$type = 'INFO';
if (is_array($error = error_get_last()) && in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
return;
}
if ($this->isInternalError($this->buffer)) {
$type = 'ERROR';
$this->buffer = str_replace(
@ -107,7 +111,6 @@ final class KernelDump
*/
private function isInternalError(string $output): bool
{
return str_contains($output, 'An error occurred inside PHPUnit.')
|| str_contains($output, 'Fatal error');
return str_contains($output, 'An error occurred inside PHPUnit.');
}
}

View File

@ -954,6 +954,7 @@ final class Expectation
} catch (Throwable $e) {
if ($exception instanceof Throwable) {
// @phpstan-ignore-next-line
expect($e)
->toBeInstanceOf($exception::class, $message)
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message);

View File

@ -6,7 +6,7 @@ namespace Pest;
function version(): string
{
return '4.6.3';
return '4.7.1';
}
function testDirectory(string $file = ''): string

View File

@ -146,7 +146,6 @@ final class WrapperRunner implements RunnerInterface
public function run(): int
{
$directory = dirname(__DIR__);
assert($directory !== '');
ExcludeList::addDirectory($directory);
TestResultFacade::init();
EventFacade::instance()->seal();

View File

@ -10,6 +10,7 @@ use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\NoAffectedTestsFound;
use Pest\Panic;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Tia\BaselineSync;
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\Contracts\State;
@ -36,7 +37,7 @@ use Symfony\Component\Process\Process;
*/
final class Tia implements AddsOutput, HandlesArguments, Terminable
{
use Concerns\HandleArguments;
use HandleArguments;
private const string OPTION = '--tia';
@ -52,6 +53,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string BASELINED_OPTION = '--baselined';
private const string BASELINE_PATH_OPTION = '--baseline';
private const string ENV_TIA = 'PEST_TIA';
private const string ENV_FILTERED = 'PEST_TIA_FILTERED';
@ -126,7 +129,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** @var array{structural: array<string, mixed>, environmental: array<string, mixed>}|null */
private ?array $startFingerprint = null;
private bool $piggybackCoverage = false;
private bool $piggybackCoverage = false;
private bool $recordingActive = false;
@ -138,6 +141,12 @@ private bool $piggybackCoverage = false;
private bool $filteredMode = false;
private ?string $driftLabel = null;
private ?string $driftDetails = null;
private ?string $freshGraphReason = null;
public function __construct(
private readonly OutputInterface $output,
private readonly Recorder $recorder,
@ -194,12 +203,16 @@ private bool $piggybackCoverage = false;
*/
public static function isEnabledForRun(array $arguments): bool
{
if (self::argumentPresent(self::NO_OPTION, $arguments)) {
return false;
}
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
assert($watchPatterns instanceof WatchPatterns);
self::applyWatchPatternMarks($arguments, $watchPatterns);
if (in_array(self::OPTION, $arguments, true) || self::envFlagEnabled(self::ENV_TIA)) {
if (self::argumentPresent(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA)) {
return true;
}
@ -207,7 +220,7 @@ private bool $piggybackCoverage = false;
return false;
}
return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true));
return ! ($watchPatterns->isLocally() && self::argumentPresent('--ci', $arguments));
}
/**
@ -215,16 +228,37 @@ private bool $piggybackCoverage = false;
*/
private static function applyWatchPatternMarks(array $arguments, WatchPatterns $watchPatterns): void
{
if (in_array(self::LOCALLY_OPTION, $arguments, true) || self::envFlagEnabled(self::ENV_LOCALLY)) {
if (self::argumentPresent(self::LOCALLY_OPTION, $arguments) || self::envFlagEnabled(self::ENV_LOCALLY)) {
$watchPatterns->markEnabled();
$watchPatterns->markLocally();
}
if (in_array(self::BASELINED_OPTION, $arguments, true) || self::envFlagEnabled(self::ENV_BASELINED)) {
if (self::argumentPresent(self::BASELINED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_BASELINED)) {
$watchPatterns->markBaselined();
}
}
/**
* Mirrors {@see HandleArguments::hasArgument()} for
* use from static contexts — matches both `--flag` and `--flag=value`.
*
* @param array<int, string> $arguments
*/
private static function argumentPresent(string $argument, array $arguments): bool
{
foreach ($arguments as $arg) {
if ($arg === $argument) {
return true;
}
if (str_starts_with($arg, "$argument=")) {
return true;
}
}
return false;
}
private static function envFlagEnabled(string $name): bool
{
return filter_var(getenv($name), FILTER_VALIDATE_BOOL);
@ -284,6 +318,12 @@ private bool $piggybackCoverage = false;
*/
public function handleArguments(array $arguments): array
{
if ($this->hasArgument(self::BASELINE_PATH_OPTION, $arguments)) {
$this->output->writeln(Storage::tempDir(TestSuite::getInstance()->rootPath));
exit(0);
}
$isWorker = Parallel::isWorker();
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
@ -297,7 +337,8 @@ private bool $piggybackCoverage = false;
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
$enabled = ! $disabled && ($cliEnabled || $alwaysEnabled);
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered())
&& ! $this->hasExplicitPathArgument($arguments);
&& ! $this->hasExplicitPathArgument($arguments)
&& ! $this->coverageReportActive();
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
@ -532,10 +573,7 @@ private bool $piggybackCoverage = false;
if (! Fingerprint::structuralMatches($stored, $current)) {
$drift = Fingerprint::structuralDrift($stored, $current);
$this->renderChild(sprintf(
'Graph structure outdated (%s).',
$this->formatStructuralDrift($drift),
));
$this->driftLabel = $this->formatStructuralDrift($drift);
if (in_array('composer_lock', $drift, true)) {
$branchSha = $graph->recordedAtSha($this->branch);
@ -545,7 +583,7 @@ private bool $piggybackCoverage = false;
$branchSha,
);
if ($summary !== '') {
$this->renderChild($summary);
$this->driftDetails = $summary;
}
}
}
@ -629,6 +667,10 @@ private bool $piggybackCoverage = false;
}
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
if ($graph instanceof Graph && $this->driftLabel === null) {
$this->freshGraphReason = 'recording coverage baseline';
}
return $this->enterRecordMode($arguments);
}
@ -852,7 +894,7 @@ private bool $piggybackCoverage = false;
$this->output->writeln('');
if ($affected === []) {
$this->renderChild('TIA mode enabled.');
$this->renderChild('Experimental TIA mode enabled.');
return;
}
@ -894,7 +936,7 @@ private bool $piggybackCoverage = false;
}
$this->renderChild(sprintf(
'TIA mode enabled / %d affected test file%s%s.',
'Experimental TIA mode enabled / %d affected test file%s%s.',
count($affected),
count($affected) === 1 ? '' : 's',
$reasons === [] ? '' : ' ('.implode(', ', $reasons).')',
@ -955,7 +997,7 @@ private bool $piggybackCoverage = false;
}
$this->output->writeln('');
$this->renderChild('TIA mode enabled / fresh graph.');
$this->renderFreshGraph();
return $arguments;
}
@ -964,7 +1006,7 @@ private bool $piggybackCoverage = false;
$this->recordingActive = true;
$this->output->writeln('');
$this->renderChild('TIA mode enabled / fresh graph.');
$this->renderFreshGraph();
return $arguments;
}
@ -977,11 +1019,32 @@ private bool $piggybackCoverage = false;
return $arguments;
}
private function renderFreshGraph(): void
{
$headline = 'Experimental TIA mode enabled / fresh graph';
if ($this->driftLabel !== null) {
$headline .= sprintf(' (%s changed)', $this->driftLabel);
} elseif ($this->freshGraphReason !== null) {
$headline .= sprintf(' (%s)', $this->freshGraphReason);
} else {
$headline .= '.';
}
$this->renderChild($headline);
if ($this->driftDetails !== null) {
foreach (explode(', ', $this->driftDetails) as $detail) {
$this->output->writeln(sprintf(' <fg=gray>%s</>', $detail));
}
}
}
private function emitCoverageDriverMissing(): void
{
$this->renderBadge('WARN', 'No coverage driver is available — skipped.');
$this->renderChild('Needs ext-pcov or Xdebug with coverage mode enabled to record the dependency graph.');
$this->renderChild('Install or enable one and rerun with --tia.');
$this->output->writeln('');
$this->renderChild('Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug.');
}
/**
@ -1299,7 +1362,16 @@ private bool $piggybackCoverage = false;
/** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class);
foreach ($collector->all() as $testId => $result) {
$results = $collector->all();
$touchedFiles = [];
foreach ($results as $testId => $result) {
$file = $result['file'] ?? null;
if (is_string($file) && $file !== '') {
$touchedFiles[$file] = true;
}
$graph->setResult(
$this->branch,
$testId,
@ -1307,10 +1379,12 @@ private bool $piggybackCoverage = false;
$result['message'],
$result['time'],
$result['assertions'],
$result['file'] ?? null,
$file,
);
}
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
$collector->reset();
}
@ -1333,6 +1407,8 @@ private bool $piggybackCoverage = false;
return;
}
$touchedFiles = [];
foreach ($results as $testId => $result) {
$file = $result['file'] ?? null;
@ -1340,6 +1416,10 @@ private bool $piggybackCoverage = false;
$file = $this->resolveFailedTestFile($testId);
}
if (is_string($file) && $file !== '') {
$touchedFiles[$file] = true;
}
$graph->setResult(
$this->branch,
$testId,
@ -1351,6 +1431,8 @@ private bool $piggybackCoverage = false;
);
}
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
$this->saveGraph($graph);
$collector->reset();
}
@ -1391,7 +1473,7 @@ private bool $piggybackCoverage = false;
$coverage = Container::getInstance()->get(Coverage::class);
assert($coverage instanceof Coverage);
return $coverage->coverage === true;
return $coverage->coverage;
}
/**
@ -1516,7 +1598,7 @@ private bool $piggybackCoverage = false;
}
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
$this->renderBadge('WARN', 'Fetched baseline still drifts — discarding.');
$this->output->writeln(' <fg=gray> However, baseline still drifts — discarding.</>');
return null;
}

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Composer\InstalledVersions;
use Pest\Exceptions\BaselineFetchFailed;
use Pest\Panic;
use Pest\Plugins\Tia;
@ -94,7 +93,7 @@ final readonly class BaselineSync
if ($payload === null) {
if ($failureKind === 'no-runs' || $failureKind === null) {
$this->startCooldown();
$this->emitPublishInstructions($repo);
$this->emitPublishInstructions();
}
return false;
@ -110,11 +109,6 @@ final readonly class BaselineSync
$this->clearCooldown();
$this->renderBadge('INFO', sprintf(
'Baseline ready (%s).',
$this->formatSize($payload['sizeOnDisk']),
));
return true;
}
@ -162,7 +156,7 @@ final readonly class BaselineSync
return $seconds.'s';
}
private function emitPublishInstructions(string $repo): void
private function emitPublishInstructions(): void
{
if ($this->isCi()) {
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
@ -170,23 +164,8 @@ final readonly class BaselineSync
return;
}
$yaml = $this->isLaravel()
? $this->laravelWorkflowYaml()
: $this->genericWorkflowYaml();
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
$this->renderChild('To share the baseline with your team, add this workflow to the repo:');
$this->renderChild('.github/workflows/tia-baseline.yml');
$indentedYaml = array_map(
static fn (string $line): string => ' '.$line,
explode("\n", $yaml),
);
$this->output->writeln(['', ...$indentedYaml, '']);
$this->renderChild(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo));
$this->renderChild('Details: https://pestphp.com/docs/tia');
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
}
private function isCi(): bool
@ -196,79 +175,6 @@ final readonly class BaselineSync
|| getenv('CIRCLECI') === 'true';
}
private function isLaravel(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('laravel/framework');
}
private function laravelWorkflowYaml(): string
{
return <<<'YAML'
name: TIA Baseline
on:
push: { branches: [main] }
schedule: [{ cron: '0 3 * * *' }]
workflow_dispatch:
jobs:
baseline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: xdebug
extensions: json, dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3, bcmath, intl
- run: cp .env.example .env
- run: composer install --no-interaction --prefer-dist
- run: php artisan key:generate
- run: ./vendor/bin/pest --parallel --tia --coverage
- name: Stage baseline for upload
shell: bash
run: |
mkdir -p .pest-tia-baseline
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
- uses: actions/upload-artifact@v4
with:
name: pest-tia-baseline
path: .pest-tia-baseline/
retention-days: 30
YAML;
}
private function genericWorkflowYaml(): string
{
return <<<'YAML'
name: TIA Baseline
on:
push: { branches: [main] }
schedule: [{ cron: '0 3 * * *' }]
workflow_dispatch:
jobs:
baseline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: shivammathur/setup-php@v2
with: { php-version: '8.4', coverage: xdebug }
- run: composer install --no-interaction --prefer-dist
- run: ./vendor/bin/pest --parallel --tia --coverage
- name: Stage baseline for upload
shell: bash
run: |
mkdir -p .pest-tia-baseline
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
- uses: actions/upload-artifact@v4
with:
name: pest-tia-baseline
path: .pest-tia-baseline/
retention-days: 30
YAML;
}
private function detectGitHubRepo(string $projectRoot): ?string
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
@ -333,7 +239,7 @@ YAML;
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
@touch($runCacheDir);
$this->renderBadge('INFO', sprintf(
$this->renderChild(sprintf(
'Using cached baseline from %s (run %s).',
$repo,
$runId,
@ -401,14 +307,15 @@ YAML;
{
$artifactSize = $this->artifactSize($repo, $runId);
$this->renderBadge('INFO', $artifactSize !== null
$this->output->writeln('');
$this->renderChild($artifactSize !== null
? sprintf(
'Fetching baseline (%s) from %s…',
'Downloading TIA baseline (%s) from %s…',
$this->formatSize($artifactSize),
$repo,
)
: sprintf(
'Fetching baseline from %s…',
'Downloading TIA baseline from %s…',
$repo,
));
@ -422,10 +329,11 @@ YAML;
$process->start();
$startedAt = microtime(true);
$tick = 0;
while ($process->isRunning()) {
$this->renderDownloadProgress($runCacheDir, $artifactSize, $startedAt);
usleep(250_000);
$this->renderDownloadProgress($startedAt, $tick++);
usleep(120_000);
}
$process->wait();
@ -491,30 +399,18 @@ YAML;
return is_numeric($size) ? (int) $size : null;
}
private function renderDownloadProgress(string $dir, ?int $totalBytes, float $startedAt): void
private function renderDownloadProgress(float $startedAt, int $tick): void
{
$current = $this->dirSize($dir);
$elapsed = max(0.001, microtime(true) - $startedAt);
$speed = (int) ($current / $elapsed);
static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
if ($totalBytes !== null && $totalBytes > 0) {
$percent = min(99, (int) floor(($current / $totalBytes) * 100));
$message = sprintf(
' <fg=cyan>Downloading</> %s / %s (%d%%, %s/s)',
$this->formatSize($current),
$this->formatSize($totalBytes),
$percent,
$this->formatSize($speed),
);
} else {
$message = sprintf(
' <fg=cyan>Downloading</> %s (%s/s)',
$this->formatSize($current),
$this->formatSize($speed),
);
}
$elapsed = max(0.0, microtime(true) - $startedAt);
$frame = $frames[$tick % count($frames)];
$this->output->write("\r\033[K".$message);
$this->output->write(sprintf(
"\r\033[K <fg=gray>%s %.1fs elapsed</>",
$frame,
$elapsed,
));
}
private function clearProgressLine(): void

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
namespace Pest\Plugins\Tia\Contracts;
/**
* @internal

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
use ReflectionClass;
use Throwable;
/**

View File

@ -31,6 +31,7 @@ final class CoverageMerger
$current = self::requireCoverage($reportPath);
if ($current instanceof CodeCoverage) {
self::primeUncoveredFiles($current);
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
}
@ -52,6 +53,9 @@ final class CoverageMerger
return;
}
self::primeUncoveredFiles($cached);
self::primeUncoveredFiles($current);
self::stripCurrentTestsFromCached($cached, $current);
$cached->merge($current);
@ -65,6 +69,11 @@ final class CoverageMerger
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress($serialised));
}
private static function primeUncoveredFiles(CodeCoverage $coverage): void
{
$coverage->getData(false);
}
private static function compress(string $bytes): string
{
$compressed = @gzencode($bytes);

View File

@ -2,16 +2,16 @@
declare(strict_types=1);
namespace Pest\Plugins\Tia;
namespace Pest\Plugins\Tia\Enums;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* @internal
*/
enum Replay
enum ReplayType
{
case No;
case None;
case Pass;
case Risky;
case Skipped;
@ -21,7 +21,7 @@ enum Replay
public static function fromStatus(?TestStatus $status): self
{
if (! $status instanceof TestStatus) {
return self::No;
return self::None;
}
return match (true) {

View File

@ -4,12 +4,14 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Symfony\Component\Finder\Finder;
/**
* @internal
*/
final readonly class Fingerprint
{
private const int SCHEMA_VERSION = 15;
private const int SCHEMA_VERSION = 17;
/**
* @return array{
@ -23,15 +25,15 @@ final readonly class Fingerprint
'structural' => [
'schema' => self::SCHEMA_VERSION,
'composer_lock' => self::composerLockHash($projectRoot),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'),
'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'),
// 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
// 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
'vite_config' => self::viteConfigHash($projectRoot),
'package_json' => self::packageJsonHash($projectRoot),
// 'package_json' => self::packageJsonHash($projectRoot),
'package_lock' => self::packageLockHash($projectRoot),
'js_config' => self::jsConfigHash($projectRoot),
'composer_json' => self::composerJsonHash($projectRoot),
// 'composer_json' => self::composerJsonHash($projectRoot),
],
'environmental' => [
'php_minor' => PHP_MAJOR_VERSION,
@ -160,6 +162,10 @@ final readonly class Fingerprint
$parts = [];
foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
if (! self::isTrackedByGit($projectRoot, $name)) {
continue;
}
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
if ($hash !== null) {
@ -175,6 +181,10 @@ final readonly class Fingerprint
$parts = [];
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
if (! self::isTrackedByGit($projectRoot, $name)) {
continue;
}
$hash = self::hashIfExists($projectRoot.'/'.$name);
if ($hash !== null) {
@ -185,52 +195,9 @@ final readonly class Fingerprint
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function packageJsonHash(string $projectRoot): ?string
{
$path = $projectRoot.'/package.json';
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
$relevant = [
'type' => $data['type'] ?? null,
'packageManager' => $data['packageManager'] ?? null,
'dependencies' => $data['dependencies'] ?? null,
'devDependencies' => $data['devDependencies'] ?? null,
'optionalDependencies' => $data['optionalDependencies'] ?? null,
'peerDependencies' => $data['peerDependencies'] ?? null,
'overrides' => $data['overrides'] ?? null,
'resolutions' => $data['resolutions'] ?? null,
'imports' => $data['imports'] ?? null,
'exports' => $data['exports'] ?? null,
'browser' => $data['browser'] ?? null,
];
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
}
private static function composerLockHash(string $projectRoot): ?string
{
return self::hashIfExists($projectRoot.'/composer.lock');
return self::trackedHash($projectRoot, 'composer.lock');
}
private static function packageLockHash(string $projectRoot): ?string
@ -238,7 +205,7 @@ final readonly class Fingerprint
$parts = [];
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
$hash = self::hashIfExists($projectRoot.'/'.$name);
$hash = self::trackedHash($projectRoot, $name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
@ -248,68 +215,47 @@ final readonly class Fingerprint
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function composerJsonHash(string $projectRoot): ?string
private static function trackedHash(string $projectRoot, string $relativePath): ?string
{
$path = $projectRoot.'/composer.json';
if (! is_file($path)) {
if (! self::isTrackedByGit($projectRoot, $relativePath)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
$relevantConfig = array_intersect_key($config, [
'platform' => true,
'allow-plugins' => true,
]);
$relevant = [
'autoload' => $data['autoload'] ?? null,
'autoload-dev' => $data['autoload-dev'] ?? null,
'require' => $data['require'] ?? null,
'require-dev' => $data['require-dev'] ?? null,
'extra' => $data['extra'] ?? null,
'repositories' => $data['repositories'] ?? null,
'minimum-stability' => $data['minimum-stability'] ?? null,
'prefer-stable' => $data['prefer-stable'] ?? null,
'config' => $relevantConfig === [] ? null : $relevantConfig,
];
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
return self::hashIfExists($projectRoot.'/'.$relativePath);
}
private static function sortRecursively(mixed &$value): void
/**
* Returns true when the file exists and is not gitignored.
*
* Gitignored lockfiles (e.g. `package-lock.json` excluded from the repo)
* regenerate per-machine with OS-specific optional deps, which would
* otherwise force a fingerprint mismatch on every fetched baseline.
*/
private static function isTrackedByGit(string $projectRoot, string $relativePath): bool
{
if (! is_array($value)) {
return;
if (! is_file($projectRoot.'/'.$relativePath)) {
return false;
}
$isAssoc = ! array_is_list($value);
static $cache = [];
if ($isAssoc) {
ksort($value);
$key = $projectRoot."\0".$relativePath;
if (isset($cache[$key])) {
return $cache[$key];
}
foreach ($value as &$child) {
self::sortRecursively($child);
if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) {
return $cache[$key] = true;
}
$finder = (new Finder)
->in($projectRoot)
->depth('== 0')
->name($relativePath)
->ignoreVCSIgnored(true);
return $cache[$key] = $finder->hasResults();
}
private static function contentHashOrNull(string $path): ?string

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Factories\Attribute;
use Pest\Factories\TestCaseFactory;
use Pest\Factories\TestCaseMethodFactory;
use Pest\Support\Container;
@ -1012,7 +1011,7 @@ final class Graph
}
foreach ($method->attributes as $attribute) {
if (! $attribute instanceof Attribute || $attribute->name !== Group::class) {
if ($attribute->name !== Group::class) {
continue;
}
@ -1322,6 +1321,51 @@ final class Graph
}
}
/**
* 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).
*
* @param array<int, string> $touchedFiles Absolute or project-relative paths.
* @param array<int, string> $keepTestIds Test IDs that produced a result this run.
*/
public function pruneStaleResults(string $branch, array $touchedFiles, array $keepTestIds): void
{
if (! isset($this->baselines[$branch]['results'])) {
return;
}
$touched = [];
foreach ($touchedFiles as $file) {
$rel = $this->relative($file);
if ($rel !== null) {
$touched[$rel] = true;
}
}
if ($touched === []) {
return;
}
$keep = array_fill_keys($keepTestIds, true);
foreach ($this->baselines[$branch]['results'] as $testId => $result) {
$file = $result['file'] ?? null;
if (! is_string($file)) {
continue;
}
if (! isset($touched[$file])) {
continue;
}
if (isset($keep[$testId])) {
continue;
}
unset($this->baselines[$branch]['results'][$testId]);
}
}
public static function decode(string $json, string $projectRoot): ?self
{
$data = json_decode($json, true);

View File

@ -27,6 +27,31 @@ final class JsModuleGraph
'vite.config.mts',
];
/**
* Candidate page directories, in priority order. Must stay in sync with
* `PAGE_DIR_CANDIDATES` in bin/pest-tia-vite-deps.mjs.
*
* @var list<string>
*/
private const array PAGE_DIR_CANDIDATES = [
'resources/js/Pages',
'resources/js/pages',
'assets/js/Pages',
'assets/js/pages',
'assets/Pages',
'assets/pages',
];
/**
* @var list<string>
*/
private const array PAGE_EXTENSIONS = [
'vue', 'svelte',
'tsx', 'jsx',
'ts', 'js',
'mts', 'cts', 'mjs', 'cjs',
];
/**
* @return array<string, list<string>>
*/
@ -51,8 +76,44 @@ final class JsModuleGraph
return false;
}
foreach (['Pages', 'pages'] as $dir) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
return self::firstExistingPagesDir($projectRoot) !== null;
}
private static function firstExistingPagesDir(string $projectRoot): ?string
{
foreach (self::PAGE_DIR_CANDIDATES as $rel) {
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (! is_dir($abs)) {
continue;
}
if (self::dirHasPageFile($abs)) {
return $abs;
}
}
return null;
}
private static function dirHasPageFile(string $dir): bool
{
try {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
);
} catch (\UnexpectedValueException) {
return false;
}
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
if (in_array(strtolower($file->getExtension()), self::PAGE_EXTENSIONS, true)) {
return true;
}
}
@ -188,17 +249,21 @@ final class JsModuleGraph
return null;
}
foreach (['Pages', 'pages'] as $dir) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
$parts[] = 'pagesDir:'.$dir;
$override = getenv('TIA_VITE_PAGES_DIR');
break;
}
if (is_string($override) && $override !== '') {
$parts[] = 'pagesDirOverride:'.$override;
}
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js';
$pagesDir = self::firstExistingPagesDir($projectRoot);
if (is_dir($jsRoot)) {
if ($pagesDir !== null) {
$parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
}
$jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
if ($jsRoot !== null && is_dir($jsRoot)) {
$entries = [];
$iterator = new \RecursiveIteratorIterator(

View File

@ -7,6 +7,7 @@ namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Browser\Support\BrowserTestIdentifier;
use Pest\Factories\TestCaseFactory;
use Pest\Plugins\Tia\Contracts\WatchDefault;
use Pest\TestSuite;
/**

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal

View File

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
use Pest\Plugins\Tia\Contracts\WatchDefault;
use Pest\TestSuite;
/**

View File

@ -24,6 +24,9 @@ final class PcovRestarter implements Restarter
}
if (getenv(self::ENV_RESTARTED) === '1') {
putenv(self::ENV_RESTARTED);
unset($_ENV[self::ENV_RESTARTED]);
return;
}

View File

@ -11,7 +11,7 @@ use PHPUnit\Event\Test\FinishedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaCoverageIsFlushed implements FinishedSubscriber
final readonly class EnsureTiaEnds implements FinishedSubscriber
{
public function __construct(private Recorder $recorder) {}

View File

@ -12,7 +12,7 @@ use PHPUnit\Event\Test\PreparedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
final readonly class EnsureTiaStarts implements PreparedSubscriber
{
public function __construct(private Recorder $recorder) {}

View File

@ -1,5 +1,5 @@
Pest Testing Framework 4.6.3.
Pest Testing Framework 4.7.1.
USAGE: pest <file> [options]

View File

@ -1,3 +1,3 @@
Pest Testing Framework 4.6.3.
Pest Testing Framework 4.7.1.

View File

@ -1,28 +1,56 @@
##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='pest_qn://tests/.tests/Failure.php' flowId='1234']
##teamcity[testCount count='8' flowId='1234']
##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='pest_qn://tests/.tests/Failure.php' flowId='1234']
##teamcity[testCount count='8' flowId='1234']
##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234']
##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234']
##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/.tests/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234']
##teamcity[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[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/.tests/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/.tests/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/.tests/Failure.php::it can fail' flowId='1234']
##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18' flowId='1234']
##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/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/.tests/Failure.php::it throws exception' flowId='1234']
##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/.tests/Failure.php:22' flowId='1234']
##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/.tests/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/.tests/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/.tests/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/.tests/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/tests/Failure' flowId='1234']
Tests: 3 failed, 1 risky, 2 todos, 1 skipped, 1 passed (3 assertions)
Duration: 1.00s
Tests: 3 failed, 1 risky, 2 todos, 1 skipped, 1 passed (3 assertions)
Duration: 1.00s

View File

@ -1,19 +1,38 @@
##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234']
##teamcity[testCount count='4' flowId='1234']
##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234']
##teamcity[testCount count='4' flowId='1234']
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234']
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/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/.tests/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/.tests/SuccessOnly.php::can pass with dataset' flowId='1234']
##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
##teamcity[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[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/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
##teamcity[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[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/tests/SuccessOnly' flowId='1234']
Tests: 4 passed (4 assertions)
Duration: 1.00s
Tests: 4 passed (4 assertions)
Duration: 1.00s

View File

@ -4,7 +4,6 @@
✓ preset → strict → ignoring ['Pest\Plugins\Tia\BaselineSync', 'usleep']
✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …]
✓ globals
✓ contracts
PASS Tests\Environments\Windows
✓ global functions are loaded
@ -1716,6 +1715,43 @@
PASS Tests\Unit\Plugins\Retry
✓ it orders by defects and stop on defects if when --retry is used
PASS Tests\Unit\Plugins\Tia\ContentHash
✓ of() → it returns false when file does not exist
✓ of() → it hashes an existing file
✓ PHP files → it produces the same hash regardless of whitespace differences
✓ PHP files → it ignores single-line comments
✓ PHP files → it ignores hash-style comments
✓ PHP files → it ignores multi-line comments
✓ PHP files → it ignores doc comments
✓ PHP files → it detects code changes
✓ PHP files → it preserves whitespace inside string literals
✓ PHP files → it treats variable renames as a change
✓ PHP files → it falls back to a raw hash for unparseable PHP
✓ PHP files → it is case-insensitive on the file extension
✓ Blade files → it strips blade comments
✓ Blade files → it strips multi-line blade comments
✓ Blade files → it collapses whitespace
✓ Blade files → it detects content changes
✓ Blade files → it keeps blade directives intact
✓ Blade files → it does not use the PHP tokenizer for blade files
✓ JavaScript-like files → it strips line comments
✓ JavaScript-like files → it strips block comments on their own lines
✓ JavaScript-like files → it collapses whitespace
✓ JavaScript-like files → it detects code changes
✓ JavaScript-like files → it does not strip inline trailing comments
✓ JavaScript-like files → it applies the same rules to .ts files
✓ JavaScript-like files → it applies the same rules to .tsx files
✓ JavaScript-like files → it applies the same rules to .jsx files
✓ JavaScript-like files → it applies the same rules to .vue files
✓ JavaScript-like files → it applies the same rules to .svelte files
✓ JavaScript-like files → it applies the same rules to .mjs, .cjs, and .mts files
✓ unknown extensions → it hashes the raw content for unknown extensions
✓ unknown extensions → it does not normalise whitespace for unknown extensions
✓ unknown extensions → it does not strip comments for unknown extensions
✓ unknown extensions → it hashes files with no extension as raw content
✓ output format → it returns a 32-character hex xxh128 hash
✓ output format → it returns a stable hash for empty content
PASS Tests\Unit\Preset
✓ preset invalid name
✓ preset → myFramework
@ -1901,4 +1937,4 @@
✓ pass with dataset with ('my-datas-set-value')
✓ within describe → pass with dataset with ('my-datas-set-value')
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1294 passed (2971 assertions)
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1328 passed (3008 assertions)

View File

@ -33,13 +33,3 @@ arch('globals')
->expect(['dd', 'dump', 'ray', 'die', 'var_dump', 'sleep'])
->not->toBeUsed()
->ignoring(Expectation::class);
arch('contracts')
->expect('Pest\Contracts')
->toOnlyUse([
'NunoMaduro\Collision\Contracts',
'Pest\Factories\TestCaseMethodFactory',
'Symfony\Component\Console',
'Pest\Arch\Contracts',
'Pest\PendingCalls',
])->toBeInterfaces();

View File

@ -22,7 +22,7 @@ describe('of()', function () {
describe('PHP files', function () {
it('produces the same hash regardless of whitespace differences', function () {
$a = ContentHash::ofContent('a.php', "<?php \$foo = 1;\n\necho \$foo;");
$b = ContentHash::ofContent('a.php', "<?php \$foo=1; echo \$foo;");
$b = ContentHash::ofContent('a.php', '<?php $foo=1; echo $foo;');
expect($a)->toBe($b);
});
@ -56,8 +56,8 @@ describe('PHP files', function () {
});
it('detects code changes', function () {
$a = ContentHash::ofContent('a.php', "<?php \$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php \$foo = 2;");
$a = ContentHash::ofContent('a.php', '<?php $foo = 1;');
$b = ContentHash::ofContent('a.php', '<?php $foo = 2;');
expect($a)->not->toBe($b);
});
@ -70,8 +70,8 @@ describe('PHP files', function () {
});
it('treats variable renames as a change', function () {
$a = ContentHash::ofContent('a.php', "<?php \$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php \$bar = 1;");
$a = ContentHash::ofContent('a.php', '<?php $foo = 1;');
$b = ContentHash::ofContent('a.php', '<?php $bar = 1;');
expect($a)->not->toBe($b);
});
@ -92,43 +92,43 @@ describe('PHP files', function () {
describe('Blade files', function () {
it('strips blade comments', function () {
$a = ContentHash::ofContent('a.blade.php', "<div>{{-- a comment --}}Hello</div>");
$b = ContentHash::ofContent('a.blade.php', "<div>Hello</div>");
$a = ContentHash::ofContent('a.blade.php', '<div>{{-- a comment --}}Hello</div>');
$b = ContentHash::ofContent('a.blade.php', '<div>Hello</div>');
expect($a)->toBe($b);
});
it('strips multi-line blade comments', function () {
$a = ContentHash::ofContent('a.blade.php', "<div>\n{{--\n multi\n line\n--}}\nHello\n</div>");
$b = ContentHash::ofContent('a.blade.php', "<div> Hello </div>");
$b = ContentHash::ofContent('a.blade.php', '<div> Hello </div>');
expect($a)->toBe($b);
});
it('collapses whitespace', function () {
$a = ContentHash::ofContent('a.blade.php', "<div>\n Hello\n World\n</div>");
$b = ContentHash::ofContent('a.blade.php', "<div> Hello World </div>");
$b = ContentHash::ofContent('a.blade.php', '<div> Hello World </div>');
expect($a)->toBe($b);
});
it('detects content changes', function () {
$a = ContentHash::ofContent('a.blade.php', "<div>Hello</div>");
$b = ContentHash::ofContent('a.blade.php', "<div>Goodbye</div>");
$a = ContentHash::ofContent('a.blade.php', '<div>Hello</div>');
$b = ContentHash::ofContent('a.blade.php', '<div>Goodbye</div>');
expect($a)->not->toBe($b);
});
it('keeps blade directives intact', function () {
$a = ContentHash::ofContent('a.blade.php', "@if(\$user)Hi @endif");
$b = ContentHash::ofContent('a.blade.php', "@if(\$user)Bye @endif");
$a = ContentHash::ofContent('a.blade.php', '@if($user)Hi @endif');
$b = ContentHash::ofContent('a.blade.php', '@if($user)Bye @endif');
expect($a)->not->toBe($b);
});
it('does not use the PHP tokenizer for blade files', function () {
$a = ContentHash::ofContent('a.blade.php', "<?php // not stripped ?> hello");
$b = ContentHash::ofContent('a.blade.php', "<?php ?> hello");
$a = ContentHash::ofContent('a.blade.php', '<?php // not stripped ?> hello');
$b = ContentHash::ofContent('a.blade.php', '<?php ?> hello');
expect($a)->not->toBe($b);
});
@ -137,70 +137,70 @@ describe('Blade files', function () {
describe('JavaScript-like files', function () {
it('strips line comments', function () {
$a = ContentHash::ofContent('a.js', "// a comment\nconst foo = 1;");
$b = ContentHash::ofContent('a.js', "const foo = 1;");
$b = ContentHash::ofContent('a.js', 'const foo = 1;');
expect($a)->toBe($b);
});
it('strips block comments on their own lines', function () {
$a = ContentHash::ofContent('a.js', "/* block */\nconst foo = 1;");
$b = ContentHash::ofContent('a.js', "const foo = 1;");
$b = ContentHash::ofContent('a.js', 'const foo = 1;');
expect($a)->toBe($b);
});
it('collapses whitespace', function () {
$a = ContentHash::ofContent('a.js', "const foo = 1;\n\nconst bar = 2;");
$b = ContentHash::ofContent('a.js', "const foo = 1; const bar = 2;");
$b = ContentHash::ofContent('a.js', 'const foo = 1; const bar = 2;');
expect($a)->toBe($b);
});
it('detects code changes', function () {
$a = ContentHash::ofContent('a.js', "const foo = 1;");
$b = ContentHash::ofContent('a.js', "const foo = 2;");
$a = ContentHash::ofContent('a.js', 'const foo = 1;');
$b = ContentHash::ofContent('a.js', 'const foo = 2;');
expect($a)->not->toBe($b);
});
it('does not strip inline trailing comments', function () {
$a = ContentHash::ofContent('a.js', "const foo = 1; // inline");
$b = ContentHash::ofContent('a.js', "const foo = 1;");
$a = ContentHash::ofContent('a.js', 'const foo = 1; // inline');
$b = ContentHash::ofContent('a.js', 'const foo = 1;');
expect($a)->not->toBe($b);
});
it('applies the same rules to .ts files', function () {
$a = ContentHash::ofContent('a.ts', "// comment\nconst foo: number = 1;");
$b = ContentHash::ofContent('a.ts', "const foo: number = 1;");
$b = ContentHash::ofContent('a.ts', 'const foo: number = 1;');
expect($a)->toBe($b);
});
it('applies the same rules to .tsx files', function () {
$a = ContentHash::ofContent('a.tsx', "// comment\nconst Foo = () => <div/>;");
$b = ContentHash::ofContent('a.tsx', "const Foo = () => <div/>;");
$b = ContentHash::ofContent('a.tsx', 'const Foo = () => <div/>;');
expect($a)->toBe($b);
});
it('applies the same rules to .jsx files', function () {
$a = ContentHash::ofContent('a.jsx', "// comment\nconst Foo = () => <div/>;");
$b = ContentHash::ofContent('a.jsx', "const Foo = () => <div/>;");
$b = ContentHash::ofContent('a.jsx', 'const Foo = () => <div/>;');
expect($a)->toBe($b);
});
it('applies the same rules to .vue files', function () {
$a = ContentHash::ofContent('a.vue', "<script>\n// comment\nexport default {}\n</script>");
$b = ContentHash::ofContent('a.vue', "<script> export default {} </script>");
$b = ContentHash::ofContent('a.vue', '<script> export default {} </script>');
expect($a)->toBe($b);
});
it('applies the same rules to .svelte files', function () {
$a = ContentHash::ofContent('a.svelte', "<script>\n// comment\nlet foo = 1;\n</script>");
$b = ContentHash::ofContent('a.svelte', "<script> let foo = 1; </script>");
$b = ContentHash::ofContent('a.svelte', '<script> let foo = 1; </script>');
expect($a)->toBe($b);
});
@ -208,7 +208,7 @@ describe('JavaScript-like files', function () {
it('applies the same rules to .mjs, .cjs, and .mts files', function () {
foreach (['mjs', 'cjs', 'mts'] as $ext) {
$a = ContentHash::ofContent("a.$ext", "// comment\nexport const foo = 1;");
$b = ContentHash::ofContent("a.$ext", "export const foo = 1;");
$b = ContentHash::ofContent("a.$ext", 'export const foo = 1;');
expect($a)->toBe($b);
}
@ -217,22 +217,22 @@ describe('JavaScript-like files', function () {
describe('unknown extensions', function () {
it('hashes the raw content for unknown extensions', function () {
$a = ContentHash::ofContent('a.txt', "hello world");
$b = ContentHash::ofContent('a.txt', "hello world");
$a = ContentHash::ofContent('a.txt', 'hello world');
$b = ContentHash::ofContent('a.txt', 'hello world');
expect($a)->toBe($b);
});
it('does not normalise whitespace for unknown extensions', function () {
$a = ContentHash::ofContent('a.txt', "hello world");
$b = ContentHash::ofContent('a.txt', "hello world");
$a = ContentHash::ofContent('a.txt', 'hello world');
$b = ContentHash::ofContent('a.txt', 'hello world');
expect($a)->not->toBe($b);
});
it('does not strip comments for unknown extensions', function () {
$a = ContentHash::ofContent('a.txt', "// not a comment here\nhello");
$b = ContentHash::ofContent('a.txt', "hello");
$b = ContentHash::ofContent('a.txt', 'hello');
expect($a)->not->toBe($b);
});

View File

@ -16,6 +16,7 @@ $run = function () {
test('parallel', function () use ($run) {
$output = $run('--exclude-group=integration');
$output = implode("\n", array_slice(explode("\n", $output), -10));
if (getenv('REBUILD_SNAPSHOTS')) {
preg_match('/Tests:\s+(.+\(\d+ assertions\))/', $output, $matches);
@ -23,13 +24,13 @@ test('parallel', function () use ($run) {
$file = file_get_contents(__FILE__);
$file = preg_replace(
'/\$expected = \'.*?\';/',
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1278 passed (2920 assertions)';",
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1312 passed (2957 assertions)';",
$file,
);
file_put_contents(__FILE__, $file);
}
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1278 passed (2920 assertions)';
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1312 passed (2957 assertions)';
expect($output)
->toContain("Tests: {$expected}")