Compare commits

...

52 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
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
42 changed files with 540 additions and 365 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: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
dependency-version: [prefer-lowest, prefer-stable] dependency-version: [prefer-stable]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with: with:
php-version: 8.3 php-version: 8.3
tools: composer:v2 tools: composer:v2
@ -44,7 +44,7 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies - name: Cache Composer dependencies
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with: with:
path: ${{ steps.composer-cache.outputs.dir }} path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }} key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
@ -53,6 +53,8 @@ jobs:
static-php-8.3-composer- static-php-8.3-composer-
- name: Install Dependencies - name: Install Dependencies
env:
COMPOSER_ROOT_VERSION: 4.x-dev
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
- name: Profanity Check - name: Profanity Check

View File

@ -35,10 +35,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
tools: composer:v2 tools: composer:v2
@ -51,7 +51,7 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies - name: Cache Composer dependencies
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with: with:
path: ${{ steps.composer-cache.outputs.dir }} path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }} key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
@ -66,6 +66,8 @@ jobs:
- name: Install PHP dependencies - name: Install PHP dependencies
shell: bash 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 }}" run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
- name: Unit Tests - name: Unit Tests

View File

@ -1,11 +1,12 @@
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST"> <img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
<p align="center"> <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="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="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://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://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>
</p> </p>

View File

@ -6,10 +6,22 @@ import { createRequire } from 'node:module'
import { resolve, relative, extname, sep, join } from 'node:path' import { resolve, relative, extname, sep, join } from 'node:path'
import { pathToFileURL } from 'node:url' 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 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 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() { async function loadRolldown() {
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json')) const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
@ -64,6 +76,22 @@ async function listPageFiles(pagesDir) {
return out 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) { function componentNameFor(pageAbs, pagesDir) {
const rel = relative(pagesDir, pageAbs).split(sep).join('/') const rel = relative(pagesDir, pageAbs).split(sep).join('/')
const ext = extname(rel) const ext = extname(rel)
@ -79,7 +107,13 @@ function isLocalSpecifier(source, aliasKeys) {
} }
async function main() { 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) const pages = await listPageFiles(pagesDir)
if (pages.length === 0) { if (pages.length === 0) {

View File

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

View File

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

View File

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

View File

@ -59,12 +59,15 @@ final class Project
/** /**
* Sets the test project to GitLab. * 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"; // Simple way to ensure only the host is used
$this->prs = "https://gitlab.com/{$project}/merge_requests/%s"; $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; return $this;
} }

View File

@ -112,7 +112,7 @@ final class Expectation
if (function_exists('dump')) { if (function_exists('dump')) {
dump($this->value, ...$arguments); dump($this->value, ...$arguments);
} else { } else {
var_dump($this->value); var_dump($this->value, ...$arguments);
} }
return $this; return $this;
@ -120,16 +120,22 @@ final class Expectation
/** /**
* Dump the expectation value and end the script. * 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')) { if (function_exists('dd')) {
dd($this->value, ...$arguments); 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); exit(1);
} }

View File

@ -163,7 +163,7 @@ final class Kernel
$this->terminate(); $this->terminate();
if (is_array($error = error_get_last())) { 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; return;
} }

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest; namespace Pest;
use Laravel\Pao\Execution;
use Pest\Support\View; use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -28,6 +29,10 @@ final class KernelDump
*/ */
public function enable(): void public function enable(): void
{ {
if (class_exists(Execution::class) && Execution::running()) {
return;
}
ob_start(function (string $message): string { ob_start(function (string $message): string {
$this->buffer .= $message; $this->buffer .= $message;
@ -68,6 +73,10 @@ final class KernelDump
$type = 'INFO'; $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)) { if ($this->isInternalError($this->buffer)) {
$type = 'ERROR'; $type = 'ERROR';
$this->buffer = str_replace( $this->buffer = str_replace(
@ -107,7 +116,6 @@ final class KernelDump
*/ */
private function isInternalError(string $output): bool private function isInternalError(string $output): bool
{ {
return str_contains($output, 'An error occurred inside PHPUnit.') return str_contains($output, 'An error occurred inside PHPUnit.');
|| str_contains($output, 'Fatal error');
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Composer\InstalledVersions;
use Pest\Exceptions\BaselineFetchFailed; use Pest\Exceptions\BaselineFetchFailed;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia; use Pest\Plugins\Tia;
@ -94,7 +93,7 @@ final readonly class BaselineSync
if ($payload === null) { if ($payload === null) {
if ($failureKind === 'no-runs' || $failureKind === null) { if ($failureKind === 'no-runs' || $failureKind === null) {
$this->startCooldown(); $this->startCooldown();
$this->emitPublishInstructions($repo); $this->emitPublishInstructions();
} }
return false; return false;
@ -110,11 +109,6 @@ final readonly class BaselineSync
$this->clearCooldown(); $this->clearCooldown();
$this->renderBadge('INFO', sprintf(
'Baseline ready (%s).',
$this->formatSize($payload['sizeOnDisk']),
));
return true; return true;
} }
@ -162,7 +156,7 @@ final readonly class BaselineSync
return $seconds.'s'; return $seconds.'s';
} }
private function emitPublishInstructions(string $repo): void private function emitPublishInstructions(): void
{ {
if ($this->isCi()) { if ($this->isCi()) {
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.'); $this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
@ -170,23 +164,8 @@ final readonly class BaselineSync
return; return;
} }
$yaml = $this->isLaravel()
? $this->laravelWorkflowYaml()
: $this->genericWorkflowYaml();
$this->renderBadge('WARN', 'No baseline published yet — recording locally.'); $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('See https://pestphp.com/docs/tia for how to publish one from CI.');
$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');
} }
private function isCi(): bool private function isCi(): bool
@ -196,79 +175,6 @@ final readonly class BaselineSync
|| getenv('CIRCLECI') === 'true'; || 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 private function detectGitHubRepo(string $projectRoot): ?string
{ {
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
@ -333,7 +239,7 @@ YAML;
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) { if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
@touch($runCacheDir); @touch($runCacheDir);
$this->renderBadge('INFO', sprintf( $this->renderChild(sprintf(
'Using cached baseline from %s (run %s).', 'Using cached baseline from %s (run %s).',
$repo, $repo,
$runId, $runId,
@ -401,14 +307,15 @@ YAML;
{ {
$artifactSize = $this->artifactSize($repo, $runId); $artifactSize = $this->artifactSize($repo, $runId);
$this->renderBadge('INFO', $artifactSize !== null $this->output->writeln('');
$this->renderChild($artifactSize !== null
? sprintf( ? sprintf(
'Fetching baseline (%s) from %s…', 'Downloading TIA baseline (%s) from %s…',
$this->formatSize($artifactSize), $this->formatSize($artifactSize),
$repo, $repo,
) )
: sprintf( : sprintf(
'Fetching baseline from %s…', 'Downloading TIA baseline from %s…',
$repo, $repo,
)); ));
@ -422,10 +329,11 @@ YAML;
$process->start(); $process->start();
$startedAt = microtime(true); $startedAt = microtime(true);
$tick = 0;
while ($process->isRunning()) { while ($process->isRunning()) {
$this->renderDownloadProgress($runCacheDir, $artifactSize, $startedAt); $this->renderDownloadProgress($startedAt, $tick++);
usleep(250_000); usleep(120_000);
} }
$process->wait(); $process->wait();
@ -491,30 +399,18 @@ YAML;
return is_numeric($size) ? (int) $size : null; 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); static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
$elapsed = max(0.001, microtime(true) - $startedAt);
$speed = (int) ($current / $elapsed);
if ($totalBytes !== null && $totalBytes > 0) { $elapsed = max(0.0, microtime(true) - $startedAt);
$percent = min(99, (int) floor(($current / $totalBytes) * 100)); $frame = $frames[$tick % count($frames)];
$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),
);
}
$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 private function clearProgressLine(): void

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,6 +27,31 @@ final class JsModuleGraph
'vite.config.mts', '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>> * @return array<string, list<string>>
*/ */
@ -51,8 +76,44 @@ final class JsModuleGraph
return false; return false;
} }
foreach (['Pages', 'pages'] as $dir) { return self::firstExistingPagesDir($projectRoot) !== null;
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) { }
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; return true;
} }
} }
@ -188,17 +249,21 @@ final class JsModuleGraph
return null; return null;
} }
foreach (['Pages', 'pages'] as $dir) { $override = getenv('TIA_VITE_PAGES_DIR');
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
$parts[] = 'pagesDir:'.$dir;
break; if (is_string($override) && $override !== '') {
} $parts[] = 'pagesDirOverride:'.$override;
} }
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'; $pagesDir = self::firstExistingPagesDir($projectRoot);
if (is_dir($jsRoot)) { if ($pagesDir !== null) {
$parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
}
$jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
if ($jsRoot !== null && is_dir($jsRoot)) {
$entries = []; $entries = [];
$iterator = new \RecursiveIteratorIterator( $iterator = new \RecursiveIteratorIterator(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
Pest Testing Framework 4.6.3. Pest Testing Framework 4.7.3.
USAGE: pest <file> [options] 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 → strict → ignoring ['Pest\Plugins\Tia\BaselineSync', 'usleep']
✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …] ✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …]
✓ globals ✓ globals
✓ contracts
PASS Tests\Environments\Windows PASS Tests\Environments\Windows
✓ global functions are loaded ✓ global functions are loaded
@ -1716,6 +1715,43 @@
PASS Tests\Unit\Plugins\Retry PASS Tests\Unit\Plugins\Retry
✓ it orders by defects and stop on defects if when --retry is used ✓ 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 PASS Tests\Unit\Preset
✓ preset invalid name ✓ preset invalid name
✓ preset → myFramework ✓ preset → myFramework
@ -1901,4 +1937,4 @@
✓ pass with dataset with ('my-datas-set-value') ✓ pass with dataset with ('my-datas-set-value')
✓ within describe → pass with dataset with ('my-datas-set-value') ✓ within describe → pass with dataset with ('my-datas-set-value')
Tests: 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']) ->expect(['dd', 'dump', 'ray', 'die', 'var_dump', 'sleep'])
->not->toBeUsed() ->not->toBeUsed()
->ignoring(Expectation::class); ->ignoring(Expectation::class);
arch('contracts')
->expect('Pest\Contracts')
->toOnlyUse([
'NunoMaduro\Collision\Contracts',
'Pest\Factories\TestCaseMethodFactory',
'Symfony\Component\Console',
'Pest\Arch\Contracts',
'Pest\PendingCalls',
])->toBeInterfaces();

View File

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

View File

@ -16,6 +16,7 @@ $run = function () {
test('parallel', function () use ($run) { test('parallel', function () use ($run) {
$output = $run('--exclude-group=integration'); $output = $run('--exclude-group=integration');
$output = implode("\n", array_slice(explode("\n", $output), -10));
if (getenv('REBUILD_SNAPSHOTS')) { if (getenv('REBUILD_SNAPSHOTS')) {
preg_match('/Tests:\s+(.+\(\d+ assertions\))/', $output, $matches); preg_match('/Tests:\s+(.+\(\d+ assertions\))/', $output, $matches);
@ -23,13 +24,13 @@ test('parallel', function () use ($run) {
$file = file_get_contents(__FILE__); $file = file_get_contents(__FILE__);
$file = preg_replace( $file = preg_replace(
'/\$expected = \'.*?\';/', '/\$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,
); );
file_put_contents(__FILE__, $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) expect($output)
->toContain("Tests: {$expected}") ->toContain("Tests: {$expected}")