Compare commits

..

1 Commits

Author SHA1 Message Date
nuno maduro d060742eb6 Upgrade nunomaduro/collision to version 11.0.0 2026-04-12 16:09:59 +01:00
369 changed files with 2295 additions and 15636 deletions
-13
View File
@@ -1,13 +0,0 @@
# 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
View File
@@ -1,12 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
groups:
github-actions:
patterns:
- "*"
+9 -14
View File
@@ -2,7 +2,7 @@ name: Static Analysis
on: on:
push: push:
branches: [5.x] branches: [4.x]
pull_request: pull_request:
schedule: schedule:
- cron: '0 9 * * *' - cron: '0 9 * * *'
@@ -11,9 +11,6 @@ concurrency:
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
static: static:
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest' if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
@@ -24,16 +21,16 @@ jobs:
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
dependency-version: [prefer-stable] dependency-version: [prefer-lowest, prefer-stable]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v6
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 uses: shivammathur/setup-php@v2
with: with:
php-version: 8.4 php-version: 8.3
tools: composer:v2 tools: composer:v2
coverage: none coverage: none
extensions: sockets extensions: sockets
@@ -44,17 +41,15 @@ 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 uses: actions/cache@v5
with: with:
path: ${{ steps.composer-cache.outputs.dir }} path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }} key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json') }}
restore-keys: | restore-keys: |
static-php-8.4-${{ matrix.dependency-version }}-composer- static-php-8.3-${{ matrix.dependency-version }}-composer-
static-php-8.4-composer- static-php-8.3-composer-
- name: Install Dependencies - name: Install Dependencies
env:
COMPOSER_ROOT_VERSION: 5.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
+10 -12
View File
@@ -2,7 +2,7 @@ name: Tests
on: on:
push: push:
branches: [5.x] branches: [4.x]
pull_request: pull_request:
schedule: schedule:
- cron: '0 9 * * *' - cron: '0 9 * * *'
@@ -11,9 +11,6 @@ concurrency:
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
tests: tests:
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest' if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
@@ -24,18 +21,21 @@ jobs:
fail-fast: true fail-fast: true
matrix: matrix:
os: [ubuntu-latest, macos-latest] # windows-latest os: [ubuntu-latest, macos-latest] # windows-latest
symfony: ['8.0'] symfony: ['7.4', '8.0']
php: ['8.4', '8.5'] php: ['8.3', '8.4', '8.5']
dependency_version: [prefer-stable] dependency_version: [prefer-stable]
exclude:
- php: '8.3'
symfony: '8.0'
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }} name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v6
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 uses: shivammathur/setup-php@v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
tools: composer:v2 tools: composer:v2
@@ -48,10 +48,10 @@ 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 uses: actions/cache@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') }}
restore-keys: | restore-keys: |
${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer- ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-
${{ matrix.os }}-php-${{ matrix.php }}-composer- ${{ matrix.os }}-php-${{ matrix.php }}-composer-
@@ -63,8 +63,6 @@ jobs:
- name: Install PHP dependencies - name: Install PHP dependencies
shell: bash shell: bash
env:
COMPOSER_ROOT_VERSION: 5.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
+24 -11
View File
@@ -1,20 +1,19 @@
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v5/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 (5.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=5.x&label=Tests%205.x"></a> <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://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>
------ ------
> Pest v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v5-is-here)**. > Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
**Pest** is an elegant testing framework for PHP developers and AI agents. **Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
- Explore our docs at **[pestphp.com »](https://pestphp.com)** - Explore our docs at **[pestphp.com »](https://pestphp.com)**
- Follow the creator Nuno Maduro: - Follow the creator Nuno Maduro:
@@ -29,12 +28,26 @@
We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**. We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**.
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
- **[PhpStorm](https://jb.gg/nuno)** ### Platinum Sponsors
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
- **[Mailtrap](https://l.rw.rw/pestphp)**
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)** - **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
- **[Typesense](https://typesense.org/?ref=nunomaduro)** - **[Tighten](https://tighten.com/?ref=nunomaduro)**
- **[Bento](https://bentonow.com/?ref=nunomaduro)** - **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)**
- **[Redberry](https://redberry.international/laravel-development/)**
### Gold Sponsors
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
### Premium Sponsors
- [Zapiet](https://zapiet.com/?ref=pestphp)
- [Load Forge](https://loadforge.com/?ref=pestphp)
- [Route4Me](https://route4me.com/pt?ref=pestphp)
- [Nerdify](https://getnerdify.com/?ref=pestphp)
- [Akaunting](https://akaunting.com/?ref=pestphp)
- [TestMu AI](https://www.testmuai.com/?utm_medium=sponsor&utm_source=pest)
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**. Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
+3 -3
View File
@@ -2,10 +2,10 @@
When releasing a new version of Pest there are some checks and updates that need to be done: When releasing a new version of Pest there are some checks and updates that need to be done:
> **For Pest v4 you should use the `4.x` branch instead.** > **For Pest v3 you should use the `3.x` branch instead.**
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.x` - Clear your local repository with: `git add . && git reset --hard && git checkout 4.x`
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...5.x) - On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...4.x](https://github.com/pestphp/pest/compare/{latest_version}...4.x)
- Update the version number in [src/Pest.php](src/Pest.php) - Update the version number in [src/Pest.php](src/Pest.php)
- Run the tests locally using: `composer test` - Run the tests locally using: `composer test`
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"` - Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
-12
View File
@@ -3,10 +3,8 @@
declare(strict_types=1); declare(strict_types=1);
use Pest\Contracts\Restarter;
use Pest\Kernel; use Pest\Kernel;
use Pest\Panic; use Pest\Panic;
use Pest\Support\Container;
use Pest\TestCaseFilters\GitDirtyTestCaseFilter; use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter; use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
use Pest\TestCaseMethodFilters\IssueTestCaseFilter; use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
@@ -144,7 +142,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
// Get $rootPath based on $autoloadPath // Get $rootPath based on $autoloadPath
$rootPath = dirname($autoloadPath, 2); $rootPath = dirname($autoloadPath, 2);
$input = new ArgvInput; $input = new ArgvInput;
$testSuite = TestSuite::getInstance( $testSuite = TestSuite::getInstance(
@@ -195,15 +192,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
try { try {
$kernel = Kernel::boot($testSuite, $input, $output); $kernel = Kernel::boot($testSuite, $input, $output);
$container = Container::getInstance();
foreach (Kernel::RESTARTERS as $restarterClass) {
$restarter = $container->get($restarterClass);
assert($restarter instanceof Restarter);
$restarter->maybeRestart($rootPath, $originalArguments);
}
$result = $kernel->handle($originalArguments, $arguments); $result = $kernel->handle($originalArguments, $arguments);
$kernel->terminate(); $kernel->terminate();
-374
View File
@@ -1,374 +0,0 @@
#!/usr/bin/env node
import { readdir, readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
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', '.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 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'))
let path = null
try { path = projectRequire.resolve('rolldown') } catch {}
if (path === null) {
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
// dependency of the vite package rather than a top-level install.
try {
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
path = viteRequire.resolve('rolldown')
} catch {}
}
if (path === null) return null
return await import(pathToFileURL(path).href)
}
export function stripJsonComments(raw) {
let out = ''
let inString = false
let quote = ''
let inLine = false
let inBlock = false
for (let i = 0; i < raw.length; i++) {
const c = raw[i]
const n = raw[i + 1]
if (inLine) {
if (c === '\n') { inLine = false; out += c }
continue
}
if (inBlock) {
if (c === '*' && n === '/') { inBlock = false; i++ }
continue
}
if (inString) {
out += c
if (c === '\\') { out += n ?? ''; i++; continue }
if (c === quote) inString = false
continue
}
if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue }
if (c === '/' && n === '/') { inLine = true; i++; continue }
if (c === '/' && n === '*') { inBlock = true; i++; continue }
if (c === '}' || c === ']') out = out.replace(/,\s*$/, '')
out += c
}
return out
}
async function readJsonWithComments(path) {
const raw = await readFile(path, 'utf8')
return JSON.parse(stripJsonComments(raw))
}
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
const alias = {}
for (const name of ['tsconfig.json', 'jsconfig.json']) {
const p = join(projectRoot, name)
if (!existsSync(p)) continue
let cfg
try { cfg = await readJsonWithComments(p) } catch { continue }
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
const paths = cfg?.compilerOptions?.paths ?? {}
for (const [key, targets] of Object.entries(paths)) {
if (!key.endsWith('/*')) continue
const t0 = Array.isArray(targets) ? targets[0] : null
if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue
const aliasKey = key.slice(0, -2)
if (alias[aliasKey] !== undefined) continue
alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2))
}
}
return alias
}
const VITE_CONFIG_FILES = [
'vite.config.ts',
'vite.config.js',
'vite.config.mjs',
'vite.config.cjs',
'vite.config.mts',
'vite.config.cts',
]
function resolveAliasTarget(projectRoot, target) {
if (target.startsWith('/')) {
// Vite resolves a leading slash against the project root, unless the
// config author used a genuinely absolute path.
return existsSync(target) ? target : resolve(projectRoot, '.' + target)
}
return resolve(projectRoot, target)
}
async function usesLaravelVitePlugin(projectRoot) {
const p = join(projectRoot, 'package.json')
if (!existsSync(p)) return false
try {
const pkg = JSON.parse(await readFile(p, 'utf8'))
return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin'])
} catch {
return false
}
}
export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
const alias = {}
let source = null
for (const name of VITE_CONFIG_FILES) {
const p = join(projectRoot, name)
if (!existsSync(p)) continue
try { source = await readFile(p, 'utf8') } catch { continue }
break
}
if (source !== null) {
// The config is executable code we cannot import safely, so extract alias
// entries textually: a quoted `@…`/`~…` key, then the last quoted path in
// the value expression — covers `'@': '/resources/js'` as well as
// `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a
// top-level comma; call arguments are kept via the paren group.
for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) {
const key = m[1]
if (alias[key] !== undefined) continue
const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1])
const target = paths.length > 0 ? paths[paths.length - 1] : null
if (!target || target.includes('*')) continue
alias[key] = resolveAliasTarget(projectRoot, target)
}
}
// laravel-vite-plugin registers '@' → resources/js by default.
if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) {
alias['@'] = resolve(projectRoot, 'resources/js')
}
return alias
}
async function listPageFiles(pagesDir) {
if (!existsSync(pagesDir)) return []
const out = []
const walk = async (dir) => {
let entries
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
for (const entry of entries) {
const full = resolve(dir, entry.name)
if (entry.isDirectory()) { await walk(full); continue }
if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full)
}
}
await walk(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)
return rel.slice(0, rel.length - ext.length)
}
function isLocalSpecifier(source, aliasKeys) {
if (source.startsWith('.') || source.startsWith('/')) return true
for (const key of aliasKeys) {
if (source === key || source.startsWith(key + '/')) return true
}
return false
}
async function main() {
const pagesDir = await discoverPagesDir()
if (pagesDir === null) {
process.stdout.write('{}')
return
}
const pages = await listPageFiles(pagesDir)
if (pages.length === 0) {
process.stdout.write('{}')
return
}
const loaded = await loadRolldown()
if (loaded === null) {
process.stdout.write('{}')
return
}
const { rolldown } = loaded
// The vite config is what the dev server actually resolves with — let it win.
const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) }
const aliasKeys = Object.keys(alias)
const graph = new Map()
const collector = {
name: 'pest-tia-collector',
moduleParsed(info) {
const id = info.id
if (!id || id.startsWith('\0')) return
const deps = new Set()
for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i)
for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i)
graph.set(id, deps)
},
}
const externalBare = {
name: 'pest-tia-external-bare',
resolveId(source) {
if (!source) return null
if (isLocalSpecifier(source, aliasKeys)) return null
return { id: source, external: true }
},
}
const assetStub = {
name: 'pest-tia-asset-stub',
load(id) {
if (!id) return null
if (ASSET_EXT_RE.test(id)) {
return { code: 'export default null', moduleSideEffects: false }
}
return null
},
}
const input = Object.create(null)
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
const bundle = await rolldown({
input,
cwd: PROJECT_ROOT,
resolve: {
alias,
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
},
transform: { jsx: 'preserve' },
treeshake: false,
plugins: [externalBare, assetStub, collector],
logLevel: 'silent',
onLog: () => {},
})
try {
await bundle.generate({ format: 'esm' })
} finally {
await bundle.close()
}
const reverse = new Map()
const transitiveCache = new Map()
const computeTransitive = (id, stack) => {
const cached = transitiveCache.get(id)
if (cached) return cached
if (stack.has(id)) return null
stack.add(id)
const acc = new Set()
// A set computed while skipping an in-stack (cyclic) dependency is missing
// that dependency's subtree and must not be memoized — the ancestor call
// completes it for the current traversal, but a cached copy would leak the
// incomplete set into other pages' traversals.
let complete = true
const deps = graph.get(id)
if (deps) {
for (const dep of deps) {
if (!dep || dep.startsWith('\0')) continue
if (dep.startsWith(PROJECT_ROOT)) {
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
acc.add(rel)
}
if (stack.has(dep)) {
complete = false
continue
}
const child = computeTransitive(dep, stack)
if (child) for (const r of child) acc.add(r)
if (!transitiveCache.has(dep)) complete = false
}
}
stack.delete(id)
if (complete) transitiveCache.set(id, acc)
return acc
}
for (const page of pages) {
const pageComponent = componentNameFor(page, pagesDir)
const reachable = computeTransitive(page, new Set())
if (!reachable) continue
for (const rel of reachable) {
const bucket = reverse.get(rel) ?? new Set()
bucket.add(pageComponent)
reverse.set(rel, bucket)
}
}
const payload = Object.create(null)
const keys = [...reverse.keys()].sort()
for (const key of keys) {
payload[key] = [...reverse.get(key)].sort()
}
process.stdout.write(JSON.stringify(payload))
}
const invokedDirectly = process.argv[1] !== undefined
&& import.meta.url === pathToFileURL(process.argv[1]).href
if (invokedDirectly) {
try {
await main()
} catch (err) {
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
process.exit(1)
}
}
-10
View File
@@ -6,7 +6,6 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
use ParaTest\WrapperRunner\WrapperWorker; use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Kernel; use Pest\Kernel;
use Pest\Plugins\Actions\CallsHandleArguments; use Pest\Plugins\Actions\CallsHandleArguments;
use Pest\Support\Container;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\ConsoleOutput;
@@ -59,15 +58,6 @@ $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'])); assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
$statusFile = fopen($getopt['status-file'], 'wb'); $statusFile = fopen($getopt['status-file'], 'wb');
assert(is_resource($statusFile)); assert(is_resource($statusFile));
+15 -18
View File
@@ -17,20 +17,20 @@
} }
], ],
"require": { "require": {
"php": "^8.4", "php": "^8.3.0",
"brianium/paratest": "^7.23.0", "brianium/paratest": "^7.20.0",
"nunomaduro/collision": "^8.9.5", "nunomaduro/collision": "^11.0.0",
"nunomaduro/termwind": "^2.4.0", "nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^5.0.0", "pestphp/pest-plugin": "^4.0.0",
"pestphp/pest-plugin-arch": "^5.0.0", "pestphp/pest-plugin-arch": "^4.0.2",
"pestphp/pest-plugin-mutate": "^5.0.0", "pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^5.0.0", "pestphp/pest-plugin-profanity": "^4.2.1",
"phpunit/phpunit": "^13.2.4", "phpunit/phpunit": "^12.5.16",
"symfony/process": "^8.1.0" "symfony/process": "^7.4.8|^8.0.8"
}, },
"conflict": { "conflict": {
"filp/whoops": "<2.18.3", "filp/whoops": "<2.18.3",
"phpunit/phpunit": ">13.2.4", "phpunit/phpunit": ">12.5.16",
"sebastian/exporter": "<7.0.0", "sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0" "webmozart/assert": "<1.11.0"
}, },
@@ -58,13 +58,11 @@
] ]
}, },
"require-dev": { "require-dev": {
"laravel/pao": "^1.1.2", "mrpunyapal/peststan": "^0.2.5",
"pestphp/pest-dev-tools": "^5.0.0", "pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^5.0.0", "pestphp/pest-plugin-browser": "^4.3.1",
"pestphp/pest-plugin-phpstan": "^5.0.0", "pestphp/pest-plugin-type-coverage": "^4.0.4",
"pestphp/pest-plugin-rector": "^5.0.0", "psy/psysh": "^0.12.22"
"pestphp/pest-plugin-type-coverage": "^5.0.0",
"psy/psysh": "^0.12.24"
}, },
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true, "prefer-stable": true,
@@ -125,7 +123,6 @@
"Pest\\Plugins\\Verbose", "Pest\\Plugins\\Verbose",
"Pest\\Plugins\\Version", "Pest\\Plugins\\Version",
"Pest\\Plugins\\Shard", "Pest\\Plugins\\Shard",
"Pest\\Plugins\\Tia",
"Pest\\Plugins\\Parallel" "Pest\\Plugins\\Parallel"
] ]
}, },
@@ -1,39 +1,6 @@
<?php <?php
/*
* BSD 3-Clause License
*
* Copyright (c) 2001-2023, Sebastian Bergmann
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
declare(strict_types=1); declare(strict_types=1);
/* /*
* This file is part of PHPUnit. * This file is part of PHPUnit.
* *
@@ -1,71 +0,0 @@
<?php
/*
* The MIT License (MIT)
*
* Copyright (c) 2011 Brian Scaturro
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use PHPUnit\TextUI\Output\Printer;
use function preg_match;
/**
* @internal
*
* This file is overridden so the "T" progress character — emitted by Pest for
* "todo" tests — is routed to the progress file next to the regular characters,
* instead of being treated as unexpected output.
*/
final readonly class ProgressPrinterOutput implements Printer
{
public function __construct(
private Printer $progressPrinter,
private Printer $outputPrinter,
) {}
public function print(string $buffer): void
{
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
if (
$buffer === "\n"
|| preg_match('/^ +$/', $buffer) === 1
|| preg_match('/^ \d+ \/ \d+ \(...%\)$/', $buffer) === 1
) {
return;
}
match ($buffer) {
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', 'T', '.' => $this->progressPrinter->print($buffer),
default => $this->outputPrinter->print($buffer),
};
}
public function flush(): void
{
$this->progressPrinter->flush();
$this->outputPrinter->flush();
}
}
-388
View File
@@ -1,388 +0,0 @@
<?php
/*
* BSD 3-Clause License
*
* Copyright (c) 2001-2023, Sebastian Bergmann
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Runner;
use PHPUnit\Framework\DataProviderTestSuite;
use PHPUnit\Framework\Reorderable;
use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Runner\ResultCache\NullResultCache;
use PHPUnit\Runner\ResultCache\ResultCache;
use PHPUnit\Runner\ResultCache\ResultCacheId;
use function array_diff;
use function array_merge;
use function array_reverse;
use function array_splice;
use function assert;
use function count;
use function in_array;
use function max;
use function shuffle;
use function usort;
/**
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*/
final class TestSuiteSorter
{
public const int ORDER_DEFAULT = 0;
public const int ORDER_RANDOMIZED = 1;
public const int ORDER_REVERSED = 2;
public const int ORDER_DEFECTS_FIRST = 3;
public const int ORDER_DURATION = 4;
public const int ORDER_SIZE = 5;
/**
* @var non-empty-array<non-empty-string, positive-int>
*/
private const array SIZE_SORT_WEIGHT = [
'small' => 1,
'medium' => 2,
'large' => 3,
'unknown' => 4,
];
/**
* @var array<string, int> Associative array of (string => DEFECT_SORT_WEIGHT) elements
*/
private array $defectSortOrder = [];
private readonly ResultCache $cache;
public function __construct(?ResultCache $cache = null)
{
$this->cache = $cache ?? new NullResultCache;
}
/**
* @throws Exception
*/
public function reorderTestsInSuite(Test $suite, int $order, bool $resolveDependencies, int $orderDefects): void
{
$allowedOrders = [
self::ORDER_DEFAULT,
self::ORDER_REVERSED,
self::ORDER_RANDOMIZED,
self::ORDER_DURATION,
self::ORDER_SIZE,
];
if (! in_array($order, $allowedOrders, true)) {
// @codeCoverageIgnoreStart
throw new InvalidOrderException;
// @codeCoverageIgnoreEnd
}
$allowedOrderDefects = [
self::ORDER_DEFAULT,
self::ORDER_DEFECTS_FIRST,
];
if (! in_array($orderDefects, $allowedOrderDefects, true)) {
// @codeCoverageIgnoreStart
throw new InvalidOrderException;
// @codeCoverageIgnoreEnd
}
if ($suite instanceof TestSuite) {
foreach ($suite as $_suite) {
$this->reorderTestsInSuite($_suite, $order, $resolveDependencies, $orderDefects);
}
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
$this->addSuiteToDefectSortOrder($suite);
}
$this->sort($suite, $order, $resolveDependencies, $orderDefects);
}
}
private function sort(TestSuite $suite, int $order, bool $resolveDependencies, int $orderDefects): void
{
if ($suite->tests() === []) {
return;
}
if ($order === self::ORDER_REVERSED) {
$suite->setTests($this->reverse($suite->tests()));
} elseif ($order === self::ORDER_RANDOMIZED) {
$suite->setTests($this->randomize($suite->tests()));
} elseif ($order === self::ORDER_DURATION) {
$suite->setTests($this->sortByDuration($suite->tests()));
} elseif ($order === self::ORDER_SIZE) {
$suite->setTests($this->sortBySize($suite->tests()));
}
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
$suite->setTests($this->sortDefectsFirst($suite->tests()));
}
if ($resolveDependencies && ! ($suite instanceof DataProviderTestSuite)) {
$tests = $suite->tests();
/** @noinspection PhpParamsInspection */
/** @phpstan-ignore argument.type */
$suite->setTests($this->resolveDependencies($tests));
}
}
private function addSuiteToDefectSortOrder(TestSuite $suite): void
{
$max = 0;
foreach ($suite->tests() as $test) {
assert($test instanceof Reorderable);
$sortId = $test->sortId();
if (! isset($this->defectSortOrder[$sortId])) {
$this->defectSortOrder[$sortId] = $this->cache->status(ResultCacheId::fromReorderable($test))->asInt();
$max = max($max, $this->defectSortOrder[$sortId]);
}
}
$this->defectSortOrder[$suite->sortId()] = $max;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function reverse(array $tests): array
{
return array_reverse($tests);
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function randomize(array $tests): array
{
shuffle($tests);
return $tests;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function sortDefectsFirst(array $tests): array
{
usort(
$tests,
fn (Test $left, Test $right) => $this->cmpDefectPriorityAndTime($left, $right),
);
return $tests;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function sortByDuration(array $tests): array
{
usort(
$tests,
fn (Test $left, Test $right) => $this->cmpDuration($left, $right),
);
return $tests;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function sortBySize(array $tests): array
{
usort(
$tests,
fn (Test $left, Test $right) => $this->cmpSize($left, $right),
);
return $tests;
}
/**
* Comparator callback function to sort tests for "reach failure as fast as possible".
*
* 1. sort tests by defect weight defined in self::DEFECT_SORT_WEIGHT
* 2. when tests are equally defective, sort the fastest to the front
* 3. do not reorder successful tests
*/
private function cmpDefectPriorityAndTime(Test $a, Test $b): int
{
assert($a instanceof Reorderable);
assert($b instanceof Reorderable);
$priorityA = $this->defectSortOrder[$a->sortId()] ?? 0;
$priorityB = $this->defectSortOrder[$b->sortId()] ?? 0;
if ($priorityA !== $priorityB) {
// Sort defect weight descending
return $priorityB <=> $priorityA;
}
if ($priorityA > 0 || $priorityB > 0) {
return $this->cmpDuration($a, $b);
}
// do not change execution order
return 0;
}
/**
* Compares test duration for sorting tests by duration ascending.
*/
private function cmpDuration(Test $a, Test $b): int
{
if (! ($a instanceof Reorderable && $b instanceof Reorderable)) {
return 0;
}
return $this->cache->time(ResultCacheId::fromReorderable($a)) <=> $this->cache->time(ResultCacheId::fromReorderable($b));
}
/**
* Compares test size for sorting tests small->medium->large->unknown.
*/
private function cmpSize(Test $a, Test $b): int
{
$sizeA = ($a instanceof TestCase || $a instanceof DataProviderTestSuite)
? $a->size()->asString()
: 'unknown';
$sizeB = ($b instanceof TestCase || $b instanceof DataProviderTestSuite)
? $b->size()->asString()
: 'unknown';
return self::SIZE_SORT_WEIGHT[$sizeA] <=> self::SIZE_SORT_WEIGHT[$sizeB];
}
/**
* Reorder Tests within a TestCase in such a way as to resolve as many dependencies as possible.
* The algorithm will leave the tests in original running order when it can.
* For more details see the documentation for test dependencies.
*
* Short description of algorithm:
* 1. Pick the next Test from remaining tests to be checked for dependencies.
* 2. If the test has no dependencies: mark done, start again from the top
* 3. If the test has dependencies but none left to do: mark done, start again from the top
* 4. When we reach the end add any leftover tests to the end. These will be marked 'skipped' during execution.
*
* @param array<TestCase> $tests
* @return array<TestCase>
*/
private function resolveDependencies(array $tests): array
{
// Pest: Fast-path. If no test in this suite declares dependencies, the
// original O(N^2) algorithm is wasted work — it would splice each test
// one-by-one back into the same order. The check deliberately walks
// TestCase instances directly instead of calling TestSuite::requires(),
// because the latter lazily builds TestSuite::provides() via
// ExecutionOrderDependency::mergeUnique, which is O(N^2) in the total
// number of tests. With thousands of tests that single call alone can
// burn several seconds before the sort even begins. Reading the
// cached TestCase::$dependencies property stays O(N) and costs nothing
// when no test uses `->depends()` / PHPUnit `@depends`.
if (! $this->anyTestHasDependencies($tests)) {
return $tests;
}
$newTestOrder = [];
$i = 0;
$provided = [];
do {
if (array_diff($tests[$i]->requires(), $provided) === []) {
$provided = array_merge($provided, $tests[$i]->provides());
$newTestOrder = array_merge($newTestOrder, array_splice($tests, $i, 1));
$i = 0;
} else {
$i++;
}
} while ($tests !== [] && ($i < count($tests)));
return array_merge($newTestOrder, $tests);
}
/**
* Cheaply determines whether any test in the tree declares @depends.
*
* Walks `TestSuite` containers recursively and inspects each `TestCase`
* directly so it never triggers `TestSuite::provides()`, which is O(N^2)
* in the total number of aggregated tests.
*
* @param iterable<Test> $tests
*/
private function anyTestHasDependencies(iterable $tests): bool
{
foreach ($tests as $test) {
if ($test instanceof TestSuite) {
if ($this->anyTestHasDependencies($test->tests())) {
return true;
}
continue;
}
if ($test instanceof TestCase && $test->requires() !== []) {
return true;
}
}
return false;
}
}
@@ -43,7 +43,7 @@ declare(strict_types=1);
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
namespace PHPUnit\TextUI\Output\Default\ProgressPrinter; namespace Pest\Logging\TeamCity\Subscriber;
use PHPUnit\Event\Test\Skipped; use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\SkippedSubscriber; use PHPUnit\Event\Test\SkippedSubscriber;
@@ -51,20 +51,16 @@ use ReflectionClass;
/** /**
* @internal This class is not covered by the backward compatibility promise for PHPUnit * @internal This class is not covered by the backward compatibility promise for PHPUnit
*
* This file is overridden so PHPUnit's progress output emits a "T" before the
* regular "S" for "todo" tests — Pest's parallel result printer consumes the
* "T" and swallows the "S" that follows it.
*/ */
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
{ {
public function notify(Skipped $event): void public function notify(Skipped $event): void
{ {
if ($event->message() === '__TODO__') { if (str_contains($event->message(), '__TODO__')) {
$this->printTodoItem(); $this->printTodoItem();
} }
$this->printer()->testSkipped(); $this->logger()->testSkipped($event);
} }
/** /**
@@ -73,7 +69,7 @@ final readonly class TestSkippedSubscriber extends Subscriber implements Skipped
private function printTodoItem(): void private function printTodoItem(): void
{ {
$mirror = new ReflectionClass($this->printer()); $mirror = new ReflectionClass($this->printer());
$printProgress = $mirror->getMethod('printProgress'); $printerMirror = $mirror->getMethod('printProgress');
$printProgress->invoke($this->printer(), 'T'); $printerMirror->invoke($this->printer(), 'T');
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
includes: includes:
- phpstan-baseline.neon - phpstan-baseline.neon
- phpstan-pest-extension.neon - phpstan-pest-extension.neon
- vendor/pestphp/pest-plugin-phpstan/extension.neon - vendor/mrpunyapal/peststan/extension.neon
parameters: parameters:
level: 7 level: 7
-38
View File
@@ -2,60 +2,22 @@
declare(strict_types=1); declare(strict_types=1);
use Pest\Rector\Rules\UseToMatchArrayRector;
use Pest\Rector\Set\PestSetList;
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector; use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
use Rector\Config\RectorConfig; use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector; use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector;
use Rector\DeadCode\Rector\Property\RemoveUnusedPrivatePropertyRector;
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector; use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector; use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
return RectorConfig::configure() return RectorConfig::configure()
->withPaths([ ->withPaths([
__DIR__.'/src', __DIR__.'/src',
__DIR__.'/tests',
])
->withSets([
PestSetList::CODING_STYLE,
]) ])
->withSkip([ ->withSkip([
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php', __DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
__DIR__.'/tests/Fixtures/Arch',
ReturnNeverTypeRector::class, ReturnNeverTypeRector::class,
ArrowFunctionDelegatingCallToFirstClassCallableRector::class, ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
NarrowObjectReturnTypeRector::class, NarrowObjectReturnTypeRector::class,
RemoveParentDelegatingConstructorRector::class, RemoveParentDelegatingConstructorRector::class,
RemoveDuplicatedReturnSelfDocblockRector::class,
RemoveUselessUnionReturnDocblockRector::class,
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [
__DIR__.'/src/Expectations/HigherOrderExpectation.php',
],
// Merges unrelated expectations into a single `toMatchArray()`, turning
// `toContain()` into exact matches, dropping `->not`, and mistaking a
// `toBeTrue()` failure message for an expected value. Unsafe here.
UseToMatchArrayRector::class,
// Test fixtures rely on "unused" constructors, params and properties
// (resolved via the container or read through reflection), so the
// dead-code and return-type rules below must not touch the test suite.
RemoveEmptyClassMethodRector::class => [
__DIR__.'/tests',
],
RemoveUnusedConstructorParamRector::class => [
__DIR__.'/tests',
],
RemoveUnusedPrivatePropertyRector::class => [
__DIR__.'/tests',
],
AddArrowFunctionReturnTypeRector::class => [
__DIR__.'/tests',
],
]) ])
->withPreparedSets( ->withPreparedSets(
deadCode: true, deadCode: true,
-2
View File
@@ -5,8 +5,6 @@
[$bgBadgeColor, $bgBadgeText] = match ($type) { [$bgBadgeColor, $bgBadgeText] = match ($type) {
'INFO' => ['blue', 'INFO'], 'INFO' => ['blue', 'INFO'],
'ERROR' => ['red', 'ERROR'], 'ERROR' => ['red', 'ERROR'],
'WARN' => ['yellow', 'WARN'],
'SUCCESS' => ['green', 'SUCCESS'],
}; };
?> ?>
@@ -7,7 +7,7 @@
<div> <div>
<span class="text-gray mr-1">- </span> <span class="text-gray mr-1">- </span>
<span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span> <span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
</div> </div>
<div> <div>
+4
View File
@@ -176,5 +176,9 @@ final class Laravel extends AbstractPreset
->toImplement('Illuminate\Contracts\Container\ContextualAttribute') ->toImplement('Illuminate\Contracts\Container\ContextualAttribute')
->toHaveAttribute('Attribute') ->toHaveAttribute('Attribute')
->toHaveMethod('resolve'); ->toHaveMethod('resolve');
$this->expectations[] = expect('App\Rules')
->classes()
->toImplement('Illuminate\Contracts\Validation\ValidationRule');
} }
} }
-2
View File
@@ -18,11 +18,9 @@ final class BootOverrides implements Bootstrapper
* @var array<int, string> * @var array<int, string>
*/ */
public const array FILES = [ public const array FILES = [
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
'Runner/Filter/NameFilterIterator.php', 'Runner/Filter/NameFilterIterator.php',
'Runner/ResultCache/DefaultResultCache.php', 'Runner/ResultCache/DefaultResultCache.php',
'Runner/TestSuiteLoader.php', 'Runner/TestSuiteLoader.php',
'Runner/TestSuiteSorter.php',
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php', 'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php', 'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
'TextUI/TestSuiteFilterProcessor.php', 'TextUI/TestSuiteFilterProcessor.php',
@@ -1,19 +0,0 @@
<?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']);
}
}
-11
View File
@@ -25,17 +25,6 @@ final readonly class BootSubscribers implements Bootstrapper
Subscribers\EnsureIgnorableTestCasesAreIgnored::class, Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class, Subscribers\EnsureKernelDumpIsFlushed::class,
Subscribers\EnsureTeamCityEnabled::class, Subscribers\EnsureTeamCityEnabled::class,
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
Subscribers\EnsureTiaStarts::class,
Subscribers\EnsureTiaEnds::class,
Subscribers\EnsureTiaResultsAreCollected::class,
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
Subscribers\EnsureTiaResultIsRecordedOnErrored::class,
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
]; ];
/** /**
+5 -87
View File
@@ -7,24 +7,16 @@ namespace Pest\Concerns;
use Closure; use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Collectors;
use Pest\Plugins\Tia\Enums\ReplayType;
use Pest\Plugins\Tia\Recorder;
use Pest\Preset; use Pest\Preset;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\Container;
use Pest\Support\ExceptionTrace; use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection; use Pest\Support\Reflection;
use Pest\Support\Shell; use Pest\Support\Shell;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\IncompleteTest; use PHPUnit\Framework\IncompleteTest;
use PHPUnit\Framework\SkippedTest; use PHPUnit\Framework\SkippedTest;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestCase\ExceptionExpectation;
use PHPUnit\Framework\TestCase\OutputBuffer;
use ReflectionException; use ReflectionException;
use ReflectionFunction; use ReflectionFunction;
use ReflectionParameter; use ReflectionParameter;
@@ -83,17 +75,6 @@ trait Testable
*/ */
public bool $__ran = false; public bool $__ran = false;
/**
* The active replay mode for this test, set in `setUp()` and checked
* in `__runTest()` / `tearDown()` to skip the body and after-each.
*/
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. * The test's test closure.
*/ */
@@ -278,35 +259,8 @@ trait Testable
self::$__latestIssues = $method->issues; self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs; self::$__latestPrs = $method->prs;
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
$replay = ReplayType::fromStatus($status);
if ($replay !== ReplayType::None) {
assert($status !== null);
match ($replay) {
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;
}
$recorder = Container::getInstance()->get(Recorder::class);
assert($recorder instanceof Recorder);
if ($recorder->isActive()) {
$recorder->beginTest($this::class, $this->name(), self::$__filename);
}
parent::setUp(); parent::setUp();
Collectors::armAll($recorder);
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
if ($this->__beforeEach instanceof Closure) { if ($this->__beforeEach instanceof Closure) {
@@ -316,13 +270,6 @@ trait Testable
$this->__callClosure($beforeEach, $arguments); $this->__callClosure($beforeEach, $arguments);
} }
private function __beginReplay(ReplayType $replay, Tia $tia): void
{
$this->__replay = $replay;
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
$this->__ran = true;
}
/** /**
* Initialize test case properties from TestSuite. * Initialize test case properties from TestSuite.
*/ */
@@ -355,12 +302,6 @@ trait Testable
*/ */
protected function tearDown(...$arguments): void protected function tearDown(...$arguments): void
{ {
if ($this->__replay !== ReplayType::None) {
TestSuite::getInstance()->test = null;
return;
}
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename); $afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
if ($this->__afterEach instanceof Closure) { if ($this->__afterEach instanceof Closure) {
@@ -386,16 +327,6 @@ trait Testable
*/ */
private function __runTest(Closure $closure, ...$args): mixed private function __runTest(Closure $closure, ...$args): mixed
{ {
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
$this->expectNotToPerformAssertions();
}
$this->addToAssertionCount($this->__replayAssertions);
return null;
}
$arguments = $this->__resolveTestArguments($args); $arguments = $this->__resolveTestArguments($args);
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments); $this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
@@ -433,15 +364,10 @@ trait Testable
unset($this->{$property}); unset($this->{$property});
} }
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)(); $hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)();
if ($outputBuffer->hasExpectation()) { if ($hasOutputExpectation) {
ob_clean(); ob_clean();
Closure::bind(function (): void {
$this->expectedString = null;
$this->expectedRegularExpression = null;
}, $outputBuffer, OutputBuffer::class)();
} }
$this->setUp(); $this->setUp();
@@ -457,9 +383,7 @@ trait Testable
*/ */
private function __isExpectedException(Throwable $e): bool private function __isExpectedException(Throwable $e): bool
{ {
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)(); $read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)();
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
$expectedClass = $read('expectedException'); $expectedClass = $read('expectedException');
@@ -467,19 +391,13 @@ trait Testable
return $e instanceof $expectedClass; return $e instanceof $expectedClass;
} }
$expectedMessage = $read('expectedMessage'); $expectedMessage = $read('expectedExceptionMessage');
if ($expectedMessage !== null) { if ($expectedMessage !== null) {
return str_contains($e->getMessage(), (string) $expectedMessage); return str_contains($e->getMessage(), (string) $expectedMessage);
} }
$expectedMessageRegex = $read('expectedMessageRegularExpression'); $expectedCode = $read('expectedExceptionCode');
if ($expectedMessageRegex !== null) {
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
}
$expectedCode = $read('expectedCode');
if ($expectedCode !== null) { if ($expectedCode !== null) {
return $e->getCode() === $expectedCode; return $e->getCode() === $expectedCode;
+3 -19
View File
@@ -33,7 +33,7 @@ final readonly class Configuration
*/ */
public function in(string ...$targets): UsesCall public function in(string ...$targets): UsesCall
{ {
return new UsesCall($this->filename, [])->in(...$targets); return (new UsesCall($this->filename, []))->in(...$targets);
} }
/** /**
@@ -60,7 +60,7 @@ final readonly class Configuration
*/ */
public function group(string ...$groups): UsesCall public function group(string ...$groups): UsesCall
{ {
return new UsesCall($this->filename, [])->group(...$groups); return (new UsesCall($this->filename, []))->group(...$groups);
} }
/** /**
@@ -68,7 +68,7 @@ final readonly class Configuration
*/ */
public function only(): void public function only(): void
{ {
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only(); (new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only();
} }
/** /**
@@ -119,22 +119,6 @@ final readonly class Configuration
return new Browser\Configuration; return new Browser\Configuration;
} }
/**
* Gets the evals configuration.
*/
public function evals(): Evals\Configuration // @phpstan-ignore-line
{
return new Evals\Configuration; // @phpstan-ignore-line
}
/**
* Gets the TIA (Test Impact Analysis) configuration.
*/
public function tia(): Plugins\Tia\Configuration
{
return new Plugins\Tia\Configuration;
}
/** /**
* Proxies calls to the uses method. * Proxies calls to the uses method.
* *
+4 -7
View File
@@ -59,15 +59,12 @@ final class Project
/** /**
* Sets the test project to GitLab. * Sets the test project to GitLab.
*/ */
public function gitlab(string $project, string $hostname = 'gitlab.com'): self public function gitlab(string $project): self
{ {
// Simple way to ensure only the host is used $this->issues = "https://gitlab.com/{$project}/issues/%s";
$hostname = parse_url($hostname, PHP_URL_HOST) ?? $hostname; $this->prs = "https://gitlab.com/{$project}/merge_requests/%s";
$this->issues = "https://{$hostname}/{$project}/-/work_items/%s"; $this->assignees = 'https://gitlab.com/%s';
$this->prs = "https://{$hostname}/{$project}/-/merge_requests/%s";
$this->assignees = "https://{$hostname}/%s";
return $this; return $this;
} }
-16
View File
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
/**
* @internal
*/
interface Restarter
{
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void;
}
-54
View File
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Pest\Support\View;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct(
private readonly string $headline,
private readonly string $hint,
private readonly bool $hasAnchor = false,
) {
parent::__construct($headline);
}
public function render(OutputInterface $output): void
{
View::renderUsing($output);
if (! $this->hasAnchor) {
View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
$output->writeln('');
return;
}
$this->renderChild($output, $this->headline);
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
$output->writeln('');
}
public function exitCode(): int
{
return 1;
}
private function renderChild(OutputInterface $output, string $text): void
{
$output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
}
}
-19
View File
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use RuntimeException;
use Throwable;
/**
* @internal
*/
final class DatasetProviderError extends RuntimeException
{
public function __construct(Throwable $previous)
{
parent::__construct($previous->getMessage(), (int) $previous->getCode(), $previous);
}
}
-32
View File
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No affected tests found.',
'',
]);
}
public function exitCode(): int
{
return 0;
}
}
-46
View File
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct(private readonly string $className, string $filename)
{
parent::__construct(sprintf(
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',
$className,
$filename,
));
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires Pest tests.',
'',
sprintf(' Encountered PHPUnit class <fg=yellow>%s</>', $this->className),
sprintf(' in <fg=gray>%s</>.', $this->file),
'',
' Convert it to a Pest test, or run without Tia.',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}
@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class TiaRequiresRepositoryRoot extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct(private readonly string $subdirectoryPrefix)
{
parent::__construct(sprintf(
'Tia mode requires the project root to be the git repository root, but it sits in the subdirectory [%s] of a larger repo.',
$this->subdirectoryPrefix,
));
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires the git repository root.',
'',
sprintf(' This project sits in a subdirectory of a larger repo <fg=yellow>%s</>.', $this->subdirectoryPrefix),
'',
' Give the project its own git repository to use Tia.',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}
+17 -15
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, ...$arguments); var_dump($this->value);
} }
return $this; return $this;
@@ -120,22 +120,16 @@ 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): never public function dd(mixed ...$arguments): void
{ {
if (function_exists('dd')) { if (function_exists('dd')) {
dd($this->value, ...$arguments); dd($this->value, ...$arguments);
} }
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) { var_dump($this->value);
ob_start();
var_dump($this->value, ...$arguments);
$output = ob_get_clean();
throw new ExpectationFailedException($output);
}
var_dump($this->value, ...$arguments);
exit(1); exit(1);
} }
@@ -244,7 +238,7 @@ final class Expectation
if ($callbacks[$index] instanceof Closure) { if ($callbacks[$index] instanceof Closure) {
$callbacks[$index](new self($value), new self($key)); $callbacks[$index](new self($value), new self($key));
} else { } else {
new self($value)->toEqual($callbacks[$index]); (new self($value))->toEqual($callbacks[$index]);
} }
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0; $index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
@@ -921,7 +915,15 @@ final class Expectation
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)), function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) {
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
"to implement '".implode("', '", $interfaces)."'", "to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@@ -1136,8 +1138,8 @@ final class Expectation
$this, $this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) fn (ObjectDescription $object): bool => isset($object->reflectionClass)
&& $object->reflectionClass->isEnum() && $object->reflectionClass->isEnum()
&& new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line && (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
&& (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line && (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
'to be '.$backingType.' backed enum', 'to be '.$backingType.' backed enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
+2 -2
View File
@@ -112,7 +112,7 @@ final class HigherOrderExpectation
* Dynamically calls methods on the class with the given arguments. * Dynamically calls methods on the class with the given arguments.
* *
* @param array<int, mixed> $arguments * @param array<int, mixed> $arguments
* @return self<TOriginalValue, mixed> * @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
*/ */
public function __call(string $name, array $arguments): self public function __call(string $name, array $arguments): self
{ {
@@ -127,7 +127,7 @@ final class HigherOrderExpectation
/** /**
* Accesses properties in the value or in the expectation. * Accesses properties in the value or in the expectation.
* *
* @return self<TOriginalValue, mixed> * @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
*/ */
public function __get(string $name): self public function __get(string $name): self
{ {
+24 -6
View File
@@ -576,7 +576,15 @@ final readonly class OppositeExpectation
return Targeted::make( return Targeted::make(
$original, $original,
fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! isset($object->reflectionClass) || ! in_array($trait, $object->reflectionClass->getTraitNames(), true)), function (ObjectDescription $object) use ($traits): bool {
foreach ($traits as $trait) {
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
return false;
}
}
return true;
},
"not to use traits '".implode("', '", $traits)."'", "not to use traits '".implode("', '", $traits)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@@ -596,7 +604,15 @@ final readonly class OppositeExpectation
return Targeted::make( return Targeted::make(
$original, $original,
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)), function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) {
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
"not to implement '".implode("', '", $interfaces)."'", "not to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@@ -798,11 +814,13 @@ final readonly class OppositeExpectation
$exporter = Exporter::default(); $exporter = Exporter::default();
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
throw new ExpectationFailedException(sprintf( throw new ExpectationFailedException(sprintf(
'Expecting %s not %s %s.', 'Expecting %s not %s %s.',
$exporter->shortenedExport($this->original->value), $toString($this->original->value),
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)), strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
implode(' ', array_map(fn (mixed $argument): string => $exporter->export($argument), $arguments)), implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
)); ));
} }
@@ -834,8 +852,8 @@ final readonly class OppositeExpectation
$original, $original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| ! $object->reflectionClass->isEnum() || ! $object->reflectionClass->isEnum()
|| ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line || ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|| (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line || (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
'not to be '.$backingType.' backed enum', 'not to be '.$backingType.' backed enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
+3 -7
View File
@@ -94,8 +94,7 @@ final class TestCaseFactory
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename); $filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
} }
$realpath = (string) realpath($filename); $filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename)));
$filename = str_replace('\\\\', '\\', addslashes($realpath));
$rootPath = TestSuite::getInstance()->rootPath; $rootPath = TestSuite::getInstance()->rootPath;
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename); $relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
@@ -150,8 +149,6 @@ final class TestCaseFactory
$attributesCode = Attributes::code($this->attributes); $attributesCode = Attributes::code($this->attributes);
$filenameLiteral = var_export($realpath, true);
$methodsCode = implode('', array_map( $methodsCode = implode('', array_map(
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(), fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
$methods $methods
@@ -161,7 +158,6 @@ final class TestCaseFactory
$classCode = <<<PHP $classCode = <<<PHP
namespace $namespace; namespace $namespace;
use Pest\Exceptions\DatasetProviderError as __PestDatasetProviderError;
use Pest\Repositories\DatasetsRepository as __PestDatasets; use Pest\Repositories\DatasetsRepository as __PestDatasets;
use Pest\TestSuite as __PestTestSuite; use Pest\TestSuite as __PestTestSuite;
@@ -170,7 +166,7 @@ final class TestCaseFactory
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN { final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode $traitsCode
public static \$__filename = $filenameLiteral; private static \$__filename = '$filename';
$methodsCode $methodsCode
} }
@@ -201,7 +197,7 @@ final class TestCaseFactory
if ( if (
$method->closure instanceof \Closure && $method->closure instanceof \Closure &&
new \ReflectionFunction($method->closure)->isStatic() (new \ReflectionFunction($method->closure))->isStatic()
) { ) {
throw new TestClosureMustNotBeStatic($method); throw new TestClosureMustNotBeStatic($method);
+1 -9
View File
@@ -235,10 +235,6 @@ final class TestCaseMethodFactory
$attributesCode $attributesCode
public function $methodName(...\$arguments) public function $methodName(...\$arguments)
{ {
if (count(\$arguments) === 1 && \$arguments[0] instanceof __PestDatasetProviderError) {
throw \$arguments[0]->getPrevious() ?? \$arguments[0];
}
return \$this->__runTest( return \$this->__runTest(
\$this->__test, \$this->__test,
...\$arguments, ...\$arguments,
@@ -265,11 +261,7 @@ final class TestCaseMethodFactory
public static function $dataProviderName() public static function $dataProviderName()
{ {
try { return __PestDatasets::get(self::\$__filename, "$methodName");
return __PestDatasets::get(self::\$__filename, "$methodName");
} catch (\Throwable \$throwable) {
return [[new __PestDatasetProviderError(\$throwable)]];
}
} }
EOF; EOF;
+9 -33
View File
@@ -27,13 +27,8 @@ use Whoops\Exception\Inspector;
/** /**
* @internal * @internal
*/ */
final class Kernel final readonly class Kernel
{ {
/**
* Either the kernel is terminated or not.
*/
private bool $terminated = false;
/** /**
* The Kernel bootstrappers. * The Kernel bootstrappers.
* *
@@ -41,8 +36,6 @@ final class Kernel
*/ */
private const array BOOTSTRAPPERS = [ private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class, Bootstrappers\BootOverrides::class,
Bootstrappers\BootPhpUnitConfiguration::class,
Plugins\Tia\Bootstrapper::class,
Bootstrappers\BootSubscribers::class, Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class, Bootstrappers\BootFiles::class,
Bootstrappers\BootView::class, Bootstrappers\BootView::class,
@@ -50,22 +43,15 @@ final class Kernel
Bootstrappers\BootExcludeList::class, Bootstrappers\BootExcludeList::class,
]; ];
/**
* The Kernel restarters — resolved and invoked from `bin/pest`
* before any other Pest class is touched, so the list is exposed
* on the Kernel rather than driven from `bin/pest` directly.
*
* @var array<int, class-string<Contracts\Restarter>>
*/
public const array RESTARTERS = [
Restarters\XdebugRestarter::class,
Restarters\PcovRestarter::class,
];
/** /**
* Creates a new Kernel instance. * Creates a new Kernel instance.
*/ */
public function __construct(private readonly Application $application, private readonly OutputInterface $output) {} public function __construct(
private Application $application,
private OutputInterface $output,
) {
//
}
/** /**
* Boots the Kernel. * Boots the Kernel.
@@ -126,13 +112,9 @@ final class Kernel
$configuration = Registry::get(); $configuration = Registry::get();
$result = Facade::result(); $result = Facade::result();
$result = CallsAddsOutput::execute( return CallsAddsOutput::execute(
Result::exitCode($configuration, $result), Result::exitCode($configuration, $result),
); );
$this->terminate();
return $result;
} }
/** /**
@@ -140,12 +122,6 @@ final class Kernel
*/ */
public function terminate(): void public function terminate(): void
{ {
if ($this->terminated) {
return;
}
$this->terminated = true;
$preBufferOutput = Container::getInstance()->get(KernelDump::class); $preBufferOutput = Container::getInstance()->get(KernelDump::class);
assert($preBufferOutput instanceof KernelDump); assert($preBufferOutput instanceof KernelDump);
@@ -163,7 +139,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_COMPILE_ERROR, E_CORE_ERROR], true)) { if (! in_array($error['type'], [E_ERROR, E_CORE_ERROR], true)) {
return; return;
} }
+2 -10
View File
@@ -4,7 +4,6 @@ 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;
@@ -29,10 +28,6 @@ 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;
@@ -73,10 +68,6 @@ 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(
@@ -116,6 +107,7 @@ 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');
} }
} }
+20 -23
View File
@@ -12,9 +12,12 @@ use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable; use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Test\AfterLastTestMethodErrored; use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored; use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed; use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestSuite\TestSuite; use PHPUnit\Event\TestSuite\TestSuite;
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider; use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
use PHPUnit\Framework\Exception as FrameworkException; use PHPUnit\Framework\Exception as FrameworkException;
@@ -249,29 +252,23 @@ final readonly class Converter
...$result->testMarkedIncompleteEvents(), ...$result->testMarkedIncompleteEvents(),
]; ];
$notPassedTests = []; $numberOfNotPassedTests = count(
array_unique(
array_map(
function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
if ($event instanceof BeforeFirstTestMethodErrored
|| $event instanceof AfterLastTestMethodErrored) {
return $event->testClassName();
}
foreach ($events as $event) { return $this->getTestCaseLocation($event->test());
if ($event instanceof AfterLastTestMethodErrored) { },
// PHPUnit's collector does not count these towards `numberOfTestsRun`... $events
continue; )
} )
if ($event instanceof AfterLastTestMethodFailed) { );
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
continue;
}
if ($event instanceof BeforeFirstTestMethodErrored || $event instanceof BeforeFirstTestMethodFailed) {
$notPassedTests[] = $event->testClassName();
continue; $numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;
}
$notPassedTests[] = $this->getTestCaseLocation($event->test());
}
$numberOfPassedTests = $result->numberOfTestsRun()
- count(array_unique($notPassedTests))
- $result->numberOfTestSkippedByTestSuiteSkippedEvents();
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result); return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
} }
@@ -14,10 +14,6 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
{ {
public function notify(Skipped $event): void public function notify(Skipped $event): void
{ {
if ($event->message() === '__TODO__') {
return; // "todo" tests are reported in the summary, not as ignored tests...
}
$this->logger()->testSkipped($event); $this->logger()->testSkipped($event);
} }
} }
-6
View File
@@ -241,12 +241,6 @@ final class TeamCityLogger
$telemetry->memoryUsageSinceStart(), $telemetry->memoryUsageSinceStart(),
$telemetry->durationSincePrevious(), $telemetry->durationSincePrevious(),
$telemetry->memoryUsageSincePrevious(), $telemetry->memoryUsageSincePrevious(),
$telemetry->userCpuTimeSinceStart(),
$telemetry->systemCpuTimeSinceStart(),
$telemetry->totalCpuTimeSinceStart(),
$telemetry->userCpuTimeSincePrevious(),
$telemetry->systemCpuTimeSincePrevious(),
$telemetry->totalCpuTimeSincePrevious(),
); );
} }
+10 -157
View File
@@ -14,7 +14,6 @@ use InvalidArgumentException;
use JsonSerializable; use JsonSerializable;
use Pest\Exceptions\InvalidExpectationValue; use Pest\Exceptions\InvalidExpectationValue;
use Pest\Matchers\Any; use Pest\Matchers\Any;
use Pest\Plugins\Snapshot;
use Pest\Support\Arr; use Pest\Support\Arr;
use Pest\Support\Exporter; use Pest\Support\Exporter;
use Pest\Support\NullClosure; use Pest\Support\NullClosure;
@@ -852,31 +851,18 @@ final class Expectation
default => InvalidExpectationValue::expected('array|object|string'), default => InvalidExpectationValue::expected('array|object|string'),
}; };
if (! $snapshots->has()) { if ($snapshots->has()) {
[$filename, $content] = $snapshots->get();
Assert::assertSame(
strtr($content, ["\r\n" => "\n", "\r" => "\n"]),
strtr($string, ["\r\n" => "\n", "\r" => "\n"]),
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
);
} else {
$filename = $snapshots->save($string); $filename = $snapshots->save($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]"); TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]");
} else {
[$filename, $content] = $snapshots->get();
$normalizedContent = strtr($content, ["\r\n" => "\n", "\r" => "\n"]);
$normalizedString = strtr($string, ["\r\n" => "\n", "\r" => "\n"]);
if (Snapshot::$updateSnapshots && $normalizedContent !== $normalizedString) {
$snapshots->save($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot updated at [$filename]");
} else {
if (Snapshot::$updateSnapshots) {
TestSuite::getInstance()->registerSnapshotChange("Snapshot unchanged at [$filename]");
}
Assert::assertSame(
$normalizedContent,
$normalizedString,
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
);
}
} }
return $this; return $this;
@@ -936,7 +922,7 @@ final class Expectation
if ($exception instanceof Closure) { if ($exception instanceof Closure) {
$callback = $exception; $callback = $exception;
$parameters = new ReflectionFunction($exception)->getParameters(); $parameters = (new ReflectionFunction($exception))->getParameters();
if (count($parameters) !== 1) { if (count($parameters) !== 1) {
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.'); throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
@@ -954,7 +940,6 @@ 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);
@@ -1142,22 +1127,6 @@ final class Expectation
return $this; return $this;
} }
/**
* Asserts that the value is a ULID.
*
* @return self<TValue>
*/
public function toBeUlid(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue(Str::isUlid($this->value), $message);
return $this;
}
/** /**
* Asserts that the value is between 2 specified values * Asserts that the value is between 2 specified values
* *
@@ -1171,22 +1140,6 @@ final class Expectation
return $this; return $this;
} }
/**
* Asserts that the value is an email address.
*
* @return self<TValue>
*/
public function toBeEmail(string $message = ''): self
{
if ($message === '') {
$message = "Failed asserting that {$this->value} is an email address.";
}
Assert::assertTrue(Str::isEmail((string) $this->value), $message);
return $this;
}
/** /**
* Asserts that the value is a url * Asserts that the value is a url
* *
@@ -1219,104 +1172,4 @@ final class Expectation
return $this; return $this;
} }
/**
* Asserts that the value is an IP address.
*
* @return self<TValue>
*/
public function toBeIpAddress(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_IP), $message);
return $this;
}
/**
* Asserts that the value is a MAC address.
*
* @return self<TValue>
*/
public function toBeMacAddress(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_MAC), $message);
return $this;
}
/**
* Asserts that the value is a hostname.
*
* @return self<TValue>
*/
public function toBeHostname(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME), $message);
return $this;
}
/**
* Asserts that the value is a domain name.
*
* @return self<TValue>
*/
public function toBeDomain(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
$isValid = filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false
&& str_contains($this->value, '.');
Assert::assertTrue($isValid, $message);
return $this;
}
/**
* Asserts that the value is a base64-encoded string.
*
* @return self<TValue>
*/
public function toBeBase64(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
$decoded = base64_decode($this->value, true);
Assert::assertTrue($decoded !== false && base64_encode($decoded) === $this->value, $message);
return $this;
}
/**
* Asserts that the value is a hexadecimal string.
*
* @return self<TValue>
*/
public function toBeHexadecimal(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue(ctype_xdigit($this->value), $message);
return $this;
}
} }
@@ -19,7 +19,7 @@ use PHPStan\Type\Type;
* $expectation, $opposite, $shouldReset) from being incorrectly resolved as * $expectation, $opposite, $shouldReset) from being incorrectly resolved as
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions. * higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
* *
* This extension must be registered BEFORE the pest-plugin-phpstan HigherOrderExpectationTypeExtension. * This extension must be registered BEFORE the peststan HigherOrderExpectationTypeExtension.
* *
* @internal * @internal
*/ */
@@ -37,7 +37,7 @@ final readonly class HigherOrderExpectationTypeExtension implements ExpressionTy
$varType = $scope->getType($expr->var); $varType = $scope->getType($expr->var);
if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) { if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) {
return null; return null;
} }
+3 -1
View File
@@ -53,7 +53,9 @@ final class UsesCall
$this->targets = [$filename]; $this->targets = [$filename];
} }
#[\Deprecated(message: 'Use `pest()->printer()->compact()` instead.')] /**
* @deprecated Use `pest()->printer()->compact()` instead.
*/
public function compact(): self public function compact(): self
{ {
DefaultPrinter::compact(true); DefaultPrinter::compact(true);
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Pest;
function version(): string function version(): string
{ {
return '5.0.0-beta.1'; return '4.4.4';
} }
function testDirectory(string $file = ''): string function testDirectory(string $file = ''): string
+1 -1
View File
@@ -50,7 +50,7 @@ final class Cache implements HandlesArguments
} }
} }
if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasArgument('--do-not-cache-result', $arguments) && ! $this->hasArgument('--cache-result', $arguments)) { if (! $this->hasArgument('--parallel', $arguments)) {
return $this->pushArgument('--cache-result', $arguments); return $this->pushArgument('--cache-result', $arguments);
} }
+3 -6
View File
@@ -50,14 +50,11 @@ trait HandleArguments
*/ */
public function popArgument(string $argument, array $arguments): array public function popArgument(string $argument, array $arguments): array
{ {
$key = array_search($argument, $arguments, true); $arguments = array_flip($arguments);
while ($key !== false) { unset($arguments[$argument]);
unset($arguments[$key]);
$key = array_search($argument, $arguments, true);
}
return array_values($arguments); return array_values(array_flip($arguments));
} }
/** /**
+3 -3
View File
@@ -17,8 +17,6 @@ use Symfony\Component\Console\Output\OutputInterface;
*/ */
final class Coverage implements AddsOutput, HandlesArguments final class Coverage implements AddsOutput, HandlesArguments
{ {
use Concerns\HandleArguments;
private const string COVERAGE_OPTION = 'coverage'; private const string COVERAGE_OPTION = 'coverage';
private const string MIN_OPTION = 'min'; private const string MIN_OPTION = 'min';
@@ -79,9 +77,11 @@ final class Coverage implements AddsOutput, HandlesArguments
return false; return false;
}))]; }))];
$originals = array_flip($originals);
foreach ($arguments as $argument) { foreach ($arguments as $argument) {
$originals = $this->popArgument($argument, $originals); unset($originals[$argument]);
} }
$originals = array_flip($originals);
$inputs = []; $inputs = [];
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE); $inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
-4
View File
@@ -123,10 +123,6 @@ final readonly class Help implements HandlesArguments
'arg' => '--update-snapshots', 'arg' => '--update-snapshots',
'desc' => 'Update snapshots for tests using the "toMatchSnapshot" expectation', 'desc' => 'Update snapshots for tests using the "toMatchSnapshot" expectation',
], ],
[
'arg' => '--update-shards',
'desc' => 'Update shards.json with test timing data for time-balanced sharding',
],
], ...$content['Execution']]; ], ...$content['Execution']];
$content['Selection'] = [[ $content['Selection'] = [[
+7 -1
View File
@@ -178,7 +178,13 @@ final class Parallel implements HandlesArguments
{ {
$arguments = new ArgvInput; $arguments = new ArgvInput;
return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument)); foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) {
if ($arguments->hasParameterOption($unsupportedArgument)) {
return true;
}
}
return false;
} }
/** /**
@@ -120,6 +120,10 @@ final class ResultPrinter
$unexpectedOutput = $this->tail($outputFile); $unexpectedOutput = $this->tail($outputFile);
if ($unexpectedOutput !== '') { if ($unexpectedOutput !== '') {
if (preg_match('/^T+$/', $unexpectedOutput) > 0) {
return;
}
$this->output->write($unexpectedOutput); $this->output->write($unexpectedOutput);
} }
+11 -122
View File
@@ -7,11 +7,11 @@ namespace Pest\Plugins\Parallel\Paratest;
use const DIRECTORY_SEPARATOR; use const DIRECTORY_SEPARATOR;
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection; use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
use ParaTest\Coverage\CoverageMerger;
use ParaTest\JUnit\LogMerger; use ParaTest\JUnit\LogMerger;
use ParaTest\JUnit\Writer; use ParaTest\JUnit\Writer;
use ParaTest\Options; use ParaTest\Options;
use ParaTest\RunnerInterface; use ParaTest\RunnerInterface;
use ParaTest\WrapperRunner\MissingResultsException;
use ParaTest\WrapperRunner\SuiteLoader; use ParaTest\WrapperRunner\SuiteLoader;
use ParaTest\WrapperRunner\WrapperWorker; use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Result; use Pest\Result;
@@ -25,17 +25,11 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TestRunner\TestResult\TestResult; use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry; use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\Util\ExcludeList; use PHPUnit\Util\ExcludeList;
use ReflectionProperty;
use SebastianBergmann\CodeCoverage\Node\Builder;
use SebastianBergmann\CodeCoverage\Serialization\Merger;
use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingSourceAnalyser;
use SebastianBergmann\Timer\Timer; use SebastianBergmann\Timer\Timer;
use SplFileInfo; use SplFileInfo;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\PhpExecutableFinder;
use function array_filter;
use function array_merge; use function array_merge;
use function array_merge_recursive; use function array_merge_recursive;
use function array_shift; use function array_shift;
@@ -43,8 +37,6 @@ use function assert;
use function count; use function count;
use function dirname; use function dirname;
use function file_get_contents; use function file_get_contents;
use function filesize;
use function is_file;
use function max; use function max;
use function realpath; use function realpath;
use function str_starts_with; use function str_starts_with;
@@ -57,14 +49,14 @@ use function usleep;
*/ */
final class WrapperRunner implements RunnerInterface final class WrapperRunner implements RunnerInterface
{ {
/**
* The time to sleep between cycles.
*/
/** /**
* The merged test result from the parallel run. * The merged test result from the parallel run.
*/ */
public static ?TestResult $result = null; public static ?TestResult $result = null;
/**
* The time to sleep between cycles.
*/
private const int CYCLE_SLEEP = 10000; private const int CYCLE_SLEEP = 10000;
/** /**
@@ -91,18 +83,6 @@ final class WrapperRunner implements RunnerInterface
/** @var array<int,int> */ /** @var array<int,int> */
private array $batches = []; private array $batches = [];
/** @var array<non-empty-string,true> */
private array $requiredTestResultFiles = [];
/** @var array<non-empty-string,true> */
private array $requiredCoverageFiles = [];
/** @var list<SplFileInfo> */
private array $statusFiles = [];
/** @var list<SplFileInfo> */
private array $progressFiles = [];
/** @var list<SplFileInfo> */ /** @var list<SplFileInfo> */
private array $unexpectedOutputFiles = []; private array $unexpectedOutputFiles = [];
@@ -166,6 +146,7 @@ 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();
@@ -232,7 +213,7 @@ final class WrapperRunner implements RunnerInterface
if ( if (
$this->exitcode > 0 $this->exitcode > 0
&& $this->options->configuration->stopOnFailureThreshold() > 0 && $this->options->configuration->stopOnFailure()
) { ) {
$this->pending = []; $this->pending = [];
} elseif (($pending = array_shift($this->pending)) !== null) { } elseif (($pending = array_shift($this->pending)) !== null) {
@@ -247,18 +228,6 @@ final class WrapperRunner implements RunnerInterface
private function flushWorker(WrapperWorker $worker): void private function flushWorker(WrapperWorker $worker): void
{ {
if ($worker->hasExecutedTests()) {
$testResultFile = $worker->testResultFile->getPathname();
if ($testResultFile !== '') {
$this->requiredTestResultFiles[$testResultFile] = true;
}
if (isset($worker->coverageFile) && $worker->coverageFile->getPathname() !== '') {
$this->requiredCoverageFiles[$worker->coverageFile->getPathname()] = true;
}
}
$this->exitcode = max($this->exitcode, $worker->getExitCode()); $this->exitcode = max($this->exitcode, $worker->getExitCode());
$this->printer->printFeedback( $this->printer->printFeedback(
$worker->progressFile, $worker->progressFile,
@@ -306,15 +275,10 @@ final class WrapperRunner implements RunnerInterface
$worker->start(); $worker->start();
$this->batches[$token] = 0; $this->batches[$token] = 0;
$this->statusFiles[] = $worker->statusFile; $this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
$this->progressFiles[] = $worker->progressFile;
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile; $this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
$this->testResultFiles[] = $worker->testResultFile; $this->testResultFiles[] = $worker->testResultFile;
if (isset($worker->resultCacheFile)) {
$this->resultCacheFiles[] = $worker->resultCacheFile;
}
if (isset($worker->junitFile)) { if (isset($worker->junitFile)) {
$this->junitFiles[] = $worker->junitFile; $this->junitFiles[] = $worker->junitFile;
} }
@@ -347,20 +311,6 @@ final class WrapperRunner implements RunnerInterface
private function complete(TestResult $testResultSum): int private function complete(TestResult $testResultSum): int
{ {
$missingTestResultFiles = [];
foreach ($this->requiredTestResultFiles as $filePath => $true) {
if (is_file($filePath)) {
continue;
}
$missingTestResultFiles[] = $filePath;
}
if ($missingTestResultFiles !== []) {
throw MissingResultsException::create($missingTestResultFiles, 'test_result');
}
foreach ($this->testResultFiles as $testResultFile) { foreach ($this->testResultFiles as $testResultFile) {
if (! $testResultFile->isFile()) { if (! $testResultFile->isFile()) {
continue; continue;
@@ -395,20 +345,6 @@ final class WrapperRunner implements RunnerInterface
// @phpstan-ignore-next-line // @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()), array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
// @phpstan-ignore-next-line // @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssueDeprecationEvents(), $testResult->testRunnerTriggeredIssueDeprecationEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssueErrorEvents(), $testResult->testRunnerTriggeredIssueErrorEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssueNoticeEvents(), $testResult->testRunnerTriggeredIssueNoticeEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(), $testResult->testRunnerTriggeredIssuePhpDeprecationEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(), $testResult->testRunnerTriggeredIssuePhpNoticeEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpWarningEvents(), $testResult->testRunnerTriggeredIssuePhpWarningEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssueWarningEvents(), $testResult->testRunnerTriggeredIssueWarningEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->errors(), $testResult->errors()), array_merge_recursive($testResultSum->errors(), $testResult->errors()),
// @phpstan-ignore-next-line // @phpstan-ignore-next-line
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()), array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
@@ -444,15 +380,8 @@ final class WrapperRunner implements RunnerInterface
$testResultSum->testRunnerTriggeredNoticeEvents(), $testResultSum->testRunnerTriggeredNoticeEvents(),
array_values(array_filter( array_values(array_filter(
$testResultSum->testRunnerTriggeredWarningEvents(), $testResultSum->testRunnerTriggeredWarningEvents(),
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found in class') fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')
)), )),
$testResultSum->testRunnerTriggeredIssueDeprecationEvents(),
$testResultSum->testRunnerTriggeredIssueErrorEvents(),
$testResultSum->testRunnerTriggeredIssueNoticeEvents(),
$testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(),
$testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(),
$testResultSum->testRunnerTriggeredIssuePhpWarningEvents(),
$testResultSum->testRunnerTriggeredIssueWarningEvents(),
$testResultSum->errors(), $testResultSum->errors(),
$testResultSum->deprecations(), $testResultSum->deprecations(),
$testResultSum->notices(), $testResultSum->notices(),
@@ -488,11 +417,8 @@ final class WrapperRunner implements RunnerInterface
$exitcode = Result::exitCode($this->options->configuration, $testResultSum); $exitcode = Result::exitCode($this->options->configuration, $testResultSum);
$this->clearFiles($this->statusFiles);
$this->clearFiles($this->progressFiles);
$this->clearFiles($this->unexpectedOutputFiles); $this->clearFiles($this->unexpectedOutputFiles);
$this->clearFiles($this->testResultFiles); $this->clearFiles($this->testResultFiles);
$this->clearFiles($this->resultCacheFiles);
$this->clearFiles($this->coverageFiles); $this->clearFiles($this->coverageFiles);
$this->clearFiles($this->junitFiles); $this->clearFiles($this->junitFiles);
$this->clearFiles($this->teamcityFiles); $this->clearFiles($this->teamcityFiles);
@@ -507,20 +433,6 @@ final class WrapperRunner implements RunnerInterface
return; return;
} }
$missingCoverageFiles = [];
foreach ($this->requiredCoverageFiles as $filePath => $true) {
if (is_file($filePath) && filesize($filePath) !== 0) {
continue;
}
$missingCoverageFiles[] = $filePath;
}
if ($missingCoverageFiles !== []) {
throw MissingResultsException::create($missingCoverageFiles, 'coverage');
}
$coverageManager = new CodeCoverage; $coverageManager = new CodeCoverage;
$coverageManager->init( $coverageManager->init(
$this->options->configuration, $this->options->configuration,
@@ -536,33 +448,10 @@ final class WrapperRunner implements RunnerInterface
return; return;
} }
$coverageFiles = []; $coverageMerger = new CoverageMerger($coverageManager->codeCoverage());
foreach ($this->coverageFiles as $fileInfo) { foreach ($this->coverageFiles as $coverageFile) {
$realPath = $fileInfo->getRealPath(); $coverageMerger->addCoverageFromFile($coverageFile);
if ($realPath !== false && $realPath !== '') {
$coverageFiles[] = $realPath;
}
} }
$serializedCoverage = (new Merger)->merge($coverageFiles);
$report = (new Builder(new FileAnalyser(new ParsingSourceAnalyser, false, false)))->build(
$serializedCoverage['codeCoverage'],
$serializedCoverage['testResults'],
$serializedCoverage['basePath'],
);
$codeCoverage = $coverageManager->codeCoverage();
$codeCoverage->excludeUncoveredFiles();
$mergedData = $serializedCoverage['codeCoverage'];
$basePath = $serializedCoverage['basePath'];
if ($basePath !== '') {
foreach ($mergedData->coveredFiles() as $relativePath) {
$mergedData->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
}
}
$codeCoverage->setData($mergedData);
$codeCoverage->setTests($serializedCoverage['testResults']);
(new ReflectionProperty(\SebastianBergmann\CodeCoverage\CodeCoverage::class, 'cachedReport'))->setValue($codeCoverage, $report);
$coverageManager->generateReports( $coverageManager->generateReports(
$this->printer->printer, $this->printer->printer,
@@ -7,7 +7,6 @@ namespace Pest\Plugins\Parallel\Support;
use NunoMaduro\Collision\Adapters\Phpunit\State; use NunoMaduro\Collision\Adapters\Phpunit\State;
use NunoMaduro\Collision\Adapters\Phpunit\Style; use NunoMaduro\Collision\Adapters\Phpunit\Style;
use ParaTest\Options; use ParaTest\Options;
use PHPUnit\Event\Telemetry\CpuTime;
use PHPUnit\Event\Telemetry\GarbageCollectorStatus; use PHPUnit\Event\Telemetry\GarbageCollectorStatus;
use PHPUnit\Event\Telemetry\HRTime; use PHPUnit\Event\Telemetry\HRTime;
use PHPUnit\Event\Telemetry\Info; use PHPUnit\Event\Telemetry\Info;
@@ -148,20 +147,11 @@ final class CompactPrinter
MemoryUsage::fromBytes(0), MemoryUsage::fromBytes(0),
MemoryUsage::fromBytes(0), MemoryUsage::fromBytes(0),
$garbageCollectorStatus, $garbageCollectorStatus,
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
), ),
$telemetryDuration, $telemetryDuration,
MemoryUsage::fromBytes(0), MemoryUsage::fromBytes(0),
\PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0), \PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0),
MemoryUsage::fromBytes(0), MemoryUsage::fromBytes(0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
); );
$this->style->writeRecap($state, $telemetry, $testResult); $this->style->writeRecap($state, $telemetry, $testResult);
+14 -460
View File
@@ -6,13 +6,7 @@ namespace Pest\Plugins;
use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\InvalidOption; use Pest\Exceptions\InvalidOption;
use Pest\Subscribers\EnsureShardTimingFinished;
use Pest\Subscribers\EnsureShardTimingsAreCollected;
use Pest\Subscribers\EnsureShardTimingStarted;
use Pest\TestSuite;
use PHPUnit\Event;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@@ -21,19 +15,12 @@ use Symfony\Component\Process\Process;
/** /**
* @internal * @internal
*/ */
final class Shard implements AddsOutput, HandlesArguments, Terminable final class Shard implements AddsOutput, HandlesArguments
{ {
use Concerns\HandleArguments; use Concerns\HandleArguments;
private const string SHARD_OPTION = 'shard'; private const string SHARD_OPTION = 'shard';
/**
* The maximum length allowed for the filter argument.
* While ARG_MAX can be 2MB, individual arguments are often limited to 128KB (MAX_ARG_STRLEN).
* Practical limits in CI environments (like Docker or pipeline runners) can be even lower.
*/
private const int MAX_FILTER_LENGTH = 32768;
/** /**
* The shard index and total number of shards. * The shard index and total number of shards.
* *
@@ -46,40 +33,6 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/ */
private static ?array $shard = null; private static ?array $shard = null;
/**
* Whether to update the shards.json file.
*/
private static bool $updateShards = false;
/**
* Whether time-balanced sharding was used.
*/
private static bool $timeBalanced = false;
/**
* Whether the shards.json file is outdated.
*/
private static bool $shardsOutdated = false;
/**
* Whether the test suite passed.
*/
private static bool $passed = false;
/**
* Collected timings from workers or subscribers.
*
* @var array<string, float>|null
*/
private static ?array $collectedTimings = null;
/**
* The canonical list of test classes from --list-tests.
*
* @var list<string>|null
*/
private static ?array $knownTests = null;
/** /**
* Creates a new Plugin instance. * Creates a new Plugin instance.
*/ */
@@ -94,19 +47,6 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/ */
public function handleArguments(array $arguments): array public function handleArguments(array $arguments): array
{ {
if ($this->hasArgument('--update-shards', $arguments)) {
return $this->handleUpdateShards($arguments);
}
if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SHARDS') === true) {
self::$updateShards = true;
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
return $arguments;
}
if (! $this->hasArgument('--shard', $arguments)) { if (! $this->hasArgument('--shard', $arguments)) {
return $arguments; return $arguments;
} }
@@ -123,25 +63,7 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
/** @phpstan-ignore-next-line */ /** @phpstan-ignore-next-line */
$tests = $this->allTests($arguments); $tests = $this->allTests($arguments);
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
$timings = $this->loadShardsFile();
if ($timings !== null) {
$knownTests = array_values(array_filter($tests, fn (string $test): bool => isset($timings[$test])));
$newTests = array_values(array_diff($tests, $knownTests));
$partitions = $this->partitionByTime($knownTests, $timings, $total);
foreach ($newTests as $i => $test) {
$partitions[$i % $total][] = $test;
}
$testsToRun = $partitions[$index - 1] ?? [];
self::$timeBalanced = true;
self::$shardsOutdated = $newTests !== [];
} else {
$isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
}
self::$shard = [ self::$shard = [
'index' => $index, 'index' => $index,
@@ -150,45 +72,7 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
'testsCount' => count($tests), 'testsCount' => count($tests),
]; ];
if ($testsToRun === []) { return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
return $arguments;
}
$filter = $this->buildFilterArgument($testsToRun);
$this->ensureFilterLengthIsSafe($filter);
return [...$arguments, '--filter', $filter];
}
/**
* Handles the --update-shards argument.
*
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function handleUpdateShards(array $arguments): array
{
if ($this->hasArgument('--shard', $arguments)) {
throw new InvalidOption('The [--update-shards] option cannot be combined with [--shard].');
}
$arguments = $this->popArgument('--update-shards', $arguments);
self::$updateShards = true;
/** @phpstan-ignore-next-line */
self::$knownTests = $this->allTests($arguments);
if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) {
Parallel::setGlobal('UPDATE_SHARDS', true);
Parallel::setGlobal('SHARD_RUN_ID', uniqid('pest-shard-', true));
} else {
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
}
return $arguments;
} }
/** /**
@@ -199,14 +83,15 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/ */
private function allTests(array $arguments): array private function allTests(array $arguments): array
{ {
$command = $this->buildListTestsCommand( $output = (new Process([
$arguments, 'php',
TestSuite::getInstance()->testPath, ...$this->removeParallelArguments($arguments),
); '--list-tests',
]))->mustRun()->getOutput();
$output = new Process($command)->setTimeout(120)->mustRun()->getOutput(); preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
return $this->parseListTestsOutput($output); return array_values(array_unique($matches[1]));
} }
/** /**
@@ -215,97 +100,15 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/ */
private function removeParallelArguments(array $arguments): array private function removeParallelArguments(array $arguments): array
{ {
return array_values(array_filter( return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
$arguments,
fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true)
&& ! str_starts_with($argument, '--processes'),
));
}
/**
* Builds the subprocess command used to enumerate tests via `--list-tests`.
*
* @param list<string> $arguments
* @return list<string>
*/
private function buildListTestsCommand(array $arguments, string $testPath): array
{
$filtered = $this->removeParallelArguments($arguments);
return ['php', ...$filtered, '--test-directory='.$testPath, '--list-tests'];
}
/**
* Parses `--list-tests` output into a unique list of test class FQCNs.
*
* @return list<string>
*/
private function parseListTestsOutput(string $output): array
{
preg_match_all('/ - (?:P\\\\)?([A-Za-z_]\w*(?:\\\\[A-Za-z_]\w*)*)::/', $output, $matches);
return array_values(array_unique($matches[1]));
} }
/** /**
* Builds the filter argument for the given tests to run. * Builds the filter argument for the given tests to run.
*
* @param array<int, string> $testsToRun
*/ */
private function buildFilterArgument(array $testsToRun): string private function buildFilterArgument(mixed $testsToRun): string
{ {
if ($testsToRun === []) { return addslashes(implode('|', $testsToRun));
return '';
}
/** @var array<string, mixed> $tree */
$tree = [];
foreach ($testsToRun as $class) {
$parts = explode('\\', $class);
$current = &$tree;
foreach ($parts as $part) {
if (! isset($current[$part])) {
$current[$part] = [];
}
$current = &$current[$part];
}
}
$buildRegex = function (array $tree) use (&$buildRegex): string {
$parts = [];
foreach ($tree as $key => $sub) {
$subRegex = $buildRegex($sub);
if ($subRegex === '') {
$parts[] = preg_quote($key, '/');
} else {
$parts[] = preg_quote($key, '/').'\\\\'.(count($sub) > 1 ? '('.$subRegex.')' : $subRegex);
}
}
return implode('|', $parts);
};
return $buildRegex($tree);
}
/**
* Ensures that the filter length is safe for the current environment.
*
* @throws InvalidOption
*/
private function ensureFilterLengthIsSafe(string $filter): void
{
$maxLength = (int) (getenv('PEST_SHARD_MAX_FILTER_LENGTH') ?: self::MAX_FILTER_LENGTH);
if (strlen($filter) > $maxLength) {
throw new InvalidOption(sprintf(
'The generated filter for this shard is too long (%d characters). '.
'This can cause issues with some environments (limit is %d characters). '.
'Please increase the number of shards (e.g., use 1/4 instead of 1/2) to reduce the filter length.',
strlen($filter),
$maxLength
));
}
} }
/** /**
@@ -313,22 +116,6 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/ */
public function addOutput(int $exitCode): int public function addOutput(int $exitCode): int
{ {
self::$passed = $exitCode === 0;
if (self::$updateShards && self::$passed && ! Parallel::isWorker()) {
self::$collectedTimings = $this->collectTimings();
$count = self::$knownTests !== null
? count(array_intersect_key(self::$collectedTimings, array_flip(self::$knownTests)))
: count(self::$collectedTimings);
$this->output->writeln(sprintf(
' <fg=gray>Shards:</> <fg=default>shards.json updated with timings for %d test class%s.</>',
$count,
$count === 1 ? '' : 'es',
));
}
if (self::$shard === null) { if (self::$shard === null) {
return $exitCode; return $exitCode;
} }
@@ -341,250 +128,17 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
] = self::$shard; ] = self::$shard;
$this->output->writeln(sprintf( $this->output->writeln(sprintf(
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d%s.', ' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d.',
$index, $index,
$total, $total,
$testsRan, $testsRan,
$testsRan === 1 ? '' : 's', $testsRan === 1 ? '' : 's',
$testsCount, $testsCount,
self::$timeBalanced ? ' <fg=gray>(time-balanced)</>' : '',
)); ));
if (self::$shardsOutdated) {
$this->output->writeln(' <fg=yellow;options=bold>WARN</> <fg=default>The [tests/.pest/shards.json] file is out of date. Run [--update-shards] to update it.</>');
}
return $exitCode; return $exitCode;
} }
/**
* Terminates the plugin.
*/
public function terminate(): void
{
if (! self::$updateShards) {
return;
}
if (Parallel::isWorker()) {
$this->writeWorkerTimings();
return;
}
if (! self::$passed) {
return;
}
$timings = self::$collectedTimings ?? $this->collectTimings();
if ($timings === []) {
return;
}
$this->writeTimings($timings);
}
/**
* Collects timings from subscribers or worker temp files.
*
* @return array<string, float>
*/
private function collectTimings(): array
{
$runId = Parallel::getGlobal('SHARD_RUN_ID');
if (is_string($runId)) {
return $this->readWorkerTimings($runId);
}
return EnsureShardTimingsAreCollected::timings();
}
/**
* Writes the current worker's timing data to a temp file.
*/
private function writeWorkerTimings(): void
{
$timings = EnsureShardTimingsAreCollected::timings();
if ($timings === []) {
return;
}
$runId = Parallel::getGlobal('SHARD_RUN_ID');
if (! is_string($runId)) {
return;
}
$path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-'.getmypid().'.json';
file_put_contents($path, json_encode($timings, JSON_THROW_ON_ERROR));
}
/**
* Reads and merges timing data from all worker temp files.
*
* @return array<string, float>
*/
private function readWorkerTimings(string $runId): array
{
$pattern = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-*.json';
$files = glob($pattern);
if ($files === false || $files === []) {
return [];
}
$merged = [];
foreach ($files as $file) {
$contents = file_get_contents($file);
if ($contents === false) {
continue;
}
$timings = json_decode($contents, true);
if (is_array($timings)) {
$merged = array_merge($merged, $timings);
}
unlink($file);
}
return $merged;
}
/**
* Returns the path to shards.json.
*/
private function shardsPath(): string
{
$testSuite = TestSuite::getInstance();
return implode(DIRECTORY_SEPARATOR, [$testSuite->rootPath, $testSuite->testPath, '.pest', 'shards.json']);
}
/**
* Loads the timings from shards.json.
*
* @return array<string, float>|null
*/
private function loadShardsFile(): ?array
{
$path = $this->shardsPath();
if (! file_exists($path)) {
return null;
}
$contents = file_get_contents($path);
if ($contents === false) {
throw new InvalidOption('The [tests/.pest/shards.json] file could not be read. Delete it or run [--update-shards] to regenerate.');
}
$data = json_decode($contents, true);
if (! is_array($data) || ! isset($data['timings']) || ! is_array($data['timings'])) {
throw new InvalidOption('The [tests/.pest/shards.json] file is corrupted. Delete it or run [--update-shards] to regenerate.');
}
return $data['timings'];
}
/**
* Partitions tests across shards using the LPT (Longest Processing Time) algorithm.
*
* @param list<string> $tests
* @param array<string, float> $timings
* @return list<list<string>>
*/
private function partitionByTime(array $tests, array $timings, int $total): array
{
$knownTimings = array_filter(
array_map(fn (string $test): ?float => $timings[$test] ?? null, $tests),
fn (?float $t): bool => $t !== null,
);
$median = $knownTimings !== [] ? $this->median(array_values($knownTimings)) : 1.0;
$testsWithTimings = array_map(
fn (string $test): array => ['test' => $test, 'time' => $timings[$test] ?? $median],
$tests,
);
usort($testsWithTimings, fn (array $a, array $b): int => $b['time'] <=> $a['time']);
/** @var list<list<string>> */
$bins = array_fill(0, $total, []);
/** @var non-empty-list<float> */
$binTimes = array_fill(0, $total, 0.0);
foreach ($testsWithTimings as $item) {
$minIndex = array_search(min($binTimes), $binTimes, strict: true);
assert(is_int($minIndex));
$bins[$minIndex][] = $item['test'];
$binTimes[$minIndex] += $item['time'];
}
return $bins;
}
/**
* Calculates the median of an array of floats.
*
* @param list<float> $values
*/
private function median(array $values): float
{
sort($values);
$count = count($values);
$middle = (int) floor($count / 2);
if ($count % 2 === 0) {
return ($values[$middle - 1] + $values[$middle]) / 2;
}
return $values[$middle];
}
/**
* Writes the timings to shards.json.
*
* @param array<string, float> $timings
*/
private function writeTimings(array $timings): void
{
$path = $this->shardsPath();
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
if (self::$knownTests !== null) {
$knownSet = array_flip(self::$knownTests);
$timings = array_intersect_key($timings, $knownSet);
}
ksort($timings);
$canonical = self::$knownTests ?? array_keys($timings);
sort($canonical);
file_put_contents($path, json_encode([
'timings' => $timings,
'checksum' => md5(implode("\n", $canonical)),
'updated_at' => date('c'),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
}
/** /**
* Returns the shard information. * Returns the shard information.
* *
+4 -98
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Plugins; namespace Pest\Plugins;
use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Exceptions\InvalidOption;
use Pest\TestSuite; use Pest\TestSuite;
/** /**
@@ -14,116 +15,21 @@ final class Snapshot implements HandlesArguments
{ {
use Concerns\HandleArguments; use Concerns\HandleArguments;
/**
* Whether snapshots should be updated on this run.
*/
public static bool $updateSnapshots = false;
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
public function handleArguments(array $arguments): array public function handleArguments(array $arguments): array
{ {
if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SNAPSHOTS') === true) {
self::$updateSnapshots = true;
return $arguments;
}
if (! $this->hasArgument('--update-snapshots', $arguments)) { if (! $this->hasArgument('--update-snapshots', $arguments)) {
return $arguments; return $arguments;
} }
self::$updateSnapshots = true; if ($this->hasArgument('--parallel', $arguments)) {
throw new InvalidOption('The [--update-snapshots] option is not supported when running in parallel.');
if ($this->isFullRun($arguments)) {
TestSuite::getInstance()->snapshots->flush();
} }
if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) { TestSuite::getInstance()->snapshots->flush();
Parallel::setGlobal('UPDATE_SNAPSHOTS', true);
}
return $this->popArgument('--update-snapshots', $arguments); return $this->popArgument('--update-snapshots', $arguments);
} }
/**
* Options that take a value as the next argument (rather than via "=value").
*
* @var list<string>
*/
private const array FLAGS_WITH_VALUES = [
'--filter',
'--group',
'--exclude-group',
'--test-suffix',
'--covers',
'--uses',
'--cache-directory',
'--cache-result-file',
'--configuration',
'--colors',
'--test-directory',
'--bootstrap',
'--order-by',
'--random-order-seed',
'--log-junit',
'--log-teamcity',
'--log-events-text',
'--log-events-verbose-text',
'--coverage-clover',
'--coverage-cobertura',
'--coverage-crap4j',
'--coverage-html',
'--coverage-php',
'--coverage-text',
'--coverage-xml',
'--assignee',
'--issue',
'--ticket',
'--pr',
'--pull-request',
'--retry',
'--shard',
'--repeat',
];
/**
* Determines whether the command targets the entire suite (no filter, no path).
*
* @param array<int, string> $arguments
*/
private function isFullRun(array $arguments): bool
{
if ($this->hasArgument('--filter', $arguments)) {
return false;
}
$tokens = array_slice($arguments, 1);
$skipNext = false;
foreach ($tokens as $arg) {
if ($skipNext) {
$skipNext = false;
continue;
}
if ($arg === '') {
continue;
}
if ($arg[0] === '-') {
if (in_array($arg, self::FLAGS_WITH_VALUES, true)) {
$skipNext = true;
}
continue;
}
return false;
}
return true;
}
} }
-1855
View File
File diff suppressed because it is too large Load Diff
-621
View File
@@ -1,621 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Exceptions\BaselineFetchFailed;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final readonly class BaselineSync
{
private const string WORKFLOW_FILE = 'tia-baseline.yml';
private const string ARTIFACT_NAME = 'pest-tia-baseline';
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
private const string DOWNLOAD_CACHE_DIR = 'artifacts';
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
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,
) {}
private function renderBadge(string $type, string $content): void
{
View::render('components.badge', ['type' => $type, 'content' => $content]);
}
private function renderChild(string $text): void
{
$this->output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
}
public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
{
$repo = $this->detectGitHubRepo($projectRoot);
if ($repo === null) {
return false;
}
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
$this->renderBadge('WARN', sprintf(
'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
$this->formatDuration($remaining),
));
return false;
}
$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();
}
return false;
}
if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) {
return false;
}
if ($payload['coverage'] !== null) {
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
}
$this->clearCooldown();
return true;
}
private function cooldownRemaining(): ?int
{
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) {
return null;
}
$remaining = $decoded['until'] - time();
return $remaining > 0 ? $remaining : null;
}
private function startCooldown(): void
{
$this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([
'until' => time() + self::FETCH_COOLDOWN_SECONDS,
]));
}
private function clearCooldown(): void
{
$this->state->delete(Tia::KEY_FETCH_COOLDOWN);
}
private function formatDuration(int $seconds): string
{
if ($seconds >= 3600) {
return (int) round($seconds / 3600).'h';
}
if ($seconds >= 60) {
return (int) round($seconds / 60).'m';
}
return $seconds.'s';
}
private function emitPublishInstructions(): void
{
if ($this->isCi()) {
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
return;
}
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
}
private function isCi(): bool
{
return getenv('GITHUB_ACTIONS') === 'true'
|| getenv('GITLAB_CI') === 'true'
|| getenv('CIRCLECI') === 'true';
}
private function detectGitHubRepo(string $projectRoot): ?string
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
if (! is_file($gitConfig)) {
return null;
}
$content = @file_get_contents($gitConfig);
if ($content === false) {
return null;
}
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
return null;
}
$url = $match[1];
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
return $m[1];
}
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return $m[1];
}
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return $m[1];
}
return null;
}
/**
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
*/
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
{
$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.',
'Install it from https://cli.github.com.',
$hasAnchor,
));
}
if (! $this->ghAuthenticated()) {
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
'Run `gh auth login` and retry.',
$hasAnchor,
));
}
}
/**
* @return array{success: bool, failureKind: ?string}
*/
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
{
$artifactSize = $this->artifactSize($repo, $runId);
$this->output->writeln('');
$this->renderChild($artifactSize !== null
? sprintf(
'Downloading TIA baseline (%s) from %s…',
$this->formatSize($artifactSize),
$repo,
)
: sprintf(
'Downloading TIA baseline from %s…',
$repo,
));
$process = new Process([
'gh', 'run', 'download', $runId,
'-R', $repo,
'-n', self::ARTIFACT_NAME,
'-D', $runCacheDir,
]);
$process->setTimeout(900.0);
$process->start();
$startedAt = microtime(true);
$tick = 0;
while ($process->isRunning()) {
$this->renderDownloadProgress($startedAt, $tick++);
usleep(120_000);
}
$process->wait();
$this->clearProgressLine();
if ($process->isSuccessful()) {
return ['success' => true, 'failureKind' => null];
}
$this->cleanup($runCacheDir);
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
$this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Baseline download failed — %s',
$diagnosis['message'],
));
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) {
$this->cleanup($runCacheDir);
Panic::with(new BaselineFetchFailed(
'Baseline downloaded but the artifact is missing expected files (graph.json).',
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
$hasAnchor,
));
}
return $payload;
}
private function artifactSize(string $repo, string $runId): ?int
{
$process = new Process([
'gh', 'api',
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
'--jq', sprintf(
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
self::ARTIFACT_NAME,
),
]);
$process->setTimeout(30.0);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$size = trim($process->getOutput());
return is_numeric($size) ? (int) $size : null;
}
private function renderDownloadProgress(float $startedAt, int $tick): void
{
static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
$elapsed = max(0.0, microtime(true) - $startedAt);
$frame = $frames[$tick % count($frames)];
$this->output->write(sprintf(
"\r\033[K <fg=gray>%s %.1fs elapsed</>",
$frame,
$elapsed,
));
}
private function clearProgressLine(): void
{
$this->output->write("\r\033[K");
}
private function dirSize(string $dir): int
{
if (! is_dir($dir)) {
return 0;
}
$total = 0;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
);
/** @var \SplFileInfo $entry */
foreach ($iterator as $entry) {
if ($entry->isFile()) {
$total += $entry->getSize();
}
}
return $total;
}
/**
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
*/
private function readArtifact(string $dir): ?array
{
$graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
$coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
if ($graph === false) {
return null;
}
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
return [
'graph' => $graph,
'coverage' => $coverage === false ? null : $coverage,
'sizeOnDisk' => $this->dirSize($dir),
];
}
private function downloadCacheDir(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
}
private function safeRunId(string $runId): string
{
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
return $sanitised === '' ? 'unknown' : $sanitised;
}
private function trimDownloadCache(string $projectRoot): void
{
$root = $this->downloadCacheDir($projectRoot);
if (! is_dir($root)) {
return;
}
$entries = @scandir($root);
if ($entries === false) {
return;
}
$candidates = [];
foreach ($entries as $entry) {
if (in_array($entry, ['.', '..'], true)) {
continue;
}
$path = $root.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($path)) {
continue;
}
$mtime = @filemtime($path);
$candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime];
}
if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) {
return;
}
usort(
$candidates,
static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'],
);
foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) {
$this->cleanup($stale['path']);
}
}
/**
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
*/
private function latestSuccessfulRunIdWithError(string $repo): array
{
$process = new Process([
'gh', 'run', 'list',
'-R', $repo,
'--workflow', self::WORKFLOW_FILE,
'--status', 'success',
'--limit', '1',
'--json', 'databaseId',
'--jq', '.[0].databaseId // empty',
]);
$process->setTimeout(30.0);
$process->run();
if (! $process->isSuccessful()) {
return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())];
}
$runId = trim($process->getOutput());
return [$runId === '' ? null : $runId, null];
}
private function ghAuthenticated(): bool
{
$process = new Process(['gh', 'auth', 'status']);
$process->setTimeout(10.0);
$process->run();
return $process->isSuccessful();
}
/**
* @return array{kind: string, message: string}
*/
private function classifyGhError(string $output): array
{
$output = trim($output);
if ($output === '') {
return ['kind' => 'unknown', 'message' => 'unknown error'];
}
foreach (self::DIAGNOSES as $kind => $diagnosis) {
if (preg_match($diagnosis['pattern'], $output) === 1) {
return ['kind' => $kind, 'message' => $diagnosis['message']];
}
}
return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))];
}
private function commandExists(string $cmd): bool
{
$process = new Process(['which', $cmd]);
$process->run();
return $process->isSuccessful();
}
private function cleanup(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
/** @var \SplFileInfo $entry */
foreach ($iterator as $entry) {
if ($entry->isDir()) {
@rmdir($entry->getPathname());
} else {
@unlink($entry->getPathname());
}
}
@rmdir($dir);
}
private function formatSize(int $bytes): string
{
if ($bytes >= 1024 * 1024) {
return sprintf('%.1f MB', $bytes / 1024 / 1024);
}
if ($bytes >= 1024) {
return sprintf('%.1f KB', $bytes / 1024);
}
return $bytes.' B';
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Contracts\Bootstrapper as BootstrapperContract;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
use Pest\TestSuite;
/**
* @internal
*/
final readonly class Bootstrapper implements BootstrapperContract
{
public function __construct(private Container $container) {}
public function boot(): void
{
$testSuite = $this->container->get(TestSuite::class);
assert($testSuite instanceof TestSuite);
$tempDir = Storage::tempDir($testSuite->rootPath);
$this->container->add(State::class, new FileState($tempDir));
}
}
-326
View File
@@ -1,326 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Exceptions\MissingDependency;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final readonly class ChangedFiles
{
public function __construct(private string $projectRoot) {}
/**
* @param array<int, string> $files project-relative paths.
* @param array<string, string> $lastRunTree path → content hash from last run.
* @return array<int, string>
*/
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
{
if ($lastRunTree === []) {
return $files;
}
$candidates = array_fill_keys($files, true);
foreach (array_keys($lastRunTree) as $snapshotted) {
$candidates[$snapshotted] = true;
}
$remaining = [];
foreach (array_keys($candidates) as $file) {
$snapshot = $lastRunTree[$file] ?? null;
$current = $this->currentHash($file);
if ($snapshot === null || $current === null || $current !== $snapshot) {
$remaining[] = $file;
}
}
return $remaining;
}
private function currentHash(string $relativePath): ?string
{
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
if (! is_file($absolute)) {
return null;
}
$hash = ContentHash::of($absolute);
return $hash === false ? null : $hash;
}
/**
* @param array<int, string> $files
* @return array<string, string> path → xxh128 content hash
*/
public function snapshotTree(array $files): array
{
$out = [];
foreach ($files as $file) {
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) {
$out[$file] = '';
continue;
}
$hash = ContentHash::of($absolute);
if ($hash !== false) {
$out[$file] = $hash;
}
}
return $out;
}
/**
* @return array<int, string>|null `null` when git is unavailable, or when
*/
public function since(?string $sha): ?array
{
$files = [];
if ($sha !== null && $sha !== '') {
if (! $this->shaIsReachable($sha)) {
return null;
}
$files = array_merge($files, $this->diffSinceSha($sha));
}
$files = array_merge($files, $this->workingTreeChanges());
$unique = [];
foreach ($files as $file) {
if ($file === '') {
continue;
}
$unique[$file] = true;
}
$candidates = array_keys($this->filterIgnored($unique));
if ($sha !== null && $sha !== '') {
return $this->filterBehaviourallyUnchanged($candidates, $sha);
}
return $candidates;
}
/**
* @param array<int, string> $files
* @return array<int, string>
*/
private function filterBehaviourallyUnchanged(array $files, string $sha): array
{
$remaining = [];
foreach ($files as $file) {
$currentHash = $this->currentHash($file);
if ($currentHash === null) {
$remaining[] = $file;
continue;
}
$baselineContent = $this->contentAtSha($sha, $file);
if ($baselineContent === null) {
$remaining[] = $file;
continue;
}
if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
$remaining[] = $file;
}
}
return $remaining;
}
private function contentAtSha(string $sha, string $path): ?string
{
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
$process->setTimeout(5.0);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
return $process->getOutput();
}
/**
* @param array<string, true> $candidates
* @return array<string, true>
*/
private function filterIgnored(array $candidates): array
{
if ($candidates === []) {
return $candidates;
}
$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 $candidates;
}
public function currentBranch(): ?string
{
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
$branch = trim($process->getOutput());
return $branch === '' || $branch === 'HEAD' ? null : $branch;
}
private function shaIsReachable(string $sha): bool
{
$process = new Process(
['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
$this->projectRoot,
);
$process->run();
return $process->getExitCode() === 0;
}
/**
* @return array<int, string>
*/
private function diffSinceSha(string $sha): array
{
$process = new Process(
['git', 'diff', '--name-only', $sha.'..HEAD'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
return $this->splitLines($process->getOutput());
}
/**
* @return array<int, string>
*/
private function workingTreeChanges(): array
{
$process = new Process(
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
if ($output === '') {
return [];
}
$records = explode("\x00", rtrim($output, "\x00"));
$files = [];
$count = count($records);
for ($i = 0; $i < $count; $i++) {
$record = $records[$i];
if (strlen($record) < 4) {
continue;
}
$status = substr($record, 0, 2);
$path = substr($record, 3);
if ($status[0] === 'R' || $status[0] === 'C') {
$files[] = $path;
if (isset($records[$i + 1]) && $records[$i + 1] !== '') {
$files[] = $records[$i + 1];
$i++;
}
continue;
}
$files[] = $path;
}
return $files;
}
public function currentSha(): ?string
{
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
$sha = trim($process->getOutput());
return $sha === '' ? null : $sha;
}
/**
* @return array<int, string>
*/
private function splitLines(string $output): array
{
$lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY);
return $lines === false ? [] : $lines;
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Edges\BladeEdges;
use Pest\Plugins\Tia\Edges\InertiaEdges;
/**
* @internal
*/
final class Collectors
{
/** @var list<class-string> */
private const array COLLECTORS = [
BladeEdges::class,
TableTracker::class,
InertiaEdges::class,
];
public static function armAll(Recorder $recorder): void
{
foreach (self::COLLECTORS as $collector) {
$collector::arm($recorder);
}
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Support\Container;
/**
* @internal
*/
final class Configuration
{
/**
* @return $this
*/
public function always(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markEnabled();
return $this;
}
/**
* @return $this
*/
public function locally(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markEnabled();
$watchPatterns->markLocally();
return $this;
}
/**
* @return $this
*/
public function filtered(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markFiltered();
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
*/
public function watch(array $patterns): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->add($patterns);
return $this;
}
}
-90
View File
@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class ContentHash
{
public static function of(string $absolute): string|false
{
$raw = @file_get_contents($absolute);
if ($raw === false) {
return false;
}
return self::ofContent($absolute, $raw);
}
public static function ofContent(string $path, string $raw): string
{
$lower = strtolower($path);
if (str_ends_with($lower, '.blade.php')) {
return self::hashBladeContent($raw);
}
if (str_ends_with($lower, '.php')) {
return self::hashPhpContent($raw);
}
foreach (['.vue', '.tsx', '.jsx', '.svelte', '.ts', '.js', '.mjs', '.cjs', '.mts'] as $extension) {
if (str_ends_with($lower, $extension)) {
return self::hashJsContent($raw);
}
}
return hash('xxh128', $raw);
}
private static function hashPhpContent(string $raw): string
{
$tokens = @token_get_all($raw);
if ($tokens === []) {
return hash('xxh128', $raw);
}
$normalised = '';
foreach ($tokens as $token) {
if (is_array($token)) {
if ($token[0] === T_WHITESPACE) {
continue;
}
if ($token[0] === T_COMMENT) {
continue;
}
if ($token[0] === T_DOC_COMMENT) {
continue;
}
$normalised .= $token[1];
} else {
$normalised .= $token;
}
}
return hash('xxh128', $normalised);
}
private static function hashBladeContent(string $raw): string
{
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
return hash('xxh128', trim($stripped));
}
private static function hashJsContent(string $raw): string
{
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;
$stripped = preg_replace('/^\s*\/\*.*?\*\/\s*$/sm', '', $stripped) ?? $stripped;
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
return hash('xxh128', trim($stripped));
}
}
-15
View File
@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts;
/**
* @internal
*/
interface Lockfile
{
public function applies(string $filename): bool;
public function fingerprint(string $contents): ?string;
}
-24
View File
@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts;
/**
* @internal
*/
interface State
{
public function read(string $key): ?string;
public function write(string $key, string $content): bool;
public function delete(string $key): bool;
public function exists(string $key): bool;
/**
* @return list<string>
*/
public function keysWithPrefix(string $prefix): array;
}
@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts;
/**
* @internal
*/
interface WatchDefault
{
public function applicable(): bool;
/**
* @return array<string, array<int, string>> pattern → list of project-relative test dirs
*/
public function defaults(string $projectRoot, string $testPath): array;
}
-110
View File
@@ -1,110 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
use Throwable;
/**
* @internal
*/
final class CoverageCollector
{
/**
* @var array<string, string|null>
*/
private array $classFileCache = [];
/**
* @return array<string, array<int, string>>
*/
public function perTestFiles(): array
{
if (! PhpUnitCodeCoverage::instance()->isActive()) {
return [];
}
try {
$lineCoverage = PhpUnitCodeCoverage::instance()
->codeCoverage()
->getData()
->lineCoverage();
} catch (Throwable) {
return [];
}
/** @var array<string, array<string, true>> $edges */
$edges = [];
foreach ($lineCoverage as $sourceFile => $lines) {
$testIds = [];
foreach ($lines as $hits) {
if ($hits === null) {
continue;
}
foreach ($hits as $id) {
$testIds[$id] = true;
}
}
foreach (array_keys($testIds) as $testId) {
$testFile = $this->testIdToFile($testId);
if ($testFile === null) {
continue;
}
$edges[$testFile][$sourceFile] = true;
}
}
$out = [];
foreach ($edges as $testFile => $sources) {
$out[$testFile] = array_keys($sources);
}
return $out;
}
public function reset(): void
{
$this->classFileCache = [];
}
private function testIdToFile(string $testId): ?string
{
$hash = strpos($testId, '#');
$identifier = $hash === false ? $testId : substr($testId, 0, $hash);
if (! str_contains($identifier, '::')) {
return null;
}
[$className] = explode('::', $identifier, 2);
if (array_key_exists($className, $this->classFileCache)) {
return $this->classFileCache[$className];
}
$file = $this->resolveClassFile($className);
$this->classFileCache[$className] = $file;
return $file;
}
private function resolveClassFile(string $className): ?string
{
if (! class_exists($className, false)) {
return null;
}
assert(property_exists($className, '__filename') && is_string($className::$__filename));
return $className::$__filename;
}
}
-233
View File
@@ -1,233 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\CodeCoverage\Serialization\Unserializer;
use Throwable;
/**
* @internal
*/
final class CoverageMerger
{
public static function applyIfMarked(string $reportPath): void
{
$state = self::state();
if (! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
return;
}
$state->delete(Tia::KEY_COVERAGE_MARKER);
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
if ($cachedBytes === null) {
$current = self::requireCoverage($reportPath);
if ($current instanceof CodeCoverage) {
self::primeUncoveredFiles($current);
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
}
return;
}
$decoded = self::decompress($cachedBytes);
if ($decoded === null) {
$state->delete(Tia::KEY_COVERAGE_CACHE);
return;
}
$cached = self::unserializeCoverage($decoded);
$current = self::requireCoverage($reportPath);
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
return;
}
self::primeUncoveredFiles($cached);
self::primeUncoveredFiles($current);
self::stripCurrentTestsFromCached($cached, $current);
$cached->merge($current);
$serialised = serialize($cached);
@file_put_contents(
$reportPath,
'<?php return unserialize('.var_export($serialised, true).");\n",
);
$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);
return $compressed === false ? $bytes : $compressed;
}
private static function decompress(string $bytes): ?string
{
$decoded = @gzdecode($bytes);
return $decoded === false ? null : $decoded;
}
private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
{
$currentIds = self::collectTestIds($current);
if ($currentIds === []) {
return;
}
$cachedData = $cached->getData();
$lineCoverage = $cachedData->lineCoverage();
foreach ($lineCoverage as $file => $lines) {
foreach ($lines as $line => $ids) {
if ($ids === null) {
continue;
}
if ($ids === []) {
continue;
}
$filtered = array_values(array_diff($ids, $currentIds));
if ($filtered !== $ids) {
$lineCoverage[$file][$line] = $filtered;
}
}
}
$cachedData->setLineCoverage($lineCoverage);
}
/**
* @return array<int, string>
*/
private static function collectTestIds(CodeCoverage $coverage): array
{
$ids = [];
foreach ($coverage->getData()->lineCoverage() as $lines) {
foreach ($lines as $hits) {
if ($hits === null) {
continue;
}
foreach ($hits as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
private static function state(): State
{
$state = Container::getInstance()->get(State::class);
assert($state instanceof State);
return $state;
}
private static function requireCoverage(string $reportPath): ?CodeCoverage
{
if (! is_file($reportPath)) {
return null;
}
try {
/** @var mixed $value */
$value = require $reportPath;
} catch (Throwable) {
return null;
}
// Legacy `--coverage-php` format: a serialized `CodeCoverage` object.
if ($value instanceof CodeCoverage) {
return $value;
}
// Since phpunit/php-code-coverage 14, `--coverage-php` writes the report
// as a serialized array (`['codeCoverage' => ..., 'testResults' => ...,
// 'basePath' => ...]`) rather than a `CodeCoverage` object, so it has to
// be rebuilt into one before it can be merged.
return self::coverageFromSerializedData($reportPath);
}
private static function coverageFromSerializedData(string $reportPath): ?CodeCoverage
{
if ($reportPath === '') {
return null;
}
try {
$serialized = new Unserializer()->unserialize($reportPath);
} catch (Throwable) {
return null;
}
$data = $serialized['codeCoverage'];
$basePath = $serialized['basePath'];
if ($basePath !== '') {
foreach ($data->coveredFiles() as $relativePath) {
$data->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
}
}
$coverage = self::emptyCoverage();
if (! $coverage instanceof CodeCoverage) {
return null;
}
$coverage->setData($data);
$coverage->setTests($serialized['testResults']);
return $coverage;
}
private static function emptyCoverage(): ?CodeCoverage
{
try {
$filter = new Filter;
return new CodeCoverage(new Selector()->forLineCoverage($filter), $filter);
} catch (Throwable) {
return null;
}
}
private static function unserializeCoverage(string $bytes): ?CodeCoverage
{
try {
$value = @unserialize($bytes);
} catch (Throwable) {
return null;
}
return $value instanceof CodeCoverage ? $value : null;
}
}
-62
View File
@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Edges;
use Pest\Plugins\Tia\Recorder;
/**
* @internal
*/
final class BladeEdges
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string MARKER = 'pest.tia.blade-edges-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->isActive()) {
return;
}
$containerClass = self::CONTAINER_CLASS;
if (! class_exists($containerClass)) {
return;
}
/** @var object $app */
$app = $containerClass::getInstance();
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
return;
}
if ($app->bound(self::MARKER) || ! $app->bound('view')) {
return;
}
$app->instance(self::MARKER, true);
$factory = $app->make('view');
if (! is_object($factory) || ! method_exists($factory, 'composer')) {
return;
}
$factory->composer('*', static function (object $view) use ($recorder): void {
if (! method_exists($view, 'getPath')) {
return;
}
/** @var mixed $path */
$path = $view->getPath();
if (is_string($path) && $path !== '') {
$recorder->linkSource($path);
}
});
}
}
-131
View File
@@ -1,131 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Edges;
use Pest\Plugins\Tia\Recorder;
/**
* @internal
*/
final class InertiaEdges
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
private const string MARKER = 'pest.tia.inertia-edges-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->isActive()) {
return;
}
$containerClass = self::CONTAINER_CLASS;
if (! class_exists($containerClass)) {
return;
}
/** @var object $app */
$app = $containerClass::getInstance();
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
return;
}
if ($app->bound(self::MARKER) || ! $app->bound('events')) {
return;
}
$app->instance(self::MARKER, true);
/** @var object $events */
$events = $app->make('events');
if (! method_exists($events, 'listen')) {
return;
}
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
if (! property_exists($event, 'response') || ! is_object($event->response)) {
return;
}
$component = self::extractComponent($event->response);
if ($component !== null) {
$recorder->linkInertiaComponent($component);
}
});
}
private static function extractComponent(object $response): ?string
{
$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]));
if ($component !== null) {
return $component;
}
}
if (str_contains($content, 'data-page=')
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
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 */
$decoded = json_decode($json, true);
if (is_array($decoded)
&& isset($decoded['component'])
&& is_string($decoded['component'])
&& $decoded['component'] !== '') {
return $decoded['component'];
}
return null;
}
private static function readContent(object $response): ?string
{
if (! method_exists($response, 'getContent')) {
return null;
}
/** @var mixed $content */
$content = $response->getContent();
return is_string($content) ? $content : null;
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Enums;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* @internal
*/
enum ReplayType
{
case None;
case Pass;
case Risky;
case Skipped;
case Incomplete;
case Failure;
public static function fromStatus(?TestStatus $status): self
{
if (! $status instanceof TestStatus) {
return self::None;
}
return match (true) {
$status->isSuccess() => self::Pass,
$status->isRisky() => self::Risky,
$status->isSkipped() => self::Skipped,
$status->isIncomplete() => self::Incomplete,
default => self::Failure,
};
}
}
-134
View File
@@ -1,134 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
/**
* @internal
*/
final class FileState implements State
{
private readonly string $rootDir;
private ?string $resolvedRoot = null;
public function __construct(string $rootDir)
{
$this->rootDir = rtrim($rootDir, DIRECTORY_SEPARATOR);
}
public function read(string $key): ?string
{
$path = $this->pathFor($key);
if (! is_file($path)) {
return null;
}
$bytes = @file_get_contents($path);
return $bytes === false ? null : $bytes;
}
public function write(string $key, string $content): bool
{
if (! $this->ensureRoot()) {
return false;
}
$path = $this->pathFor($key);
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
if (@file_put_contents($tmp, $content) === false) {
return false;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
}
public function delete(string $key): bool
{
$path = $this->pathFor($key);
if (! is_file($path)) {
return true;
}
return @unlink($path);
}
public function exists(string $key): bool
{
return is_file($this->pathFor($key));
}
public function keysWithPrefix(string $prefix): array
{
$root = $this->resolvedRoot();
if ($root === null) {
return [];
}
$pattern = $root.DIRECTORY_SEPARATOR.$prefix.'*';
$matches = glob($pattern);
if ($matches === false) {
return [];
}
$keys = [];
foreach ($matches as $path) {
if (str_ends_with($path, '.tmp')) {
continue;
}
$keys[] = basename($path);
}
return $keys;
}
public function pathFor(string $key): string
{
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
}
private function resolvedRoot(): ?string
{
if ($this->resolvedRoot !== null) {
return $this->resolvedRoot;
}
$resolved = @realpath($this->rootDir);
if ($resolved === false) {
return null;
}
return $this->resolvedRoot = $resolved;
}
private function ensureRoot(): bool
{
if (is_dir($this->rootDir)) {
return true;
}
if (@mkdir($this->rootDir, 0755, true)) {
return true;
}
return is_dir($this->rootDir);
}
}
-325
View File
@@ -1,325 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\Lockfile;
use Symfony\Component\Finder\Finder;
/**
* @internal
*/
final readonly class Fingerprint
{
private const int SCHEMA_VERSION = 18;
/**
* @var array<int, class-string<Lockfile>>
*/
private const array LOCKFILES = [
Lockfiles\PackageLock::class,
];
/**
* @return array{
* structural: array<string, int|string|null>,
* environmental: array<string, int|string|null>,
* }
*/
public static function compute(string $projectRoot): array
{
return [
'structural' => [
'schema' => self::SCHEMA_VERSION,
'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_lock' => self::packageLockHash($projectRoot),
'js_config' => self::jsConfigHash($projectRoot),
// 'composer_json' => self::composerJsonHash($projectRoot),
],
'environmental' => [
'php_minor' => PHP_MAJOR_VERSION,
// 'extensions' => self::extensionsFingerprint($projectRoot),
// 'env_files' => self::envFilesHash($projectRoot),
],
];
}
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
*/
public static function structuralMatches(array $a, array $b): bool
{
$aStructural = self::structuralOnly($a);
$bStructural = self::structuralOnly($b);
ksort($aStructural);
ksort($bStructural);
return $aStructural === $bStructural;
}
/**
* @param array<string, mixed> $stored
* @param array<string, mixed> $current
* @return list<string>
*/
public static function structuralDrift(array $stored, array $current): array
{
return self::detectDrift(
self::structuralOnly($stored),
self::structuralOnly($current),
'schema',
);
}
/**
* @param array<string, mixed> $stored
* @param array<string, mixed> $current
* @return list<string>
*/
public static function environmentalDrift(array $stored, array $current): array
{
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;
}
}
return array_values(array_unique($drifts));
}
/**
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function structuralOnly(array $fingerprint): array
{
return self::bucket($fingerprint, 'structural');
}
/**
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function environmentalOnly(array $fingerprint): array
{
return self::bucket($fingerprint, 'environmental');
}
/**
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function bucket(array $fingerprint, string $key): array
{
$raw = $fingerprint[$key] ?? null;
if (! is_array($raw)) {
return [];
}
$normalised = [];
foreach ($raw as $k => $v) {
if (is_string($k)) {
$normalised[$k] = $v;
}
}
return $normalised;
}
private static function viteConfigHash(string $projectRoot): ?string
{
$parts = [];
foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
if (! self::isTrackedByGit($projectRoot, $name)) {
continue;
}
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function jsConfigHash(string $projectRoot): ?string
{
$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) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function composerLockHash(string $projectRoot): ?string
{
return self::trackedHash($projectRoot, 'composer.lock');
}
private static function packageLockHash(string $projectRoot): ?string
{
$parts = [];
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
if (! self::isTrackedByGit($projectRoot, $name)) {
continue;
}
$hash = self::lockfileHash($projectRoot.'/'.$name, $name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function lockfileHash(string $path, string $name): ?string
{
if (! is_file($path)) {
return null;
}
$contents = @file_get_contents($path);
if ($contents === false) {
return null;
}
foreach (self::LOCKFILES as $class) {
$handler = new $class;
if (! $handler->applies($name)) {
continue;
}
$fingerprint = $handler->fingerprint($contents);
if ($fingerprint !== null) {
return $fingerprint;
}
break;
}
return hash('xxh128', $contents);
}
private static function trackedHash(string $projectRoot, string $relativePath): ?string
{
if (! self::isTrackedByGit($projectRoot, $relativePath)) {
return null;
}
return self::hashIfExists($projectRoot.'/'.$relativePath);
}
/**
* 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_file($projectRoot.'/'.$relativePath)) {
return false;
}
static $cache = [];
$key = $projectRoot."\0".$relativePath;
if (isset($cache[$key])) {
return $cache[$key];
}
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
{
if (! is_file($path)) {
return null;
}
$hash = ContentHash::of($path);
return $hash === false ? null : $hash;
}
private static function hashIfExists(string $path): ?string
{
if (! is_file($path)) {
return null;
}
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
}
File diff suppressed because it is too large Load Diff
-393
View File
@@ -1,393 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final class JsModuleGraph
{
private const int NODE_TIMEOUT_SECONDS = 180;
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>>
*/
public static function build(string $projectRoot): array
{
$result = self::resolve($projectRoot);
return $result ?? [];
}
/**
* @return array<string, list<string>>|null
*/
public static function buildStrict(string $projectRoot): ?array
{
return self::resolve($projectRoot);
}
public static function isApplicable(string $projectRoot): bool
{
if (! self::hasViteConfig($projectRoot)) {
return false;
}
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;
}
}
return false;
}
/**
* @return array<string, list<string>>|null
*/
private static function resolve(string $projectRoot): ?array
{
$fingerprint = self::fingerprint($projectRoot);
if ($fingerprint !== null) {
$cached = self::readCache($projectRoot, $fingerprint);
if ($cached !== null) {
return $cached;
}
}
$process = self::buildNodeProcess($projectRoot);
if (! $process instanceof Process) {
return null;
}
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$result = self::parseNodeOutput($process->getOutput());
if ($result !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
}
private static function buildNodeProcess(string $projectRoot): ?Process
{
if (! self::hasViteConfig($projectRoot)) {
return null;
}
$nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';
if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
return null;
}
$nodeBinary = (new ExecutableFinder)->find('node');
if ($nodeBinary === null) {
return null;
}
$helperPath = dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-tia-vite-deps.mjs';
if (! is_file($helperPath)) {
return null;
}
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
return $process;
}
/**
* @return array<string, list<string>>|null
*/
private static function parseNodeOutput(string $output): ?array
{
/** @var mixed $decoded */
$decoded = json_decode($output, true);
if (! is_array($decoded)) {
return null;
}
$out = [];
foreach ($decoded as $path => $components) {
if (! is_string($path)) {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
sort($names);
$out[$path] = $names;
}
}
ksort($out);
return $out;
}
private static function fingerprint(string $projectRoot): ?string
{
$parts = [];
foreach (self::VITE_CONFIG_NAMES as $name) {
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (! is_file($path)) {
continue;
}
$stat = @stat($path);
$bytes = @file_get_contents($path);
$parts[] = 'config:'.$name
.':'.($stat === false ? '0' : (string) $stat['mtime'])
.':'.($stat === false ? '0' : (string) $stat['size'])
.':'.($bytes === false ? '' : hash('sha256', $bytes));
}
if ($parts === []) {
return null;
}
$override = getenv('TIA_VITE_PAGES_DIR');
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(
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$entries[] = $file->getPathname()
.':'.$file->getSize()
.':'.$file->getMTime();
}
sort($entries);
$parts[] = 'js:'.hash('sha256', implode("\n", $entries));
}
return hash('sha256', implode('|', $parts));
}
/**
* @return array<string, list<string>>|null
*/
private static function readCache(string $projectRoot, string $fingerprint): ?array
{
$path = self::cachePath($projectRoot);
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
/** @var mixed $decoded */
$decoded = json_decode($raw, true);
if (! is_array($decoded)) {
return null;
}
if (($decoded['fingerprint'] ?? null) !== $fingerprint) {
return null;
}
$graph = $decoded['graph'] ?? null;
if (! is_array($graph)) {
return null;
}
$out = [];
foreach ($graph as $key => $value) {
if (! is_string($key)) {
continue;
}
if (! is_array($value)) {
continue;
}
$names = [];
foreach ($value as $name) {
if (is_string($name) && $name !== '') {
$names[] = $name;
}
}
$out[$key] = $names;
}
return $out;
}
/**
* @param array<string, list<string>> $graph
*/
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
{
$path = self::cachePath($projectRoot);
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return;
}
$payload = json_encode([
'fingerprint' => $fingerprint,
'graph' => $graph,
]);
if ($payload === false) {
return;
}
$tmp = $path.'.tmp.'.bin2hex(random_bytes(4));
if (@file_put_contents($tmp, $payload) === false) {
return;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
}
}
private static function cachePath(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE;
}
private static function hasViteConfig(string $projectRoot): bool
{
return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
}
}
-79
View File
@@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Lockfiles;
use Pest\Plugins\Tia\Contracts\Lockfile;
/**
* @internal
*/
final readonly class PackageLock implements Lockfile
{
public function applies(string $filename): bool
{
return $filename === 'package-lock.json';
}
public function fingerprint(string $contents): ?string
{
$data = json_decode($contents, true);
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
return null;
}
$packages = $data['packages'];
$platformPaths = [];
foreach ($packages as $path => $meta) {
if (is_string($path) && is_array($meta) && $this->isPlatformSpecific($meta)) {
$platformPaths[] = $path;
}
}
$entries = [];
foreach ($packages as $path => $meta) {
if (! is_string($path)) {
continue;
}
if (! is_array($meta)) {
continue;
}
if ($this->isPlatformSpecific($meta)) {
continue;
}
if ($this->isBundledUnderPlatform($path, $platformPaths)) {
continue;
}
$version = $meta['version'] ?? null;
$entries[$path] = is_string($version) ? $version : '';
}
ksort($entries);
$encoded = json_encode($entries, JSON_UNESCAPED_SLASHES);
return $encoded === false ? null : hash('xxh128', $encoded);
}
/**
* @param array<string, mixed> $meta
*/
private function isPlatformSpecific(array $meta): bool
{
return isset($meta['os']) || isset($meta['cpu']) || isset($meta['libc']);
}
/**
* @param list<string> $platformPaths
*/
private function isBundledUnderPlatform(string $path, array $platformPaths): bool
{
return array_any($platformPaths, fn (string $platformPath): bool => str_starts_with($path, $platformPath.'/node_modules/'));
}
}
-391
View File
@@ -1,391 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\TestSuite;
use ReflectionClass;
/**
* @internal
*/
final class Recorder
{
private ?string $currentTestFile = null;
/** @var array<string, array<string, true>> */
private array $perTestFiles = [];
/** @var array<string, array<string, true>> */
private array $perTestTables = [];
/** @var array<string, array<string, true>> */
private array $perTestInertiaComponents = [];
/** @var array<string, true> */
private array $perTestUsesDatabase = [];
/** @var array<string, string|null> */
private array $classFileCache = [];
/** @var array<string, bool> */
private array $classUsesDatabaseCache = [];
private bool $active = false;
private bool $captureCoverage = false;
private bool $driverChecked = false;
private bool $driverAvailable = false;
private string $driver = 'none';
private ?SourceScope $sourceScope = null;
public function activate(): void
{
$this->active = true;
$this->captureCoverage = true;
}
/**
* Enable per-test link tracking (tables, Inertia components, database
* usage, rendered views) without driving pcov/xdebug — for runs where
* coverage edges are piggybacked from an existing PHPUnit coverage session.
*/
public function activateLinkTracking(): void
{
$this->active = true;
}
public function isActive(): bool
{
return $this->active;
}
public function driverAvailable(): bool
{
if (! $this->driverChecked) {
if (function_exists('pcov\\start')) {
$this->driver = 'pcov';
$this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
$modes = \xdebug_info('mode');
if (is_array($modes) && in_array('coverage', $modes, true)) {
$this->driver = 'xdebug';
$this->driverAvailable = true;
}
}
$this->driverChecked = true;
}
return $this->driverAvailable;
}
public function beginTest(string $className, string $methodName, string $fallbackFile): void
{
if (! $this->active) {
return;
}
if ($this->captureCoverage && ! $this->driverAvailable()) {
return;
}
if ($this->currentTestFile !== null) {
return;
}
$file = $this->resolveTestFile($className, $fallbackFile);
if ($file === null) {
return;
}
$this->currentTestFile = $file;
if ($this->classUsesDatabase($className)) {
$this->perTestUsesDatabase[$file] = true;
}
if (! $this->captureCoverage) {
return;
}
if ($this->driver === 'pcov') {
\pcov\clear();
\pcov\start();
return;
}
\xdebug_start_code_coverage();
}
public function endTest(): void
{
if (! $this->active || $this->currentTestFile === null) {
return;
}
if (! $this->captureCoverage || ! $this->driverAvailable()) {
$this->currentTestFile = null;
return;
}
if ($this->driver === 'pcov') {
\pcov\stop();
$scope = $this->sourceScope();
$filesToCollectCoverageFor = [];
foreach (\pcov\waiting() as $file) {
if (is_string($file) && $scope->contains($file)) {
$filesToCollectCoverageFor[] = $file;
}
}
/** @var array<string, mixed> $data */
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
$coveredFiles = $this->filesWithExecutedLines($data);
} else {
/** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage();
\xdebug_stop_code_coverage(true);
$scope = $this->sourceScope();
foreach (array_keys($data) as $file) {
if (! $scope->contains($file)) {
unset($data[$file]);
}
}
$coveredFiles = $this->filesWithExecutedLines($data);
}
foreach ($coveredFiles as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
$this->currentTestFile = null;
}
public function linkSource(string $sourceFile): void
{
if (! $this->active) {
return;
}
if ($this->currentTestFile === null) {
return;
}
if ($sourceFile === '') {
return;
}
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
private function classUsesDatabase(string $className): bool
{
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
return $this->classUsesDatabaseCache[$className];
}
if (! class_exists($className, false)) {
return $this->classUsesDatabaseCache[$className] = false;
}
static $needles = [
'Illuminate\\Foundation\\Testing\\RefreshDatabase' => true,
'Illuminate\\Foundation\\Testing\\DatabaseMigrations' => true,
'Illuminate\\Foundation\\Testing\\DatabaseTransactions' => true,
];
$reflection = new ReflectionClass($className);
do {
foreach (array_keys($reflection->getTraits()) as $traitName) {
if (isset($needles[$traitName])) {
return $this->classUsesDatabaseCache[$className] = true;
}
}
$reflection = $reflection->getParentClass();
} while ($reflection !== false && ! $reflection->isInternal());
return $this->classUsesDatabaseCache[$className] = false;
}
public function linkTable(string $table): void
{
if (! $this->active) {
return;
}
if ($this->currentTestFile === null) {
return;
}
if ($table === '') {
return;
}
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
}
public function linkInertiaComponent(string $component): void
{
if (! $this->active) {
return;
}
if ($this->currentTestFile === null) {
return;
}
if ($component === '') {
return;
}
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
}
/** @return array<string, array<int, string>> */
public function perTestFiles(): array
{
$out = [];
foreach ($this->perTestFiles as $testFile => $sources) {
$out[$testFile] = array_keys($sources);
}
return $out;
}
/** @return array<string, array<int, string>> */
public function perTestTables(): array
{
$out = [];
foreach ($this->perTestTables as $testFile => $tables) {
$names = array_keys($tables);
sort($names);
$out[$testFile] = $names;
}
return $out;
}
/** @return array<string, array<int, string>> */
public function perTestInertiaComponents(): array
{
$out = [];
foreach ($this->perTestInertiaComponents as $testFile => $components) {
$names = array_keys($components);
sort($names);
$out[$testFile] = $names;
}
return $out;
}
/** @return array<string, true> */
public function perTestUsesDatabase(): array
{
return $this->perTestUsesDatabase;
}
private function resolveTestFile(string $className, string $fallbackFile): ?string
{
if (array_key_exists($className, $this->classFileCache)) {
$file = $this->classFileCache[$className];
} else {
$file = $this->readPestFilename($className);
$this->classFileCache[$className] = $file;
}
if ($file !== null) {
return $file;
}
if ($fallbackFile !== '' && $fallbackFile !== 'unknown' && ! str_contains($fallbackFile, "eval()'d")) {
return $fallbackFile;
}
return null;
}
private function readPestFilename(string $className): ?string
{
if (! class_exists($className, false)) {
return null;
}
assert(property_exists($className, '__filename') && is_string($className::$__filename));
return $className::$__filename;
}
/**
* @param array<string, mixed> $data
* @return list<string>
*/
private function filesWithExecutedLines(array $data): array
{
$out = [];
foreach ($data as $file => $lines) {
if (! is_array($lines)) {
continue;
}
$covered = [];
foreach ($lines as $line => $count) {
if (is_int($count) && $count > 0) {
$covered[] = $line;
}
}
if ($covered === []) {
continue;
}
$lineKeys = array_keys($lines);
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
continue;
}
$out[] = $file;
}
return $out;
}
private function sourceScope(): SourceScope
{
return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath);
}
public function reset(): void
{
$this->currentTestFile = null;
$this->perTestFiles = [];
$this->perTestTables = [];
$this->perTestInertiaComponents = [];
$this->perTestUsesDatabase = [];
$this->classFileCache = [];
$this->classUsesDatabaseCache = [];
$this->sourceScope = null;
$this->active = false;
$this->captureCoverage = false;
}
}
-149
View File
@@ -1,149 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* @internal
*/
final class ResultCollector
{
/**
* @var array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
*/
private array $results = [];
private ?string $currentTestId = null;
private ?string $currentTestFile = null;
private ?float $startTime = null;
public function testPrepared(string $testId, ?string $testFile = null): void
{
$this->currentTestId = $testId;
$this->currentTestFile = $testFile;
$this->startTime = microtime(true);
}
public function testPassed(): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::success());
}
public function testFailed(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::failure($message));
}
public function testErrored(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::error($message));
}
public function testSkipped(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::skipped($message));
}
public function testIncomplete(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::incomplete($message));
}
public function testRisky(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::risky($message));
}
/**
* @return array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
*/
public function all(): array
{
return $this->results;
}
public function recordAssertions(string $testId, int $assertions): void
{
if (isset($this->results[$testId])) {
$this->results[$testId]['assertions'] = $assertions;
}
}
/**
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
*/
public function merge(array $results): void
{
foreach ($results as $testId => $result) {
$this->results[$testId] = $result;
}
}
public function reset(): void
{
$this->results = [];
$this->currentTestId = null;
$this->currentTestFile = null;
$this->startTime = null;
}
public function finishTest(): void
{
$this->currentTestId = null;
$this->currentTestFile = null;
$this->startTime = null;
}
private function record(TestStatus $status): void
{
if ($this->currentTestId === null) {
return;
}
$time = $this->startTime !== null
? round(microtime(true) - $this->startTime, 3)
: 0.0;
$existing = $this->results[$this->currentTestId] ?? null;
$this->results[$this->currentTestId] = [
'status' => $status->asInt(),
'message' => $status->message(),
'time' => $time,
'assertions' => $existing['assertions'] ?? 0,
];
if ($this->currentTestFile !== null) {
$this->results[$this->currentTestId]['file'] = $this->currentTestFile;
}
}
}
-196
View File
@@ -1,196 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\TextUI\Configuration\Registry;
use Throwable;
/**
* @internal
*/
final class SourceScope
{
/** @var array<string, bool> */
private array $containsCache = [];
private const array TOP_LEVEL_NOISE = [
'vendor',
'node_modules',
'.git',
'.idea',
'.vscode',
'.github',
'.pest',
'.phpunit.cache',
'.cache',
];
private const array NESTED_NOISE = [
'storage/framework',
'storage/logs',
'bootstrap/cache',
];
/**
* @param list<string> $includes Absolute, normalised directory paths.
* @param list<string> $excludes Absolute, normalised directory paths.
*/
public function __construct(
private readonly array $includes,
private readonly array $excludes,
) {}
public static function fromProjectRoot(string $projectRoot): self
{
$phpunitIncludes = [];
$phpunitExcludes = [];
try {
$source = Registry::get()->source();
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);
$includes = array_values(array_unique([...$phpunitIncludes, ...$rootIncludes]));
$excludes = array_values(array_unique([
...$phpunitExcludes,
...self::nestedNoiseDirs($projectRoot),
]));
if ($includes === []) {
$includes = [self::normalise($projectRoot)];
}
return new self($includes, $excludes);
}
/**
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
*/
public static function testPaths(): array
{
try {
$suites = Registry::get()->testSuite();
} catch (Throwable) {
return [];
}
$out = [];
foreach ($suites as $suite) {
foreach ($suite->directories() as $directory) {
$out[] = self::normalise($directory->path());
}
foreach ($suite->files() as $file) {
$out[] = self::normalise($file->path());
}
}
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 $this->containsCache[$absoluteFile] = false;
}
}
foreach ($this->includes as $included) {
if ($this->startsWithDir($candidate, $included)) {
return $this->containsCache[$absoluteFile] = true;
}
}
return $this->containsCache[$absoluteFile] = false;
}
/**
* @return list<string>
*/
private static function topLevelProjectDirs(string $projectRoot): array
{
$entries = @scandir($projectRoot);
if ($entries === false) {
return [];
}
$out = [];
foreach ($entries as $entry) {
if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue;
}
if (in_array($entry, self::TOP_LEVEL_NOISE, true)) {
continue;
}
if ($entry !== '' && $entry[0] === '.') {
continue;
}
$abs = $projectRoot.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($abs)) {
continue;
}
$out[] = self::normalise(@realpath($abs) ?: $abs);
}
return $out;
}
/**
* @return list<string>
*/
private static function nestedNoiseDirs(string $projectRoot): array
{
$out = [];
foreach (self::NESTED_NOISE as $relative) {
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
$out[] = self::normalise(@realpath($abs) ?: $abs);
}
return $out;
}
private static function normalise(string $path): string
{
return rtrim($path, '/\\');
}
private function startsWithDir(string $candidate, string $dir): bool
{
if ($candidate === $dir) {
return true;
}
return str_starts_with($candidate, $dir.DIRECTORY_SEPARATOR);
}
}
-146
View File
@@ -1,146 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class Storage
{
public static function tempDir(string $projectRoot): string
{
$home = self::homeDir();
if ($home === null) {
return $projectRoot
.DIRECTORY_SEPARATOR.'.pest'
.DIRECTORY_SEPARATOR.'tia';
}
return $home
.DIRECTORY_SEPARATOR.'.pest'
.DIRECTORY_SEPARATOR.'tia'
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
}
public static function purge(string $projectRoot): void
{
$dir = self::tempDir($projectRoot);
if (! is_dir($dir)) {
return;
}
self::removeRecursive($dir);
}
private static function removeRecursive(string $dir): void
{
$entries = @scandir($dir);
if ($entries === false) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue;
}
$path = $dir.DIRECTORY_SEPARATOR.$entry;
if (is_dir($path) && ! is_link($path)) {
self::removeRecursive($path);
continue;
}
@unlink($path);
}
@rmdir($dir);
}
private static function homeDir(): ?string
{
foreach (['HOME', 'USERPROFILE'] as $key) {
$value = getenv($key);
if (is_string($value) && $value !== '' && is_dir($value)) {
return rtrim($value, '/\\');
}
}
return null;
}
/**
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar`
*/
private static function projectKey(string $projectRoot): string
{
$origin = self::originIdentity($projectRoot);
$realpath = @realpath($projectRoot);
$input = $origin ?? ($realpath === false ? $projectRoot : $realpath);
$hash = substr(hash('sha256', $input), 0, 16);
$slug = self::slug(basename($projectRoot));
return $slug === '' ? $hash : $slug.'-'.$hash;
}
private static function originIdentity(string $projectRoot): ?string
{
$url = self::rawOriginUrl($projectRoot);
if ($url === null) {
return null;
}
// git@host:org/repo(.git)
if (preg_match('#^[\w.-]+@([\w.-]+):([\w./-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return strtolower($m[1].'/'.$m[2]);
}
// scheme://[user@]host[:port]/org/repo(.git) — https, ssh, git, file
if (preg_match('#^[a-z]+://(?:[^@/]+@)?([^/:]+)(?::\d+)?/([\w./-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return strtolower($m[1].'/'.$m[2]);
}
return strtolower($url);
}
private static function rawOriginUrl(string $projectRoot): ?string
{
$config = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
if (! is_file($config)) {
return null;
}
$raw = @file_get_contents($config);
if ($raw === false) {
return null;
}
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $raw, $match) === 1) {
return trim($match[1]);
}
return null;
}
private static function slug(string $name): string
{
$slug = strtolower($name);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
return trim($slug, '-');
}
}
-162
View File
@@ -1,162 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class TableExtractor
{
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete', 'with', 'replace'];
/**
* A single (optionally quoted) identifier segment.
*/
private const string IDENTIFIER = '(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)';
/**
* @return list<string> Sorted, deduped table names referenced by the
*/
public static function fromSql(string $sql): array
{
$trimmed = ltrim($sql);
if ($trimmed === '') {
return [];
}
if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
return [];
}
if (! in_array(strtolower($prefixMatch[0]), self::DML_PREFIXES, true)) {
return [];
}
$pattern = '/\b(?:from|into|update|join)\s+('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)/i';
if (preg_match_all($pattern, $sql, $matches) === false) {
return [];
}
$tables = [];
foreach ($matches[1] as $qualified) {
$name = self::unqualified($qualified);
if ($name === '') {
continue;
}
if (self::isSchemaMeta($name)) {
continue;
}
$tables[strtolower($name)] = true;
}
$out = array_keys($tables);
sort($out);
return $out;
}
/**
* @return list<string> Table names referenced by `Schema::` calls,
*/
public static function fromMigrationSource(string $php): array
{
$tables = [];
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
foreach ($matches[1] as $i => $primary) {
$tables[strtolower(self::lastDottedSegment($primary))] = true;
$secondary = $matches[2][$i] ?? '';
if ($secondary !== '') {
$tables[strtolower(self::lastDottedSegment($secondary))] = true;
}
}
}
$qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
$sqlPatterns = [
'/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+'.$qualified.'/i',
'/INSERT\s+(?:IGNORE\s+)?INTO\s+'.$qualified.'/i',
'/UPDATE\s+'.$qualified.'\s+SET\b/i',
'/DELETE\s+FROM\s+'.$qualified.'/i',
];
foreach ($sqlPatterns as $pattern) {
if (preg_match_all($pattern, $php, $matches) === false) {
continue;
}
foreach ($matches[1] as $name) {
$lower = strtolower(self::unqualified($name));
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
$tables[$lower] = true;
}
}
}
if (preg_match_all('/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $php, $matches) !== false) {
foreach ($matches[1] as $name) {
$lower = strtolower(self::lastDottedSegment($name));
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
$tables[$lower] = true;
}
}
}
$out = array_keys($tables);
sort($out);
return $out;
}
/**
* The table segment of a possibly schema-qualified identifier chain,
* e.g. `"public"."users"` or `analytics.events` yield `users` / `events`.
* Empty when any segment is schema metadata (`information_schema.tables`, ...).
*/
private static function unqualified(string $qualified): string
{
$name = '';
foreach (explode('.', $qualified) as $segment) {
$segment = trim($segment, " \t\n\r\"`[]");
if ($segment === '') {
continue;
}
if (self::isSchemaMeta($segment)) {
return '';
}
$name = $segment;
}
return $name;
}
private static function lastDottedSegment(string $name): string
{
$position = strrpos($name, '.');
return $position === false ? $name : substr($name, $position + 1);
}
private static function isSchemaMeta(string $name): bool
{
$lower = strtolower($name);
return in_array($lower, ['sqlite_master', 'sqlite_sequence', 'migrations'], true)
|| str_starts_with($lower, 'pg_')
|| str_starts_with($lower, 'information_schema');
}
}
-86
View File
@@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class TableTracker
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string MARKER = 'pest.tia.table-tracker-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->isActive()) {
return;
}
$containerClass = self::CONTAINER_CLASS;
if (! class_exists($containerClass)) {
return;
}
/** @var object $app */
$app = $containerClass::getInstance();
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
return;
}
if ($app->bound(self::MARKER)) {
return;
}
if (! $app->bound('db')) {
return;
}
$app->instance(self::MARKER, true);
$listener = static function (object $query) use ($recorder): void {
if (! property_exists($query, 'sql')) {
return;
}
/** @var mixed $sql */
$sql = $query->sql;
if (! is_string($sql) || $sql === '') {
return;
}
foreach (TableExtractor::fromSql($sql) as $table) {
$recorder->linkTable($table);
}
};
/** @var object $db */
$db = $app->make('db');
if (is_callable([$db, 'listen'])) {
/** @var callable $listen */
$listen = [$db, 'listen'];
$listen($listener);
return;
}
if (! $app->bound('events')) {
return;
}
/** @var object $events */
$events = $app->make('events');
if (! method_exists($events, 'listen')) {
return;
}
$events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
}
}
-151
View File
@@ -1,151 +0,0 @@
<?php
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,
* driven by phpunit.xml's <testsuites>. Falls back to the runtime TestSuite
* configuration when no config file is present.
*
* @internal
*/
final readonly class TestPaths
{
/**
* @param list<string> $directories Project-relative directory prefixes (no trailing slash).
* @param list<string> $files Project-relative file paths.
* @param list<string> $suffixes Filename suffixes (e.g. '.php').
*/
public function __construct(
private array $directories,
private array $files,
private array $suffixes,
) {}
public static function fromProjectRoot(string $projectRoot): self
{
$directories = [];
$files = [];
$suffixes = [];
try {
$configuration = Registry::get();
foreach ($configuration->testSuite() as $suite) {
foreach ($suite->directories() as $directory) {
$rel = self::toRelative($directory->path(), $projectRoot);
if ($rel !== null) {
$directories[] = $rel;
}
$suffixes[] = $directory->suffix();
}
foreach ($suite->files() as $file) {
$rel = self::toRelative($file->path(), $projectRoot);
if ($rel !== null) {
$files[] = $rel;
}
}
}
if ($suffixes === []) {
foreach ($configuration->testSuffixes() as $suffix) {
$suffixes[] = $suffix;
}
}
} catch (Throwable) {
// Registry not initialized — fall through to defaults.
}
if ($suffixes === []) {
$suffixes = ['.php'];
}
if ($directories === [] && $files === []) {
$fallback = self::testSuiteFallback($projectRoot);
if ($fallback !== null) {
$directories[] = $fallback;
}
}
return new self(
array_values(array_unique($directories)),
array_values(array_unique($files)),
array_values(array_unique($suffixes)),
);
}
public function isTestFile(string $relativePath): bool
{
if (in_array($relativePath, $this->files, true)) {
return true;
}
$matchesSuffix = array_any($this->suffixes, fn (string $suffix): bool => str_ends_with($relativePath, $suffix));
if (! $matchesSuffix) {
return false;
}
foreach ($this->directories as $dir) {
if ($dir === '') {
continue;
}
if (str_starts_with($relativePath, $dir.'/')) {
return true;
}
}
return false;
}
private static function toRelative(string $value, string $projectRoot): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$real = @realpath($value);
$resolved = $real === false ? $value : $real;
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
if (! str_starts_with($resolved.'/', $root)) {
return null;
}
return rtrim(substr($resolved, strlen($root)), '/');
}
private static function testSuiteFallback(string $projectRoot): ?string
{
try {
$testPath = TestSuite::getInstance()->testPath;
} catch (Throwable) {
return null;
}
$real = @realpath($testPath);
$resolved = $real === false ? $testPath : $real;
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
if (! str_starts_with($resolved.'/', $root)) {
return null;
}
return rtrim(substr($resolved, strlen($root)), '/');
}
}
-100
View File
@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
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;
/**
* @internal
*/
final readonly class Browser implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
}
public function defaults(string $projectRoot, string $testPath): array
{
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
$globs = [
'resources/js/** !*.php',
'resources/css/** !*.php',
'public/hot !*.php',
'public/** !*.php',
];
$patterns = [];
foreach ($globs as $glob) {
$patterns[$glob] = $browserTargets;
}
return $patterns;
}
/**
* @return array<int, string>
*/
public static function detectBrowserTestTargets(string $projectRoot, string $testPath): array
{
$targets = [];
$candidate = $testPath.'/Browser';
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
$targets[] = $candidate;
}
if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests;
foreach ($repo->getFilenames() as $filename) {
$factory = $repo->get($filename);
if (! $factory instanceof TestCaseFactory) {
continue;
}
foreach ($factory->methods as $method) {
if (BrowserTestIdentifier::isBrowserTest($method)) {
$rel = self::fileRelative($projectRoot, $filename);
if ($rel !== null) {
$targets[] = $rel;
}
break;
}
}
}
}
return array_values(array_unique($targets));
}
private static function fileRelative(string $projectRoot, string $path): ?string
{
$real = @realpath($path);
if ($real === false) {
$real = $path;
}
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (! str_starts_with($real, $root)) {
return null;
}
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Inertia implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& (InstalledVersions::isInstalled('inertiajs/inertia-laravel')
|| InstalledVersions::isInstalled('rompetomp/inertia-bundle'));
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'resources/js/** !*.php' => [$testPath],
];
}
}
-41
View File
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Laravel implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('laravel/framework');
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'database/migrations/**/*.php' => [$testPath],
'storage/fixtures/**/*' => [$testPath],
'app/** !*.php' => [$testPath],
'resources/views/**' => [$testPath],
'lang/**' => [$testPath],
'resources/lang/**' => [$testPath],
'vite.config.* !*.php' => [$testPath],
'webpack.mix.* !*.php' => [$testPath],
'tailwind.config.* !*.php' => [$testPath],
'postcss.config.* !*.php' => [$testPath],
];
}
}
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Livewire implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('livewire/livewire');
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'resources/views/livewire/**/*.blade.php' => [$testPath],
'resources/views/components/**/*.blade.php' => [$testPath],
'resources/views/pages/**/*.blade.php' => [$testPath],
'resources/js/**/*.js' => [$testPath],
'resources/js/**/*.ts' => [$testPath],
];
}
}
-38
View File
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Php implements WatchDefault
{
public function applicable(): bool
{
return true;
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'.env' => [$testPath],
'.env.testing' => [$testPath],
'.env.local' => [$testPath],
'.env.*.local' => [$testPath],
'docker-compose.yml' => [$testPath],
'docker-compose.yaml' => [$testPath],
'phpunit.xml*' => [$testPath],
$testPath.'/Fixtures/**/*' => [$testPath],
$testPath.'/**/Fixtures/**/*' => [$testPath],
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
];
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Symfony implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('symfony/framework-bundle');
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'config/** !*.php' => [$testPath],
'config/routes/** !*.php' => [$testPath],
'migrations/**/*.php' => [$testPath],
'src/Migrations/**/*.php' => [$testPath],
'templates/** !*.php' => [$testPath],
'translations/** !*.php' => [$testPath],
'config/doctrine/**/*.xml' => [$testPath],
'config/doctrine/**/*.yaml' => [$testPath],
'webpack.config.js' => [$testPath],
'importmap.php' => [$testPath],
'assets/** !*.php' => [$testPath],
];
}
}
-313
View File
@@ -1,313 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\WatchDefault;
use Pest\TestSuite;
/**
* @internal
*/
final class WatchPatterns
{
/**
* @var array<int, class-string<WatchDefault>>
*/
private const array DEFAULTS = [
WatchDefaults\Php::class,
WatchDefaults\Laravel::class,
WatchDefaults\Symfony::class,
WatchDefaults\Livewire::class,
WatchDefaults\Inertia::class,
WatchDefaults\Browser::class,
];
private const array VCS_DIRS = ['.git', '.svn', '.hg'];
/**
* @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;
foreach (self::DEFAULTS as $class) {
$default = new $class;
if (! $default->applicable()) {
continue;
}
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 pattern key → project-relative test dir/file
*/
public function add(array $patterns): void
{
foreach ($patterns as $key => $dir) {
$this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$key] ?? [], [$dir]),
));
}
}
/**
* @param string $projectRoot Absolute path.
* @param array<int, string> $changedFiles Project-relative paths.
* @return array<int, string> Project-relative test dirs/files.
*/
public function matchedDirectories(string $projectRoot, array $changedFiles): array
{
if ($this->patterns === []) {
return [];
}
$matched = [];
foreach ($changedFiles as $file) {
foreach ($this->patterns as $key => $dirs) {
if (! $this->keyMatches($key, $file)) {
continue;
}
foreach ($dirs as $dir) {
$matched[$dir] = true;
}
}
}
return array_keys($matched);
}
/**
* @param array<int, string> $directories Project-relative dirs/files.
* @param array<int, string> $allTestFiles Project-relative test files from graph.
* @return array<int, string>
*/
public function testsUnderDirectories(array $directories, array $allTestFiles): array
{
if ($directories === []) {
return [];
}
$affected = [];
foreach ($allTestFiles as $testFile) {
foreach ($directories as $target) {
if ($testFile === $target) {
$affected[] = $testFile;
break;
}
$prefix = rtrim($target, '/').'/';
if (str_starts_with($testFile, $prefix)) {
$affected[] = $testFile;
break;
}
}
}
return $affected;
}
public function markEnabled(): void
{
$this->enabled = true;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function markLocally(): void
{
$this->locally = true;
}
public function isLocally(): bool
{
return $this->locally;
}
public function markFiltered(): void
{
$this->filtered = true;
}
public function isFiltered(): bool
{
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
{
return array_any(explode('/', str_replace('\\', '/', $pattern)), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
}
private function touchesVcs(string $file): bool
{
return array_any(explode('/', $file), fn (string $segment): bool => in_array($segment, self::VCS_DIRS, true));
}
private function touchesDotfile(string $file): bool
{
return array_any(explode('/', $file), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
}
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
{
$pattern = str_replace('\\', '/', $pattern);
$file = str_replace('\\', '/', $file);
$regex = '';
$len = strlen($pattern);
$i = 0;
while ($i < $len) {
$c = $pattern[$i];
if ($c === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') {
$regex .= '.*';
$i += 2;
if (isset($pattern[$i]) && $pattern[$i] === '/') {
$i++;
}
} elseif ($c === '*') {
$regex .= '[^/]*';
$i++;
} elseif ($c === '?') {
$regex .= '[^/]';
$i++;
} else {
$regex .= preg_quote($c, '#');
$i++;
}
}
return (bool) preg_match('#^'.$regex.'$#', $file);
}
}
+2 -4
View File
@@ -59,10 +59,8 @@ final class SnapshotRepository
{ {
$snapshotFilename = $this->getSnapshotFilename(); $snapshotFilename = $this->getSnapshotFilename();
$directory = dirname($snapshotFilename); if (! file_exists(dirname($snapshotFilename))) {
mkdir(dirname($snapshotFilename), 0755, true);
if (! is_dir($directory)) {
@mkdir($directory, 0755, true);
} }
file_put_contents($snapshotFilename, $snapshot); file_put_contents($snapshotFilename, $snapshot);
-95
View File
@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Restarters;
use Pest\Contracts\Restarter;
use Pest\Plugins\Tia;
/**
* @internal
*/
final class PcovRestarter implements Restarter
{
private const string ENV_RESTARTED = 'PEST_PCOV_RESTARTER_RESTARTED';
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void
{
if (! extension_loaded('pcov')) {
return;
}
if (getenv(self::ENV_RESTARTED) === '1') {
putenv(self::ENV_RESTARTED);
unset($_ENV[self::ENV_RESTARTED]);
return;
}
if (! Tia::isEnabledForRun($arguments)) {
return;
}
$desired = $this->normalise($projectRoot);
$current = $this->normalise((string) ini_get('pcov.directory'));
if ($current === $desired) {
return;
}
$this->restart($projectRoot, $arguments);
}
/**
* @param array<int, string> $arguments
*/
private function restart(string $projectRoot, array $arguments): void
{
$env = $this->inheritEnv();
$env[self::ENV_RESTARTED] = '1';
$command = array_merge(
[PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
array_values($arguments),
);
$proc = @proc_open(
$command,
[STDIN, STDOUT, STDERR],
$pipes,
null,
$env,
);
if (! is_resource($proc)) {
return;
}
$exitCode = proc_close($proc);
exit($exitCode === -1 ? 1 : $exitCode);
}
/**
* @return array<string, string>
*/
private function inheritEnv(): array
{
$env = [];
foreach (getenv() as $name => $value) {
$env[$name] = $value;
}
return $env;
}
private function normalise(string $path): string
{
return rtrim($path, '/\\');
}
}
-113
View File
@@ -1,113 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Restarters;
use Composer\XdebugHandler\XdebugHandler;
use Pest\Contracts\Restarter;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage;
/**
* @internal
*/
final class XdebugRestarter implements Restarter
{
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void
{
if (! class_exists(XdebugHandler::class)) {
return;
}
if (! extension_loaded('xdebug')) {
return;
}
if (! $this->xdebugIsCoverageOnly()) {
return;
}
if (! $this->runLooksDroppable($arguments, $projectRoot)) {
return;
}
new XdebugHandler('pest')->check();
}
private function xdebugIsCoverageOnly(): bool
{
if (! function_exists('xdebug_info')) {
return false;
}
$modes = @xdebug_info('mode');
if (! is_array($modes)) {
return false;
}
$modes = array_values(array_filter($modes, is_string(...)));
if ($modes === []) {
return true;
}
return $modes === ['coverage'];
}
/**
* @param array<int, string> $arguments
*/
private function runLooksDroppable(array $arguments, string $projectRoot): bool
{
foreach ($arguments as $value) {
if ($value === '--coverage'
|| str_starts_with($value, '--coverage=')
|| str_starts_with($value, '--coverage-')) {
return false;
}
if ($value === '--fresh') {
return false;
}
}
if (! Tia::isEnabledForRun($arguments)) {
return false;
}
return $this->tiaWillReplay($projectRoot);
}
private function tiaWillReplay(string $projectRoot): bool
{
$path = Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
if (! is_file($path)) {
return false;
}
$json = @file_get_contents($path);
if ($json === false) {
return false;
}
$graph = Graph::decode($json, $projectRoot);
if (! $graph instanceof Graph) {
return false;
}
return Fingerprint::structuralMatches(
$graph->fingerprint(),
Fingerprint::compute($projectRoot),
);
}
}
@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use PHPUnit\Event\TestSuite\Finished;
use PHPUnit\Event\TestSuite\FinishedSubscriber;
/**
* @internal
*/
final class EnsureShardTimingFinished implements FinishedSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Finished $event): void
{
EnsureShardTimingsAreCollected::finished($event);
}
}
@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use PHPUnit\Event\TestSuite\Started;
use PHPUnit\Event\TestSuite\StartedSubscriber;
/**
* @internal
*/
final class EnsureShardTimingStarted implements StartedSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Started $event): void
{
EnsureShardTimingsAreCollected::started($event);
}
}
@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use PHPUnit\Event\Telemetry\HRTime;
use PHPUnit\Event\TestSuite\Finished;
use PHPUnit\Event\TestSuite\Started;
/**
* @internal
*/
final class EnsureShardTimingsAreCollected
{
/**
* The start times for each test class.
*
* @var array<string, HRTime>
*/
private static array $startTimes = [];
/**
* The collected timings for each test class.
*
* @var array<string, float>
*/
private static array $timings = [];
/**
* Records the start time for a test suite.
*/
public static function started(Started $event): void
{
if (! $event->testSuite()->isForTestClass()) {
return;
}
$name = preg_replace('/^P\\\\/', '', $event->testSuite()->name());
if (is_string($name)) {
self::$startTimes[$name] = $event->telemetryInfo()->time();
}
}
/**
* Records the duration for a test suite.
*/
public static function finished(Finished $event): void
{
if (! $event->testSuite()->isForTestClass()) {
return;
}
$name = preg_replace('/^P\\\\/', '', $event->testSuite()->name());
if (! is_string($name) || ! isset(self::$startTimes[$name])) {
return;
}
$duration = $event->telemetryInfo()->time()->duration(self::$startTimes[$name]);
self::$timings[$name] = round($duration->asFloat(), 4);
}
/**
* Returns the collected timings.
*
* @return array<string, float>
*/
public static function timings(): array
{
return self::$timings;
}
}
+4 -15
View File
@@ -15,20 +15,15 @@ use Symfony\Component\Console\Output\OutputInterface;
/** /**
* @internal * @internal
*/ */
final class EnsureTeamCityEnabled implements ConfiguredSubscriber final readonly 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 readonly InputInterface $input, private InputInterface $input,
private readonly OutputInterface $output, private OutputInterface $output,
private readonly TestSuite $testSuite, private TestSuite $testSuite,
) {} ) {}
/** /**
@@ -36,16 +31,10 @@ final 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();
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(Finished $event): void
{
$test = $event->test();
if ($test instanceof TestMethod) {
$this->collector->recordAssertions(
$test->className().'::'.$test->methodName(),
$event->numberOfAssertionsPerformed(),
);
}
$this->collector->finishTest();
}
}
-22
View File
@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaEnds implements FinishedSubscriber
{
public function __construct(private Recorder $recorder) {}
public function notify(Finished $event): void
{
$this->recorder->endTest();
}
}
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
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;
/**
* @internal
*/
final readonly class EnsureTiaIsRunningPestTestsOnly implements PreparedSubscriber
{
public function __construct(private Recorder $recorder) {}
public function notify(Prepared $event): void
{
if (! $this->recorder->isActive()) {
return;
}
$test = $event->test();
if (! $test instanceof TestMethod) {
return;
}
$className = $test->className();
if (! class_exists($className, false)) {
return;
}
if (method_exists($className, '__initializeTestCase')) {
return;
}
Panic::with(new TiaRequiresPestTests($className, $test->file()));
}
}
@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\ErroredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnErrored implements ErroredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(Errored $event): void
{
$this->collector->testErrored($event->throwable()->message());
}
}

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