Compare commits

..

91 Commits

Author SHA1 Message Date
87882a8561 fix: exit code 2026-06-12 06:57:27 +01:00
77ef7e0df1 ci: bumps setup-php pinned commit 2026-06-12 06:51:58 +01:00
12100dd901 Bump actions/checkout from 6.0.2 to 6.0.3 in the github-actions group (#1730)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](de0fac2e45...df4cb1c069)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-12 06:51:20 +01:00
20c12d006e docs: fixes alt 2026-06-12 06:32:20 +01:00
3aab02d2eb ci: fixes dd test 2026-06-11 10:50:24 +01:00
553aac65e6 release: v4.7.3 2026-06-11 10:40:18 +01:00
be49a3ce18 Fix: dd (#1692)
* fix: update dd method to return never type

Co-authored-by: Copilot <copilot@github.com>

* fix: enhance var_dump calls to accept additional arguments

* fix: update dd method to handle paratest and collision printer environments

* test: add dd method tests for ExpectationFailedException in parallel mode

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
2026-06-11 10:16:07 +01:00
04c9d41895 fix: update gitlab urls for issues and prs to match new format (#1728)
* fix: update gitlab urls for issues and prs to match new format

Added an optional host parameter to the gitlab method, defaulting to 'gitlab.com'. Updated the issues and prs URLs to include '/-/' in the path, which is the new format for GitLab URLs. This change ensures that the URLs generated for GitLab projects are correct and reflect the new structure of GitLab's URLs for issues and merge requests.

Host parameter allows users to specify a custom GitLab instance if they are using a self-hosted version of GitLab, while still maintaining the default behavior for users who are using gitlab.com.

* fix: update gitlab method to use hostname parameter correctly
2026-06-11 10:13:26 +01:00
dfb7b870af fix: retry output expected string 2026-06-11 10:12:41 +01:00
37821e1108 chore: fixes output snapshots on team city 2026-06-11 10:12:32 +01:00
cd711a25d5 chore: bumps phpunit 2026-06-11 10:08:36 +01:00
eee60c9e11 fix: duplicated team city output 2026-06-11 10:08:27 +01:00
jp
c40c8dbc24 [4.x] Fix Checks (#1709)
* fix(composer): set root version for feature-branch CI

pest-plugin-browser ^4.3.1 requires pestphp/pest ^4.4.5 on the root package.
Without an explicit version, Composer infers dev-<branch> on PR branches,
which fails composer update before tests run.

* set root version of composer

* fix indent
2026-06-06 01:23:08 +01:00
2d93c9c373 Bump shivammathur/setup-php in the github-actions group (#1707)
Bumps the github-actions group with 1 update: [shivammathur/setup-php](https://github.com/shivammathur/setup-php).


Updates `shivammathur/setup-php` from 2.37.0 to 2.37.1
- [Release notes](https://github.com/shivammathur/setup-php/releases)
- [Commits](accd6127cb...7c071dfe9d)

---
updated-dependencies:
- dependency-name: shivammathur/setup-php
  dependency-version: 2.37.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 17:55:17 +01:00
e5ab3af05b ci: update dependabot config (add cooldown, single update entry) 2026-06-04 17:54:45 +01:00
40b88b62ef release: v4.7.2 2026-06-01 07:08:59 +01:00
e3361bc321 fix: don't ob_start with pao 2026-06-01 07:08:50 +01:00
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
7bea819978 wip 2026-05-02 18:47:26 +01:00
4280233b40 wip 2026-05-02 18:37:24 +01:00
d6db3a8a20 wip 2026-05-02 18:32:05 +01:00
51c8ce4df6 wip 2026-05-02 18:31:32 +01:00
5b8393b925 wip 2026-05-02 18:25:41 +01:00
e4d9b61fdf wip 2026-05-02 18:25:27 +01:00
e2d940cd53 wip 2026-05-02 18:25:21 +01:00
380ccd30b4 wip 2026-05-02 18:03:25 +01:00
31c200716d wip 2026-05-02 18:03:14 +01:00
6add4da543 wip 2026-05-02 18:02:20 +01:00
8ddcd3e853 wip 2026-05-02 18:02:13 +01:00
e3e178fd94 wip 2026-05-02 17:59:21 +01:00
7b1ec9f003 wip 2026-05-02 17:59:13 +01:00
1e48c5d473 wip 2026-05-02 17:59:00 +01:00
d00ec95dd9 wip 2026-05-02 17:58:55 +01:00
89f3d6cb39 wip 2026-05-02 17:45:54 +01:00
a07a2e512a wip 2026-05-02 17:39:15 +01:00
57eecb2b3d wip 2026-05-02 17:38:12 +01:00
9f804dc954 wip 2026-05-02 17:38:08 +01:00
7cbad4c589 wip 2026-05-02 17:38:01 +01:00
5cae93b059 wip 2026-05-02 17:37:56 +01:00
df829ad19d wip 2026-05-02 17:37:47 +01:00
635460653c wip 2026-05-02 17:37:34 +01:00
1aa80dc398 wip 2026-05-02 17:18:35 +01:00
8a14056111 wip 2026-05-02 17:15:46 +01:00
f247dd8e7b wip 2026-05-02 17:11:49 +01:00
1c7c9754fd wip 2026-05-02 17:07:08 +01:00
5f37939fda wip 2026-05-02 17:02:11 +01:00
28305fcb7a wip 2026-05-02 16:35:52 +01:00
5242803694 wip 2026-05-02 15:54:00 +01:00
925935a7e8 wip 2026-05-02 15:33:38 +01:00
460401c379 wip 2026-05-02 15:26:58 +01:00
348b439172 wip 2026-05-02 15:15:53 +01:00
a4e77766c5 wip 2026-05-02 15:07:51 +01:00
4a8c2d7d78 wip 2026-05-02 15:03:44 +01:00
7d51601120 wip 2026-05-02 14:15:37 +01:00
631bbe318b wip 2026-05-02 13:43:32 +01:00
9b7c15d5b6 wip 2026-05-02 12:03:35 +01:00
872796bd9b wip 2026-05-02 12:00:47 +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
61 changed files with 1808 additions and 1951 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.

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

@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 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') }}
@ -53,6 +53,8 @@ jobs:
static-php-8.3-composer-
- name: Install Dependencies
env:
COMPOSER_ROOT_VERSION: 4.x-dev
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
- name: Profanity Check

View File

@ -35,10 +35,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 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') }}
@ -66,6 +66,8 @@ jobs:
- name: Install PHP dependencies
shell: bash
env:
COMPOSER_ROOT_VERSION: 4.x-dev
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
- name: Unit Tests

View File

@ -1,11 +1,12 @@
<p align="center">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
<p align="center">
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (4.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
<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

@ -6,6 +6,7 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Kernel;
use Pest\Plugins\Actions\CallsHandleArguments;
use Pest\Support\Container;
use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
@ -58,6 +59,15 @@ $bootPest = (static function (): void {
}
}
$container = Container::getInstance();
$rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2);
foreach (Kernel::RESTARTERS as $restarterClass) {
$restarter = $container->get($restarterClass);
$restarter->maybeRestart($rootPath, $_SERVER['argv']);
}
assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
$statusFile = fopen($getopt['status-file'], 'wb');
assert(is_resource($statusFile));

View File

@ -20,19 +20,18 @@
"php": "^8.3.0",
"brianium/paratest": "^7.20.0",
"composer/xdebug-handler": "^3.0.5",
"fidry/cpu-core-counter": "^1.3",
"nunomaduro/collision": "^8.9.4",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0",
"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.29",
"symfony/process": "^7.4.13|^8.1.0"
},
"conflict": {
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.24",
"phpunit/phpunit": ">12.5.29",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@ -60,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,
@ -94,7 +93,6 @@
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
"test:integration": "php bin/pest --group=integration -v",
"test:tia": "php bin/pest --configuration=tests-tia/phpunit.xml",
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
"test": [
"@test:lint",
@ -102,8 +100,7 @@
"@test:type:coverage",
"@test:unit",
"@test:parallel",
"@test:integration",
"@test:tia"
"@test:integration"
]
},
"extra": {

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use PHPUnit\TextUI\Configuration\Builder;
/**
* @internal
*/
final class BootPhpUnitConfiguration implements Bootstrapper
{
public function boot(): void
{
(new Builder)->build(['pest']);
}
}

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,9 +9,8 @@ use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Collectors;
use Pest\Plugins\Tia\Edges\AutoloadEdges;
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;
@ -83,10 +82,15 @@ trait Testable
public bool $__ran = false;
/**
* Set when a `BeforeEachable` plugin returns a cached success result.
* Checked in `__runTest` and `tearDown` to skip body + cleanup.
* The active replay mode for this test, set in `setUp()` and checked
* in `__runTest()` / `tearDown()` to skip the body and after-each.
*/
private bool $__cachedPass = false;
private ReplayType $__replay = ReplayType::None;
/**
* The cached assertion count to replay, captured when entering replay mode.
*/
private int $__replayAssertions = 0;
/**
* The test's test closure.
@ -240,8 +244,6 @@ trait Testable
{
TestSuite::getInstance()->test = $this;
$this->__cachedPass = false;
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$description = $method->description;
@ -277,17 +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 => $this->__shortCircuitCachedPass(),
Replay::Skipped => $this->markTestSkipped($status->message()),
Replay::Incomplete => $this->markTestIncomplete($status->message()),
Replay::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
Replay::No => null,
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;
@ -300,10 +301,6 @@ trait Testable
$recorder->beginTest($this::class, $this->name(), self::$__filename);
}
$autoloadBeforeSetUp = $recorder->isActive()
? AutoloadEdges::snapshot()
: [];
parent::setUp();
Collectors::armAll($recorder);
@ -315,23 +312,12 @@ trait Testable
}
$this->__callClosure($beforeEach, $arguments);
if ($recorder->isActive() && $autoloadBeforeSetUp !== []) {
$recorder->linkSourcesForTest(
self::$__filename,
AutoloadEdges::newProjectFiles(
$autoloadBeforeSetUp,
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
self::$__filename,
),
);
}
}
private function __shortCircuitCachedPass(): void
private function __beginReplay(ReplayType $replay, Tia $tia): void
{
$this->__cachedPass = true;
$this->__replay = $replay;
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
$this->__ran = true;
}
@ -367,7 +353,7 @@ trait Testable
*/
protected function tearDown(...$arguments): void
{
if ($this->__cachedPass) {
if ($this->__replay !== ReplayType::None) {
TestSuite::getInstance()->test = null;
return;
@ -398,19 +384,12 @@ trait Testable
*/
private function __runTest(Closure $closure, ...$args): mixed
{
if ($this->__cachedPass) {
// Feed the exact assertion count captured during the recorded
// run so Pest's "Tests: N passed (M assertions)" banner stays
// accurate on replay instead of collapsing to 1-per-test.
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$assertions = $tia->getAssertionCount($this::class.'::'.$this->name());
if ($assertions === 0) {
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
$this->expectNotToPerformAssertions();
}
$this->addToAssertionCount($assertions);
$this->addToAssertionCount($this->__replayAssertions);
return null;
}
@ -456,6 +435,11 @@ trait Testable
if ($hasOutputExpectation) {
ob_clean();
Closure::bind(function (): void {
$this->outputExpectedString = null;
$this->outputExpectedRegex = null;
}, $this, TestCase::class)();
}
$this->setUp();

View File

@ -59,12 +59,15 @@ final class Project
/**
* Sets the test project to GitLab.
*/
public function gitlab(string $project): self
public function gitlab(string $project, string $hostname = 'gitlab.com'): self
{
$this->issues = "https://gitlab.com/{$project}/issues/%s";
$this->prs = "https://gitlab.com/{$project}/merge_requests/%s";
// Simple way to ensure only the host is used
$hostname = parse_url($hostname, PHP_URL_HOST) ?? $hostname;
$this->assignees = 'https://gitlab.com/%s';
$this->issues = "https://{$hostname}/{$project}/-/work_items/%s";
$this->prs = "https://{$hostname}/{$project}/-/merge_requests/%s";
$this->assignees = "https://{$hostname}/%s";
return $this;
}

View File

@ -16,12 +16,12 @@ use Symfony\Component\Console\Output\OutputInterface;
*/
final class TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct(private readonly string $className, private readonly string $file)
public function __construct(private readonly string $className, string $filename)
{
parent::__construct(sprintf(
'Tia mode requires Pest tests, but encountered PHPUnit class [%s] in [%s].',
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',
$className,
$file,
$filename,
));
}

View File

@ -112,7 +112,7 @@ final class Expectation
if (function_exists('dump')) {
dump($this->value, ...$arguments);
} else {
var_dump($this->value);
var_dump($this->value, ...$arguments);
}
return $this;
@ -120,16 +120,22 @@ final class Expectation
/**
* Dump the expectation value and end the script.
*
* @return never
*/
public function dd(mixed ...$arguments): void
public function dd(mixed ...$arguments): never
{
if (function_exists('dd')) {
dd($this->value, ...$arguments);
}
var_dump($this->value);
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
ob_start();
var_dump($this->value, ...$arguments);
$output = (string) ob_get_clean();
throw new ExpectationFailedException($output);
}
var_dump($this->value, ...$arguments);
exit(1);
}

View File

@ -166,7 +166,7 @@ final class TestCaseFactory
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode
private static \$__filename = '$filename';
public static \$__filename = '$filename';
$methodsCode
}

View File

@ -27,8 +27,13 @@ use Whoops\Exception\Inspector;
/**
* @internal
*/
final readonly class Kernel
final class Kernel
{
/**
* Either the kernel is terminated or not.
*/
private bool $terminated = false;
/**
* The Kernel bootstrappers.
*
@ -36,6 +41,7 @@ final readonly class Kernel
*/
private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class,
Bootstrappers\BootPhpUnitConfiguration::class,
Plugins\Tia\Bootstrapper::class,
Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class,
@ -59,12 +65,7 @@ final readonly class Kernel
/**
* Creates a new Kernel instance.
*/
public function __construct(
private Application $application,
private OutputInterface $output,
) {
//
}
public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
/**
* Boots the Kernel.
@ -125,9 +126,13 @@ final readonly class Kernel
$configuration = Registry::get();
$result = Facade::result();
return CallsAddsOutput::execute(
$result = CallsAddsOutput::execute(
Result::exitCode($configuration, $result),
);
$this->terminate();
return $result;
}
/**
@ -135,6 +140,12 @@ final readonly class Kernel
*/
public function terminate(): void
{
if ($this->terminated) {
return;
}
$this->terminated = true;
$preBufferOutput = Container::getInstance()->get(KernelDump::class);
assert($preBufferOutput instanceof KernelDump);
@ -152,7 +163,7 @@ final readonly 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

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest;
use Laravel\Pao\Execution;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface;
@ -28,6 +29,10 @@ final class KernelDump
*/
public function enable(): void
{
if (class_exists(Execution::class) && Execution::running()) {
return;
}
ob_start(function (string $message): string {
$this->buffer .= $message;
@ -68,6 +73,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 +116,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.3';
}
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;
@ -19,6 +20,7 @@ use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\JsModuleGraph;
use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\ResultCollector;
use Pest\Plugins\Tia\SourceScope;
use Pest\Plugins\Tia\Storage;
use Pest\Plugins\Tia\TableExtractor;
use Pest\Plugins\Tia\WatchPatterns;
@ -29,23 +31,38 @@ use Pest\TestSuite;
use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
use Throwable;
/**
* @internal
*/
final class Tia implements AddsOutput, HandlesArguments, Terminable
{
use Concerns\HandleArguments;
use HandleArguments;
private const string OPTION = '--tia';
private const string NO_OPTION = '--no-tia';
private const string FRESH_OPTION = '--fresh';
private const string REFETCH_OPTION = '--refetch';
private const string FILTERED_OPTION = '--filtered';
private const string LOCALLY_OPTION = '--locally';
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';
private const string ENV_LOCALLY = 'PEST_TIA_LOCALLY';
private const string ENV_BASELINED = 'PEST_TIA_BASELINED';
public const string KEY_GRAPH = 'graph.json';
public const string KEY_AFFECTED = 'affected.json';
@ -70,6 +87,25 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
/**
* PHPUnit/Pest CLI flags whose subsequent argument is a value, not a path.
*
* @var list<string>
*/
private const array VALUE_TAKING_FLAGS = [
'-c', '--configuration', '--bootstrap', '--cache-directory',
'--filter', '--group', '--exclude-group', '--covers', '--uses',
'--test-suffix', '--testsuite', '--exclude-testsuite',
'--printer', '--columns', '--colors', '--order-by', '--random-order-seed',
'--include-path', '--whitelist',
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
'--coverage-html', '--coverage-php', '--coverage-text', '--coverage-xml',
'--coverage-filter', '--path-coverage',
'--repeat', '--retry-times', '--memory-limit', '--seed',
'--compact', '--ci-build-id', '--min',
];
private bool $graphWritten = false;
private bool $replayRan = false;
@ -93,16 +129,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** @var array{structural: array<string, mixed>, environmental: array<string, mixed>}|null */
private ?array $startFingerprint = null;
private function workerEdgesKey(string $token): string
{
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
}
private function workerResultsKey(string $token): string
{
return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
}
private bool $piggybackCoverage = false;
private bool $recordingActive = false;
@ -115,6 +141,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
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,
@ -171,18 +203,65 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
public static function isEnabledForRun(array $arguments): bool
{
if (in_array(self::OPTION, $arguments, true)) {
return true;
if (self::argumentPresent(self::NO_OPTION, $arguments)) {
return false;
}
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
assert($watchPatterns instanceof WatchPatterns);
self::applyWatchPatternMarks($arguments, $watchPatterns);
if (self::argumentPresent(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA)) {
return true;
}
if (! $watchPatterns->isEnabled()) {
return false;
}
return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true));
return ! ($watchPatterns->isLocally() && self::argumentPresent('--ci', $arguments));
}
/**
* @param array<int, string> $arguments
*/
private static function applyWatchPatternMarks(array $arguments, WatchPatterns $watchPatterns): void
{
if (self::argumentPresent(self::LOCALLY_OPTION, $arguments) || self::envFlagEnabled(self::ENV_LOCALLY)) {
$watchPatterns->markEnabled();
$watchPatterns->markLocally();
}
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);
}
public function getStatus(string $filename, string $testId): ?TestStatus
@ -239,25 +318,45 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
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';
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$cliEnabled = $this->hasArgument(self::OPTION, $arguments);
self::applyWatchPatternMarks($arguments, $watchPatterns);
$disabled = $this->hasArgument(self::NO_OPTION, $arguments);
$cliEnabled = $this->hasArgument(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA);
$alwaysEnabled = $watchPatterns->isEnabled()
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
$enabled = $cliEnabled || $alwaysEnabled;
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || $watchPatterns->isFiltered())
&& ! $this->hasExplicitPathArgument($arguments);
$enabled = ! $disabled && ($cliEnabled || $alwaysEnabled);
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered())
&& ! $this->hasExplicitPathArgument($arguments)
&& ! $this->coverageReportActive();
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
$arguments = $this->popArgument(self::OPTION, $arguments);
$arguments = $this->popArgument(self::NO_OPTION, $arguments);
$arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
$arguments = $this->popArgument(self::FILTERED_OPTION, $arguments);
$arguments = $this->popArgument(self::LOCALLY_OPTION, $arguments);
$arguments = $this->popArgument(self::BASELINED_OPTION, $arguments);
if ($disabled) {
$this->forceRefetch = false;
$this->filteredMode = false;
$this->freshRebuild = false;
return $arguments;
}
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
$this->freshRebuild = $forceRebuild;
@ -429,67 +528,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
);
$mergedFiles = [];
$mergedTables = [];
$mergedInertia = [];
foreach ($partialKeys as $key) {
$data = $this->readPartial($key);
if ($data === null) {
continue;
}
foreach ($data['files'] as $testFile => $sources) {
if (! isset($mergedFiles[$testFile])) {
$mergedFiles[$testFile] = [];
}
foreach ($sources as $source) {
$mergedFiles[$testFile][$source] = true;
}
}
foreach ($data['tables'] as $testFile => $tables) {
if (! isset($mergedTables[$testFile])) {
$mergedTables[$testFile] = [];
}
foreach ($tables as $table) {
$mergedTables[$testFile][$table] = true;
}
}
foreach ($data['inertia'] as $testFile => $components) {
if (! isset($mergedInertia[$testFile])) {
$mergedInertia[$testFile] = [];
}
foreach ($components as $component) {
$mergedInertia[$testFile][$component] = true;
}
}
$this->state->delete($key);
}
$finalised = [];
foreach ($mergedFiles as $testFile => $sourceSet) {
$finalised[$testFile] = array_keys($sourceSet);
}
$finalisedTables = [];
foreach ($mergedTables as $testFile => $tableSet) {
$finalisedTables[$testFile] = array_keys($tableSet);
}
$finalisedInertia = [];
foreach ($mergedInertia as $testFile => $componentSet) {
$finalisedInertia[$testFile] = array_keys($componentSet);
}
[$finalised, $finalisedTables, $finalisedInertia] = $this->consumePartials($partialKeys);
if ($finalised === []) {
if ($this->replayRan) {
@ -534,10 +573,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (! Fingerprint::structuralMatches($stored, $current)) {
$drift = Fingerprint::structuralDrift($stored, $current);
$this->renderBadge('INFO', 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);
@ -547,7 +583,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$branchSha,
);
if ($summary !== '') {
$this->renderChild($summary);
$this->driftDetails = $summary;
}
}
}
@ -607,8 +643,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changedFiles = new ChangedFiles($projectRoot);
$branchSha = $graph->recordedAtSha($this->branch);
if ($changedFiles->gitAvailable()
&& $branchSha !== null
if ($branchSha !== null
&& $changedFiles->since($branchSha) === null) {
$this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
$graph = null;
@ -618,6 +653,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (! $graph instanceof Graph
&& ! $forceRebuild
&& ! $this->baselineFetchAttemptedForDrift
&& $this->watchPatterns->isBaselined()
&& $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
$this->baselineFetchAttemptedForDrift = true;
$graph = $this->loadGraph($projectRoot);
@ -631,6 +667,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
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);
}
@ -761,12 +801,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
{
$changedFiles = new ChangedFiles($projectRoot);
if (! $changedFiles->gitAvailable()) {
$this->renderBadge('WARN', 'Git unavailable — running full suite.');
return $arguments;
}
$branchSha = $graph->recordedAtSha($this->branch);
$changed = $changedFiles->since($branchSha) ?? [];
@ -860,7 +894,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->output->writeln('');
if ($affected === []) {
$this->renderChild('TIA mode enabled.');
$this->renderChild('Experimental TIA mode enabled.');
return;
}
@ -902,7 +936,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
$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).')',
@ -963,7 +997,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
$this->output->writeln('');
$this->renderChild('TIA mode enabled / fresh graph.');
$this->renderFreshGraph();
return $arguments;
}
@ -972,7 +1006,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->recordingActive = true;
$this->output->writeln('');
$this->renderChild('TIA mode enabled / fresh graph.');
$this->renderFreshGraph();
return $arguments;
}
@ -985,11 +1019,32 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
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.');
}
/**
@ -1009,7 +1064,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
$this->state->write($this->workerEdgesKey($this->workerToken()), $json);
$this->state->write(self::KEY_WORKER_EDGES_PREFIX.$this->workerToken().'.json', $json);
}
/**
@ -1071,7 +1126,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
$this->state->write($this->workerResultsKey($this->workerToken()), $json);
$this->state->write(self::KEY_WORKER_RESULTS_PREFIX.$this->workerToken().'.json', $json);
}
/**
@ -1157,6 +1212,43 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $token;
}
/**
* @param list<string> $partialKeys
* @return array{0: array<string, list<string>>, 1: array<string, list<string>>, 2: array<string, list<string>>}
*/
private function consumePartials(array $partialKeys): array
{
$merged = ['files' => [], 'tables' => [], 'inertia' => []];
foreach ($partialKeys as $key) {
$data = $this->readPartial($key);
if ($data === null) {
continue;
}
foreach (['files', 'tables', 'inertia'] as $section) {
foreach ($data[$section] as $testFile => $values) {
if (! isset($merged[$section][$testFile])) {
$merged[$section][$testFile] = [];
}
foreach ($values as $value) {
$merged[$section][$testFile][$value] = true;
}
}
}
$this->state->delete($key);
}
return [
array_map(array_keys(...), $merged['files']),
array_map(array_keys(...), $merged['tables']),
array_map(array_keys(...), $merged['inertia']),
];
}
/**
* @return array{files: array<string, array<int, string>>, tables: array<string, array<int, string>>, inertia: array<string, array<int, string>>}|null
*/
@ -1270,7 +1362,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** @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,
@ -1278,10 +1379,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$result['message'],
$result['time'],
$result['assertions'],
$result['file'] ?? null,
$file,
);
}
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
$collector->reset();
}
@ -1304,6 +1407,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
$touchedFiles = [];
foreach ($results as $testId => $result) {
$file = $result['file'] ?? null;
@ -1311,6 +1416,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$file = $this->resolveFailedTestFile($testId);
}
if (is_string($file) && $file !== '') {
$touchedFiles[$file] = true;
}
$graph->setResult(
$this->branch,
$testId,
@ -1322,6 +1431,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
);
}
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
$this->saveGraph($graph);
$collector->reset();
}
@ -1334,21 +1445,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return null;
}
$reflection = new \ReflectionClass($class);
assert(property_exists($class, '__filename') && is_string($class::$__filename));
if ($reflection->hasProperty('__filename')) {
try {
$filename = $reflection->getStaticPropertyValue('__filename');
} catch (\ReflectionException) {
$filename = null;
}
$filename = $class::$__filename;
if (is_string($filename) && $filename !== '' && ! str_contains($filename, "eval()'d")) {
if ($filename !== '' && ! str_contains($filename, "eval()'d")) {
return $filename;
}
}
$current = $reflection;
$current = new \ReflectionClass($class);
while ($current !== false) {
$file = $current->getFileName();
@ -1365,14 +1470,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function coverageReportActive(): bool
{
try {
/** @var Coverage $coverage */
$coverage = Container::getInstance()->get(Coverage::class);
} catch (Throwable) {
return false;
}
assert($coverage instanceof Coverage);
return $coverage->coverage === true;
return $coverage->coverage;
}
/**
@ -1380,35 +1481,23 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private function hasExplicitPathArgument(array $arguments): bool
{
static $valueTakingFlags = [
'-c', '--configuration', '--bootstrap', '--cache-directory',
'--filter', '--group', '--exclude-group', '--covers', '--uses',
'--test-suffix', '--testsuite', '--exclude-testsuite',
'--printer', '--columns', '--colors', '--order-by', '--random-order-seed',
'--include-path', '--whitelist',
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
'--coverage-html', '--coverage-php', '--coverage-text', '--coverage-xml',
'--coverage-filter', '--path-coverage',
'--repeat', '--retry-times', '--memory-limit', '--seed',
'--compact', '--ci-build-id', '--min',
];
$projectRoot = TestSuite::getInstance()->rootPath;
$testPaths = \Pest\Plugins\Tia\SourceScope::testPaths($projectRoot);
$testPaths = SourceScope::testPaths();
if ($testPaths === []) {
return false;
}
foreach ($arguments as $index => $arg) {
if ($arg === '' || str_starts_with($arg, '-')) {
if ($arg === '') {
continue;
}
if (str_starts_with($arg, '-')) {
continue;
}
if ($index > 0) {
$previous = $arguments[$index - 1] ?? '';
if (in_array($previous, $valueTakingFlags, true)) {
if (in_array($previous, self::VALUE_TAKING_FLAGS, true)) {
continue;
}
}
@ -1494,6 +1583,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$projectRoot = TestSuite::getInstance()->rootPath;
$this->baselineFetchAttemptedForDrift = true;
if (! $this->watchPatterns->isBaselined()) {
return null;
}
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) {
return null;
}
@ -1505,7 +1598,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
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;
@ -32,6 +31,29 @@ final readonly class BaselineSync
private const int FETCH_COOLDOWN_SECONDS = 86400;
private const array DIAGNOSES = [
'network' => [
'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i',
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
],
'gh-auth' => [
'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i',
'message' => 'authentication failed — run `gh auth login` and retry.',
],
'rate-limit' => [
'pattern' => '/rate limit|too many requests|secondary rate limit/i',
'message' => 'GitHub API rate limit hit — try again later.',
],
'not-found' => [
'pattern' => '/404|not found|repository not found/i',
'message' => 'workflow or artifact not found in repo.',
],
'forbidden' => [
'pattern' => '/403|forbidden|access denied/i',
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
],
];
public function __construct(
private State $state,
private OutputInterface $output,
@ -64,13 +86,14 @@ final readonly class BaselineSync
return false;
}
$failureKind = null;
$payload = $this->download($repo, $projectRoot, $failureKind, $hasAnchor);
$result = $this->download($repo, $projectRoot, $hasAnchor);
$payload = $result['payload'];
$failureKind = $result['failureKind'];
if ($payload === null) {
if ($failureKind === 'no-runs' || $failureKind === null) {
$this->startCooldown();
$this->emitPublishInstructions($repo);
$this->emitPublishInstructions();
}
return false;
@ -86,11 +109,6 @@ final readonly class BaselineSync
$this->clearCooldown();
$this->renderBadge('INFO', sprintf(
'Baseline ready (%s).',
$this->formatSize($payload['sizeOnDisk']),
));
return true;
}
@ -138,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.');
@ -146,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/ci');
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
}
private function isCi(): bool
@ -172,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';
@ -281,14 +211,78 @@ YAML;
}
/**
* @param-out string|null $failureKind
*
* @return array{graph: string, coverage: ?string}|null
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
*/
private function download(string $repo, string $projectRoot, ?string &$failureKind = null, bool $hasAnchor = false): ?array
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
{
$failureKind = null;
$this->validateGhDependencies($hasAnchor);
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
if ($listError !== null) {
$this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Failed to query baseline runs — %s',
$listError['message'],
));
return ['payload' => null, 'failureKind' => $listError['kind']];
}
if ($runId === null) {
return ['payload' => null, 'failureKind' => 'no-runs'];
}
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
@touch($runCacheDir);
$this->renderChild(sprintf(
'Using cached baseline from %s (run %s).',
$repo,
$runId,
));
return ['payload' => $this->readArtifact($runCacheDir), 'failureKind' => null];
}
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
return ['payload' => null, 'failureKind' => null];
}
$download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor);
if (! $download['success']) {
return ['payload' => null, 'failureKind' => $download['failureKind']];
}
$payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor);
$this->trimDownloadCache($projectRoot);
return ['payload' => $payload, 'failureKind' => null];
}
/**
* @param array{kind: string, message: string} $diagnosis
*/
private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void
{
if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) {
return;
}
Panic::with(new BaselineFetchFailed(
sprintf('%s — %s', $contextPrefix, $diagnosis['message']),
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
));
}
private function validateGhDependencies(bool $hasAnchor): void
{
if (! $this->commandExists('gh')) {
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) not found — cannot fetch baseline.',
@ -304,62 +298,24 @@ YAML;
$hasAnchor,
));
}
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
if ($listError !== null) {
$failureKind = $listError['kind'];
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
Panic::with(new BaselineFetchFailed(
sprintf('Failed to query baseline runs — %s', $listError['message']),
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
));
}
$this->renderBadge('WARN', sprintf(
'Failed to query baseline runs — %s',
$listError['message'],
));
return null;
}
if ($runId === null) {
$failureKind = 'no-runs';
return null;
}
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
@touch($runCacheDir);
$this->renderBadge('INFO', sprintf(
'Using cached baseline from %s (run %s).',
$repo,
$runId,
));
return $this->readArtifact($runCacheDir);
}
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
return null;
}
/**
* @return array{success: bool, failureKind: ?string}
*/
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
{
$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,
));
@ -373,37 +329,39 @@ 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();
$this->clearProgressLine();
if (! $process->isSuccessful()) {
if ($process->isSuccessful()) {
return ['success' => true, 'failureKind' => null];
}
$this->cleanup($runCacheDir);
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
$failureKind = $diagnosis['kind'];
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
Panic::with(new BaselineFetchFailed(
sprintf('Baseline download failed — %s', $diagnosis['message']),
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
));
}
$this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Baseline download failed — %s',
$diagnosis['message'],
));
return null;
return ['success' => false, 'failureKind' => $diagnosis['kind']];
}
/**
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}
*/
private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
{
$payload = $this->readArtifact($runCacheDir);
if ($payload === null) {
@ -416,8 +374,6 @@ YAML;
));
}
$this->trimDownloadCache($projectRoot);
return $payload;
}
@ -443,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
@ -548,12 +492,10 @@ YAML;
$candidates = [];
foreach ($entries as $entry) {
if ($entry === '.') {
continue;
}
if ($entry === '..') {
if (in_array($entry, ['.', '..'], true)) {
continue;
}
$path = $root.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($path)) {
@ -624,59 +566,21 @@ YAML;
return ['kind' => 'unknown', 'message' => 'unknown error'];
}
if (preg_match('/(could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo)/i', $output) === 1) {
return [
'kind' => 'network',
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
];
foreach (self::DIAGNOSES as $kind => $diagnosis) {
if (preg_match($diagnosis['pattern'], $output) === 1) {
return ['kind' => $kind, 'message' => $diagnosis['message']];
}
}
if (preg_match('/(authentication failed|not logged in|requires authentication|bad credentials|401)/i', $output) === 1) {
return [
'kind' => 'gh-auth',
'message' => 'authentication failed — run `gh auth login` and retry.',
];
}
if (preg_match('/(rate limit|too many requests|secondary rate limit)/i', $output) === 1) {
return [
'kind' => 'rate-limit',
'message' => 'GitHub API rate limit hit — try again later.',
];
}
if (preg_match('/(404|not found|repository not found)/i', $output) === 1) {
return [
'kind' => 'not-found',
'message' => 'workflow or artifact not found in repo.',
];
}
if (preg_match('/(403|forbidden|access denied)/i', $output) === 1) {
return [
'kind' => 'forbidden',
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
];
}
$message = trim(strtok($output, "\n"));
return ['kind' => 'unknown', 'message' => $message];
return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))];
}
private function commandExists(string $cmd): bool
{
$probe = new Process(['command', '-v', $cmd]);
$probe->run();
$process = new Process(['which', $cmd]);
$process->run();
if ($probe->isSuccessful()) {
return true;
}
$which = new Process(['which', $cmd]);
$which->run();
return $which->isSuccessful();
return $process->isSuccessful();
}
private function cleanup(string $dir): void
@ -685,13 +589,17 @@ YAML;
return;
}
$entries = glob($dir.DIRECTORY_SEPARATOR.'*');
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
if ($entries !== false) {
foreach ($entries as $entry) {
if (is_file($entry)) {
@unlink($entry);
}
/** @var \SplFileInfo $entry */
foreach ($iterator as $entry) {
if ($entry->isDir()) {
@rmdir($entry->getPathname());
} else {
@unlink($entry->getPathname());
}
}

View File

@ -17,18 +17,12 @@ final readonly class Bootstrapper implements BootstrapperContract
public function __construct(private Container $container) {}
public function boot(): void
{
$this->container->add(State::class, new FileState($this->tempDir()));
}
/**
* across worktrees of the same repo. See {@see Storage} for the key
*/
private function tempDir(): string
{
$testSuite = $this->container->get(TestSuite::class);
assert($testSuite instanceof TestSuite);
return Storage::tempDir($testSuite->rootPath);
$tempDir = Storage::tempDir($testSuite->rootPath);
$this->container->add(State::class, new FileState($tempDir));
}
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Exceptions\MissingDependency;
use Symfony\Component\Process\Process;
/**
@ -34,37 +35,27 @@ final readonly class ChangedFiles
foreach (array_keys($candidates) as $file) {
$snapshot = $lastRunTree[$file] ?? null;
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
$exists = is_file($absolute);
$current = $this->currentHash($file);
if ($snapshot === null) {
if ($snapshot === null || $current === null || $current !== $snapshot) {
$remaining[] = $file;
continue;
}
}
if (! $exists) {
$remaining[] = $file;
return $remaining;
}
continue;
private function currentHash(string $relativePath): ?string
{
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
if (! is_file($absolute)) {
return null;
}
$hash = ContentHash::of($absolute);
if ($hash === false) {
$remaining[] = $file;
continue;
}
if ($hash === $snapshot) {
continue;
}
$remaining[] = $file;
}
return $remaining;
return $hash === false ? null : $hash;
}
/**
@ -99,10 +90,6 @@ final readonly class ChangedFiles
*/
public function since(?string $sha): ?array
{
if (! $this->gitAvailable()) {
return null;
}
$files = [];
if ($sha !== null && $sha !== '') {
@ -121,13 +108,10 @@ final readonly class ChangedFiles
if ($file === '') {
continue;
}
if ($this->shouldIgnore($file)) {
continue;
}
$unique[$file] = true;
}
$candidates = array_keys($unique);
$candidates = array_keys($this->filterIgnored($unique));
if ($sha !== null && $sha !== '') {
return $this->filterBehaviourallyUnchanged($candidates, $sha);
@ -145,17 +129,9 @@ final readonly class ChangedFiles
$remaining = [];
foreach ($files as $file) {
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
$currentHash = $this->currentHash($file);
if (! is_file($absolute)) {
$remaining[] = $file;
continue;
}
$currentHash = ContentHash::of($absolute);
if ($currentHash === false) {
if ($currentHash === null) {
$remaining[] = $file;
continue;
@ -169,9 +145,7 @@ final readonly class ChangedFiles
continue;
}
$baselineHash = ContentHash::ofContent($file, $baselineContent);
if ($currentHash !== $baselineHash) {
if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
$remaining[] = $file;
}
}
@ -192,37 +166,52 @@ final readonly class ChangedFiles
return $process->getOutput();
}
private function shouldIgnore(string $path): bool
/**
* @param array<string, true> $candidates
* @return array<string, true>
*/
private function filterIgnored(array $candidates): array
{
static $prefixes = [
'.pest/',
'.phpunit.cache/',
'.phpunit.result.cache',
'vendor/',
'node_modules/',
'bootstrap/cache/',
];
if ($candidates === []) {
return $candidates;
}
foreach ($prefixes as $prefix) {
if (str_starts_with($path, (string) $prefix)) {
return true;
$process = new Process(
['git', 'check-ignore', '--no-index', '-z', '--stdin'],
$this->projectRoot,
);
$process->setTimeout(5.0);
$process->setInput(implode("\x00", array_keys($candidates)));
$process->run();
$exitCode = $process->getExitCode();
if ($exitCode !== 0 && $exitCode !== 1) {
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
if ($output === '') {
return $candidates;
}
foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
if ($ignored !== '') {
unset($candidates[$ignored]);
}
}
return false;
return $candidates;
}
public function currentBranch(): ?string
{
if (! $this->gitAvailable()) {
return null;
}
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
return null;
throw new MissingDependency('Tia mode', 'git');
}
$branch = trim($process->getOutput());
@ -230,14 +219,6 @@ final readonly class ChangedFiles
return $branch === '' || $branch === 'HEAD' ? null : $branch;
}
public function gitAvailable(): bool
{
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot);
$process->run();
return $process->isSuccessful();
}
private function shaIsReachable(string $sha): bool
{
$process = new Process(
@ -261,7 +242,7 @@ final readonly class ChangedFiles
$process->run();
if (! $process->isSuccessful()) {
return [];
throw new MissingDependency('Tia mode', 'git');
}
return $this->splitLines($process->getOutput());
@ -279,7 +260,7 @@ final readonly class ChangedFiles
$process->run();
if (! $process->isSuccessful()) {
return [];
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
@ -321,15 +302,11 @@ final readonly class ChangedFiles
public function currentSha(): ?string
{
if (! $this->gitAvailable()) {
return null;
}
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
return null;
throw new MissingDependency('Tia mode', 'git');
}
$sha = trim($process->getOutput());

View File

@ -48,6 +48,18 @@ final class Configuration
return $this;
}
/**
* @return $this
*/
public function baselined(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markBaselined();
return $this;
}
/**
* @param array<string, string> $patterns glob → project-relative test dir
* @return $this

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
namespace Pest\Plugins\Tia\Contracts;
/**
* @internal
@ -12,7 +12,7 @@ interface WatchDefault
public function applicable(): bool;
/**
* @return array<string, array<int, string>> glob list of project-relative test dirs
* @return array<string, array<int, string>> pattern list of project-relative test dirs
*/
public function defaults(string $projectRoot, string $testPath): array;
}

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
use ReflectionClass;
use Throwable;
/**
@ -104,22 +103,8 @@ final class CoverageCollector
return null;
}
$reflection = new ReflectionClass($className);
assert(property_exists($className, '__filename') && is_string($className::$__filename));
if ($reflection->hasProperty('__filename')) {
$property = $reflection->getProperty('__filename');
if ($property->isStatic()) {
$value = $property->getValue();
if (is_string($value)) {
return $value;
}
}
}
$file = $reflection->getFileName();
return is_string($file) ? $file : null;
return $className::$__filename;
}
}

View File

@ -19,7 +19,7 @@ final class CoverageMerger
{
$state = self::state();
if (! $state instanceof State || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
if (! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
return;
}
@ -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);
@ -131,15 +140,12 @@ final class CoverageMerger
return array_keys($ids);
}
private static function state(): ?State
private static function state(): State
{
try {
$state = Container::getInstance()->get(State::class);
} catch (Throwable) {
return null;
}
assert($state instanceof State);
return $state instanceof State ? $state : null;
return $state;
}
private static function requireCoverage(string $reportPath): ?CodeCoverage

View File

@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Edges;
/**
* @internal
*/
final readonly class AutoloadEdges
{
/**
* @return array<string, true>
*/
public static function snapshot(): array
{
$files = [];
foreach (get_included_files() as $file) {
if ($file !== '') {
$files[$file] = true;
}
}
return $files;
}
/**
* @param array<string, true> $before
* @param array<string, true> $after
* @return list<string>
*/
public static function newProjectFiles(array $before, array $after, string $projectRoot, ?string $testFile = null): array
{
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$testReal = is_string($testFile) && $testFile !== '' ? @realpath($testFile) : false;
$out = [];
foreach (array_keys($after) as $file) {
if (isset($before[$file])) {
continue;
}
$real = @realpath($file);
if ($real === false) {
$real = $file;
}
if ($testReal !== false && $real === $testReal) {
continue;
}
if (! str_starts_with($real, $root)) {
continue;
}
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
if (self::ignored($relative)) {
continue;
}
if (! str_ends_with($relative, '.php')) {
continue;
}
$out[$real] = true;
}
return array_keys($out);
}
private static function ignored(string $relative): bool
{
static $prefixes = [
'vendor/',
'node_modules/',
'storage/framework/',
'bootstrap/cache/',
];
foreach ($prefixes as $prefix) {
if (str_starts_with($relative, (string) $prefix)) {
return true;
}
}
return false;
}
}

View File

@ -34,11 +34,7 @@ final class BladeEdges
return;
}
if ($app->bound(self::MARKER)) {
return;
}
if (! $app->bound('view')) {
if ($app->bound(self::MARKER) || ! $app->bound('view')) {
return;
}

View File

@ -36,11 +36,7 @@ final class InertiaEdges
return;
}
if ($app->bound(self::MARKER)) {
return;
}
if (! $app->bound('events')) {
if ($app->bound(self::MARKER) || ! $app->bound('events')) {
return;
}
@ -54,18 +50,11 @@ final class InertiaEdges
}
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
if (! property_exists($event, 'response')) {
if (! property_exists($event, 'response') || ! is_object($event->response)) {
return;
}
/** @var mixed $response */
$response = $event->response;
if (! is_object($response)) {
return;
}
$component = self::extractComponent($response);
$component = self::extractComponent($event->response);
if ($component !== null) {
$recorder->linkInertiaComponent($component);
@ -75,32 +64,16 @@ final class InertiaEdges
private static function extractComponent(object $response): ?string
{
if (property_exists($response, 'headers') && is_object($response->headers)) {
$headers = $response->headers;
if (method_exists($headers, 'has') && $headers->has('X-Inertia')) {
$content = self::readContent($response);
if ($content !== null) {
/** @var mixed $decoded */
$decoded = json_decode($content, true);
if (is_array($decoded)
&& isset($decoded['component'])
&& is_string($decoded['component'])
&& $decoded['component'] !== '') {
return $decoded['component'];
}
}
}
}
$content = self::readContent($response);
if ($content === null) {
return null;
}
if (self::isInertiaJsonResponse($response)) {
return self::componentFromJson($content);
}
if (str_contains($content, 'type="application/json"')
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1]));
@ -112,16 +85,23 @@ final class InertiaEdges
if (str_contains($content, 'data-page=')
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1]));
if ($component !== null) {
return $component;
}
return self::componentFromJson(html_entity_decode($match[1]));
}
return null;
}
private static function isInertiaJsonResponse(object $response): bool
{
if (! property_exists($response, 'headers') || ! is_object($response->headers)) {
return false;
}
$headers = $response->headers;
return method_exists($headers, 'has') && $headers->has('X-Inertia') === true;
}
private static function componentFromJson(string $json): ?string
{
/** @var mixed $decoded */

View File

@ -2,17 +2,18 @@
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;
case Incomplete;
case Failure;
@ -20,11 +21,12 @@ enum Replay
public static function fromStatus(?TestStatus $status): self
{
if (! $status instanceof TestStatus) {
return self::No;
return self::None;
}
return match (true) {
$status->isSuccess(), $status->isRisky() => self::Pass,
$status->isSuccess() => self::Pass,
$status->isRisky() => self::Risky,
$status->isSkipped() => self::Skipped,
$status->isIncomplete() => self::Incomplete,
default => self::Failure,

View File

@ -9,9 +9,11 @@ use Pest\Plugins\Tia\Contracts\State;
/**
* @internal
*/
final readonly class FileState implements State
final class FileState implements State
{
private string $rootDir;
private readonly string $rootDir;
private ?string $resolvedRoot = null;
public function __construct(string $rootDir)
{
@ -100,9 +102,17 @@ final readonly class FileState implements State
private function resolvedRoot(): ?string
{
if ($this->resolvedRoot !== null) {
return $this->resolvedRoot;
}
$resolved = @realpath($this->rootDir);
return $resolved === false ? null : $resolved;
if ($resolved === false) {
return null;
}
return $this->resolvedRoot = $resolved;
}
private function ensureRoot(): bool

View File

@ -4,17 +4,19 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Symfony\Component\Finder\Finder;
/**
* @internal
*/
final readonly class Fingerprint
{
private const int SCHEMA_VERSION = 14;
private const int SCHEMA_VERSION = 17;
/**
* @return array{
* structural: array<string, int|string|null>,
* environmental: array<string, string|null>,
* environmental: array<string, int|string|null>,
* }
*/
public static function compute(string $projectRoot): array
@ -22,17 +24,22 @@ final readonly class Fingerprint
return [
'structural' => [
'schema' => self::SCHEMA_VERSION,
'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'),
'composer_lock' => self::composerLockHash($projectRoot),
'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,
// 'extensions' => self::extensionsFingerprint($projectRoot),
// 'env_files' => self::envFilesHash($projectRoot),
],
];
}
@ -59,30 +66,11 @@ final readonly class Fingerprint
*/
public static function structuralDrift(array $stored, array $current): array
{
$a = self::structuralOnly($stored);
$b = self::structuralOnly($current);
$drifts = [];
foreach ($a as $key => $value) {
if ($key === 'schema') {
continue;
}
if (($b[$key] ?? null) !== $value) {
$drifts[] = $key;
}
}
foreach ($b as $key => $value) {
if ($key === 'schema') {
continue;
}
if (! array_key_exists($key, $a) && $value !== null) {
$drifts[] = $key;
}
}
return array_values(array_unique($drifts));
return self::detectDrift(
self::structuralOnly($stored),
self::structuralOnly($current),
'schema',
);
}
/**
@ -92,18 +80,34 @@ final readonly class Fingerprint
*/
public static function environmentalDrift(array $stored, array $current): array
{
$a = self::environmentalOnly($stored);
$b = self::environmentalOnly($current);
return self::detectDrift(
self::environmentalOnly($stored),
self::environmentalOnly($current),
);
}
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
* @return list<string>
*/
private static function detectDrift(array $a, array $b, ?string $skipKey = null): array
{
$drifts = [];
foreach ($a as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (($b[$key] ?? null) !== $value) {
$drifts[] = $key;
}
}
foreach ($b as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (! array_key_exists($key, $a) && $value !== null) {
$drifts[] = $key;
}
@ -157,7 +161,11 @@ final readonly class Fingerprint
{
$parts = [];
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
if (! self::isTrackedByGit($projectRoot, $name)) {
continue;
}
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
if ($hash !== null) {
@ -173,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) {
@ -183,47 +195,9 @@ final readonly class Fingerprint
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function packageJsonHash(string $projectRoot): ?string
private static function composerLockHash(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);
return self::trackedHash($projectRoot, 'composer.lock');
}
private static function packageLockHash(string $projectRoot): ?string
@ -231,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;
@ -241,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;
return self::hashIfExists($projectRoot.'/'.$relativePath);
}
$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);
}
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

@ -5,11 +5,13 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Factories\TestCaseFactory;
use Pest\Factories\TestCaseMethodFactory;
use Pest\Support\Container;
use Pest\Support\View;
use Pest\TestSuite;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestStatus\TestStatus;
use PHPUnit\TextUI\Configuration\Registry;
/**
* @internal
@ -51,6 +53,9 @@ final class Graph
/** @var array<string, true>|null */
private ?array $archTestFiles = null;
/** @var array<string, string|false> */
private array $realpathCache = [];
public function __construct(string $projectRoot)
{
$real = @realpath($projectRoot);
@ -82,37 +87,76 @@ final class Graph
*/
public function affected(array $changedFiles): array
{
$normalised = [];
[$migrationPaths, $nonMigrationPaths] = $this->partitionChangedPaths($changedFiles);
$affectedSet = [];
$unparseableMigrations = $this->applyMigrationChanges($migrationPaths, $affectedSet);
[$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved]
= $this->applyInertiaChanges($nonMigrationPaths, $affectedSet);
$unknownSourceDirs = $this->applyPhpEdgeChanges($nonMigrationPaths, $affectedSet);
$this->applyTestFileChanges($nonMigrationPaths, $affectedSet);
$staticallyHandledBlade = $this->applyBladeStaticChanges($nonMigrationPaths, $affectedSet);
$this->applyWatchPatternFallback(
$nonMigrationPaths,
$unparseableMigrations,
$preciselyHandledPages,
$sharedFilesResolved,
$staticallyHandledBlade,
$affectedSet,
);
$this->applyUnknownSourceDirs($unknownSourceDirs, $affectedSet);
return array_keys($affectedSet);
}
/**
* @param array<int, string> $changedFiles
* @return array{0: list<string>, 1: list<string>}
*/
private function partitionChangedPaths(array $changedFiles): array
{
$migrations = [];
$nonMigrations = [];
foreach ($changedFiles as $file) {
$rel = $this->relative($file);
if ($rel !== null) {
$normalised[] = $rel;
}
if ($rel === null) {
continue;
}
$affectedSet = [];
$migrationPaths = [];
$nonMigrationPaths = [];
foreach ($normalised as $rel) {
if ($this->isMigrationPath($rel)) {
$migrationPaths[] = $rel;
$migrations[] = $rel;
} else {
$nonMigrationPaths[] = $rel;
$nonMigrations[] = $rel;
}
}
return [$migrations, $nonMigrations];
}
/**
* @param list<string> $migrationPaths
* @param array<string, true> $affectedSet
* @return list<string> Unparseable migrations (caller treats as unknown-to-graph).
*/
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
{
$changedTables = [];
$unparseableMigrations = [];
$unparseable = [];
foreach ($migrationPaths as $rel) {
$tables = $this->tablesForMigration($rel);
if ($tables === []) {
$unparseableMigrations[] = $rel;
$unparseable[] = $rel;
continue;
}
@ -138,6 +182,17 @@ final class Graph
}
}
return $unparseable;
}
/**
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array{0: array<string, true>, 1: array<string, true>, 2: array<string, true>}
* globalFrontendRuntimeFiles, preciselyHandledPages, sharedFilesResolved
*/
private function applyInertiaChanges(array $nonMigrationPaths, array &$affectedSet): array
{
$globalFrontendRuntimeFiles = [];
foreach ($nonMigrationPaths as $rel) {
@ -169,6 +224,7 @@ final class Graph
}
$sharedFilesResolved = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) {
continue;
@ -176,12 +232,12 @@ final class Graph
if (isset($preciselyHandledPages[$rel])) {
continue;
}
if (! isset($this->jsFileToComponents[$rel])) {
continue;
}
$touchedAny = false;
foreach ($this->jsFileToComponents[$rel] as $pageComponent) {
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
$changedComponents[$pageComponent] = true;
@ -195,6 +251,7 @@ final class Graph
}
$newJsFiles = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) {
continue;
@ -215,39 +272,7 @@ final class Graph
}
if ($newJsFiles !== []) {
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
if ($freshMap === null) {
View::render('components.badge', [
'type' => 'WARN',
'content' => sprintf(
'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
count($newJsFiles),
),
]);
} else {
foreach ($newJsFiles as $rel) {
$pages = $freshMap[$rel] ?? [];
if ($pages === []) {
$sharedFilesResolved[$rel] = true;
continue;
}
$touchedAny = false;
foreach ($pages as $pageComponent) {
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
$changedComponents[$pageComponent] = true;
$touchedAny = true;
}
}
if ($touchedAny) {
$sharedFilesResolved[$rel] = true;
}
}
}
$this->resolveNewJsFiles($newJsFiles, $changedComponents, $sharedFilesResolved);
}
if ($changedComponents !== []) {
@ -266,6 +291,61 @@ final class Graph
}
}
return [$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved];
}
/**
* @param list<string> $newJsFiles
* @param array<string, true> $changedComponents
* @param array<string, true> $sharedFilesResolved
*/
private function resolveNewJsFiles(array $newJsFiles, array &$changedComponents, array &$sharedFilesResolved): void
{
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
if ($freshMap === null) {
View::render('components.badge', [
'type' => 'WARN',
'content' => sprintf(
'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
count($newJsFiles),
),
]);
return;
}
foreach ($newJsFiles as $rel) {
$pages = $freshMap[$rel] ?? [];
if ($pages === []) {
$sharedFilesResolved[$rel] = true;
continue;
}
$touchedAny = false;
foreach ($pages as $pageComponent) {
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
$changedComponents[$pageComponent] = true;
$touchedAny = true;
}
}
if ($touchedAny) {
$sharedFilesResolved[$rel] = true;
}
}
}
/**
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array<string, true> Unknown source dirs (sibling-heuristic).
*/
private function applyPhpEdgeChanges(array $nonMigrationPaths, array &$affectedSet): array
{
$changedIds = [];
$unknownSourceDirs = [];
$sourcePhpChanged = false;
@ -282,9 +362,7 @@ final class Graph
}
if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
$absolute = $this->projectRoot.'/'.$rel;
if (! is_file($absolute)) {
if (! is_file($this->projectRoot.'/'.$rel)) {
continue;
}
@ -316,8 +394,18 @@ final class Graph
}
}
// A changed file inside the configured test suites is itself the unit
// of work — always run it (new untracked tests, edited tests, renames).
return $unknownSourceDirs;
}
/**
* A changed file inside the configured test suites is itself the unit of
* work — always run it (new untracked tests, edited tests, renames).
*
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
*/
private function applyTestFileChanges(array $nonMigrationPaths, array &$affectedSet): void
{
$testPaths = TestPaths::fromProjectRoot($this->projectRoot);
foreach ($nonMigrationPaths as $rel) {
@ -332,9 +420,19 @@ final class Graph
}
$affectedSet[$rel] = true;
}
}
/**
* Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered.
*
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array<string, true>
*/
private function applyBladeStaticChanges(array $nonMigrationPaths, array &$affectedSet): array
{
$staticallyHandled = [];
// Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
$staticallyHandledBlade = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($this->fileIds[$rel])) {
continue;
@ -353,13 +451,33 @@ final class Graph
$affectedSet[$testFile] = true;
}
$staticallyHandledBlade[$rel] = true;
$staticallyHandled[$rel] = true;
} elseif ($this->isBladeComponentPath($rel)) {
$staticallyHandledBlade[$rel] = true;
$staticallyHandled[$rel] = true;
}
}
return $staticallyHandled;
}
/**
* @param list<string> $nonMigrationPaths
* @param list<string> $unparseableMigrations
* @param array<string, true> $preciselyHandledPages
* @param array<string, true> $sharedFilesResolved
* @param array<string, true> $staticallyHandledBlade
* @param array<string, true> $affectedSet
*/
private function applyWatchPatternFallback(
array $nonMigrationPaths,
array $unparseableMigrations,
array $preciselyHandledPages,
array $sharedFilesResolved,
array $staticallyHandledBlade,
array &$affectedSet,
): void {
$unknownToGraph = $unparseableMigrations;
foreach ($nonMigrationPaths as $rel) {
if (isset($preciselyHandledPages[$rel])) {
continue;
@ -388,8 +506,18 @@ final class Graph
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
$affectedSet[$testFile] = true;
}
}
/**
* @param array<string, true> $unknownSourceDirs
* @param array<string, true> $affectedSet
*/
private function applyUnknownSourceDirs(array $unknownSourceDirs, array &$affectedSet): void
{
if ($unknownSourceDirs === []) {
return;
}
if ($unknownSourceDirs !== []) {
foreach ($this->edges as $testFile => $ids) {
if (isset($affectedSet[$testFile])) {
continue;
@ -411,9 +539,6 @@ final class Graph
}
}
return array_keys($affectedSet);
}
public function knowsTest(string $testFile): bool
{
$rel = $this->relative($testFile);
@ -522,7 +647,7 @@ final class Graph
$files = [];
foreach ($baseline['results'] as $result) {
if (! self::shouldRerun($result['status'])) {
if (! $this->shouldRerun($result['status'])) {
continue;
}
@ -549,7 +674,7 @@ final class Graph
$baseline = $this->baselineFor($branch, $fallbackBranch);
foreach ($baseline['results'] as $result) {
if (! self::shouldRerun($result['status'])) {
if (! $this->shouldRerun($result['status'])) {
continue;
}
@ -563,14 +688,61 @@ final class Graph
return false;
}
private static function shouldRerun(int $status): bool
private function shouldRerun(int $status): bool
{
$testStatus = TestStatus::from($status);
return $testStatus->isFailure()
|| $testStatus->isError()
|| $testStatus->isIncomplete()
|| $testStatus->isRisky();
if ($testStatus->isFailure() || $testStatus->isError()) {
return true;
}
$configuration = Registry::get();
if ($testStatus->isRisky()) {
return $configuration->failOnRisky();
}
if ($testStatus->isWarning()) {
if ($configuration->failOnWarning()) {
return true;
}
return $configuration->displayDetailsOnTestsThatTriggerWarnings();
}
if ($testStatus->isNotice()) {
if ($configuration->failOnNotice()) {
return true;
}
return $configuration->displayDetailsOnTestsThatTriggerNotices();
}
if ($testStatus->isDeprecation()) {
if ($configuration->failOnDeprecation()) {
return true;
}
return $configuration->displayDetailsOnTestsThatTriggerDeprecations();
}
if ($testStatus->isIncomplete()) {
if ($configuration->failOnIncomplete()) {
return true;
}
return $configuration->displayDetailsOnIncompleteTests();
}
if ($testStatus->isSkipped()) {
if ($configuration->failOnSkipped()) {
return true;
}
return $configuration->displayDetailsOnSkippedTests();
}
return false;
}
/**
@ -832,29 +1004,16 @@ final class Graph
return $this->archTestFiles;
}
private function methodHasGroup(object $method, string $group): bool
private function methodHasGroup(TestCaseMethodFactory $method, string $group): bool
{
if (property_exists($method, 'groups') && is_array($method->groups) && in_array($group, $method->groups, true)) {
if (in_array($group, $method->groups, true)) {
return true;
}
if (! property_exists($method, 'attributes') || ! is_array($method->attributes)) {
return false;
}
foreach ($method->attributes as $attribute) {
if (! is_object($attribute)) {
continue;
}
if (! property_exists($attribute, 'name')) {
continue;
}
if ($attribute->name !== Group::class) {
continue;
}
if (! property_exists($attribute, 'arguments')) {
continue;
}
foreach ($attribute->arguments as $argument) {
if ($argument === $group) {
@ -988,9 +1147,8 @@ final class Graph
);
foreach ($iterator as $file) {
if (! $file instanceof \SplFileInfo) {
continue;
}
assert($file instanceof \SplFileInfo);
if (! $file->isFile()) {
continue;
}
@ -1163,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);
@ -1178,78 +1381,51 @@ final class Graph
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
if (isset($data['test_tables']) && is_array($data['test_tables'])) {
foreach ($data['test_tables'] as $testRel => $tables) {
if (! is_string($testRel)) {
continue;
}
if (! is_array($tables)) {
continue;
}
$names = [];
foreach ($tables as $table) {
if (is_string($table) && $table !== '') {
$names[] = $table;
}
}
if ($names !== []) {
$graph->testTables[$testRel] = $names;
}
}
}
if (isset($data['test_inertia_components']) && is_array($data['test_inertia_components'])) {
foreach ($data['test_inertia_components'] as $testRel => $components) {
if (! is_string($testRel)) {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
$graph->testInertiaComponents[$testRel] = $names;
}
}
}
if (isset($data['js_file_to_components']) && is_array($data['js_file_to_components'])) {
foreach ($data['js_file_to_components'] as $path => $components) {
if (! is_string($path)) {
continue;
}
if ($path === '') {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
$graph->jsFileToComponents[$path] = $names;
}
}
}
$graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
$graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
$graph->jsFileToComponents = self::decodeStringMap($data['js_file_to_components'] ?? null);
return $graph;
}
/**
* @return array<string, list<string>>
*/
private static function decodeStringMap(mixed $section): array
{
if (! is_array($section)) {
return [];
}
$out = [];
foreach ($section as $key => $values) {
if (! is_string($key)) {
continue;
}
if ($key === '') {
continue;
}
if (! is_array($values)) {
continue;
}
$names = [];
foreach ($values as $value) {
if (is_string($value) && $value !== '') {
$names[] = $value;
}
}
if ($names !== []) {
$out[$key] = $names;
}
}
return $out;
}
public function encode(): ?string
{
$payload = [
@ -1283,7 +1459,11 @@ final class Graph
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|| (strlen($path) >= 2 && $path[1] === ':');
if ($isAbsolute) {
$real = @realpath($path);
if (array_key_exists($path, $this->realpathCache)) {
$real = $this->realpathCache[$path];
} else {
$real = $this->realpathCache[$path] = @realpath($path);
}
if ($real === false) {
$real = $path;

View File

@ -1,234 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class JsImportParser
{
private const array PAGE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte'];
private const array RESOLVABLE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js', 'mjs', 'mts'];
private const string JS_DIR = 'resources/js';
/**
* @return array<string, list<string>>
*/
public static function parse(string $projectRoot): array
{
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.self::JS_DIR;
$pagesRoot = null;
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
$abs = $projectRoot.DIRECTORY_SEPARATOR.$candidate;
if (is_dir($abs)) {
$pagesRoot = $abs;
break;
}
}
if ($pagesRoot === null) {
return [];
}
$reverse = [];
foreach (self::collectPages($pagesRoot) as $pageAbs) {
$component = self::componentName($pagesRoot, $pageAbs);
if ($component === null) {
continue;
}
$visited = [];
self::collectTransitive($pageAbs, $projectRoot, $jsRoot, $visited);
foreach (array_keys($visited) as $depAbs) {
if ($depAbs === $pageAbs) {
continue;
}
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($depAbs, strlen($projectRoot) + 1));
$reverse[$rel][$component] = true;
}
}
$out = [];
foreach ($reverse as $path => $components) {
$names = array_keys($components);
sort($names);
$out[$path] = $names;
}
ksort($out);
return $out;
}
/**
* @return list<string>
*/
private static function collectPages(string $pagesRoot): array
{
$out = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($pagesRoot, \FilesystemIterator::SKIP_DOTS),
);
foreach ($iterator as $fileInfo) {
if (! $fileInfo->isFile()) {
continue;
}
$ext = strtolower((string) $fileInfo->getExtension());
if (in_array($ext, self::PAGE_EXTENSIONS, true)) {
$out[] = $fileInfo->getPathname();
}
}
return $out;
}
private static function componentName(string $pagesRoot, string $pageAbs): ?string
{
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($pageAbs, strlen($pagesRoot) + 1));
$dot = strrpos($rel, '.');
if ($dot === false) {
return null;
}
$name = substr($rel, 0, $dot);
return $name === '' ? null : $name;
}
/**
* @param array<string, true> $visited
*/
private static function collectTransitive(string $fileAbs, string $projectRoot, string $jsRoot, array &$visited): void
{
if (isset($visited[$fileAbs])) {
return;
}
$visited[$fileAbs] = true;
$source = self::loadSource($fileAbs);
if ($source === null) {
return;
}
foreach (self::extractImports($source) as $spec) {
$resolved = self::resolveImport($spec, $fileAbs, $jsRoot);
if ($resolved === null) {
continue;
}
if (! is_file($resolved)) {
continue;
}
self::collectTransitive($resolved, $projectRoot, $jsRoot, $visited);
}
}
private static function loadSource(string $fileAbs): ?string
{
$content = @file_get_contents($fileAbs);
if ($content === false) {
return null;
}
if (str_ends_with(strtolower($fileAbs), '.vue')) {
$scripts = [];
if (preg_match_all('/<script[^>]*>(.*?)<\/script>/si', $content, $m) !== false) {
foreach ($m[1] as $block) {
$scripts[] = $block;
}
}
return implode("\n", $scripts);
}
return $content;
}
/**
* @return list<string>
*/
private static function extractImports(string $source): array
{
$stripped = preg_replace('#//[^\n]*#', '', $source) ?? $source;
$stripped = preg_replace('#/\*.*?\*/#s', '', $stripped) ?? $stripped;
$specs = [];
if (preg_match_all('/\bimport\s+(?:[^\'"()]*?\s+from\s+)?[\'"]([^\'"]+)[\'"]/', $stripped, $matches) !== false) {
foreach ($matches[1] as $spec) {
$specs[] = $spec;
}
}
if (preg_match_all('/\bimport\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $stripped, $matches) !== false) {
foreach ($matches[1] as $spec) {
$specs[] = $spec;
}
}
return $specs;
}
private static function resolveImport(string $spec, string $importerAbs, string $jsRoot): ?string
{
if ($spec === '' || $spec[0] === '.' || $spec[0] === '/') {
return self::resolveRelative($spec, $importerAbs);
}
if (str_starts_with($spec, '@/') || str_starts_with($spec, '~/')) {
$tail = substr($spec, 2);
return self::withExtension($jsRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $tail));
}
return null;
}
private static function resolveRelative(string $spec, string $importerAbs): ?string
{
if ($spec === '' || $spec[0] === '/') {
return null;
}
$base = dirname($importerAbs);
$path = $base.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $spec);
return self::withExtension($path);
}
private static function withExtension(string $path): ?string
{
if (is_file($path)) {
return realpath($path) ?: $path;
}
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
$candidate = $path.'.'.$ext;
if (is_file($candidate)) {
return realpath($candidate) ?: $candidate;
}
}
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
$candidate = $path.DIRECTORY_SEPARATOR.'index.'.$ext;
if (is_file($candidate)) {
return realpath($candidate) ?: $candidate;
}
}
return null;
}
}

View File

@ -16,6 +16,42 @@ final class JsModuleGraph
private const string CACHE_FILE = 'js-module-graph.cache.json';
/**
* @var list<string>
*/
public const array VITE_CONFIG_NAMES = [
'vite.config.ts',
'vite.config.js',
'vite.config.mjs',
'vite.config.cjs',
'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>>
*/
@ -40,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;
}
}
@ -155,13 +227,9 @@ final class JsModuleGraph
private static function fingerprint(string $projectRoot): ?string
{
if (! self::hasViteConfig($projectRoot)) {
return null;
}
$parts = [];
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
foreach (self::VITE_CONFIG_NAMES as $name) {
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (! is_file($path)) {
@ -177,17 +245,25 @@ final class JsModuleGraph
.':'.($bytes === false ? '' : hash('sha256', $bytes));
}
foreach (['Pages', 'pages'] as $dir) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
$parts[] = 'pagesDir:'.$dir;
break;
}
if ($parts === []) {
return null;
}
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js';
$override = getenv('TIA_VITE_PAGES_DIR');
if (is_dir($jsRoot)) {
if (is_string($override) && $override !== '') {
$parts[] = 'pagesDirOverride:'.$override;
}
$pagesDir = self::firstExistingPagesDir($projectRoot);
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(
@ -310,7 +386,7 @@ final class JsModuleGraph
private static function hasViteConfig(string $projectRoot): bool
{
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
foreach (self::VITE_CONFIG_NAMES as $name) {
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
return true;
}

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Edges\AutoloadEdges;
use Pest\TestSuite;
use ReflectionClass;
@ -33,21 +32,6 @@ final class Recorder
/** @var array<string, bool> */
private array $classUsesDatabaseCache = [];
/** @var array<string, list<string>> */
private array $fileToClassNames = [];
/** @var array<string, true> */
private array $indexedClassNames = [];
/** @var array<string, list<string>> */
private array $classDependencyCache = [];
/** @var array<string, list<string>> */
private array $testImportFileCache = [];
/** @var array<string, true> */
private array $includedFilesAtTestStart = [];
private bool $active = false;
private bool $driverChecked = false;
@ -89,13 +73,6 @@ final class Recorder
return $this->driverAvailable;
}
public function driver(): string
{
$this->driverAvailable();
return $this->driver;
}
public function beginTest(string $className, string $methodName, string $fallbackFile): void
{
if (! $this->active || ! $this->driverAvailable()) {
@ -113,15 +90,11 @@ final class Recorder
}
$this->currentTestFile = $file;
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
if ($this->classUsesDatabase($className)) {
$this->perTestUsesDatabase[$file] = true;
}
// $this->linkAncestorFiles($className);
// $this->linkImportedFiles($file);
if ($this->driver === 'pcov') {
\pcov\clear();
\pcov\start();
@ -166,19 +139,7 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
foreach (AutoloadEdges::newProjectFiles(
$this->includedFilesAtTestStart,
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
$this->currentTestFile,
) as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
// $this->linkSourceDependencies($coveredFiles);
$this->currentTestFile = null;
$this->includedFilesAtTestStart = [];
}
public function linkSource(string $sourceFile): void
@ -198,295 +159,6 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
/** @param iterable<int, string> $sourceFiles */
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
{
if (! $this->active) {
return;
}
if ($testFile === '') {
return;
}
foreach ($sourceFiles as $sourceFile) {
if ($sourceFile === '') {
continue;
}
$this->perTestFiles[$testFile][$sourceFile] = true;
}
}
/** @param array<int, string> $coveredFiles */
private function linkSourceDependencies(array $coveredFiles): void
{
if ($this->currentTestFile === null) {
return;
}
$this->refreshClassMap();
foreach ($coveredFiles as $coveredFile) {
if (! isset($this->fileToClassNames[$coveredFile])) {
continue;
}
foreach ($this->fileToClassNames[$coveredFile] as $name) {
foreach ($this->classDependencies($name) as $depFile) {
$this->perTestFiles[$this->currentTestFile][$depFile] = true;
}
}
}
}
private function refreshClassMap(): void
{
$names = array_merge(
get_declared_classes(),
get_declared_interfaces(),
get_declared_traits(),
);
foreach ($names as $name) {
if (isset($this->indexedClassNames[$name])) {
continue;
}
$this->indexedClassNames[$name] = true;
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
continue;
}
$reflection = new ReflectionClass($name);
if ($reflection->isInternal()) {
continue;
}
$file = $reflection->getFileName();
if (! is_string($file)) {
continue;
}
if (str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
continue;
}
$this->fileToClassNames[$file][] = $name;
}
}
/** @return list<string> */
private function classDependencies(string $className): array
{
if (isset($this->classDependencyCache[$className])) {
return $this->classDependencyCache[$className];
}
if (! class_exists($className, false)
&& ! interface_exists($className, false)
&& ! trait_exists($className, false)) {
return $this->classDependencyCache[$className] = [];
}
$reflection = new ReflectionClass($className);
$files = [];
$linkSymbol = static function (string $name) use (&$files): void {
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
return;
}
$r = new ReflectionClass($name);
if ($r->isInternal()) {
return;
}
$f = $r->getFileName();
if (! is_string($f) || str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
return;
}
$files[$f] = true;
};
foreach ($reflection->getInterfaceNames() as $iname) {
$linkSymbol($iname);
}
foreach ($reflection->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $reflection->getParentClass();
while ($parent !== false && ! $parent->isInternal()) {
$f = $parent->getFileName();
if (is_string($f) && ! str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$f] = true;
}
foreach ($parent->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $parent->getParentClass();
}
return $this->classDependencyCache[$className] = array_keys($files);
}
private function linkAncestorFiles(string $className): void
{
if (! class_exists($className, false)) {
return;
}
$reflection = new ReflectionClass($className);
$parent = $reflection->getParentClass();
while ($parent !== false) {
if ($parent->isInternal()) {
break;
}
$file = $parent->getFileName();
if (is_string($file) && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$this->perTestFiles[(string) $this->currentTestFile][$file] = true;
}
$parent = $parent->getParentClass();
}
}
private function linkImportedFiles(string $testFile): void
{
if ($this->currentTestFile === null) {
return;
}
foreach ($this->importedFilesFor($testFile) as $file) {
$this->perTestFiles[$this->currentTestFile][$file] = true;
}
}
/**
* @return list<string>
*/
private function importedFilesFor(string $testFile): array
{
if (array_key_exists($testFile, $this->testImportFileCache)) {
return $this->testImportFileCache[$testFile];
}
$source = @file_get_contents($testFile);
if ($source === false) {
return $this->testImportFileCache[$testFile] = [];
}
$files = [];
foreach ($this->importedClassNames($source) as $className) {
$file = $this->findAutoloadFile($className);
if ($file !== null && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$file] = true;
}
}
return $this->testImportFileCache[$testFile] = array_keys($files);
}
/**
* @return list<string>
*/
private function importedClassNames(string $source): array
{
preg_match_all('/^use\s+(?!function\s|const\s)([^;]+);/mi', $source, $matches);
$classes = [];
foreach ($matches[1] as $import) {
$import = trim($import);
if ($import === '') {
continue;
}
$open = strpos($import, '{');
$close = strrpos($import, '}');
if ($open !== false && $close !== false && $close > $open) {
$prefix = trim(trim(substr($import, 0, $open)), '\\');
$items = explode(',', substr($import, $open + 1, $close - $open - 1));
foreach ($items as $item) {
$class = $this->normaliseImportedClass($prefix.'\\'.trim($item));
if ($class !== null) {
$classes[$class] = true;
}
}
continue;
}
$class = $this->normaliseImportedClass($import);
if ($class !== null) {
$classes[$class] = true;
}
}
return array_keys($classes);
}
private function normaliseImportedClass(string $import): ?string
{
$import = trim(trim($import), '\\');
if ($import === '') {
return null;
}
$parts = preg_split('/\s+as\s+/i', $import);
if ($parts === false) {
return null;
}
$class = trim(trim($parts[0]), '\\');
return $class === '' ? null : $class;
}
private function findAutoloadFile(string $className): ?string
{
foreach (spl_autoload_functions() as $loader) {
if (! is_array($loader)) {
continue;
}
if (! is_object($loader[0])) {
continue;
}
if (! method_exists($loader[0], 'findFile')) {
continue;
}
/** @var mixed $file */
$file = $loader[0]->findFile($className);
if (is_string($file) && $file !== '') {
$real = @realpath($file);
return $real === false ? $file : $real;
}
}
return null;
}
private function classUsesDatabase(string $className): bool
{
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
@ -624,23 +296,9 @@ final class Recorder
return null;
}
$reflection = new ReflectionClass($className);
assert(property_exists($className, '__filename') && is_string($className::$__filename));
if ($reflection->hasProperty('__filename')) {
$property = $reflection->getProperty('__filename');
if ($property->isStatic()) {
$value = $property->getValue();
if (is_string($value)) {
return $value;
}
}
}
$file = $reflection->getFileName();
return is_string($file) ? $file : null;
return $className::$__filename;
}
/**
@ -691,9 +349,6 @@ final class Recorder
$this->perTestUsesDatabase = [];
$this->classFileCache = [];
$this->classUsesDatabaseCache = [];
$this->fileToClassNames = [];
$this->indexedClassNames = [];
$this->classDependencyCache = [];
$this->sourceScope = null;
$this->active = false;
}

View File

@ -4,11 +4,17 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\TextUI\Configuration\Registry;
use Throwable;
/**
* @internal
*/
final readonly class SourceScope
final class SourceScope
{
/** @var array<string, bool> */
private array $containsCache = [];
private const array TOP_LEVEL_NOISE = [
'vendor',
'node_modules',
@ -32,25 +38,27 @@ final readonly class SourceScope
* @param list<string> $excludes Absolute, normalised directory paths.
*/
public function __construct(
private array $includes,
private array $excludes,
private readonly array $includes,
private readonly array $excludes,
) {}
public static function fromProjectRoot(string $projectRoot): self
{
$configPath = self::configPath($projectRoot);
$phpunitIncludes = [];
$phpunitExcludes = [];
if ($configPath !== null) {
$xml = @simplexml_load_file($configPath);
try {
$source = Registry::get()->source();
if ($xml !== false) {
$configDir = dirname($configPath);
$phpunitIncludes = self::extractDirectories($xml, 'source/include/directory', $configDir);
$phpunitExcludes = self::extractDirectories($xml, 'source/exclude/directory', $configDir);
foreach ($source->includeDirectories() as $dir) {
$phpunitIncludes[] = self::normalise($dir->path());
}
foreach ($source->excludeDirectories() as $dir) {
$phpunitExcludes[] = self::normalise($dir->path());
}
} catch (Throwable) {
// Registry not initialized — fall back to project-root scanning.
}
$rootIncludes = self::topLevelProjectDirs($projectRoot);
@ -71,94 +79,50 @@ final readonly class SourceScope
/**
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
*/
public static function testPaths(string $projectRoot): array
public static function testPaths(): array
{
$configPath = self::configPath($projectRoot);
if ($configPath === null) {
try {
$suites = Registry::get()->testSuite();
} catch (Throwable) {
return [];
}
$xml = @simplexml_load_file($configPath);
if ($xml === false) {
return [];
$out = [];
foreach ($suites as $suite) {
foreach ($suite->directories() as $directory) {
$out[] = self::normalise($directory->path());
}
$configDir = dirname($configPath);
foreach ($suite->files() as $file) {
$out[] = self::normalise($file->path());
}
}
return array_values(array_unique([
...self::extractDirectories($xml, 'testsuites/testsuite/directory', $configDir),
...self::extractDirectories($xml, 'testsuites/testsuite/file', $configDir),
]));
return array_values(array_unique($out));
}
public function contains(string $absoluteFile): bool
{
if (isset($this->containsCache[$absoluteFile])) {
return $this->containsCache[$absoluteFile];
}
$real = @realpath($absoluteFile);
$candidate = $real === false ? $absoluteFile : $real;
$candidate = self::normalise($candidate);
foreach ($this->excludes as $excluded) {
if ($this->startsWithDir($candidate, $excluded)) {
return false;
return $this->containsCache[$absoluteFile] = false;
}
}
foreach ($this->includes as $included) {
if ($this->startsWithDir($candidate, $included)) {
return true;
return $this->containsCache[$absoluteFile] = true;
}
}
return false;
}
/**
* @return list<string>
*/
public function includes(): array
{
return $this->includes;
}
private static function configPath(string $projectRoot): ?string
{
foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) {
$candidate = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (is_file($candidate)) {
return $candidate;
}
}
return null;
}
/**
* @return list<string>
*/
private static function extractDirectories(\SimpleXMLElement $xml, string $xpath, string $configDir): array
{
$nodes = $xml->xpath($xpath);
if (! is_array($nodes)) {
return [];
}
$out = [];
foreach ($nodes as $node) {
$value = trim((string) $node);
if ($value === '') {
continue;
}
$out[] = self::resolveRelative($value, $configDir);
}
return array_values(array_unique($out));
return $this->containsCache[$absoluteFile] = false;
}
/**
@ -216,22 +180,6 @@ final readonly class SourceScope
return $out;
}
private static function resolveRelative(string $path, string $configDir): string
{
$isAbsolute = $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || $path[0] === '/'
|| (strlen($path) >= 2 && $path[1] === ':'));
$combined = $isAbsolute ? $path : $configDir.DIRECTORY_SEPARATOR.$path;
$real = @realpath($combined);
if ($real === false) {
return self::normalise($combined);
}
return self::normalise($real);
}
private static function normalise(string $path): string
{
return rtrim($path, '/\\');

View File

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\TestSuite;
use PHPUnit\TextUI\Configuration\Registry;
use Throwable;
/**
* Resolves the set of project-relative paths that are considered test files,
@ -28,39 +30,48 @@ final readonly class TestPaths
public static function fromProjectRoot(string $projectRoot): self
{
$configPath = self::configPath($projectRoot);
$directories = [];
$files = [];
$suffixes = ['.php'];
$suffixes = [];
if ($configPath !== null) {
$xml = @simplexml_load_file($configPath);
try {
$configuration = Registry::get();
if ($xml !== false) {
$configDir = dirname($configPath);
foreach ($xml->xpath('testsuites/testsuite/directory') ?: [] as $node) {
$rel = self::toRelative((string) $node, $configDir, $projectRoot);
foreach ($configuration->testSuite() as $suite) {
foreach ($suite->directories() as $directory) {
$rel = self::toRelative($directory->path(), $projectRoot);
if ($rel !== null) {
$directories[] = $rel;
}
$suffix = (string) ($node['suffix'] ?? '');
if ($suffix !== '' && ! in_array($suffix, $suffixes, true)) {
$suffix = $directory->suffix();
if ($suffix !== '') {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
}
}
foreach ($xml->xpath('testsuites/testsuite/file') ?: [] as $node) {
$rel = self::toRelative((string) $node, $configDir, $projectRoot);
foreach ($suite->files() as $file) {
$rel = self::toRelative($file->path(), $projectRoot);
if ($rel !== null) {
$files[] = $rel;
}
}
}
if ($suffixes === []) {
foreach ($configuration->testSuffixes() as $suffix) {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
}
}
} catch (Throwable) {
// Registry not initialized — fall through to defaults.
}
if ($suffixes === []) {
$suffixes = ['.php'];
}
if ($directories === [] && $files === []) {
@ -109,20 +120,7 @@ final readonly class TestPaths
return false;
}
private static function configPath(string $projectRoot): ?string
{
foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) {
$candidate = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (is_file($candidate)) {
return $candidate;
}
}
return null;
}
private static function toRelative(string $value, string $configDir, string $projectRoot): ?string
private static function toRelative(string $value, string $projectRoot): ?string
{
$value = trim($value);
@ -130,13 +128,8 @@ final readonly class TestPaths
return null;
}
$isAbsolute = $value[0] === '/' || $value[0] === DIRECTORY_SEPARATOR
|| (strlen($value) >= 2 && $value[1] === ':');
$combined = $isAbsolute ? $value : $configDir.DIRECTORY_SEPARATOR.$value;
$real = @realpath($combined);
$resolved = $real === false ? $combined : $real;
$real = @realpath($value);
$resolved = $real === false ? $value : $real;
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
@ -152,7 +145,7 @@ final readonly class TestPaths
{
try {
$testPath = TestSuite::getInstance()->testPath;
} catch (\Throwable) {
} catch (Throwable) {
return null;
}

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;
/**
@ -25,29 +26,10 @@ final readonly class Browser implements WatchDefault
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
$globs = [
'resources/js/**/*.js',
'resources/js/**/*.ts',
'resources/js/**/*.tsx',
'resources/js/**/*.jsx',
'resources/js/**/*.vue',
'resources/js/**/*.svelte',
'resources/css/**/*.css',
'resources/css/**/*.scss',
'resources/css/**/*.less',
'public/build/**/*.js',
'public/build/**/*.css',
'public/**/*.js',
'public/**/*.css',
'public/**/*.svg',
'public/**/*.png',
'public/**/*.jpg',
'public/**/*.jpeg',
'public/**/*.webp',
'public/**/*.ico',
'public/**/*.txt',
'public/**/*.json',
'public/**/*.xml',
'public/hot',
'resources/js/** !*.php',
'resources/css/** !*.php',
'public/hot !*.php',
'public/** !*.php',
];
$patterns = [];

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
@ -20,27 +21,8 @@ final readonly class Inertia implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array
{
$browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath);
$patterns = [];
foreach (['Pages', 'pages'] as $pages) {
foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) {
$patterns["resources/js/{$pages}/**/*.{$ext}"] = $browserTargets;
}
}
foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) {
foreach (['vue', 'tsx', 'ts', 'js'] as $ext) {
$patterns["resources/js/{$shared}/**/*.{$ext}"] = $browserTargets;
}
}
$patterns['resources/js/ssr.js'] = $browserTargets;
$patterns['resources/js/ssr.ts'] = $browserTargets;
$patterns['resources/js/app.js'] = $browserTargets;
$patterns['resources/js/app.ts'] = $browserTargets;
return $patterns;
return [
'resources/js/** !*.php' => [$testPath],
];
}
}

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
@ -20,46 +21,21 @@ final readonly class Laravel implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array
{
return [
'config/*.php' => [$testPath],
'config/**/*.php' => [$testPath],
'routes/*.php' => [$testPath],
'routes/**/*.php' => [$testPath],
'bootstrap/app.php' => [$testPath],
'bootstrap/providers.php' => [$testPath],
'database/migrations/**/*.php' => [$testPath],
'database/seeders/**/*.php' => [$testPath],
'database/factories/**/*.php' => [$testPath],
'storage/fixtures/**/*' => [$testPath],
'app/**/*.tpl' => [$testPath],
'app/**/*.stub' => [$testPath],
'app/**/*.json' => [$testPath],
'app/**/*.yaml' => [$testPath],
'app/**/*.yml' => [$testPath],
'app/**/*.txt' => [$testPath],
'app/** !*.php' => [$testPath],
'resources/views/**/*.blade.php' => [$testPath],
'resources/views/**/*.css' => [$testPath],
'resources/views/email/**/*.blade.php' => [$testPath],
'resources/views/emails/**/*.blade.php' => [$testPath],
'resources/views/**' => [$testPath],
'lang/**/*.php' => [$testPath],
'lang/**/*.json' => [$testPath],
'resources/lang/**/*.php' => [$testPath],
'resources/lang/**/*.json' => [$testPath],
'lang/**' => [$testPath],
'resources/lang/**' => [$testPath],
'vite.config.js' => [$testPath],
'vite.config.ts' => [$testPath],
'webpack.mix.js' => [$testPath],
'tailwind.config.js' => [$testPath],
'tailwind.config.ts' => [$testPath],
'postcss.config.js' => [$testPath],
'vite.config.* !*.php' => [$testPath],
'webpack.mix.* !*.php' => [$testPath],
'tailwind.config.* !*.php' => [$testPath],
'postcss.config.* !*.php' => [$testPath],
];
}
}

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
*/
@ -25,11 +27,7 @@ final readonly class Php implements WatchDefault
'docker-compose.yml' => [$testPath],
'docker-compose.yaml' => [$testPath],
'phpunit.xml.dist' => [$testPath],
$testPath.'/Pest.php' => [$testPath],
$testPath.'/Datasets/**/*.php' => [$testPath],
'phpunit.xml*' => [$testPath],
$testPath.'/Fixtures/**/*' => [$testPath],
$testPath.'/**/Fixtures/**/*' => [$testPath],

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
@ -20,43 +21,22 @@ final readonly class Symfony implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array
{
return [
'config/*.yaml' => [$testPath],
'config/*.yml' => [$testPath],
'config/*.php' => [$testPath],
'config/*.xml' => [$testPath],
'config/**/*.yaml' => [$testPath],
'config/**/*.yml' => [$testPath],
'config/**/*.php' => [$testPath],
'config/**/*.xml' => [$testPath],
'config/routes/*.yaml' => [$testPath],
'config/routes/*.php' => [$testPath],
'config/routes/*.xml' => [$testPath],
'config/routes/**/*.yaml' => [$testPath],
'src/Kernel.php' => [$testPath],
'config/** !*.php' => [$testPath],
'config/routes/** !*.php' => [$testPath],
'migrations/**/*.php' => [$testPath],
'src/Migrations/**/*.php' => [$testPath],
'templates/**/*.html.twig' => [$testPath],
'templates/**/*.twig' => [$testPath],
'templates/** !*.php' => [$testPath],
'translations/**/*.yaml' => [$testPath],
'translations/**/*.yml' => [$testPath],
'translations/**/*.xlf' => [$testPath],
'translations/**/*.xliff' => [$testPath],
'translations/** !*.php' => [$testPath],
'config/doctrine/**/*.xml' => [$testPath],
'config/doctrine/**/*.yaml' => [$testPath],
'webpack.config.js' => [$testPath],
'importmap.php' => [$testPath],
'assets/**/*.js' => [$testPath],
'assets/**/*.ts' => [$testPath],
'assets/**/*.vue' => [$testPath],
'assets/**/*.css' => [$testPath],
'assets/**/*.scss' => [$testPath],
'assets/** !*.php' => [$testPath],
];
}
}

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;
/**
@ -24,17 +24,26 @@ final class WatchPatterns
WatchDefaults\Browser::class,
];
private const array VCS_DIRS = ['.git', '.svn', '.hg'];
/**
* @var array<string, array<int, string>> glob → list of project-relative test dirs/files
* @var array<string, array<int, string>> raw pattern key → list of project-relative test dirs/files
*/
private array $patterns = [];
/**
* @var array<string, array{include: string, excludes: array<int, string>, allowDotfiles: bool}>
*/
private array $parsed = [];
private bool $enabled = false;
private bool $locally = false;
private bool $filtered = false;
private bool $baselined = false;
public function useDefaults(string $projectRoot): void
{
$testPath = TestSuite::getInstance()->testPath;
@ -46,22 +55,22 @@ final class WatchPatterns
continue;
}
foreach ($default->defaults($projectRoot, $testPath) as $glob => $dirs) {
$this->patterns[$glob] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], $dirs),
foreach ($default->defaults($projectRoot, $testPath) as $key => $dirs) {
$this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$key] ?? [], $dirs),
));
}
}
}
/**
* @param array<string, string> $patterns glob → project-relative test dir/file
* @param array<string, string> $patterns pattern key → project-relative test dir/file
*/
public function add(array $patterns): void
{
foreach ($patterns as $glob => $dir) {
$this->patterns[$glob] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], [$dir]),
foreach ($patterns as $key => $dir) {
$this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$key] ?? [], [$dir]),
));
}
}
@ -80,14 +89,16 @@ final class WatchPatterns
$matched = [];
foreach ($changedFiles as $file) {
foreach ($this->patterns as $glob => $dirs) {
if ($this->globMatches($glob, $file)) {
foreach ($this->patterns as $key => $dirs) {
if (! $this->keyMatches($key, $file)) {
continue;
}
foreach ($dirs as $dir) {
$matched[$dir] = true;
}
}
}
}
return array_keys($matched);
}
@ -156,12 +167,132 @@ final class WatchPatterns
return $this->filtered;
}
public function markBaselined(): void
{
$this->baselined = true;
}
public function isBaselined(): bool
{
return $this->baselined;
}
public function reset(): void
{
$this->patterns = [];
$this->parsed = [];
$this->enabled = false;
$this->locally = false;
$this->filtered = false;
$this->baselined = false;
}
private function keyMatches(string $key, string $file): bool
{
$rule = $this->parse($key);
if (! $this->globMatches($rule['include'], $file)) {
return false;
}
$file = str_replace('\\', '/', $file);
if ($this->touchesVcs($file)) {
return false;
}
if (! $rule['allowDotfiles'] && $this->touchesDotfile($file)) {
return false;
}
foreach ($rule['excludes'] as $exclude) {
if ($this->excludeMatches($exclude, $file)) {
return false;
}
}
return true;
}
/**
* @return array{include: string, excludes: array<int, string>, allowDotfiles: bool}
*/
private function parse(string $key): array
{
if (isset($this->parsed[$key])) {
return $this->parsed[$key];
}
$tokens = preg_split('/\s+/', trim($key)) ?: [];
$include = '';
$excludes = [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if ($token[0] === '!') {
$excludes[] = substr($token, 1);
continue;
}
if ($include === '') {
$include = $token;
}
}
return $this->parsed[$key] = [
'include' => $include,
'excludes' => $excludes,
'allowDotfiles' => $this->patternTargetsDotfiles($include),
];
}
private function patternTargetsDotfiles(string $pattern): bool
{
foreach (explode('/', str_replace('\\', '/', $pattern)) as $segment) {
if ($segment !== '' && $segment[0] === '.') {
return true;
}
}
return false;
}
private function touchesVcs(string $file): bool
{
foreach (explode('/', $file) as $segment) {
if (in_array($segment, self::VCS_DIRS, true)) {
return true;
}
}
return false;
}
private function touchesDotfile(string $file): bool
{
foreach (explode('/', $file) as $segment) {
if ($segment !== '' && $segment[0] === '.') {
return true;
}
}
return false;
}
private function excludeMatches(string $exclude, string $file): bool
{
$pattern = str_contains($exclude, '/') ? $exclude : '**/'.$exclude;
if ($this->globMatches($pattern, $file)) {
return true;
}
return $this->globMatches($exclude, basename($file));
}
private function globMatches(string $pattern, string $file): bool

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

@ -15,15 +15,20 @@ use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber
final class EnsureTeamCityEnabled implements ConfiguredSubscriber
{
/**
* Indicates if the TeamCity logger has already been registered.
*/
private static bool $registered = false;
/**
* Creates a new Configured Subscriber instance.
*/
public function __construct(
private InputInterface $input,
private OutputInterface $output,
private TestSuite $testSuite,
private readonly InputInterface $input,
private readonly OutputInterface $output,
private readonly TestSuite $testSuite,
) {}
/**
@ -31,10 +36,16 @@ final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber
*/
public function notify(Configured $event): void
{
if (self::$registered) {
return;
}
if (! $this->input->hasParameterOption('--teamcity')) {
return;
}
self::$registered = true;
$flowId = getenv('FLOW_ID');
$flowId = is_string($flowId) ? (int) $flowId : getmypid();

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

@ -4,14 +4,12 @@ declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Concerns\Testable;
use Pest\Exceptions\TiaRequiresPestTests;
use Pest\Panic;
use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;
use ReflectionClass;
/**
* @internal
@ -38,27 +36,10 @@ final readonly class EnsureTiaIsRunningPestTestsOnly implements PreparedSubscrib
return;
}
if ($this->usesTestableTrait($className)) {
if (method_exists($className, '__initializeTestCase')) {
return;
}
Panic::with(new TiaRequiresPestTests($className, $test->file()));
}
private function usesTestableTrait(string $className): bool
{
$reflection = new ReflectionClass($className);
do {
foreach ($reflection->getTraitNames() as $trait) {
if ($trait === Testable::class) {
return true;
}
}
$reflection = $reflection->getParentClass();
} while ($reflection !== false);
return false;
}
}

View File

@ -6,17 +6,17 @@ namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;
use PHPUnit\Event\Test\PreparationStarted;
use PHPUnit\Event\Test\PreparationStartedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber
final readonly class EnsureTiaResultsAreCollected implements PreparationStartedSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(Prepared $event): void
public function notify(PreparationStarted $event): void
{
$test = $event->test();

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

@ -89,10 +89,6 @@ final class Coverage
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
}
// If TIA's marker is present, this run executed only the affected
// tests. Merge their fresh coverage slice into the cached full-run
// snapshot (stored by the previous `--tia --coverage` pass) so the
// report reflects the entire suite, not just what re-ran.
CoverageMerger::applyIfMarked($reportPath);
/** @var CodeCoverage $codeCoverage */

View File

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Fidry\CpuCoreCounter\CpuCoreCounter;
/**
* @internal
*/
final class Cpu
{
public static function cores(int $fallback = 4): int
{
return (new CpuCoreCounter)->getCountWithFallback($fallback);
}
}

View File

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

View File

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

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

@ -0,0 +1,261 @@
<?php
use Pest\Plugins\Tia\ContentHash;
describe('of()', function () {
it('returns false when file does not exist', function () {
expect(ContentHash::of('/path/that/does/not/exist.php'))->toBeFalse();
});
it('hashes an existing file', function () {
$path = tempnam(sys_get_temp_dir(), 'pest_').'.php';
file_put_contents($path, "<?php echo 'hi';");
try {
expect(ContentHash::of($path))->toBeString()->not->toBeEmpty();
} finally {
@unlink($path);
}
});
});
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;');
expect($a)->toBe($b);
});
it('ignores single-line comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n// this is a comment\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
it('ignores hash-style comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n# hash comment\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
it('ignores multi-line comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n/* a multi\n line comment */\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
it('ignores doc comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n/**\n * @return int\n */\nfunction foo() { return 1; }");
$b = ContentHash::ofContent('a.php', "<?php\nfunction foo() { return 1; }");
expect($a)->toBe($b);
});
it('detects code changes', function () {
$a = ContentHash::ofContent('a.php', '<?php $foo = 1;');
$b = ContentHash::ofContent('a.php', '<?php $foo = 2;');
expect($a)->not->toBe($b);
});
it('preserves whitespace inside string literals', function () {
$a = ContentHash::ofContent('a.php', "<?php \$foo = 'hello world';");
$b = ContentHash::ofContent('a.php', "<?php \$foo = 'helloworld';");
expect($a)->not->toBe($b);
});
it('treats variable renames as a change', function () {
$a = ContentHash::ofContent('a.php', '<?php $foo = 1;');
$b = ContentHash::ofContent('a.php', '<?php $bar = 1;');
expect($a)->not->toBe($b);
});
it('falls back to a raw hash for unparseable PHP', function () {
$hash = ContentHash::ofContent('a.php', 'not valid php at all');
expect($hash)->toBeString()->not->toBeEmpty();
});
it('is case-insensitive on the file extension', function () {
$a = ContentHash::ofContent('a.PHP', "<?php\n// comment\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
});
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>');
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>');
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>');
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>');
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');
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');
expect($a)->not->toBe($b);
});
});
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;');
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;');
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;');
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;');
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;');
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;');
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/>;');
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/>;');
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>');
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>');
expect($a)->toBe($b);
});
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;');
expect($a)->toBe($b);
}
});
});
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');
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');
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');
expect($a)->not->toBe($b);
});
it('hashes files with no extension as raw content', function () {
$a = ContentHash::ofContent('Makefile', "all:\n\techo hi");
$b = ContentHash::ofContent('Makefile', "all:\n\techo hi");
expect($a)->toBe($b);
});
});
describe('output format', function () {
it('returns a 32-character hex xxh128 hash', function () {
$hash = ContentHash::ofContent('a.php', '<?php $foo = 1;');
expect($hash)->toMatch('/^[a-f0-9]{32}$/');
});
it('returns a stable hash for empty content', function () {
$a = ContentHash::ofContent('a.php', '');
$b = ContentHash::ofContent('a.php', '');
expect($a)->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}")