mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 14:23:34 +02:00
Compare commits
114 Commits
v4.7.3
..
db70017cb2
| Author | SHA1 | Date | |
|---|---|---|---|
| db70017cb2 | |||
| 86adaedbbd | |||
| 92c7677c6e | |||
| 872f0a50c2 | |||
| 668809bc75 | |||
| 8d8f45c843 | |||
| 086b3e9107 | |||
| 43fe26f324 | |||
| 865c1e5113 | |||
| 19eed8d581 | |||
| ad1850b110 | |||
| 585de259a2 | |||
| 953664dce0 | |||
| 1dd959848c | |||
| 047753c836 | |||
| b63626a94d | |||
| ae080becd6 | |||
| aa86304794 | |||
| 75963ea973 | |||
| 5d0ceb1a5d | |||
| 6f0c0536a4 | |||
| 6b2cd358e8 | |||
| e09e1be76d | |||
| 759ac63139 | |||
| 333f469efb | |||
| c4ec925aa5 | |||
| 5c8cfed483 | |||
| 962510b7e3 | |||
| e9c268a584 | |||
| fa91fcf744 | |||
| c6073511a2 | |||
| b5e1b83bb4 | |||
| 1c11595504 | |||
| cd0e921158 | |||
| f8c4c061f8 | |||
| 81eacd79fd | |||
| 1ef680c75d | |||
| 820fa08313 | |||
| 9e79e491e2 | |||
| 1b83b8fcda | |||
| ff4aba2ffa | |||
| feaac1ab7e | |||
| bbc6ada21e | |||
| 3927dbfcdf | |||
| b52bbb4cca | |||
| 52819501eb | |||
| 7b18287374 | |||
| 33e3bf2888 | |||
| 72ab506d7b | |||
| 5dc49a71d6 | |||
| 1adb484d3f | |||
| aadf7e92a6 | |||
| df42b9ed7b | |||
| 632ae79c8b | |||
| b5fed82883 | |||
| 145e20db75 | |||
| ee2e97e932 | |||
| b561236325 | |||
| e685179b7e | |||
| ac8feafdcc | |||
| b49cd150c9 | |||
| 5cfb4133bf | |||
| 3c8bae5f05 | |||
| b2998bc69e | |||
| 3876093cd2 | |||
| 932f8bcc07 | |||
| d393799d2a | |||
| 0d7814ca16 | |||
| 8467c64c22 | |||
| 97714a7088 | |||
| afb582616d | |||
| 15e9b6a507 | |||
| 520ce29376 | |||
| 774a340400 | |||
| 3d5bba93f8 | |||
| 79bc7a8257 | |||
| fc48c1bd1e | |||
| da726beffc | |||
| 4ef12b9aac | |||
| 4d550cecfd | |||
| 34695843b3 | |||
| d17be9decd | |||
| b828ddcec7 | |||
| f859bb179d | |||
| 18bbca748f | |||
| f142aad8ad | |||
| 74a28d4f5e | |||
| 6053e15d00 | |||
| 2d649d765f | |||
| 4fb4908570 | |||
| e63a886f98 | |||
| 8dd650fd05 | |||
| fbca346d7c | |||
| 3f13bca0f7 | |||
| d3acb1c56a | |||
| e601e6df31 | |||
| 6fdbca1226 | |||
| 54359b895f | |||
| 44c04bfce1 | |||
| 271c680d3c | |||
| 4a1d8d27b8 | |||
| 0f6924984c | |||
| 668ca9f5de | |||
| f659a45311 | |||
| 12c1da29ee | |||
| fa27c8daef | |||
| f0a08f0503 | |||
| 2c040c5b1f | |||
| a9ce1fd739 | |||
| 3533356262 | |||
| 4aa41d0b14 | |||
| e4ed60085c | |||
| e2b119655d | |||
| fcf5baf0a9 |
@@ -2,7 +2,7 @@ name: Static Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [4.x]
|
||||
branches: [5.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
@@ -28,12 +28,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
extensions: sockets
|
||||
@@ -44,17 +44,17 @@ jobs:
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
restore-keys: |
|
||||
static-php-8.3-${{ matrix.dependency-version }}-composer-
|
||||
static-php-8.3-composer-
|
||||
static-php-8.4-${{ matrix.dependency-version }}-composer-
|
||||
static-php-8.4-composer-
|
||||
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
COMPOSER_ROOT_VERSION: 4.x-dev
|
||||
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
|
||||
|
||||
- name: Profanity Check
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [4.x]
|
||||
branches: [5.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
@@ -24,18 +24,15 @@ jobs:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest] # windows-latest
|
||||
symfony: ['7.4', '8.0']
|
||||
php: ['8.3', '8.4', '8.5']
|
||||
symfony: ['8.0']
|
||||
php: ['8.4', '8.5']
|
||||
dependency_version: [prefer-stable]
|
||||
exclude:
|
||||
- php: '8.3'
|
||||
symfony: '8.0'
|
||||
|
||||
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
@@ -51,7 +48,7 @@ jobs:
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
@@ -67,7 +64,7 @@ jobs:
|
||||
- name: Install PHP dependencies
|
||||
shell: bash
|
||||
env:
|
||||
COMPOSER_ROOT_VERSION: 4.x-dev
|
||||
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
|
||||
|
||||
- name: Unit Tests
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# CLAUDE.md
|
||||
|
||||
**Do not edit this file.** Agents must never add, remove, or reword anything here. If a change seems needed, say so and let a human do it.
|
||||
|
||||
## Ask before testing
|
||||
|
||||
When asked to review code or build a feature, do not run the test suite and do not write new tests. Make the change, report it, then ask the user whether tests should be added — describing the tests you have in mind — and wait for the user to confirm.
|
||||
|
||||
Two reasons this matters here: the suite takes minutes, and `tests/.snapshots/success.txt` plus the tally in `tests/Visual/Parallel.php` encode the whole suite's result, so a single added test breaks both.
|
||||
|
||||
Once the user confirms:
|
||||
|
||||
```bash
|
||||
composer test:unit # fast, excludes the visual group
|
||||
composer test:integration # visual and snapshot tests
|
||||
composer test # everything CI runs, in CI's order
|
||||
composer update:snapshots # only when a test was added or removed
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
|
||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v5/social.png" width="600" alt="PEST">
|
||||
<p align="center">
|
||||
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (4.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
|
||||
<a href="https://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://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
------
|
||||
|
||||
> Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
|
||||
> Pest v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest5-now-available)**.
|
||||
|
||||
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
|
||||
**Pest** is an elegant testing framework for PHP developers and AI agents.
|
||||
|
||||
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
|
||||
- Follow the creator Nuno Maduro:
|
||||
@@ -29,26 +29,13 @@
|
||||
|
||||
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)**.
|
||||
|
||||
|
||||
### Platinum Sponsors
|
||||
|
||||
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
|
||||
- **[Mailtrap](https://l.rw.rw/pestphp)**
|
||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||
- **[Tighten](https://tighten.com/?ref=nunomaduro)**
|
||||
- **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)**
|
||||
|
||||
### 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)
|
||||
- **[PhpStorm](https://jb.gg/nuno)**
|
||||
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
|
||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
|
||||
- **[Bento](https://bentonow.com/?ref=nunomaduro)**
|
||||
- **[Pixel](https://wearepixel.com.au/?ref=nunomaduro)**
|
||||
- **[Redberry](https://redberry.international/laravel-development/?ref=nunomaduro)**
|
||||
|
||||
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
|
||||
|
||||
+3
-3
@@ -2,10 +2,10 @@
|
||||
|
||||
When releasing a new version of Pest there are some checks and updates that need to be done:
|
||||
|
||||
> **For Pest v3 you should use the `3.x` branch instead.**
|
||||
> **For Pest v4 you should use the `4.x` branch instead.**
|
||||
|
||||
- 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}...4.x](https://github.com/pestphp/pest/compare/{latest_version}...4.x)
|
||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.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)
|
||||
- Update the version number in [src/Pest.php](src/Pest.php)
|
||||
- Run the tests locally using: `composer test`
|
||||
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
|
||||
|
||||
+154
-19
@@ -25,26 +25,72 @@ const PAGE_DIR_CANDIDATES = [
|
||||
|
||||
async function loadRolldown() {
|
||||
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||
const path = projectRequire.resolve('rolldown')
|
||||
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')
|
||||
const stripped = raw
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
||||
return JSON.parse(stripped)
|
||||
return JSON.parse(stripJsonComments(raw))
|
||||
}
|
||||
|
||||
async function loadAliasFromTsconfig() {
|
||||
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
|
||||
const alias = {}
|
||||
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
||||
const p = join(PROJECT_ROOT, name)
|
||||
const p = join(projectRoot, name)
|
||||
if (!existsSync(p)) continue
|
||||
let cfg
|
||||
try { cfg = await readJsonWithComments(p) } catch { continue }
|
||||
const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.')
|
||||
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
|
||||
const paths = cfg?.compilerOptions?.paths ?? {}
|
||||
for (const [key, targets] of Object.entries(paths)) {
|
||||
if (!key.endsWith('/*')) continue
|
||||
@@ -58,6 +104,74 @@ async function loadAliasFromTsconfig() {
|
||||
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 []
|
||||
|
||||
@@ -121,8 +235,16 @@ async function main() {
|
||||
return
|
||||
}
|
||||
|
||||
const { rolldown } = await loadRolldown()
|
||||
const alias = await loadAliasFromTsconfig()
|
||||
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()
|
||||
@@ -167,7 +289,7 @@ async function main() {
|
||||
cwd: PROJECT_ROOT,
|
||||
resolve: {
|
||||
alias,
|
||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
|
||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
|
||||
},
|
||||
transform: { jsx: 'preserve' },
|
||||
treeshake: false,
|
||||
@@ -192,6 +314,11 @@ async function main() {
|
||||
|
||||
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) {
|
||||
@@ -200,13 +327,17 @@ async function main() {
|
||||
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
||||
acc.add(rel)
|
||||
}
|
||||
if (stack.has(dep)) continue
|
||||
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)
|
||||
transitiveCache.set(id, acc)
|
||||
if (complete) transitiveCache.set(id, acc)
|
||||
return acc
|
||||
}
|
||||
|
||||
@@ -230,10 +361,14 @@ async function main() {
|
||||
process.stdout.write(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
try {
|
||||
void pathToFileURL
|
||||
await main()
|
||||
} catch (err) {
|
||||
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
||||
process.exit(1)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -17,21 +17,20 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.3.0",
|
||||
"brianium/paratest": "^7.20.0",
|
||||
"composer/xdebug-handler": "^3.0.5",
|
||||
"nunomaduro/collision": "^8.9.4",
|
||||
"php": "^8.4",
|
||||
"brianium/paratest": "^7.23.1",
|
||||
"nunomaduro/collision": "^8.9.5",
|
||||
"nunomaduro/termwind": "^2.4.0",
|
||||
"pestphp/pest-plugin": "^4.0.0",
|
||||
"pestphp/pest-plugin-arch": "^4.0.2",
|
||||
"pestphp/pest-plugin-mutate": "^4.0.1",
|
||||
"pestphp/pest-plugin-profanity": "^4.2.1",
|
||||
"phpunit/phpunit": "^12.5.29",
|
||||
"symfony/process": "^7.4.13|^8.1.0"
|
||||
"pestphp/pest-plugin": "^5.0.0",
|
||||
"pestphp/pest-plugin-arch": "^5.0.0",
|
||||
"pestphp/pest-plugin-mutate": "^5.0.1",
|
||||
"pestphp/pest-plugin-profanity": "^5.0.0",
|
||||
"phpunit/phpunit": "^13.2.6",
|
||||
"symfony/process": "^8.1.0"
|
||||
},
|
||||
"conflict": {
|
||||
"filp/whoops": "<2.18.3",
|
||||
"phpunit/phpunit": ">12.5.29",
|
||||
"phpunit/phpunit": ">13.2.6",
|
||||
"sebastian/exporter": "<7.0.0",
|
||||
"webmozart/assert": "<1.11.0"
|
||||
},
|
||||
@@ -59,11 +58,12 @@
|
||||
]
|
||||
},
|
||||
"require-dev": {
|
||||
"mrpunyapal/peststan": "^0.2.10",
|
||||
"pestphp/pest-dev-tools": "^4.1.0",
|
||||
"pestphp/pest-plugin-browser": "^4.3.1",
|
||||
"pestphp/pest-plugin-type-coverage": "^4.0.4",
|
||||
"psy/psysh": "^0.12.23"
|
||||
"pestphp/pest-dev-tools": "^5.0.0",
|
||||
"pestphp/pest-plugin-browser": "^5.0.0",
|
||||
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
||||
"pestphp/pest-plugin-rector": "^5.0.2",
|
||||
"pestphp/pest-plugin-type-coverage": "^5.0.2",
|
||||
"psy/psysh": "^0.12.24"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -90,11 +90,11 @@ final class TestSuiteLoader
|
||||
(static function () use ($suiteClassFile) {
|
||||
try {
|
||||
include_once $suiteClassFile;
|
||||
|
||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||
} catch (Throwable $e) {
|
||||
Panic::with($e);
|
||||
}
|
||||
|
||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||
})();
|
||||
|
||||
$loadedClasses = array_values(
|
||||
|
||||
+10
-6
@@ -43,7 +43,7 @@ declare(strict_types=1);
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
namespace PHPUnit\TextUI\Output\Default\ProgressPrinter;
|
||||
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\Test\SkippedSubscriber;
|
||||
@@ -51,16 +51,20 @@ use ReflectionClass;
|
||||
|
||||
/**
|
||||
* @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 class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
{
|
||||
public function notify(Skipped $event): void
|
||||
{
|
||||
if (str_contains($event->message(), '__TODO__')) {
|
||||
if ($event->message() === '__TODO__') {
|
||||
$this->printTodoItem();
|
||||
}
|
||||
|
||||
$this->logger()->testSkipped($event);
|
||||
$this->printer()->testSkipped();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +73,7 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
||||
private function printTodoItem(): void
|
||||
{
|
||||
$mirror = new ReflectionClass($this->printer());
|
||||
$printerMirror = $mirror->getMethod('printProgress');
|
||||
$printerMirror->invoke($this->printer(), 'T');
|
||||
$printProgress = $mirror->getMethod('printProgress');
|
||||
$printProgress->invoke($this->printer(), 'T');
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
includes:
|
||||
- phpstan-baseline.neon
|
||||
- phpstan-pest-extension.neon
|
||||
- vendor/mrpunyapal/peststan/extension.neon
|
||||
- vendor/pestphp/pest-plugin-phpstan/extension.neon
|
||||
|
||||
parameters:
|
||||
level: 7
|
||||
|
||||
+1
-1
@@ -18,8 +18,8 @@
|
||||
<directory suffix=".php">./tests</directory>
|
||||
<directory suffix=".php">./tests-external</directory>
|
||||
<exclude>./tests/.snapshots</exclude>
|
||||
<exclude>./tests/.tests</exclude>
|
||||
<exclude>./tests/Fixtures/Inheritance</exclude>
|
||||
<exclude>./tests/Fixtures/Suites</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
|
||||
+41
@@ -2,22 +2,63 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Rector\Rules\UseToMatchArrayRector;
|
||||
use Pest\Rector\Set\PestSetList;
|
||||
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
||||
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\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\ReturnNeverTypeRector;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__.'/src',
|
||||
__DIR__.'/tests',
|
||||
])
|
||||
->withSets([
|
||||
PestSetList::CODING_STYLE,
|
||||
])
|
||||
->withSkip([
|
||||
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
||||
__DIR__.'/tests/Fixtures/Arch',
|
||||
// Fixture suites are pinned by the TeamCity / JUnit snapshots, down to
|
||||
// the line numbers — rewriting their source would break them.
|
||||
__DIR__.'/tests/Fixtures/Suites',
|
||||
ReturnNeverTypeRector::class,
|
||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
||||
NarrowObjectReturnTypeRector::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(
|
||||
deadCode: true,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: pest-testing
|
||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, architecture tests, or faster test runs with Test Impact Analysis. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, Tia (--tia), sharding, and all Pest 5 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
@php
|
||||
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
|
||||
@endphp
|
||||
# Pest Testing 5
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Pest 5 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Tests
|
||||
|
||||
All tests must be written using Pest. Use `{{ $assist->artisanCommand('make:test --pest {name}') }}`.
|
||||
|
||||
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest Feature/SomeFeatureTest') }}` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
||||
- Correct: `{{ $assist->artisanCommand('make:test --pest SomeControllerTest') }}` will generate `tests/Feature/SomeControllerTest.php`
|
||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest --unit Unit/SomeServiceTest') }}` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
||||
- Correct: `{{ $assist->artisanCommand('make:test --pest --unit SomeServiceTest') }}` will generate `tests/Unit/SomeServiceTest.php`
|
||||
|
||||
### Test Organization
|
||||
|
||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||
- Browser tests: `tests/Browser/` directory.
|
||||
- Do NOT remove tests without approval - these are core application code.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||
|
||||
@boostsnippet("Basic Pest Test Example", "php")
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
### Running Tests
|
||||
|
||||
- Run minimal tests with filter before finalizing: `{{ $assist->artisanCommand('test --compact --filter=testName') }}`.
|
||||
- Run all tests: `{{ $assist->artisanCommand('test --compact') }}`.
|
||||
- Run file: `{{ $assist->artisanCommand('test --compact tests/Feature/ExampleTest.php') }}`.
|
||||
- Run only tests affected by recent changes (Tia): `./vendor/bin/pest --parallel --tia`.
|
||||
|
||||
## Assertions
|
||||
|
||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||
|
||||
@boostsnippet("Pest Response Assertion", "php")
|
||||
it('returns all', function () {
|
||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
| Use | Instead of |
|
||||
|-----|------------|
|
||||
| `assertSuccessful()` | `assertStatus(200)` |
|
||||
| `assertNotFound()` | `assertStatus(404)` |
|
||||
| `assertForbidden()` | `assertStatus(403)` |
|
||||
|
||||
## Mocking
|
||||
|
||||
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||
|
||||
## Datasets
|
||||
|
||||
Use datasets for repetitive tests (validation rules, etc.):
|
||||
|
||||
@boostsnippet("Pest Dataset Example", "php")
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
@endboostsnippet
|
||||
|
||||
## Pest 5 Features
|
||||
|
||||
| Feature | Purpose |
|
||||
|---------|---------|
|
||||
| Tia (Test Impact Analysis) | Rerun only tests affected by recent changes |
|
||||
| Time-Balanced Sharding | Split tests across CI shards by execution time |
|
||||
| New Validation Expectations | `toBeEmail()`, `toBeUlid()`, `toBeIpAddress()`, and more |
|
||||
| Browser Testing | Full integration tests in real browsers |
|
||||
| Smoke Testing | Validate multiple pages quickly |
|
||||
| Visual Regression | Compare screenshots for visual changes |
|
||||
| Architecture Testing | Enforce code conventions |
|
||||
|
||||
### Tia (Test Impact Analysis)
|
||||
|
||||
Tia reruns only tests affected by recent changes and replays cached results for the rest, dramatically reducing suite duration:
|
||||
|
||||
@boostsnippet("Tia Example", "shell")
|
||||
./vendor/bin/pest --parallel --tia
|
||||
@endboostsnippet
|
||||
|
||||
- Replayed tests are not skipped — cached tests store everything they produced, including covered lines and branches.
|
||||
- Detects Laravel, Symfony, Livewire, and Inertia automatically.
|
||||
|
||||
### New Validation Expectations
|
||||
|
||||
Pest 5 ships eight new validation matchers, all supporting `.not` negation:
|
||||
|
||||
@boostsnippet("Pest 5 Validation Expectations", "php")
|
||||
expect('nuno@pestphp.com')->toBeEmail();
|
||||
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
|
||||
expect('192.168.1.1')->toBeIpAddress();
|
||||
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress();
|
||||
expect('example.com')->toBeHostname();
|
||||
expect('example.co.uk')->toBeDomain();
|
||||
expect('Zm9vYmFy')->toBeBase64();
|
||||
expect('deadbeef')->toBeHexadecimal();
|
||||
@endboostsnippet
|
||||
|
||||
### Time-Balanced Sharding
|
||||
|
||||
Distribute tests across CI shards by execution time rather than count:
|
||||
|
||||
@boostsnippet("Pest Sharding Example", "shell")
|
||||
./vendor/bin/pest --update-shards
|
||||
./vendor/bin/pest --shard=1/4
|
||||
@endboostsnippet
|
||||
|
||||
Commit `tests/.pest/shards.json` to the repository so CI shards stay consistent.
|
||||
|
||||
### Browser Test Example
|
||||
|
||||
Browser tests run in real browsers for full integration testing:
|
||||
|
||||
- Browser tests live in `tests/Browser/`.
|
||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||
- Use `RefreshDatabase` for clean state per test.
|
||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging.
|
||||
|
||||
@boostsnippet("Pest Browser Test Example", "php")
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in');
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavaScriptErrors()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!');
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
### Smoke Testing
|
||||
|
||||
Quickly validate multiple pages have no JavaScript errors:
|
||||
|
||||
@boostsnippet("Pest Smoke Testing Example", "php")
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||
@endboostsnippet
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
Capture and compare screenshots to detect visual changes.
|
||||
|
||||
### Architecture Testing
|
||||
|
||||
@boostsnippet("Architecture Test Example", "php")
|
||||
arch('controllers')
|
||||
->expect('App\Http\Controllers')
|
||||
->toExtendNothing()
|
||||
->toHaveSuffix('Controller');
|
||||
@endboostsnippet
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||
- Forgetting datasets for repetitive validation tests
|
||||
- Deleting tests without approval
|
||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<div>
|
||||
<span class="text-gray mr-1">- </span>
|
||||
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
|
||||
<span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -18,6 +18,7 @@ final class BootOverrides implements Bootstrapper
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const array FILES = [
|
||||
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
|
||||
'Runner/Filter/NameFilterIterator.php',
|
||||
'Runner/ResultCache/DefaultResultCache.php',
|
||||
'Runner/TestSuiteLoader.php',
|
||||
|
||||
+25
-10
@@ -23,6 +23,8 @@ use PHPUnit\Framework\Attributes\PostCondition;
|
||||
use PHPUnit\Framework\IncompleteTest;
|
||||
use PHPUnit\Framework\SkippedTest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\TestCase\ExceptionExpectation;
|
||||
use PHPUnit\Framework\TestCase\OutputBuffer;
|
||||
use ReflectionException;
|
||||
use ReflectionFunction;
|
||||
use ReflectionParameter;
|
||||
@@ -278,12 +280,17 @@ trait Testable
|
||||
|
||||
/** @var Tia $tia */
|
||||
$tia = Container::getInstance()->get(Tia::class);
|
||||
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
|
||||
$status = $tia->getStatus(self::$__filename, $this->valueObjectForEvents()->id());
|
||||
$replay = ReplayType::fromStatus($status);
|
||||
|
||||
if ($replay !== ReplayType::None) {
|
||||
assert($status !== null);
|
||||
|
||||
// Marks the replay before the branches below throw, so `tearDown`
|
||||
// short-circuits for every replayed result — the throwing branches
|
||||
// never reach `parent::setUp`, so no user hook may run after them.
|
||||
$this->__replay = $replay;
|
||||
|
||||
match ($replay) {
|
||||
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
||||
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
||||
@@ -317,7 +324,7 @@ trait Testable
|
||||
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
||||
{
|
||||
$this->__replay = $replay;
|
||||
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
|
||||
$this->__replayAssertions = $tia->getAssertionCount($this->valueObjectForEvents()->id());
|
||||
$this->__ran = true;
|
||||
}
|
||||
|
||||
@@ -431,15 +438,15 @@ trait Testable
|
||||
unset($this->{$property});
|
||||
}
|
||||
|
||||
$hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)();
|
||||
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
|
||||
|
||||
if ($hasOutputExpectation) {
|
||||
if ($outputBuffer->hasExpectation()) {
|
||||
ob_clean();
|
||||
|
||||
Closure::bind(function (): void {
|
||||
$this->outputExpectedString = null;
|
||||
$this->outputExpectedRegex = null;
|
||||
}, $this, TestCase::class)();
|
||||
$this->expectedString = null;
|
||||
$this->expectedRegularExpression = null;
|
||||
}, $outputBuffer, OutputBuffer::class)();
|
||||
}
|
||||
|
||||
$this->setUp();
|
||||
@@ -455,7 +462,9 @@ trait Testable
|
||||
*/
|
||||
private function __isExpectedException(Throwable $e): bool
|
||||
{
|
||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)();
|
||||
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
|
||||
|
||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
|
||||
|
||||
$expectedClass = $read('expectedException');
|
||||
|
||||
@@ -463,13 +472,19 @@ trait Testable
|
||||
return $e instanceof $expectedClass;
|
||||
}
|
||||
|
||||
$expectedMessage = $read('expectedExceptionMessage');
|
||||
$expectedMessage = $read('expectedMessage');
|
||||
|
||||
if ($expectedMessage !== null) {
|
||||
return str_contains($e->getMessage(), (string) $expectedMessage);
|
||||
}
|
||||
|
||||
$expectedCode = $read('expectedExceptionCode');
|
||||
$expectedMessageRegex = $read('expectedMessageRegularExpression');
|
||||
|
||||
if ($expectedMessageRegex !== null) {
|
||||
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
|
||||
}
|
||||
|
||||
$expectedCode = $read('expectedCode');
|
||||
|
||||
if ($expectedCode !== null) {
|
||||
return $e->getCode() === $expectedCode;
|
||||
|
||||
+11
-3
@@ -33,7 +33,7 @@ final readonly class Configuration
|
||||
*/
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
(new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only();
|
||||
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +119,14 @@ final readonly class 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.
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,6 @@ final class AfterAllAlreadyExist extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The afterAll already exists in the filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The afterAll already exists in the filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class AfterAllWithinDescribe extends InvalidArgumentException implements E
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class AfterBeforeTestFunction extends InvalidArgumentException implements
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename `['.$filename.']`.');
|
||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename ['.$filename.'].');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class BeforeAllAlreadyExist extends InvalidArgumentException implements Ex
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The beforeAll already exists in the filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class BeforeAllWithinDescribe extends InvalidArgumentException implements
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class DatasetAlreadyExists extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $name, string $scope)
|
||||
{
|
||||
parent::__construct(sprintf('A dataset with the name `%s` already exists in scope [%s].', $name, $scope));
|
||||
parent::__construct(sprintf('A dataset with the name [%s] already exists in scope [%s].', $name, $scope));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class DatasetDoesNotExist extends InvalidArgumentException implements Exce
|
||||
*/
|
||||
public function __construct(string $name)
|
||||
{
|
||||
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||
parent::__construct(sprintf("A dataset with the name [%s] does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,6 @@ final class FileOrFolderNotFound extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The file or folder with the name `%s` could not be found.', $filename));
|
||||
parent::__construct(sprintf('The file or folder with the name [%s] could not be found.', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class InvalidTestClassName extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance for the given class name.
|
||||
*/
|
||||
public static function fromClassName(string $filename, string $className): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'The test file [%s] would create the class [%s], which is not a valid PHP class name. Please rename the test file.',
|
||||
$filename,
|
||||
$className,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Exception instance for the given namespace.
|
||||
*/
|
||||
public static function fromNamespace(string $filename, string $namespace, string $part): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'The test file [%s] would create the namespace [%s], which is not a valid PHP namespace, as [%s] may not be used as a namespace name. Please rename the folder in question.',
|
||||
$filename,
|
||||
$namespace,
|
||||
$part,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,6 @@ final class TestAlreadyExist extends InvalidArgumentException implements Excepti
|
||||
*/
|
||||
public function __construct(string $fileName, string $description)
|
||||
{
|
||||
parent::__construct(sprintf('A test with the description `%s` already exists in the filename `%s`.', $description, $fileName));
|
||||
parent::__construct(sprintf('A test with the description [%s] already exists in the filename [%s].', $description, $fileName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implem
|
||||
*/
|
||||
public function __construct(string $testCaseClass)
|
||||
{
|
||||
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass));
|
||||
parent::__construct(sprintf('The class [%s] was not found.', $testCaseClass));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class TestDescriptionMissing extends InvalidArgumentException implements E
|
||||
*/
|
||||
public function __construct(string $fileName)
|
||||
{
|
||||
parent::__construct(sprintf('Test description is missing in the filename `%s`.', $fileName));
|
||||
parent::__construct(sprintf('Test description is missing in the filename [%s].', $fileName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
+5
-13
@@ -130,7 +130,7 @@ final class Expectation
|
||||
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
|
||||
ob_start();
|
||||
var_dump($this->value, ...$arguments);
|
||||
$output = (string) ob_get_clean();
|
||||
$output = ob_get_clean();
|
||||
|
||||
throw new ExpectationFailedException($output);
|
||||
}
|
||||
@@ -244,7 +244,7 @@ final class Expectation
|
||||
if ($callbacks[$index] instanceof Closure) {
|
||||
$callbacks[$index](new self($value), new self($key));
|
||||
} else {
|
||||
(new self($value))->toEqual($callbacks[$index]);
|
||||
new self($value)->toEqual($callbacks[$index]);
|
||||
}
|
||||
|
||||
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
|
||||
@@ -921,15 +921,7 @@ final class Expectation
|
||||
|
||||
return Targeted::make(
|
||||
$this,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
|
||||
"to implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -1144,8 +1136,8 @@ final class Expectation
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
|
||||
&& $object->reflectionClass->isEnum()
|
||||
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
||||
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||
&& new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||
&& (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||
'to be '.$backingType.' backed enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
|
||||
@@ -112,7 +112,7 @@ final class HigherOrderExpectation
|
||||
* Dynamically calls methods on the class with the given arguments.
|
||||
*
|
||||
* @param array<int, mixed> $arguments
|
||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
public function __call(string $name, array $arguments): self
|
||||
{
|
||||
@@ -127,7 +127,7 @@ final class HigherOrderExpectation
|
||||
/**
|
||||
* Accesses properties in the value or in the expectation.
|
||||
*
|
||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
public function __get(string $name): self
|
||||
{
|
||||
|
||||
@@ -576,15 +576,7 @@ final readonly class OppositeExpectation
|
||||
|
||||
return Targeted::make(
|
||||
$original,
|
||||
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;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! isset($object->reflectionClass) || ! in_array($trait, $object->reflectionClass->getTraitNames(), true)),
|
||||
"not to use traits '".implode("', '", $traits)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -604,15 +596,7 @@ final readonly class OppositeExpectation
|
||||
|
||||
return Targeted::make(
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)),
|
||||
"not to implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -814,13 +798,11 @@ final readonly class OppositeExpectation
|
||||
|
||||
$exporter = Exporter::default();
|
||||
|
||||
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
|
||||
|
||||
throw new ExpectationFailedException(sprintf(
|
||||
'Expecting %s not %s %s.',
|
||||
$toString($this->original->value),
|
||||
$exporter->shortenedExport($this->original->value),
|
||||
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
|
||||
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
|
||||
implode(' ', array_map(fn (mixed $argument): string => $exporter->export($argument), $arguments)),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -852,8 +834,8 @@ final readonly class OppositeExpectation
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||
|| ! $object->reflectionClass->isEnum()
|
||||
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
||||
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||
|| ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||
|| (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||
'not to be '.$backingType.' backed enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ use Pest\Concerns;
|
||||
use Pest\Contracts\HasPrintableTestCaseName;
|
||||
use Pest\Evaluators\Attributes;
|
||||
use Pest\Exceptions\DatasetMissing;
|
||||
use Pest\Exceptions\InvalidTestClassName;
|
||||
use Pest\Exceptions\ShouldNotHappen;
|
||||
use Pest\Exceptions\TestAlreadyExist;
|
||||
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
||||
@@ -94,7 +95,8 @@ final class TestCaseFactory
|
||||
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
|
||||
}
|
||||
|
||||
$filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename)));
|
||||
$realpath = (string) realpath($filename);
|
||||
$filename = str_replace('\\\\', '\\', addslashes($realpath));
|
||||
$rootPath = TestSuite::getInstance()->rootPath;
|
||||
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
|
||||
|
||||
@@ -137,6 +139,16 @@ final class TestCaseFactory
|
||||
|
||||
if (trim($className) === '') {
|
||||
$className = 'InvalidTestName'.Str::random();
|
||||
} elseif (! Str::isValidClassName($className)) {
|
||||
throw InvalidTestClassName::fromClassName($this->filename, $className);
|
||||
}
|
||||
|
||||
if ($this->namespace === null) {
|
||||
foreach ($partsFQN as $partFQN) {
|
||||
if (! Str::isValidIdentifier($partFQN)) {
|
||||
throw InvalidTestClassName::fromNamespace($this->filename, $namespace, $partFQN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->attributes = [
|
||||
@@ -149,6 +161,8 @@ final class TestCaseFactory
|
||||
|
||||
$attributesCode = Attributes::code($this->attributes);
|
||||
|
||||
$filenameLiteral = var_export($realpath, true);
|
||||
|
||||
$methodsCode = implode('', array_map(
|
||||
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
|
||||
$methods
|
||||
@@ -158,6 +172,7 @@ final class TestCaseFactory
|
||||
$classCode = <<<PHP
|
||||
namespace $namespace;
|
||||
|
||||
use Pest\Exceptions\DatasetProviderError as __PestDatasetProviderError;
|
||||
use Pest\Repositories\DatasetsRepository as __PestDatasets;
|
||||
use Pest\TestSuite as __PestTestSuite;
|
||||
|
||||
@@ -166,7 +181,7 @@ final class TestCaseFactory
|
||||
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
||||
$traitsCode
|
||||
|
||||
public static \$__filename = '$filename';
|
||||
public static \$__filename = $filenameLiteral;
|
||||
|
||||
$methodsCode
|
||||
}
|
||||
@@ -197,7 +212,7 @@ final class TestCaseFactory
|
||||
|
||||
if (
|
||||
$method->closure instanceof \Closure &&
|
||||
(new \ReflectionFunction($method->closure))->isStatic()
|
||||
new \ReflectionFunction($method->closure)->isStatic()
|
||||
) {
|
||||
|
||||
throw new TestClosureMustNotBeStatic($method);
|
||||
|
||||
@@ -235,6 +235,10 @@ final class TestCaseMethodFactory
|
||||
$attributesCode
|
||||
public function $methodName(...\$arguments)
|
||||
{
|
||||
if (count(\$arguments) === 1 && \$arguments[0] instanceof __PestDatasetProviderError) {
|
||||
throw \$arguments[0]->getPrevious() ?? \$arguments[0];
|
||||
}
|
||||
|
||||
return \$this->__runTest(
|
||||
\$this->__test,
|
||||
...\$arguments,
|
||||
@@ -261,7 +265,11 @@ final class TestCaseMethodFactory
|
||||
|
||||
public static function $dataProviderName()
|
||||
{
|
||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||
try {
|
||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||
} catch (\Throwable \$throwable) {
|
||||
return [[new __PestDatasetProviderError(\$throwable)]];
|
||||
}
|
||||
}
|
||||
|
||||
EOF;
|
||||
|
||||
+21
-22
@@ -15,11 +15,6 @@ use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||
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\TestSuiteForTestMethodWithDataProvider;
|
||||
use PHPUnit\Framework\Exception as FrameworkException;
|
||||
@@ -254,25 +249,29 @@ final readonly class Converter
|
||||
...$result->testMarkedIncompleteEvents(),
|
||||
];
|
||||
|
||||
$numberOfNotPassedTests = count(
|
||||
array_unique(
|
||||
array_map(
|
||||
function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
|
||||
if ($event instanceof BeforeFirstTestMethodErrored
|
||||
|| $event instanceof AfterLastTestMethodErrored
|
||||
|| $event instanceof BeforeFirstTestMethodFailed
|
||||
|| $event instanceof AfterLastTestMethodFailed) {
|
||||
return $event->testClassName();
|
||||
}
|
||||
$notPassedTests = [];
|
||||
|
||||
return $this->getTestCaseLocation($event->test());
|
||||
},
|
||||
$events
|
||||
)
|
||||
)
|
||||
);
|
||||
foreach ($events as $event) {
|
||||
if ($event instanceof AfterLastTestMethodErrored) {
|
||||
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
||||
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();
|
||||
|
||||
$numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;
|
||||
continue;
|
||||
}
|
||||
|
||||
$notPassedTests[] = $this->getTestCaseLocation($event->test());
|
||||
}
|
||||
|
||||
$numberOfPassedTests = $result->numberOfTestsRun()
|
||||
- count(array_unique($notPassedTests))
|
||||
- $result->numberOfTestSkippedByTestSuiteSkippedEvents();
|
||||
|
||||
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,12 @@ final class TeamCityLogger
|
||||
$telemetry->memoryUsageSinceStart(),
|
||||
$telemetry->durationSincePrevious(),
|
||||
$telemetry->memoryUsageSincePrevious(),
|
||||
$telemetry->userCpuTimeSinceStart(),
|
||||
$telemetry->systemCpuTimeSinceStart(),
|
||||
$telemetry->totalCpuTimeSinceStart(),
|
||||
$telemetry->userCpuTimeSincePrevious(),
|
||||
$telemetry->systemCpuTimeSincePrevious(),
|
||||
$telemetry->totalCpuTimeSincePrevious(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+133
-1
@@ -936,7 +936,7 @@ final class Expectation
|
||||
|
||||
if ($exception instanceof Closure) {
|
||||
$callback = $exception;
|
||||
$parameters = (new ReflectionFunction($exception))->getParameters();
|
||||
$parameters = new ReflectionFunction($exception)->getParameters();
|
||||
|
||||
if (count($parameters) !== 1) {
|
||||
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
||||
@@ -1142,6 +1142,22 @@ final class Expectation
|
||||
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
|
||||
*
|
||||
@@ -1155,6 +1171,22 @@ final class Expectation
|
||||
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
|
||||
*
|
||||
@@ -1187,4 +1219,104 @@ final class Expectation
|
||||
|
||||
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
|
||||
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
|
||||
*
|
||||
* This extension must be registered BEFORE the peststan HigherOrderExpectationTypeExtension.
|
||||
* This extension must be registered BEFORE the pest-plugin-phpstan HigherOrderExpectationTypeExtension.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -37,7 +37,7 @@ final readonly class HigherOrderExpectationTypeExtension implements ExpressionTy
|
||||
|
||||
$varType = $scope->getType($expr->var);
|
||||
|
||||
if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) {
|
||||
if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ final class UsesCall
|
||||
$this->targets = [$filename];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `pest()->printer()->compact()` instead.
|
||||
*/
|
||||
#[\Deprecated(message: 'Use `pest()->printer()->compact()` instead.')]
|
||||
public function compact(): self
|
||||
{
|
||||
DefaultPrinter::compact(true);
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ namespace Pest;
|
||||
|
||||
function version(): string
|
||||
{
|
||||
return '4.7.3';
|
||||
return '5.0.3';
|
||||
}
|
||||
|
||||
function testDirectory(string $file = ''): string
|
||||
|
||||
@@ -50,7 +50,7 @@ final class Cache implements HandlesArguments
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->hasArgument('--parallel', $arguments)) {
|
||||
if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasArgument('--do-not-cache-result', $arguments) && ! $this->hasArgument('--cache-result', $arguments)) {
|
||||
return $this->pushArgument('--cache-result', $arguments);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,14 @@ trait HandleArguments
|
||||
*/
|
||||
public function popArgument(string $argument, array $arguments): array
|
||||
{
|
||||
$arguments = array_flip($arguments);
|
||||
$key = array_search($argument, $arguments, true);
|
||||
|
||||
unset($arguments[$argument]);
|
||||
while ($key !== false) {
|
||||
unset($arguments[$key]);
|
||||
$key = array_search($argument, $arguments, true);
|
||||
}
|
||||
|
||||
return array_values(array_flip($arguments));
|
||||
return array_values($arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,8 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final class Coverage implements AddsOutput, HandlesArguments
|
||||
{
|
||||
use Concerns\HandleArguments;
|
||||
|
||||
private const string COVERAGE_OPTION = 'coverage';
|
||||
|
||||
private const string MIN_OPTION = 'min';
|
||||
@@ -77,11 +79,9 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
return false;
|
||||
}))];
|
||||
|
||||
$originals = array_flip($originals);
|
||||
foreach ($arguments as $argument) {
|
||||
unset($originals[$argument]);
|
||||
$originals = $this->popArgument($argument, $originals);
|
||||
}
|
||||
$originals = array_flip($originals);
|
||||
|
||||
$inputs = [];
|
||||
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
|
||||
|
||||
@@ -114,6 +114,41 @@ final readonly class Help implements HandlesArguments
|
||||
],
|
||||
];
|
||||
|
||||
$content['Tia'] = [
|
||||
[
|
||||
'arg' => '--tia',
|
||||
'desc' => 'Re-run only the tests affected by your changes, replaying the rest from cache',
|
||||
],
|
||||
[
|
||||
'arg' => '--no-tia',
|
||||
'desc' => 'Disable test impact analysis for this run',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --fresh',
|
||||
'desc' => 'Discard the recorded dependency graph and record it again',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --filtered',
|
||||
'desc' => 'Narrow the run to the affected test files only',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --locally',
|
||||
'desc' => 'Enable test impact analysis on local machines only',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --baselined',
|
||||
'desc' => 'Fetch the shared dependency graph recorded by the CI baseline',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --refetch',
|
||||
'desc' => 'Force a fresh fetch of the shared dependency graph',
|
||||
],
|
||||
[
|
||||
'arg' => '--baseline',
|
||||
'desc' => 'Output to standard output the test impact analysis storage directory',
|
||||
],
|
||||
];
|
||||
|
||||
$content['Execution'] = [...[
|
||||
[
|
||||
'arg' => '--parallel',
|
||||
|
||||
@@ -178,13 +178,7 @@ final class Parallel implements HandlesArguments
|
||||
{
|
||||
$arguments = new ArgvInput;
|
||||
|
||||
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) {
|
||||
if ($arguments->hasParameterOption($unsupportedArgument)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -120,10 +120,6 @@ final class ResultPrinter
|
||||
|
||||
$unexpectedOutput = $this->tail($outputFile);
|
||||
if ($unexpectedOutput !== '') {
|
||||
if (preg_match('/^T+$/', $unexpectedOutput) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output->write($unexpectedOutput);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ namespace Pest\Plugins\Parallel\Paratest;
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
||||
use ParaTest\Coverage\CoverageMerger;
|
||||
use ParaTest\JUnit\LogMerger;
|
||||
use ParaTest\JUnit\Writer;
|
||||
use ParaTest\Options;
|
||||
use ParaTest\RunnerInterface;
|
||||
use ParaTest\WrapperRunner\MissingResultsException;
|
||||
use ParaTest\WrapperRunner\SuiteLoader;
|
||||
use ParaTest\WrapperRunner\WrapperWorker;
|
||||
use Pest\Result;
|
||||
@@ -25,11 +25,17 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
||||
use PHPUnit\TestRunner\TestResult\TestResult;
|
||||
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
|
||||
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 SplFileInfo;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use function array_filter;
|
||||
use function array_merge;
|
||||
use function array_merge_recursive;
|
||||
use function array_shift;
|
||||
@@ -37,6 +43,8 @@ use function assert;
|
||||
use function count;
|
||||
use function dirname;
|
||||
use function file_get_contents;
|
||||
use function filesize;
|
||||
use function is_file;
|
||||
use function max;
|
||||
use function realpath;
|
||||
use function str_starts_with;
|
||||
@@ -49,14 +57,14 @@ use function usleep;
|
||||
*/
|
||||
final class WrapperRunner implements RunnerInterface
|
||||
{
|
||||
/**
|
||||
* The time to sleep between cycles.
|
||||
*/
|
||||
/**
|
||||
* The merged test result from the parallel run.
|
||||
*/
|
||||
public static ?TestResult $result = null;
|
||||
|
||||
/**
|
||||
* The time to sleep between cycles.
|
||||
*/
|
||||
private const int CYCLE_SLEEP = 10000;
|
||||
|
||||
/**
|
||||
@@ -83,6 +91,18 @@ final class WrapperRunner implements RunnerInterface
|
||||
/** @var array<int,int> */
|
||||
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> */
|
||||
private array $unexpectedOutputFiles = [];
|
||||
|
||||
@@ -212,7 +232,7 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
if (
|
||||
$this->exitcode > 0
|
||||
&& $this->options->configuration->stopOnFailure()
|
||||
&& $this->options->configuration->stopOnFailureThreshold() > 0
|
||||
) {
|
||||
$this->pending = [];
|
||||
} elseif (($pending = array_shift($this->pending)) !== null) {
|
||||
@@ -227,6 +247,18 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
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->printer->printFeedback(
|
||||
$worker->progressFile,
|
||||
@@ -274,10 +306,15 @@ final class WrapperRunner implements RunnerInterface
|
||||
$worker->start();
|
||||
$this->batches[$token] = 0;
|
||||
|
||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||
$this->statusFiles[] = $worker->statusFile;
|
||||
$this->progressFiles[] = $worker->progressFile;
|
||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||
$this->testResultFiles[] = $worker->testResultFile;
|
||||
|
||||
if (isset($worker->resultCacheFile)) {
|
||||
$this->resultCacheFiles[] = $worker->resultCacheFile;
|
||||
}
|
||||
|
||||
if (isset($worker->junitFile)) {
|
||||
$this->junitFiles[] = $worker->junitFile;
|
||||
}
|
||||
@@ -310,6 +347,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
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) {
|
||||
if (! $testResultFile->isFile()) {
|
||||
continue;
|
||||
@@ -344,6 +395,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
|
||||
// @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()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
|
||||
@@ -379,8 +444,15 @@ final class WrapperRunner implements RunnerInterface
|
||||
$testResultSum->testRunnerTriggeredNoticeEvents(),
|
||||
array_values(array_filter(
|
||||
$testResultSum->testRunnerTriggeredWarningEvents(),
|
||||
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')
|
||||
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found in class')
|
||||
)),
|
||||
$testResultSum->testRunnerTriggeredIssueDeprecationEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueErrorEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueNoticeEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpWarningEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueWarningEvents(),
|
||||
$testResultSum->errors(),
|
||||
$testResultSum->deprecations(),
|
||||
$testResultSum->notices(),
|
||||
@@ -416,8 +488,11 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
$exitcode = Result::exitCode($this->options->configuration, $testResultSum);
|
||||
|
||||
$this->clearFiles($this->statusFiles);
|
||||
$this->clearFiles($this->progressFiles);
|
||||
$this->clearFiles($this->unexpectedOutputFiles);
|
||||
$this->clearFiles($this->testResultFiles);
|
||||
$this->clearFiles($this->resultCacheFiles);
|
||||
$this->clearFiles($this->coverageFiles);
|
||||
$this->clearFiles($this->junitFiles);
|
||||
$this->clearFiles($this->teamcityFiles);
|
||||
@@ -432,6 +507,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
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->init(
|
||||
$this->options->configuration,
|
||||
@@ -447,10 +536,33 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
return;
|
||||
}
|
||||
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage());
|
||||
foreach ($this->coverageFiles as $coverageFile) {
|
||||
$coverageMerger->addCoverageFromFile($coverageFile);
|
||||
$coverageFiles = [];
|
||||
foreach ($this->coverageFiles as $fileInfo) {
|
||||
$realPath = $fileInfo->getRealPath();
|
||||
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(
|
||||
$this->printer->printer,
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Pest\Plugins\Parallel\Support;
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\State;
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Style;
|
||||
use ParaTest\Options;
|
||||
use PHPUnit\Event\Telemetry\CpuTime;
|
||||
use PHPUnit\Event\Telemetry\GarbageCollectorStatus;
|
||||
use PHPUnit\Event\Telemetry\HRTime;
|
||||
use PHPUnit\Event\Telemetry\Info;
|
||||
@@ -147,11 +148,20 @@ final class CompactPrinter
|
||||
MemoryUsage::fromBytes(0),
|
||||
MemoryUsage::fromBytes(0),
|
||||
$garbageCollectorStatus,
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
),
|
||||
$telemetryDuration,
|
||||
MemoryUsage::fromBytes(0),
|
||||
\PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 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);
|
||||
|
||||
+105
-12
@@ -27,6 +27,13 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -132,7 +139,8 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
self::$timeBalanced = true;
|
||||
self::$shardsOutdated = $newTests !== [];
|
||||
} else {
|
||||
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
|
||||
$isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
|
||||
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
|
||||
}
|
||||
|
||||
self::$shard = [
|
||||
@@ -146,7 +154,11 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
|
||||
$filter = $this->buildFilterArgument($testsToRun);
|
||||
|
||||
$this->ensureFilterLengthIsSafe($filter);
|
||||
|
||||
return [...$arguments, '--filter', $filter];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,15 +199,14 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function allTests(array $arguments): array
|
||||
{
|
||||
$output = (new Process([
|
||||
'php',
|
||||
...$this->removeParallelArguments($arguments),
|
||||
'--list-tests',
|
||||
]))->setTimeout(120)->mustRun()->getOutput();
|
||||
$command = $this->buildListTestsCommand(
|
||||
$arguments,
|
||||
TestSuite::getInstance()->testPath,
|
||||
);
|
||||
|
||||
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
|
||||
$output = new Process($command)->setTimeout(120)->mustRun()->getOutput();
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
return $this->parseListTestsOutput($output);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,15 +215,97 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function removeParallelArguments(array $arguments): array
|
||||
{
|
||||
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
|
||||
return array_values(array_filter(
|
||||
$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.
|
||||
*
|
||||
* @param array<int, string> $testsToRun
|
||||
*/
|
||||
private function buildFilterArgument(mixed $testsToRun): string
|
||||
private function buildFilterArgument(array $testsToRun): string
|
||||
{
|
||||
return addslashes(implode('|', $testsToRun));
|
||||
if ($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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+375
-54
@@ -7,8 +7,11 @@ namespace Pest\Plugins;
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
|
||||
use Pest\Contracts\Plugins\AddsOutput;
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
use Pest\Contracts\Plugins\HandlesOriginalArguments;
|
||||
use Pest\Contracts\Plugins\Terminable;
|
||||
use Pest\Exceptions\MissingDependency;
|
||||
use Pest\Exceptions\NoAffectedTestsFound;
|
||||
use Pest\Exceptions\TiaRequiresRepositoryRoot;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Concerns\HandleArguments;
|
||||
use Pest\Plugins\Tia\BaselineSync;
|
||||
@@ -29,13 +32,14 @@ use Pest\Support\View;
|
||||
use Pest\TestCaseFilters\TiaTestCaseFilter;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArguments, Terminable
|
||||
{
|
||||
use HandleArguments;
|
||||
|
||||
@@ -55,6 +59,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private const string BASELINE_PATH_OPTION = '--baseline';
|
||||
|
||||
/**
|
||||
* Set by the mutation plugin on the subprocess running a single mutant,
|
||||
* and nowhere else. Its own `--mutate` flag is popped before the argv is
|
||||
* handed to that subprocess, so the flag cannot be matched instead.
|
||||
*/
|
||||
private const string ENV_MUTATION_TESTING = 'PEST_MUTATION_TESTING';
|
||||
|
||||
private const string ENV_TIA = 'PEST_TIA';
|
||||
|
||||
private const string ENV_FILTERED = 'PEST_TIA_FILTERED';
|
||||
@@ -106,6 +117,34 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
'--compact', '--ci-build-id', '--min',
|
||||
];
|
||||
|
||||
/**
|
||||
* Flags that narrow this run to a subset of the suite.
|
||||
*
|
||||
* Only user-supplied, per-run narrowing belongs here. A filter that is
|
||||
* always in force — `<groups>` in phpunit.xml, or a plugin registering a
|
||||
* test case filter from `boot()` — applies equally to the runs that build
|
||||
* the baseline, so it does not make this run narrower than the baseline
|
||||
* and must not disable baseline writes.
|
||||
*
|
||||
* `--shard` is rewritten to `--filter` before this plugin sees the
|
||||
* arguments, so it is covered here too. The `bin/pest`-only flags are
|
||||
* stripped from the handled arguments, so they are matched against the
|
||||
* original argv instead.
|
||||
*
|
||||
* Flags that cut a run short instead of narrowing it — `--bail`, `--retry`,
|
||||
* `--stop-on-*` — do not belong here either. They only narrow the run when
|
||||
* something actually fails, and that is not known until it is over, so they
|
||||
* are handled by stoppedEarly() from addOutput().
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array PARTIAL_SELECTION_FLAGS = [
|
||||
'--filter', '--exclude-filter', '--group', '--exclude-group',
|
||||
'--covers', '--uses', '--testsuite', '--exclude-testsuite', '--test-suffix',
|
||||
'--dirty', '--todo', '--todos', '--flaky', '--notes',
|
||||
'--assignee', '--issue', '--ticket', '--pr', '--pull-request',
|
||||
];
|
||||
|
||||
private bool $graphWritten = false;
|
||||
|
||||
private bool $replayRan = false;
|
||||
@@ -121,8 +160,19 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private ?Graph $replayGraph = null;
|
||||
|
||||
/**
|
||||
* The baseline this run reads from and writes to.
|
||||
*
|
||||
* `main` is only the fallback for a repository whose branch cannot be read
|
||||
* — it is also the branch every other baseline falls back to reading, so
|
||||
* writing there by accident corrupts the shared baseline. Resolved through
|
||||
* resolveBranch() rather than at every use site, because the git call it
|
||||
* needs is not free.
|
||||
*/
|
||||
private string $branch = 'main';
|
||||
|
||||
private bool $branchResolved = false;
|
||||
|
||||
/** @var array<string, true> */
|
||||
private array $affectedFiles = [];
|
||||
|
||||
@@ -141,6 +191,27 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private bool $filteredMode = false;
|
||||
|
||||
/**
|
||||
* Bars this run from touching the graph at all, results included.
|
||||
*
|
||||
* Reserved for runs whose results describe something other than the code
|
||||
* in the working tree, which is nothing the baseline can ever use.
|
||||
*/
|
||||
private bool $writesSuppressed = false;
|
||||
|
||||
/**
|
||||
* Narrows this run's writes to the results of the tests it actually ran.
|
||||
*
|
||||
* A run that covered only part of the suite still learns something true
|
||||
* about the tests it did reach. What it cannot do is speak for the rest:
|
||||
* pruning results, advancing the recorded sha and replacing the edge map
|
||||
* all claim the whole suite reported, so they stay behind a complete run.
|
||||
*/
|
||||
private bool $resultsOnlyWrites = false;
|
||||
|
||||
/** @var array<int, string> */
|
||||
private array $originalArguments = [];
|
||||
|
||||
private ?string $driftLabel = null;
|
||||
|
||||
private ?string $driftDetails = null;
|
||||
@@ -219,8 +290,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
if (! $watchPatterns->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
if (! $watchPatterns->isLocally()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! ($watchPatterns->isLocally() && self::argumentPresent('--ci', $arguments));
|
||||
return ! self::argumentPresent('--ci', $arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,7 +325,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return true;
|
||||
}
|
||||
|
||||
if (str_starts_with($arg, "$argument=")) {
|
||||
if (str_starts_with((string) $arg, "$argument=")) { // @phpstan-ignore-line
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -292,7 +366,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$result = $this->replayGraph->getResult($this->branch, $testId);
|
||||
|
||||
if ($result instanceof TestStatus) {
|
||||
if ($result->isFailure() || $result->isError()) {
|
||||
if ($this->replayGraph->shouldRerunStatus($result)) {
|
||||
$this->executedCount++;
|
||||
|
||||
return null;
|
||||
@@ -313,6 +387,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $this->cachedAssertionsByTestId[$testId] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function handleOriginalArguments(array $arguments): void
|
||||
{
|
||||
$this->originalArguments = $arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@@ -335,9 +417,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$cliEnabled = $this->hasArgument(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA);
|
||||
$alwaysEnabled = $watchPatterns->isEnabled()
|
||||
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
|
||||
$hasExplicitPath = $this->hasExplicitPathArgument($arguments);
|
||||
$partial = ! $isWorker && ($hasExplicitPath || $this->hasPartialSelection($arguments));
|
||||
$disabled = $disabled || $partial;
|
||||
|
||||
// A mutation subprocess runs the suite against source the mutation
|
||||
// plugin has deliberately broken. Its failures describe the mutant, not
|
||||
// the working tree, so unlike every other narrowed run there is nothing
|
||||
// in its results worth keeping. The parent `--mutate` run is untouched
|
||||
// by this: it runs the whole suite against real source.
|
||||
if (getenv(self::ENV_MUTATION_TESTING) !== false) {
|
||||
$this->writesSuppressed = true;
|
||||
}
|
||||
$enabled = ! $disabled && ($cliEnabled || $alwaysEnabled);
|
||||
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered())
|
||||
&& ! $this->hasExplicitPathArgument($arguments)
|
||||
&& ! $hasExplicitPath
|
||||
&& ! $this->coverageReportActive();
|
||||
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
|
||||
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
|
||||
@@ -351,6 +445,25 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$arguments = $this->popArgument(self::BASELINED_OPTION, $arguments);
|
||||
|
||||
if ($disabled) {
|
||||
if ($partial) {
|
||||
// TIA cannot choose what runs here — the user already did — but
|
||||
// the tests they picked still report honestly, so their results
|
||||
// are kept and everything that would speak for the excluded ones
|
||||
// is not. `--no-tia` needs none of this: it still runs the whole
|
||||
// suite, so it remains a complete run.
|
||||
$this->resultsOnlyWrites = true;
|
||||
|
||||
// `$this->filteredMode` counts as asking for it: reaching here
|
||||
// means the narrowing came from the command line while filtered
|
||||
// mode came from the environment or the config, and a run that
|
||||
// silently declines what the config asked for is the one most
|
||||
// in need of the explanation.
|
||||
if ($cliEnabled || $freshRequested || $this->forceRefetch || $this->filteredMode) {
|
||||
$this->output->writeln('');
|
||||
$this->renderChild('TIA does not apply to partial runs — running the selected tests directly.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->forceRefetch = false;
|
||||
$this->filteredMode = false;
|
||||
$this->freshRebuild = false;
|
||||
@@ -388,6 +501,18 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$this->flushWorkerReplay();
|
||||
}
|
||||
|
||||
// Both only ever set for the parent — addOutput() returns early in
|
||||
// workers, whose partials are ephemeral and only reach the baseline if
|
||||
// the parent consumes them. Everything this method goes on to write is
|
||||
// whole-suite by nature — the edge map above all — so a narrowed run
|
||||
// stops here too, its results already persisted by addOutput().
|
||||
if ($this->writesSuppressed || $this->resultsOnlyWrites) {
|
||||
$this->recorder->reset();
|
||||
$this->coverageCollector->reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$recorder = $this->recorder;
|
||||
|
||||
if (! $this->recordingActive && ! $recorder->isActive()) {
|
||||
@@ -398,7 +523,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$perTest = $this->piggybackCoverage
|
||||
? $this->coverageCollector->perTestFiles()
|
||||
? $this->mergePerTestFiles($this->coverageCollector->perTestFiles(), $recorder->perTestFiles())
|
||||
: $recorder->perTestFiles();
|
||||
|
||||
if ($perTest === []) {
|
||||
@@ -477,12 +602,34 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
// `->only()` narrows the executed set exactly like `--filter` does, but
|
||||
// is only knowable once the suite has been collected — too late to turn
|
||||
// TIA off up front. Sampled in addOutput() because Only's lock file is
|
||||
// already gone by the time terminate() runs (its plugin terminates
|
||||
// first). Whether the run was cut short is likewise only knowable now.
|
||||
if (Only::isEnabled() || $this->stoppedEarly()) {
|
||||
$this->resultsOnlyWrites = true;
|
||||
}
|
||||
|
||||
$this->reportMissingWorkerDrivers();
|
||||
|
||||
// Runs before the checks below: it is what fills the parent's result
|
||||
// collector in parallel, and a worker that stopped early narrows the
|
||||
// whole run.
|
||||
if (Parallel::isEnabled()) {
|
||||
$this->mergeWorkerReplayPartials();
|
||||
}
|
||||
|
||||
if ($this->writesSuppressed) {
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
if ($this->resultsOnlyWrites) {
|
||||
$this->snapshotTestResults(complete: false);
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
if ($this->replayRan) {
|
||||
$this->bumpRecordedSha();
|
||||
}
|
||||
@@ -558,7 +705,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$this->snapshotTestResults();
|
||||
$this->snapshotTestResults(markKnownTestFiles: true);
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
@@ -624,7 +771,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array
|
||||
{
|
||||
$this->watchPatterns->useDefaults($projectRoot);
|
||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
||||
|
||||
$subdirectoryPrefix = $this->gitSubdirectoryPrefix($projectRoot);
|
||||
|
||||
if ($subdirectoryPrefix !== null) {
|
||||
Panic::with(new TiaRequiresRepositoryRoot($subdirectoryPrefix));
|
||||
}
|
||||
|
||||
$this->resolveBranch($projectRoot);
|
||||
|
||||
$fingerprint = Fingerprint::compute($projectRoot);
|
||||
$this->startFingerprint = $fingerprint;
|
||||
@@ -687,7 +841,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array
|
||||
{
|
||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
||||
$this->resolveBranch($projectRoot);
|
||||
|
||||
if ($replayingGlobal) {
|
||||
$this->installWorkerReplay($projectRoot);
|
||||
@@ -704,6 +858,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
if ($this->piggybackCoverage) {
|
||||
$this->recorder->activateLinkTracking();
|
||||
$this->recordingActive = true;
|
||||
|
||||
return $arguments;
|
||||
@@ -771,6 +926,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
private function activateWorkerRecorderForReplay(array $arguments): array
|
||||
{
|
||||
if ($this->piggybackCoverage) {
|
||||
$this->recorder->activateLinkTracking();
|
||||
$this->recordingActive = true;
|
||||
|
||||
return $arguments;
|
||||
@@ -823,6 +979,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$affectedFromChanges = $changed === [] ? [] : $graph->affected($changed);
|
||||
$rerunFromCache = [];
|
||||
|
||||
if ($this->filteredMode && $graph->hasUnlocatedTestsToRerun($this->branch)) {
|
||||
$this->filteredMode = false;
|
||||
|
||||
$this->renderBadge('WARN', 'Some cached tests due a re-run could not be located on disk.');
|
||||
$this->renderChild('Running the full suite with replay instead of a filtered run.');
|
||||
}
|
||||
|
||||
if ($this->filteredMode) {
|
||||
$rerunFromCache = $graph->testFilesToRerun($this->branch);
|
||||
}
|
||||
@@ -1003,6 +1166,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
if ($this->piggybackCoverage) {
|
||||
$recorder->activateLinkTracking();
|
||||
$this->recordingActive = true;
|
||||
|
||||
$this->output->writeln('');
|
||||
@@ -1044,7 +1208,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
{
|
||||
$this->output->writeln('');
|
||||
|
||||
$this->renderChild('Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug.');
|
||||
$this->renderChild('Running in TIA mode, however TIA is skipped as it needs ext-pcov or Xdebug.');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1120,6 +1284,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
'replayed' => $this->replayedCount,
|
||||
'affected' => $this->affectedCount,
|
||||
'executed' => $this->executedCount,
|
||||
// Only the worker knows it stopped early — the parent runs no tests
|
||||
// of its own, so its own check would always come back clean.
|
||||
'truncated' => $this->stoppedEarly(),
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
@@ -1156,6 +1323,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
continue;
|
||||
}
|
||||
|
||||
// One worker stopping early leaves the whole suite incomplete: the
|
||||
// tests it never reached are missing from the merged result set just
|
||||
// as if they had been filtered out.
|
||||
if (($decoded['truncated'] ?? false) === true) {
|
||||
$this->resultsOnlyWrites = true;
|
||||
}
|
||||
|
||||
if (isset($decoded['replayed']) && is_int($decoded['replayed'])) {
|
||||
$this->replayedCount += $decoded['replayed'];
|
||||
}
|
||||
@@ -1224,6 +1398,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$data = $this->readPartial($key);
|
||||
|
||||
if ($data === null) {
|
||||
$this->state->delete($key);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1357,6 +1533,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$this->saveGraph($graph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of two per-test edge maps — piggybacked line-coverage edges plus
|
||||
* the recorder's link-tracked edges (rendered Blade views, ...), which
|
||||
* never appear in line coverage.
|
||||
*
|
||||
* @param array<string, array<int, string>> $coverage
|
||||
* @param array<string, array<int, string>> $linked
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
private function mergePerTestFiles(array $coverage, array $linked): array
|
||||
{
|
||||
foreach ($linked as $testFile => $sources) {
|
||||
$existing = $coverage[$testFile] ?? [];
|
||||
|
||||
$coverage[$testFile] = array_values(array_unique([...$existing, ...$sources]));
|
||||
}
|
||||
|
||||
return $coverage;
|
||||
}
|
||||
|
||||
private function seedResultsInto(Graph $graph): void
|
||||
{
|
||||
/** @var ResultCollector $collector */
|
||||
@@ -1365,50 +1561,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$results = $collector->all();
|
||||
$touchedFiles = [];
|
||||
|
||||
foreach ($results as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if (is_string($file) && $file !== '') {
|
||||
$touchedFiles[$file] = true;
|
||||
}
|
||||
|
||||
$graph->setResult(
|
||||
$this->branch,
|
||||
$testId,
|
||||
$result['status'],
|
||||
$result['message'],
|
||||
$result['time'],
|
||||
$result['assertions'],
|
||||
$file,
|
||||
);
|
||||
}
|
||||
|
||||
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
||||
|
||||
$collector->reset();
|
||||
}
|
||||
|
||||
private function snapshotTestResults(): void
|
||||
{
|
||||
/** @var ResultCollector $collector */
|
||||
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||
|
||||
$results = $collector->all();
|
||||
|
||||
if ($results === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
|
||||
$graph = $this->loadGraph($projectRoot);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
$touchedFiles = [];
|
||||
|
||||
foreach ($results as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
@@ -1431,8 +1583,90 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
);
|
||||
}
|
||||
|
||||
$graph->markKnownTestFiles(array_keys($touchedFiles));
|
||||
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
||||
|
||||
$collector->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds the run's results into the existing graph.
|
||||
*
|
||||
* An incomplete run passes `$complete: false`, which keeps the additive
|
||||
* half — the results of the tests it did run — and drops the half that
|
||||
* speaks for the suite as a whole.
|
||||
*/
|
||||
private function snapshotTestResults(bool $markKnownTestFiles = false, bool $complete = true): void
|
||||
{
|
||||
/** @var ResultCollector $collector */
|
||||
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||
|
||||
$results = $collector->all();
|
||||
|
||||
if ($results === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
|
||||
$graph = $this->loadGraph($projectRoot);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->resolveBranch($projectRoot);
|
||||
} catch (MissingDependency) {
|
||||
// This run never asked for TIA, so a missing git must not turn it
|
||||
// into a failure the way it does on the TIA path. Writing to the
|
||||
// fallback baseline is the lesser of the two evils.
|
||||
}
|
||||
|
||||
$touchedFiles = [];
|
||||
|
||||
foreach ($results as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if ($file === null || str_contains($file, "eval()'d")) {
|
||||
$file = $this->resolveFailedTestFile($testId);
|
||||
}
|
||||
|
||||
if (is_string($file) && $file !== '') {
|
||||
$touchedFiles[$file] = true;
|
||||
}
|
||||
|
||||
// A result is only ever invalidated through the edges of the test
|
||||
// that produced it, so one recorded for a test the graph has no
|
||||
// edges for could never be invalidated again — it would be replayed
|
||||
// as settled however far the code around it moved. Only a complete
|
||||
// run records the edges that would close that gap, so until one
|
||||
// does, the test stays unknown.
|
||||
if (! $complete && (! is_string($file) || ! $graph->knowsTest($file))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$graph->setResult(
|
||||
$this->branch,
|
||||
$testId,
|
||||
$result['status'],
|
||||
$result['message'],
|
||||
$result['time'],
|
||||
$result['assertions'],
|
||||
$file,
|
||||
);
|
||||
}
|
||||
|
||||
if ($markKnownTestFiles) {
|
||||
$graph->markKnownTestFiles(array_keys($touchedFiles));
|
||||
}
|
||||
|
||||
// Pruning reads the absence of a test from this run as the test being
|
||||
// gone. That only holds if every test was invited to report.
|
||||
if ($complete) {
|
||||
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
||||
}
|
||||
|
||||
$this->saveGraph($graph);
|
||||
$collector->reset();
|
||||
}
|
||||
@@ -1476,6 +1710,70 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $coverage->coverage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a selection-narrowing flag was given, either among the arguments
|
||||
* PHPUnit receives or — for the flags `bin/pest` consumes itself — among
|
||||
* the original argv. Explicit path arguments and `->only()` are detected
|
||||
* separately.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
private function hasPartialSelection(array $arguments): bool
|
||||
{
|
||||
foreach (self::PARTIAL_SELECTION_FLAGS as $flag) {
|
||||
if ($this->hasArgument($flag, $arguments)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->hasArgument($flag, $this->originalArguments)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the run stopped before reaching every test it had queued.
|
||||
*
|
||||
* Covers `--bail`, `--retry` and every `--stop-on-*` flag, the equivalent
|
||||
* `phpunit.xml` attributes, and an interrupted run — none of which narrow
|
||||
* the selection up front, so hasPartialSelection() cannot see them. The
|
||||
* tests queued behind the defect that halted the run never reported, and
|
||||
* folding what did report into the baseline prunes the cached results of
|
||||
* their siblings in every file the run had already entered.
|
||||
*
|
||||
* Deliberately unguarded. Both callers run only once PHPUnit's
|
||||
* configuration is registered — the kernel reads it unguarded itself just
|
||||
* before dispatching addOutput(), and flushWorkerReplay() bails out unless
|
||||
* the worker actually executed something. Swallowing a failure here would
|
||||
* report every truncated run as complete, which is the corruption this
|
||||
* guards against in the first place.
|
||||
*/
|
||||
private function stoppedEarly(): bool
|
||||
{
|
||||
return TestResultFacade::shouldStop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the baseline this run reads from and writes to, once.
|
||||
*
|
||||
* Results are written on runs where TIA itself took no part, and those
|
||||
* never reach handleParent(). Without this the default would stand and
|
||||
* every such run would write its results to `main`, whatever branch it
|
||||
* actually ran on.
|
||||
*/
|
||||
private function resolveBranch(string $projectRoot): void
|
||||
{
|
||||
if ($this->branchResolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->branchResolved = true;
|
||||
|
||||
$this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? $this->branch;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
@@ -1489,6 +1787,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
foreach ($arguments as $index => $arg) {
|
||||
$arg = (string) $arg; // @phpstan-ignore-line
|
||||
|
||||
if ($arg === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -1638,6 +1938,27 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return implode(', ', array_keys($seen));
|
||||
}
|
||||
|
||||
/**
|
||||
* The path from the git repository root down to $projectRoot (e.g.
|
||||
* `laravel-app`) when the project is nested inside a larger repo, or `null`
|
||||
* when the project root is itself the repo root (or git is unavailable).
|
||||
* TIA requires the two to coincide: git reports and addresses paths
|
||||
* relative to the repo root, while the dependency graph is project-relative.
|
||||
*/
|
||||
private function gitSubdirectoryPrefix(string $projectRoot): ?string
|
||||
{
|
||||
$process = new Process(['git', 'rev-parse', '--show-prefix'], $projectRoot);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prefix = trim($process->getOutput());
|
||||
|
||||
return $prefix === '' ? null : rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/');
|
||||
}
|
||||
|
||||
private function composerLockDelta(string $projectRoot, string $sha): string
|
||||
{
|
||||
$current = @file_get_contents($projectRoot.'/composer.lock');
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -8,6 +8,9 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -161,7 +164,60 @@ final class CoverageMerger
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value instanceof CodeCoverage ? $value : 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
|
||||
|
||||
@@ -89,6 +89,10 @@ final class FileState implements State
|
||||
$keys = [];
|
||||
|
||||
foreach ($matches as $path) {
|
||||
if (str_ends_with($path, '.tmp')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keys[] = basename($path);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
@@ -11,7 +12,14 @@ use Symfony\Component\Finder\Finder;
|
||||
*/
|
||||
final readonly class Fingerprint
|
||||
{
|
||||
private const int SCHEMA_VERSION = 17;
|
||||
private const int SCHEMA_VERSION = 18;
|
||||
|
||||
/**
|
||||
* @var array<int, class-string<Lockfile>>
|
||||
*/
|
||||
private const array LOCKFILES = [
|
||||
Lockfiles\PackageLock::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
@@ -205,7 +213,11 @@ final readonly class Fingerprint
|
||||
$parts = [];
|
||||
|
||||
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
||||
$hash = self::trackedHash($projectRoot, $name);
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = self::lockfileHash($projectRoot.'/'.$name, $name);
|
||||
|
||||
if ($hash !== null) {
|
||||
$parts[] = $name.':'.$hash;
|
||||
@@ -215,6 +227,37 @@ final readonly class Fingerprint
|
||||
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)) {
|
||||
|
||||
+67
-11
@@ -149,6 +149,13 @@ final class Graph
|
||||
*/
|
||||
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
|
||||
{
|
||||
// With no recorded table usage at all, table intersection can never
|
||||
// select anything — route every migration change through the
|
||||
// watch-pattern fallback instead of silently skipping tests.
|
||||
if ($this->testTables === []) {
|
||||
return $migrationPaths;
|
||||
}
|
||||
|
||||
$changedTables = [];
|
||||
$unparseable = [];
|
||||
|
||||
@@ -446,13 +453,14 @@ final class Graph
|
||||
|
||||
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
|
||||
|
||||
// Only a walk that actually selected tests counts as handled — a
|
||||
// component whose usage the static walk missed must still reach
|
||||
// the watch-pattern fallback instead of being silently swallowed.
|
||||
if ($bladeAffected !== []) {
|
||||
foreach ($bladeAffected as $testFile) {
|
||||
$affectedSet[$testFile] = true;
|
||||
}
|
||||
|
||||
$staticallyHandled[$rel] = true;
|
||||
} elseif ($this->isBladeComponentPath($rel)) {
|
||||
$staticallyHandled[$rel] = true;
|
||||
}
|
||||
}
|
||||
@@ -690,8 +698,16 @@ final class Graph
|
||||
|
||||
private function shouldRerun(int $status): bool
|
||||
{
|
||||
$testStatus = TestStatus::from($status);
|
||||
return $this->shouldRerunStatus(TestStatus::from($status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cached result with this status must be re-executed rather
|
||||
* than replayed, honouring the configured failOn* / displayDetailsOn*
|
||||
* policies.
|
||||
*/
|
||||
public function shouldRerunStatus(TestStatus $testStatus): bool
|
||||
{
|
||||
if ($testStatus->isFailure() || $testStatus->isError()) {
|
||||
return true;
|
||||
}
|
||||
@@ -813,6 +829,38 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark test files that executed under a recorded coverage session as "known",
|
||||
* seeding an empty edge set for any that produced zero project-source edges.
|
||||
*
|
||||
* Without this, a test that covers no application source (e.g. a pure unit
|
||||
* test asserting on language primitives) never becomes an edge key, so
|
||||
* {@see self::knowsTest()} reports it as unknown and it re-runs on every TIA
|
||||
* run. Recording it with an empty edge set lets it be replayed/skipped while
|
||||
* unchanged; it is still re-run whenever its own file changes, via
|
||||
* {@see self::applyTestFileChanges()}.
|
||||
*
|
||||
* Must only be called from the recording path, where coverage was actually
|
||||
* collected — otherwise a missing edge set could mean "coverage was off",
|
||||
* not "genuinely covered nothing".
|
||||
*
|
||||
* @param array<int, string> $testFiles Absolute or project-relative test file paths.
|
||||
*/
|
||||
public function markKnownTestFiles(array $testFiles): void
|
||||
{
|
||||
foreach ($testFiles as $testFile) {
|
||||
$rel = $this->relative($testFile);
|
||||
|
||||
if ($rel === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($this->edges[$rel])) {
|
||||
$this->edges[$rel] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, string>> $testToTables
|
||||
*/
|
||||
@@ -1217,7 +1265,21 @@ final class Graph
|
||||
$tail = substr($tail, 0, -strlen('.blade.php'));
|
||||
$name = str_replace('/', '.', $tail);
|
||||
|
||||
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
|
||||
if ($name === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = [$name, str_replace('_', '-', $name)];
|
||||
|
||||
// Anonymous index components: components/card/index.blade.php resolves as <x-card>.
|
||||
if (str_ends_with($name, '.index') && $name !== '.index') {
|
||||
$base = substr($name, 0, -strlen('.index'));
|
||||
|
||||
$names[] = $base;
|
||||
$names[] = str_replace('_', '-', $base);
|
||||
}
|
||||
|
||||
return array_values(array_unique($names));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
@@ -1289,13 +1351,7 @@ final class Graph
|
||||
/** @param array<string, array<int, string>> $edges */
|
||||
private function anyTestUses(array $edges, string $component): bool
|
||||
{
|
||||
foreach ($edges as $components) {
|
||||
if (in_array($component, $components, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any($edges, fn (array $components): bool => in_array($component, $components, true));
|
||||
}
|
||||
|
||||
public function pruneMissingTests(): void
|
||||
|
||||
@@ -163,7 +163,9 @@ final class JsModuleGraph
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
|
||||
$nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';
|
||||
|
||||
if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -386,12 +388,6 @@ final class JsModuleGraph
|
||||
|
||||
private static function hasViteConfig(string $projectRoot): bool
|
||||
{
|
||||
foreach (self::VITE_CONFIG_NAMES as $name) {
|
||||
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?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/'));
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ final class Recorder
|
||||
|
||||
private bool $active = false;
|
||||
|
||||
private bool $captureCoverage = false;
|
||||
|
||||
private bool $driverChecked = false;
|
||||
|
||||
private bool $driverAvailable = false;
|
||||
@@ -43,6 +45,17 @@ final class Recorder
|
||||
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;
|
||||
}
|
||||
@@ -55,7 +68,7 @@ final class Recorder
|
||||
public function driverAvailable(): bool
|
||||
{
|
||||
if (! $this->driverChecked) {
|
||||
if (function_exists('pcov\\start')) {
|
||||
if (function_exists('pcov\\start') && filter_var((string) ini_get('pcov.enabled'), FILTER_VALIDATE_BOOL)) {
|
||||
$this->driver = 'pcov';
|
||||
$this->driverAvailable = true;
|
||||
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
|
||||
@@ -75,7 +88,11 @@ final class Recorder
|
||||
|
||||
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
||||
{
|
||||
if (! $this->active || ! $this->driverAvailable()) {
|
||||
if (! $this->active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->captureCoverage && ! $this->driverAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,6 +112,10 @@ final class Recorder
|
||||
$this->perTestUsesDatabase[$file] = true;
|
||||
}
|
||||
|
||||
if (! $this->captureCoverage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\clear();
|
||||
\pcov\start();
|
||||
@@ -107,7 +128,13 @@ final class Recorder
|
||||
|
||||
public function endTest(): void
|
||||
{
|
||||
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
|
||||
if (! $this->active || $this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->captureCoverage || ! $this->driverAvailable()) {
|
||||
$this->currentTestFile = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,7 +159,15 @@ final class Recorder
|
||||
$data = \xdebug_get_code_coverage();
|
||||
\xdebug_stop_code_coverage(true);
|
||||
|
||||
$coveredFiles = array_keys($data);
|
||||
$scope = $this->sourceScope();
|
||||
|
||||
foreach (array_keys($data) as $file) {
|
||||
if (! $scope->contains($file)) {
|
||||
unset($data[$file]);
|
||||
}
|
||||
}
|
||||
|
||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||
}
|
||||
|
||||
foreach ($coveredFiles as $sourceFile) {
|
||||
@@ -351,5 +386,6 @@ final class Recorder
|
||||
$this->classUsesDatabaseCache = [];
|
||||
$this->sourceScope = null;
|
||||
$this->active = false;
|
||||
$this->captureCoverage = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ namespace Pest\Plugins\Tia;
|
||||
*/
|
||||
final class TableExtractor
|
||||
{
|
||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
|
||||
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
|
||||
@@ -22,22 +27,15 @@ final class TableExtractor
|
||||
return [];
|
||||
}
|
||||
|
||||
$prefix = strtolower(substr($trimmed, 0, 6));
|
||||
|
||||
$matched = false;
|
||||
foreach (self::DML_PREFIXES as $dml) {
|
||||
if (str_starts_with($prefix, $dml)) {
|
||||
$matched = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $matched) {
|
||||
if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
|
||||
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 [];
|
||||
@@ -45,14 +43,9 @@ final class TableExtractor
|
||||
|
||||
$tables = [];
|
||||
|
||||
for ($i = 0, $n = count($matches[0]); $i < $n; $i++) {
|
||||
$name = $matches[1][$i] !== ''
|
||||
? $matches[1][$i]
|
||||
: ($matches[2][$i] !== ''
|
||||
? $matches[2][$i]
|
||||
: ($matches[3][$i] !== ''
|
||||
? $matches[3][$i]
|
||||
: $matches[4][$i]));
|
||||
foreach ($matches[1] as $qualified) {
|
||||
$name = self::unqualified($qualified);
|
||||
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -63,7 +56,7 @@ final class TableExtractor
|
||||
$tables[strtolower($name)] = true;
|
||||
}
|
||||
|
||||
$out = array_keys($tables);
|
||||
$out = array_map(strval(...), array_keys($tables));
|
||||
sort($out);
|
||||
|
||||
return $out;
|
||||
@@ -80,51 +73,84 @@ final class TableExtractor
|
||||
|
||||
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $i => $primary) {
|
||||
$tables[strtolower($primary)] = true;
|
||||
$tables[strtolower(self::lastDottedSegment($primary))] = true;
|
||||
|
||||
$secondary = $matches[2][$i] ?? '';
|
||||
if ($secondary !== '') {
|
||||
$tables[strtolower($secondary)] = true;
|
||||
$tables[strtolower(self::lastDottedSegment($secondary))] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
|
||||
$qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
|
||||
|
||||
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $primary) {
|
||||
$lower = strtolower($primary);
|
||||
if (! self::isSchemaMeta($lower)) {
|
||||
$tables[$lower] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$dmlPatterns = [
|
||||
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
|
||||
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
|
||||
'/DELETE\s+FROM\s+["`\[]?(\w+)["`\]]?/i',
|
||||
'/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
|
||||
$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 ($dmlPatterns as $pattern) {
|
||||
foreach ($sqlPatterns as $pattern) {
|
||||
if (preg_match_all($pattern, $php, $matches) === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($matches[1] as $name) {
|
||||
$lower = strtolower($name);
|
||||
if (! self::isSchemaMeta($lower)) {
|
||||
$lower = strtolower(self::unqualified($name));
|
||||
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||
$tables[$lower] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out = array_keys($tables);
|
||||
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_map(strval(...), 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);
|
||||
|
||||
@@ -45,11 +45,7 @@ final readonly class TestPaths
|
||||
$directories[] = $rel;
|
||||
}
|
||||
|
||||
$suffix = $directory->suffix();
|
||||
|
||||
if ($suffix !== '') {
|
||||
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
|
||||
}
|
||||
$suffixes[] = $directory->suffix();
|
||||
}
|
||||
|
||||
foreach ($suite->files() as $file) {
|
||||
@@ -63,7 +59,7 @@ final readonly class TestPaths
|
||||
|
||||
if ($suffixes === []) {
|
||||
foreach ($configuration->testSuffixes() as $suffix) {
|
||||
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
|
||||
$suffixes[] = $suffix;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
@@ -94,15 +90,7 @@ final readonly class TestPaths
|
||||
if (in_array($relativePath, $this->files, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$matchesSuffix = false;
|
||||
foreach ($this->suffixes as $suffix) {
|
||||
if (str_ends_with($relativePath, $suffix)) {
|
||||
$matchesSuffix = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
$matchesSuffix = array_any($this->suffixes, fn (string $suffix): bool => str_ends_with($relativePath, $suffix));
|
||||
|
||||
if (! $matchesSuffix) {
|
||||
return false;
|
||||
|
||||
@@ -253,35 +253,17 @@ final class WatchPatterns
|
||||
|
||||
private function patternTargetsDotfiles(string $pattern): bool
|
||||
{
|
||||
foreach (explode('/', str_replace('\\', '/', $pattern)) as $segment) {
|
||||
if ($segment !== '' && $segment[0] === '.') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(explode('/', str_replace('\\', '/', $pattern)), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
||||
}
|
||||
|
||||
private function touchesVcs(string $file): bool
|
||||
{
|
||||
foreach (explode('/', $file) as $segment) {
|
||||
if (in_array($segment, self::VCS_DIRS, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(explode('/', $file), fn (string $segment): bool => in_array($segment, self::VCS_DIRS, true));
|
||||
}
|
||||
|
||||
private function touchesDotfile(string $file): bool
|
||||
{
|
||||
foreach (explode('/', $file) as $segment) {
|
||||
if ($segment !== '' && $segment[0] === '.') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(explode('/', $file), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
||||
}
|
||||
|
||||
private function excludeMatches(string $exclude, string $file): bool
|
||||
|
||||
@@ -37,7 +37,7 @@ final class XdebugRestarter implements Restarter
|
||||
return;
|
||||
}
|
||||
|
||||
(new XdebugHandler('pest'))->check();
|
||||
new XdebugHandler('pest')->check();
|
||||
}
|
||||
|
||||
private function xdebugIsCoverageOnly(): bool
|
||||
|
||||
@@ -22,7 +22,7 @@ final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements Finishe
|
||||
|
||||
if ($test instanceof TestMethod) {
|
||||
$this->collector->recordAssertions(
|
||||
$test->className().'::'.$test->methodName(),
|
||||
$test->id(),
|
||||
$event->numberOfAssertionsPerformed(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ final readonly class EnsureTiaResultsAreCollected implements PreparationStartedS
|
||||
$test = $event->test();
|
||||
|
||||
if ($test instanceof TestMethod) {
|
||||
$this->collector->testPrepared($test->className().'::'.$test->methodName(), $test->file());
|
||||
$this->collector->testPrepared($test->id(), $test->file());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Pest\Plugins\Tia\CoverageMerger;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use SebastianBergmann\CodeCoverage\Node\Directory;
|
||||
use SebastianBergmann\CodeCoverage\Node\File;
|
||||
use SebastianBergmann\CodeCoverage\Report\Facade;
|
||||
use SebastianBergmann\Environment\Runtime;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
@@ -95,10 +96,18 @@ final class Coverage
|
||||
$codeCoverage = require $reportPath;
|
||||
unlink($reportPath);
|
||||
|
||||
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines();
|
||||
// @phpstan-ignore-next-line
|
||||
if (is_array($codeCoverage)) {
|
||||
$facade = Facade::fromSerializedData($codeCoverage);
|
||||
|
||||
/** @var Directory<File|Directory> $report */
|
||||
$report = $codeCoverage->getReport();
|
||||
/** @var Directory<File|Directory> $report */
|
||||
$report = (fn (): Directory => $this->report)->call($facade);
|
||||
} else {
|
||||
/** @var Directory<File|Directory> $report */
|
||||
$report = $codeCoverage->getReport();
|
||||
}
|
||||
|
||||
$totalCoverage = $report->percentageOfExecutedLines();
|
||||
|
||||
foreach ($report->getIterator() as $file) {
|
||||
if (! $file instanceof File) {
|
||||
|
||||
@@ -86,4 +86,17 @@ final readonly class Exporter
|
||||
|
||||
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->shortenedExport($value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a value into a full single-line string without truncation.
|
||||
*/
|
||||
public function export(mixed $value): string
|
||||
{
|
||||
$map = [
|
||||
'#\\\n\s*#' => '',
|
||||
'# Object \(\.{3}\)#' => '',
|
||||
];
|
||||
|
||||
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->export($value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ final class HigherOrderMessage
|
||||
}
|
||||
|
||||
if ($this->hasHigherOrderCallable()) {
|
||||
return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments);
|
||||
return new HigherOrderCallables($target)->{$this->name}(...$this->arguments);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -31,7 +31,7 @@ final class HigherOrderMessageCollection
|
||||
*/
|
||||
public function addWhen(callable $condition, string $filename, int $line, string $name, ?array $arguments): void
|
||||
{
|
||||
$this->messages[] = (new HigherOrderMessage($filename, $line, $name, $arguments))->when($condition);
|
||||
$this->messages[] = new HigherOrderMessage($filename, $line, $name, $arguments)->when($condition);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,7 +38,7 @@ final class HigherOrderTapProxy
|
||||
return $this->target->{$property};
|
||||
}
|
||||
|
||||
$className = (new ReflectionClass($this->target))->getName();
|
||||
$className = new ReflectionClass($this->target)->getName();
|
||||
|
||||
if (str_starts_with($className, 'P\\')) {
|
||||
$className = substr($className, 2);
|
||||
@@ -60,7 +60,7 @@ final class HigherOrderTapProxy
|
||||
$filename = Backtrace::file();
|
||||
$line = Backtrace::line();
|
||||
|
||||
return (new HigherOrderMessage($filename, $line, $methodName, $arguments))
|
||||
return new HigherOrderMessage($filename, $line, $methodName, $arguments)
|
||||
->call($this->target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ final class Reflection
|
||||
*/
|
||||
public static function getFunctionArguments(Closure $function): array
|
||||
{
|
||||
$parameters = (new ReflectionFunction($function))->getParameters();
|
||||
$parameters = new ReflectionFunction($function)->getParameters();
|
||||
$arguments = [];
|
||||
|
||||
foreach ($parameters as $parameter) {
|
||||
@@ -207,7 +207,7 @@ final class Reflection
|
||||
|
||||
public static function getFunctionVariable(Closure $function, string $key): mixed
|
||||
{
|
||||
return (new ReflectionFunction($function))->getStaticVariables()[$key] ?? null;
|
||||
return new ReflectionFunction($function)->getStaticVariables()[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,10 @@ use NunoMaduro\Collision\Exceptions\TestOutcome;
|
||||
use PHPUnit\Event\Code\TestDoxBuilder;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Code\ThrowableBuilder;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
use PHPUnit\Event\Test\Failed;
|
||||
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
|
||||
@@ -35,8 +39,7 @@ final class StateGenerator
|
||||
$testResultEvent->throwable()
|
||||
));
|
||||
} else {
|
||||
// @phpstan-ignore-next-line
|
||||
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
|
||||
$this->addClassLevelEvent($state, $testResultEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +51,7 @@ final class StateGenerator
|
||||
$testResultEvent->throwable()
|
||||
));
|
||||
} else {
|
||||
// @phpstan-ignore-next-line
|
||||
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
|
||||
$this->addClassLevelEvent($state, $testResultEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +186,36 @@ final class StateGenerator
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given class-level "hook" failure to the state. Collision's
|
||||
* `fromBeforeFirstTestMethodErrored` only accepts `BeforeFirstTestMethodErrored`
|
||||
* events, so the remaining class-level events get a synthesized test method.
|
||||
*/
|
||||
private function addClassLevelEvent(State $state, AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed $event): void
|
||||
{
|
||||
if ($event instanceof BeforeFirstTestMethodErrored) {
|
||||
$state->add(TestResult::fromBeforeFirstTestMethodErrored($event));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$methodName = $event instanceof BeforeFirstTestMethodFailed ? 'beforeAll' : 'afterAll';
|
||||
|
||||
$state->add(TestResult::fromPestParallelTestCase(
|
||||
new TestMethod(
|
||||
$event->testClassName(), // @phpstan-ignore-line
|
||||
$methodName,
|
||||
'', // @phpstan-ignore-line
|
||||
1,
|
||||
TestDoxBuilder::fromClassNameAndMethodName($event->testClassName(), $methodName), // @phpstan-ignore-line
|
||||
MetadataCollection::fromArray([]),
|
||||
TestDataCollection::fromArray([])
|
||||
),
|
||||
TestResult::FAIL,
|
||||
$event->throwable()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<PhpunitDeprecationTriggered|PhpunitErrorTriggered|PhpunitNoticeTriggered|PhpunitWarningTriggered>> $testResultEvents
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,33 @@ final class Str
|
||||
|
||||
private const string PREFIX = '__pest_evaluable_';
|
||||
|
||||
/**
|
||||
* The list of names PHP reserves, and therefore refuses, as class names.
|
||||
*
|
||||
* @see https://github.com/php/php-src/blob/master/Zend/zend_compile.c
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const array RESERVED_CLASS_NAMES = [
|
||||
'array',
|
||||
'bool',
|
||||
'callable',
|
||||
'false',
|
||||
'float',
|
||||
'int',
|
||||
'iterable',
|
||||
'mixed',
|
||||
'never',
|
||||
'null',
|
||||
'object',
|
||||
'parent',
|
||||
'self',
|
||||
'static',
|
||||
'string',
|
||||
'true',
|
||||
'void',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a (unsecure & non-cryptographically safe) random alpha-numeric
|
||||
* string value.
|
||||
@@ -64,6 +91,35 @@ final class Str
|
||||
return (string) preg_replace('/[^a-zA-Z0-9_\x80-\xff]/', '_', $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the given name is a valid PHP identifier, and therefore may
|
||||
* be used as a single namespace name.
|
||||
*/
|
||||
public static function isValidIdentifier(string $name): bool
|
||||
{
|
||||
return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $name) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the given name may be declared as a class name by an `eval`.
|
||||
*/
|
||||
public static function isValidClassName(string $name): bool
|
||||
{
|
||||
if (! self::isValidIdentifier($name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array(strtolower($name), self::RESERVED_CLASS_NAMES, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokens = token_get_all(sprintf('<?php %s;', $name));
|
||||
|
||||
// Anything the lexer sees as a keyword, like `list` or `match`, may not
|
||||
// be used as a class name.
|
||||
return is_array($tokens[1] ?? null) && $tokens[1][0] === T_STRING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the portion of a string before the last occurrence of a given value.
|
||||
*/
|
||||
@@ -98,6 +154,14 @@ final class Str
|
||||
return preg_match('/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iD', $value) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a given value is a valid ULID.
|
||||
*/
|
||||
public static function isUlid(string $value): bool
|
||||
{
|
||||
return preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/', $value) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a describe block as `$describeDescription` → `$testDescription` format.
|
||||
*
|
||||
@@ -110,6 +174,14 @@ final class Str
|
||||
return sprintf(str_repeat('`%s` → ', count($describeDescriptions)).'%s', ...$descriptionComponents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a given value is a valid email address.
|
||||
*/
|
||||
public static function isEmail(string $value): bool
|
||||
{
|
||||
return (bool) filter_var($value, FILTER_VALIDATE_EMAIL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a given value is a valid URL.
|
||||
*/
|
||||
|
||||
@@ -6,18 +6,18 @@
|
||||
FAILED Tests\Fixtures\CollisionTest > error Exception
|
||||
error
|
||||
|
||||
at tests/Fixtures/CollisionTest.php:4
|
||||
1▕ <?php
|
||||
at tests/Fixtures/CollisionTest.php:6
|
||||
2▕
|
||||
3▕ test('error', function () {
|
||||
➜ 4▕ throw new Exception('error');
|
||||
5▕ })->skip(! isset($_SERVER['COLLISION_TEST']));
|
||||
6▕
|
||||
7▕ test('success', function () {
|
||||
8▕ expect(true)->toBeTrue();
|
||||
9▕ })->skip(! isset($_SERVER['COLLISION_TEST']));
|
||||
3▕ declare(strict_types=1);
|
||||
4▕
|
||||
5▕ test('error', function (): void {
|
||||
➜ 6▕ throw new Exception('error');
|
||||
7▕ })->skip(! isset($_SERVER['COLLISION_TEST']));
|
||||
8▕
|
||||
9▕ test('success', function (): void {
|
||||
10▕ expect(true)->toBeTrue();
|
||||
|
||||
1 tests/Fixtures/CollisionTest.php:4
|
||||
1 tests/Fixtures/CollisionTest.php:6
|
||||
|
||||
|
||||
Tests: 1 failed, 1 passed (1 assertions)
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
Pest Testing Framework 4.7.3.
|
||||
Pest Testing Framework 5.0.3.
|
||||
|
||||
USAGE: pest <file> [options]
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
--cache-directory [dir] ............................ Specify cache directory
|
||||
--generate-configuration Generate configuration file with suggested settings
|
||||
--migrate-configuration ....... Migrate configuration file to current format
|
||||
--validate-configuration ................... Validate XML configuration file
|
||||
--generate-baseline [file] .................... Generate baseline for issues
|
||||
--use-baseline [file] ........................ Use baseline to ignore issues
|
||||
--ignore-baseline ..................... Do not use baseline to ignore issues
|
||||
@@ -40,11 +41,15 @@
|
||||
--uses [name] ..................... Only run tests that intend to use [name]
|
||||
--requires-php-extension [name] Only run tests that require PHP extension [name]
|
||||
--list-test-files ................................ List available test files
|
||||
--list-test-ids ........................... List available tests as test IDs
|
||||
--list-tests .......................................... List available tests
|
||||
--list-tests-xml [file] ................. List available tests in XML format
|
||||
--filter [pattern] ............................... Filter which tests to run
|
||||
--exclude-filter [pattern] .. Exclude tests for the specified filter pattern
|
||||
--test-suffix [suffixes] Only search for test in files with specified suffix(es). Default: Test.php,.phpt
|
||||
--test-files-file [file] Only run test files listed in file (one file by line)
|
||||
--run-test-id [test-id] Only run the test identified by the specified test ID
|
||||
--test-id-filter-file [file] Only run tests listed by test ID in file (one test ID per line)
|
||||
|
||||
EXECUTION OPTIONS:
|
||||
--parallel ........................................... Run tests in parallel
|
||||
@@ -53,20 +58,21 @@
|
||||
--globals-backup ................. Backup and restore $GLOBALS for each test
|
||||
--static-backup ......... Backup and restore static properties for each test
|
||||
--strict-coverage ................... Be strict about code coverage metadata
|
||||
--require-coverage-contribution Be strict about tests that do not contribute to code coverage
|
||||
--strict-global-state .............. Be strict about changes to global state
|
||||
--disallow-test-output ................. Be strict about output during tests
|
||||
--enforce-time-limit ................. Enforce time limit based on test size
|
||||
--default-time-limit [sec] Timeout in seconds for tests that have no declared size
|
||||
--do-not-report-useless-tests Do not report tests that do not test anything
|
||||
--stop-on-defect ... Stop after first error, failure, warning, or risky test
|
||||
--stop-on-error ..................................... Stop after first error
|
||||
--stop-on-failure ................................. Stop after first failure
|
||||
--stop-on-warning ................................. Stop after first warning
|
||||
--stop-on-risky ................................ Stop after first risky test
|
||||
--stop-on-deprecation ... Stop after first test that triggered a deprecation
|
||||
--stop-on-notice ............. Stop after first test that triggered a notice
|
||||
--stop-on-skipped ............................ Stop after first skipped test
|
||||
--stop-on-incomplete ...................... Stop after first incomplete test
|
||||
--stop-on-defect[=[n]] Stop after first (or n-th) error, failure, warning, or risky test
|
||||
--stop-on-error[=[n]] ..................... Stop after first (or n-th) error
|
||||
--stop-on-failure[=[n]] ................. Stop after first (or n-th) failure
|
||||
--stop-on-warning[=[n]] ................. Stop after first (or n-th) warning
|
||||
--stop-on-risky[=[n]] ................ Stop after first (or n-th) risky test
|
||||
--stop-on-deprecation[=[n]] Stop after first (or n-th) test that triggered a deprecation
|
||||
--stop-on-notice[=[n]] Stop after first (or n-th) test that triggered a notice
|
||||
--stop-on-skipped[=[n]] ............ Stop after first (or n-th) skipped test
|
||||
--stop-on-incomplete[=[n]] ...... Stop after first (or n-th) incomplete test
|
||||
--fail-on-empty-test-suite Signal failure using shell exit code when no tests were run
|
||||
--fail-on-warning Signal failure using shell exit code when a warning was triggered
|
||||
--fail-on-risky Signal failure using shell exit code when a test was considered risky
|
||||
@@ -90,7 +96,7 @@
|
||||
--do-not-fail-on-incomplete Do not signal failure using shell exit code when a test was marked incomplete
|
||||
--cache-result ............................ Write test results to cache file
|
||||
--do-not-cache-result .............. Do not write test results to cache file
|
||||
--order-by [order] Run tests in order: default|defects|depends|duration|no-depends|random|reverse|size
|
||||
--order-by [order] Run tests in order: default|defects|depends|duration-ascending|duration-descending|no-depends|random|reverse|size-ascending|size-descending
|
||||
--resolve-dependencies ...................... Alias for "--order-by depends"
|
||||
--ignore-dependencies .................... Alias for "--order-by no-depends"
|
||||
--random-order ............................... Alias for "--order-by random"
|
||||
@@ -101,6 +107,7 @@
|
||||
--colors=[flag] ......... Use colors in output ("never", "auto" or "always")
|
||||
--columns [n] ................. Number of columns to use for progress output
|
||||
--columns max ............ Use maximum number of columns for progress output
|
||||
--diff-context [n] Number of context lines shown around changes in diffs (default: 3)
|
||||
--stderr ................................. Write to STDERR instead of STDOUT
|
||||
--no-progress .................... Disable output of test execution progress
|
||||
--no-results ................................ Disable output of test results
|
||||
@@ -115,6 +122,7 @@
|
||||
--display-warnings ......... Display details for warnings triggered by tests
|
||||
--display-all-issues ..... Display details for all issues that are triggered
|
||||
--reverse-list .............................. Print defects in reverse order
|
||||
--compact ... Replace default progress and result output with compact format
|
||||
--teamcity . Replace default progress and result output with TeamCity format
|
||||
--testdox ................ Replace default result output with TestDox format
|
||||
--testdox-summary Repeat TestDox output for tests with errors, failures, or issues
|
||||
@@ -125,12 +133,12 @@
|
||||
LOGGING OPTIONS:
|
||||
--log-junit [file] .......... Write test results in JUnit XML format to file
|
||||
--log-otr [file] Write test results in Open Test Reporting XML format to file
|
||||
--include-git-information Include Git information in Open Test Reporting XML logfile
|
||||
--log-teamcity [file] ........ Write test results in TeamCity format to file
|
||||
--testdox-html [file] .. Write test results in TestDox format (HTML) to file
|
||||
--testdox-text [file] Write test results in TestDox format (plain text) to file
|
||||
--log-events-text [file] ............... Stream events as plain text to file
|
||||
--log-events-verbose-text [file] Stream events as plain text with extended information to file
|
||||
--include-git-information ..... Include Git information in supported formats
|
||||
--no-logging ....... Ignore logging configured in the XML configuration file
|
||||
|
||||
CODE COVERAGE OPTIONS:
|
||||
@@ -151,13 +159,25 @@
|
||||
--exclude-source-from-xml-coverage Exclude [source] element from code coverage report in XML format
|
||||
--warm-coverage-cache ........................... Warm static analysis cache
|
||||
--coverage-filter [dir] ........... Include [dir] in code coverage reporting
|
||||
--path-coverage .......... Report path coverage in addition to line coverage
|
||||
--branch-coverage ...... Report branch coverage in addition to line coverage
|
||||
--path-coverage ... Report path coverage in addition to line/branch coverage
|
||||
--disable-coverage-ignore ...... Disable metadata for ignoring code coverage
|
||||
--disable-coverage-targeting .. Disable metadata for code coverage targeting
|
||||
--no-coverage Ignore code coverage reporting configured in the XML configuration file
|
||||
|
||||
AI OPTIONS:
|
||||
--ai ..... Run a code snippet as a fully scaffolded test for AI verification
|
||||
|
||||
TIA OPTIONS:
|
||||
--tia Re-run only the tests affected by your changes, replaying the rest from cache
|
||||
--no-tia ......................... Disable test impact analysis for this run
|
||||
--tia --fresh .... Discard the recorded dependency graph and record it again
|
||||
--tia --filtered ............ Narrow the run to the affected test files only
|
||||
--tia --locally ......... Enable test impact analysis on local machines only
|
||||
--tia --baselined Fetch the shared dependency graph recorded by the CI baseline
|
||||
--tia --refetch ......... Force a fresh fetch of the shared dependency graph
|
||||
--baseline Output to standard output the test impact analysis storage directory
|
||||
|
||||
MUTATION TESTING OPTIONS:
|
||||
--mutate .... Runs mutation testing, to understand the quality of your tests
|
||||
--mutate --parallel ...................... Runs mutation testing in parallel
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
|
||||
Pest Testing Framework 4.7.3.
|
||||
Pest Testing Framework 5.0.3.
|
||||
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='pest_qn://tests/.tests/Failure.php' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='Tests/Fixtures/Suites/Failure' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php' flowId='1234']
|
||||
##teamcity[testCount count='8' flowId='1234']
|
||||
##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234']
|
||||
##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/.tests/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234']
|
||||
##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can fail with comparison' flowId='1234']
|
||||
##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/Fixtures/Suites/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234']
|
||||
##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234']
|
||||
##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can be ignored because of no assertions' flowId='1234']
|
||||
##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234']
|
||||
##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234']
|
||||
##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can be ignored because it is skipped' flowId='1234']
|
||||
##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234']
|
||||
##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234']
|
||||
##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18' flowId='1234']
|
||||
##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can fail' flowId='1234']
|
||||
##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/Fixtures/Suites/Failure.php:18' flowId='1234']
|
||||
##teamcity[testFinished name='it can fail' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/.tests/Failure.php::it throws exception' flowId='1234']
|
||||
##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/.tests/Failure.php:22' flowId='1234']
|
||||
##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it throws exception' flowId='1234']
|
||||
##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/Fixtures/Suites/Failure.php:22' flowId='1234']
|
||||
##teamcity[testFinished name='it throws exception' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234']
|
||||
##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it is not done yet' flowId='1234']
|
||||
##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234']
|
||||
##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::build this one.' flowId='1234']
|
||||
##teamcity[testFinished name='build this one.' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/.tests/Failure.php::it is passing' flowId='1234']
|
||||
##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it is passing' flowId='1234']
|
||||
##teamcity[testFinished name='it is passing' duration='100000' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='Tests/tests/Failure' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='Tests/Fixtures/Suites/Failure' flowId='1234']
|
||||
|
||||
[90mTests:[39m [31;1m3 failed[39;22m[90m,[39m[39m [39m[33;1m1 risky[39;22m[90m,[39m[39m [39m[36;1m2 todos[39;22m[90m,[39m[39m [39m[33;1m1 skipped[39;22m[90m,[39m[39m [39m[32;1m1 passed[39;22m[90m (3 assertions)[39m
|
||||
[90mDuration:[39m [39m1.00s[39m
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='Tests/Fixtures/Suites/SuccessOnly' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php' flowId='1234']
|
||||
##teamcity[testCount count='4' flowId='1234']
|
||||
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234']
|
||||
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::it can pass with comparison' flowId='1234']
|
||||
##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234']
|
||||
##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::can also pass' flowId='1234']
|
||||
##teamcity[testFinished name='can also pass' duration='100000' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset' flowId='1234']
|
||||
##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::can pass with dataset' flowId='1234']
|
||||
##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
|
||||
##teamcity[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='can pass with dataset' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
|
||||
##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
|
||||
##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
|
||||
##teamcity[testFinished name='`block` → can pass with dataset in describe block with data set "(1)"' duration='100000' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='`block` → can pass with dataset in describe block' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='Tests/tests/SuccessOnly' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='Tests/Fixtures/Suites/SuccessOnly' flowId='1234']
|
||||
|
||||
[90mTests:[39m [32;1m4 passed[39;22m[90m (4 assertions)[39m
|
||||
[90mDuration:[39m [39m1.00s[39m
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
WARN Tests\Fixtures\CollisionTest
|
||||
- error
|
||||
- success
|
||||
|
||||
PASS Tests\Fixtures\DirectoryWithTests\ExampleTest
|
||||
✓ it example 1
|
||||
|
||||
PASS Tests\Fixtures\ExampleTest
|
||||
✓ it example 2
|
||||
|
||||
WARN Tests\Fixtures\Inheritance\Base\ExampleTest
|
||||
- example
|
||||
|
||||
PASS Tests\Fixtures\Inheritance\ExampleTest
|
||||
✓ example
|
||||
|
||||
Tests: 3 skipped, 3 passed (3 assertions)
|
||||
Tests: 1 passed (1 assertions)
|
||||
|
||||
@@ -136,6 +136,11 @@
|
||||
✓ describe()->with() preserves depends → first with (9)
|
||||
✓ describe()->with() preserves depends → second with (9)
|
||||
|
||||
PASS Tests\Features\DatasetProviderErrors
|
||||
✓ reports missing datasets as errors for a single file run
|
||||
✓ reports missing datasets as errors alongside passing tests
|
||||
✓ reports dataset closure exceptions as errors
|
||||
|
||||
PASS Tests\Features\DatasetsTests - 1 todo
|
||||
✓ it throws exception if dataset does not exist
|
||||
✓ it throws exception if dataset already exist
|
||||
@@ -293,8 +298,8 @@
|
||||
✓ it uses correct parent class
|
||||
|
||||
DEPR Tests\Features\Deprecated
|
||||
! deprecated → str_contains(): Passing null to parameter #1 ($haystack) of type string is deprecated // tests/Features/Deprecated.php:4
|
||||
! user deprecated → Since foo 1.0: This is a deprecation description // tests/Features/Deprecated.php:10
|
||||
✓ deprecated
|
||||
! user deprecated → Since foo 1.0: This is a deprecation description // tests/Features/Deprecated.php:12
|
||||
|
||||
PASS Tests\Features\Describe - 5 todos
|
||||
✓ before each
|
||||
@@ -471,6 +476,16 @@
|
||||
✓ pass
|
||||
✓ failures
|
||||
✓ failures with custom message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeBase64
|
||||
✓ pass
|
||||
✓ failures
|
||||
✓ failures with invalid padding
|
||||
✓ failures with padding only
|
||||
✓ failures with malformed input
|
||||
✓ failures with invalid type
|
||||
✓ failures with custom message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeBetween
|
||||
@@ -517,6 +532,21 @@
|
||||
✓ pass
|
||||
✓ failures
|
||||
✓ failures with custom message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeDomain
|
||||
✓ pass
|
||||
✓ failures
|
||||
✓ failures with leading dot
|
||||
✓ failures with invalid type
|
||||
✓ failures with custom message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeEmail
|
||||
✓ pass
|
||||
✓ failures
|
||||
✓ failures with custom message
|
||||
✓ failures with default message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeEmpty
|
||||
@@ -572,6 +602,24 @@
|
||||
✓ passes with strings
|
||||
✓ failures
|
||||
✓ failures with custom message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeHexadecimal
|
||||
✓ pass
|
||||
✓ failures
|
||||
✓ failures with invalid characters
|
||||
✓ failures with zero-x prefix
|
||||
✓ failures with empty string
|
||||
✓ failures with invalid type
|
||||
✓ failures with custom message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeHostname
|
||||
✓ pass
|
||||
✓ failures
|
||||
✓ failures with trailing hyphen
|
||||
✓ failures with invalid type
|
||||
✓ failures with custom message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeIn
|
||||
@@ -610,6 +658,13 @@
|
||||
✓ failure when the class is not invokable
|
||||
✓ class is not invokable
|
||||
|
||||
PASS Tests\Features\Expect\toBeIpAddress
|
||||
✓ pass
|
||||
✓ failures
|
||||
✓ failures with invalid type
|
||||
✓ failures with custom message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeIterable
|
||||
✓ pass
|
||||
✓ failures
|
||||
@@ -654,6 +709,13 @@
|
||||
✓ pass
|
||||
✓ failures
|
||||
✓ failures with custom message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeMacAddress
|
||||
✓ pass
|
||||
✓ failures
|
||||
✓ failures with invalid type
|
||||
✓ failures with custom message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeNAN
|
||||
@@ -752,6 +814,13 @@
|
||||
✓ passes as not truthy with ('0')
|
||||
✓ failures
|
||||
✓ failures with custom message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeUlid
|
||||
✓ failures with wrong type
|
||||
✓ pass
|
||||
✓ failures
|
||||
✓ failures with message
|
||||
✓ not failures
|
||||
|
||||
PASS Tests\Features\Expect\toBeUppercase
|
||||
@@ -1260,8 +1329,8 @@
|
||||
// This is another runtime note
|
||||
|
||||
NOTI Tests\Features\Notices
|
||||
! notice → This is a notice description // tests/Features/Notices.php:4
|
||||
! a "describe" group of tests → notice → This is a notice description // tests/Features/Notices.php:11
|
||||
! notice → This is a notice description // tests/Features/Notices.php:6
|
||||
! a "describe" group of tests → notice → This is a notice description // tests/Features/Notices.php:13
|
||||
|
||||
PASS Tests\Features\Pr
|
||||
✓ it may be associated with an pr #1, #2
|
||||
@@ -1496,6 +1565,9 @@
|
||||
✓ it allows performing no expectations without being risky
|
||||
✓ a "describe" group of tests → it allows performing no expectations without being risky
|
||||
|
||||
PASS Tests\Features\Tia
|
||||
✓ it does not run user hooks when replaying cached skipped and incomplete results
|
||||
|
||||
PASS Tests\Features\Ticket
|
||||
✓ it may be associated with an ticket #1, #2
|
||||
✓ nested → it may be associated with an ticket #1, #4, #5, #6, #3
|
||||
@@ -1696,6 +1768,8 @@
|
||||
PASS Tests\Unit\Expectations\OppositeExpectation
|
||||
✓ it throw expectation failed exception with string argument
|
||||
✓ it throw expectation failed exception with array argument
|
||||
✓ it does not truncate long string arguments in error message
|
||||
✓ it does not truncate custom error message when using not()
|
||||
|
||||
PASS Tests\Unit\Overrides\ThrowableBuilder
|
||||
✓ collision editor can be added to the stack trace
|
||||
@@ -1707,6 +1781,8 @@
|
||||
✓ method hasArgument with ('someValue', true)
|
||||
✓ method hasArgument with ('--a', false)
|
||||
✓ method hasArgument with ('--undefined-argument', false)
|
||||
✓ popArgument preserves duplicate values when removing a missing argument
|
||||
✓ popArgument preserves duplicate values when removing an existing argument
|
||||
|
||||
PASS Tests\Unit\Plugins\Environment
|
||||
✓ environment is set to CI when --ci option is used
|
||||
@@ -1715,6 +1791,52 @@
|
||||
PASS Tests\Unit\Plugins\Retry
|
||||
✓ it orders by defects and stop on defects if when --retry is used
|
||||
|
||||
PASS Tests\Unit\Plugins\Shard
|
||||
✓ getShard → it parses valid shard format with ('1/2', 1, 2)
|
||||
✓ getShard → it parses valid shard format with ('2/2', 2, 2)
|
||||
✓ getShard → it parses valid shard format with ('1/4', 1, 4)
|
||||
✓ getShard → it parses valid shard format with ('4/4', 4, 4)
|
||||
✓ getShard → it parses valid shard format with ('1/10', 1, 10)
|
||||
✓ getShard → it parses valid shard format with ('10/10', 10, 10)
|
||||
✓ getShard → it parses valid shard format with ('5/100', 5, 100)
|
||||
✓ getShard → it throws exception for invalid format with (['test', '--shard', 'invalid'])
|
||||
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1'])
|
||||
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1/'])
|
||||
✓ getShard → it throws exception for invalid format with (['test', '--shard', '/2'])
|
||||
✓ getShard → it throws exception for invalid format with (['test', '--shard', 'a/b'])
|
||||
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1.5/2'])
|
||||
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '0/2'])
|
||||
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '1/0'])
|
||||
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '3/2'])
|
||||
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '5/4'])
|
||||
✓ buildFilterArgument → it generates compact filter for single test
|
||||
✓ buildFilterArgument → it generates compact filter for multiple tests with common prefix
|
||||
✓ buildFilterArgument → it generates compact filter for tests with different namespaces
|
||||
✓ buildFilterArgument → it returns empty string for empty test list
|
||||
✓ buildFilterArgument → it generates compact filter for deeply nested namespaces
|
||||
✓ buildFilterArgument → it handles mix of nested and flat namespaces
|
||||
✓ ensureFilterLengthIsSafe → it accepts filter within length limit
|
||||
✓ ensureFilterLengthIsSafe → it throws exception when filter exceeds default limit
|
||||
✓ ensureFilterLengthIsSafe → it respects custom limit from environment variable
|
||||
✓ ensureFilterLengthIsSafe → it accepts filter within custom limit
|
||||
✓ handleArguments → it returns original arguments when shard option is not present
|
||||
✓ handleArguments → it removes parallel arguments from test discovery
|
||||
✓ parseListTestsOutput → it parses Tests\ namespaced classes from --list-tests output
|
||||
✓ parseListTestsOutput → it deduplicates repeated class names from multiple test methods
|
||||
✓ parseListTestsOutput → it returns an empty list for output with no matching lines
|
||||
✓ parseListTestsOutput → it parses non-Tests namespaced classes
|
||||
✓ parseListTestsOutput → it parses unnamespaced top-level classes
|
||||
✓ parseListTestsOutput → it strips the P\ Pest prefix but keeps the rest of the FQCN
|
||||
✓ parseListTestsOutput → it ignores junk lines that lack the " - …::" framing
|
||||
✓ buildListTestsCommand → it builds the list-tests command with the forwarded --test-directory
|
||||
✓ buildListTestsCommand → it strips --parallel and -p when building the list-tests command
|
||||
✓ buildListTestsCommand → it forwards --test-directory even when input arguments include one
|
||||
✓ buildListTestsCommand → it strips --processes=N when building the list-tests command
|
||||
✓ buildListTestsCommand → it strips --processes N (space-separated) when building the list-tests command
|
||||
✓ addOutput → it displays shard information after test execution
|
||||
✓ addOutput → it uses singular form for single test file
|
||||
✓ addOutput → it returns original exit code when shard is not set
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\ContentHash
|
||||
✓ of() → it returns false when file does not exist
|
||||
✓ of() → it hashes an existing file
|
||||
@@ -1752,6 +1874,142 @@
|
||||
✓ output format → it returns a 32-character hex xxh128 hash
|
||||
✓ output format → it returns a stable hash for empty content
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\FileState
|
||||
✓ keysWithPrefix() → it lists keys matching the prefix
|
||||
✓ keysWithPrefix() → it ignores in-flight temporary files from concurrent writes
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\Graph
|
||||
✓ shouldRerunStatus() → it re-runs failures and errors, replays successes
|
||||
✓ applyMigrationChanges() → it selects tests whose recorded tables intersect the changed migration
|
||||
✓ applyMigrationChanges() → it falls back to watch patterns when no table usage was recorded at all
|
||||
✓ rerun tracking → it reruns cached failures via their file
|
||||
✓ rerun tracking → it flags cached failures whose file is unknown
|
||||
✓ applyBladeStaticChanges() → it maps an anonymous index component to the views that render it
|
||||
✓ applyBladeStaticChanges() → it falls back to watch patterns for components with no matched usage
|
||||
✓ markKnownTestFiles() → it makes a test file with no edges known
|
||||
✓ markKnownTestFiles() → it does not clobber edges of an already-known test file
|
||||
✓ markKnownTestFiles() → it ignores paths outside the project root
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\IsEnabledForRun
|
||||
✓ does not throw when an integer --random-order-seed is passed as a separate argv element
|
||||
✓ still detects --tia when an integer argument is present
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\Lockfiles\PackageLock
|
||||
✓ it applies only to package-lock.json
|
||||
✓ it returns null for contents that are not an npm lockfile
|
||||
✓ it is stable when the directory-derived top-level name changes
|
||||
✓ it ignores platform-specific native binaries added or dropped per OS
|
||||
✓ it ignores libc-constrained variants
|
||||
✓ it ignores runtime deps bundled beneath a platform-specific package
|
||||
✓ it detects a real dependency version change
|
||||
✓ it detects an added or removed non-platform dependency
|
||||
✓ it is independent of package ordering
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\Recorder
|
||||
✓ activateLinkTracking() → it tracks tables without a coverage driver
|
||||
✓ activateLinkTracking() → it tracks inertia components without a coverage driver
|
||||
✓ activateLinkTracking() → it tracks linked sources across consecutive tests
|
||||
✓ activateLinkTracking() → it records nothing while inactive
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\ResultKey
|
||||
✓ it keys a result per dataset row rather than per method
|
||||
✓ it records assertions against the same per-dataset key
|
||||
✓ it leaves a test without a dataset keyed by class and method
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\TableExtractor
|
||||
✓ fromSql() → it extracts tables from plain DML
|
||||
✓ fromSql() → it extracts tables from joins
|
||||
✓ fromSql() → it extracts tables from CTE queries
|
||||
✓ fromSql() → it extracts tables from REPLACE INTO
|
||||
✓ fromSql() → it records the table, not the schema, for qualified identifiers
|
||||
✓ fromSql() → it handles quoted identifiers
|
||||
✓ fromSql() → it ignores schema metadata tables
|
||||
✓ fromSql() → it does not leak int keys for numeric identifiers
|
||||
✓ fromSql() → it returns nothing for non-DML statements
|
||||
✓ fromMigrationSource() → it extracts tables from Schema builder calls
|
||||
✓ fromMigrationSource() → it extracts tables from raw DDL statements
|
||||
✓ fromMigrationSource() → it records the table, not the schema, in qualified DDL and DML
|
||||
✓ fromMigrationSource() → it does not leak int keys for numeric table names
|
||||
✓ fromMigrationSource() → it extracts tables from DB::table calls
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\TestPaths
|
||||
✓ isTestFile() → it matches a standard test file under a configured directory
|
||||
✓ isTestFile() → it does not match a non-test file that shares the directory
|
||||
✓ isTestFile() → it does not match files outside the configured directories
|
||||
✓ isTestFile() → it matches an explicitly listed file
|
||||
✓ isTestFile() → it honours a bare .php suffix
|
||||
✓ isTestFile() → it does not require a dot before the suffix
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\ViteDepsHelper
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('plain-object')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('plain-array')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('nested')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('empty-object')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('empty-array')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-own-line')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-trailing')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('leading-line-comments')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-eof-no-newline')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-leading')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-inline')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-multiline')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-eof')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('jsdoc-style-block')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('block-contains-double-slash')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('line-then-block')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('comment-with-quotes')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('comment-with-braces-commas')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-object')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-array')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-nested')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-newline')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-then-block')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-then-line')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('crlf-line-comment')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('tabs-whitespace')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('url-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('glob-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('path-alias-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('block-open-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('block-close-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('block-both-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('star-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('single-slashes-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('escaped-quote-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('escaped-backslash-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('backslash-then-slash')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('newline-escape-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('unicode-in-string')
|
||||
✓ it strips JSONC down to something JSON.parse accepts with ('block-secret')
|
||||
✓ it never touches comment-looking sequences inside string values
|
||||
✓ it removes the comment body entirely
|
||||
✓ it builds the expected alias map from a tsconfig with ('basic')
|
||||
✓ it builds the expected alias map from a tsconfig with ('multiple-aliases')
|
||||
✓ it builds the expected alias map from a tsconfig with ('default-baseUrl')
|
||||
✓ it builds the expected alias map from a tsconfig with ('baseUrl-subdir')
|
||||
✓ it builds the expected alias map from a tsconfig with ('jsconfig-fallback')
|
||||
✓ it builds the expected alias map from a tsconfig with ('first-target-wins')
|
||||
✓ it builds the expected alias map from a tsconfig with ('multiple-keys-mixed')
|
||||
✓ it builds the expected alias map from a tsconfig with ('no-paths')
|
||||
✓ it builds the expected alias map from a tsconfig with ('no-compiler-options')
|
||||
✓ it builds the expected alias map from a tsconfig with ('key-without-glob')
|
||||
✓ it builds the expected alias map from a tsconfig with ('target-without-glob')
|
||||
✓ it builds the expected alias map from a tsconfig with ('target-not-array')
|
||||
✓ it builds the expected alias map from a tsconfig with ('target-empty-array')
|
||||
✓ it builds the expected alias map from a tsconfig with ('commented-tsconfig')
|
||||
✓ it builds the expected alias map from a tsconfig with ('trailing-comma-paths')
|
||||
✓ it builds the expected alias map from a tsconfig with ('malformed-json')
|
||||
✓ it builds the expected alias map from a tsconfig with ('missing-config')
|
||||
✓ it builds the expected alias map from a tsconfig with ('tsconfig-precedence')
|
||||
✓ it builds the expected alias map from a vite config with ('root-slash-literal')
|
||||
✓ it builds the expected alias map from a vite config with ('path-resolve')
|
||||
✓ it builds the expected alias map from a vite config with ('file-url')
|
||||
✓ it builds the expected alias map from a vite config with ('tilde-alias')
|
||||
✓ it builds the expected alias map from a vite config with ('multiple-aliases')
|
||||
✓ it builds the expected alias map from a vite config with ('laravel-plugin-default')
|
||||
✓ it builds the expected alias map from a vite config with ('no-alias-no-plugin')
|
||||
✓ it builds the expected alias map from a vite config with ('no-config')
|
||||
|
||||
PASS Tests\Unit\Preset
|
||||
✓ preset invalid name
|
||||
✓ preset → myFramework
|
||||
@@ -1904,6 +2162,8 @@
|
||||
✓ parallel
|
||||
✓ a parallel test can extend another test with same name
|
||||
✓ parallel reports invalid datasets as failures
|
||||
✓ parallel can have multiple exclude-groups
|
||||
✓ parallel can have multiple groups
|
||||
|
||||
PASS Tests\Visual\ParallelNestedDatasets
|
||||
✓ parallel loads nested datasets from nested directories
|
||||
@@ -1937,4 +2197,4 @@
|
||||
✓ pass with dataset with ('my-datas-set-value')
|
||||
✓ within describe → pass with dataset with ('my-datas-set-value')
|
||||
|
||||
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1328 passed (3008 assertions)
|
||||
Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1549 passed (3389 assertions)
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Expectation;
|
||||
use Pest\Plugins\Tia\BaselineSync;
|
||||
|
||||
|
||||
+5
-3
@@ -1,12 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Plugin;
|
||||
|
||||
trait PluginTrait
|
||||
{
|
||||
public function assertPluginTraitGotRegistered(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
expect(true)->toBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +16,14 @@ trait SecondPluginTrait
|
||||
{
|
||||
public function assertSecondPluginTraitGotRegistered(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
expect(true)->toBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
Plugin::uses(PluginTrait::class);
|
||||
Plugin::uses(SecondPluginTrait::class);
|
||||
|
||||
function _assertThat()
|
||||
function _assertThat(): void
|
||||
{
|
||||
expect(true)->toBeTrue();
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
dataset('bound.closure', function () {
|
||||
yield function () {
|
||||
return 1;
|
||||
};
|
||||
yield function () {
|
||||
return 2;
|
||||
};
|
||||
yield fn (): int => 1;
|
||||
yield fn (): int => 2;
|
||||
});
|
||||
|
||||
dataset('bound.array', [
|
||||
function () {
|
||||
return 1;
|
||||
},
|
||||
function () {
|
||||
return 2;
|
||||
},
|
||||
fn (): int => 1,
|
||||
fn (): int => 2,
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
dataset('numbers.closure', function () {
|
||||
yield [1];
|
||||
yield [2];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
test('global functions are loaded', function () {
|
||||
declare(strict_types=1);
|
||||
|
||||
test('global functions are loaded', function (): void {
|
||||
expect(helper_returns_string())->toBeString();
|
||||
});
|
||||
|
||||
+25
-25
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
|
||||
beforeEach(function () {
|
||||
beforeEach(function (): void {
|
||||
$this->count = 0;
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
afterEach(function (): void {
|
||||
match ($this->name()) {
|
||||
'__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(1),
|
||||
'__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(1),
|
||||
@@ -21,37 +21,37 @@ afterEach(function () {
|
||||
$this->count++;
|
||||
});
|
||||
|
||||
it('can run after test', function () {
|
||||
it('can run after test', function (): void {
|
||||
expect($this->count)->toBe(0);
|
||||
|
||||
$this->count++;
|
||||
})->after(function () {
|
||||
})->after(function (): void {
|
||||
expect($this->count)->toBe(2);
|
||||
|
||||
$this->count++;
|
||||
});
|
||||
|
||||
it('can run after test twice', function () {
|
||||
it('can run after test twice', function (): void {
|
||||
expect($this->count)->toBe(0);
|
||||
|
||||
$this->count++;
|
||||
})->after(function () {
|
||||
})->after(function (): void {
|
||||
expect($this->count)->toBe(2);
|
||||
|
||||
$this->count++;
|
||||
})->after(function () {
|
||||
})->after(function (): void {
|
||||
expect($this->count)->toBe(3);
|
||||
|
||||
$this->count++;
|
||||
});
|
||||
|
||||
it('does not run when skipped', function () {
|
||||
it('does not run when skipped', function (): void {
|
||||
dd('This should not run 1');
|
||||
})->skip()->after(function () {
|
||||
})->skip()->after(function (): void {
|
||||
dd('This should not run 2');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
afterEach(function (): void {
|
||||
match ($this->name()) {
|
||||
'__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(3),
|
||||
'__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(4),
|
||||
@@ -70,7 +70,7 @@ afterEach(function () {
|
||||
$this->count++;
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
afterEach(function (): void {
|
||||
match ($this->name()) {
|
||||
'__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(4),
|
||||
'__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(5),
|
||||
@@ -87,47 +87,47 @@ afterEach(function () {
|
||||
$this->count++;
|
||||
});
|
||||
|
||||
describe('something', function () {
|
||||
it('does not run when skipped', function () {
|
||||
describe('something', function (): void {
|
||||
it('does not run when skipped', function (): void {
|
||||
dd('This should not run 3');
|
||||
})->skip()->after(function () {
|
||||
})->skip()->after(function (): void {
|
||||
dd('This should not run 4');
|
||||
});
|
||||
|
||||
it('can run after test', function () {
|
||||
it('can run after test', function (): void {
|
||||
expect($this->count)->toBe(0);
|
||||
|
||||
$this->count++;
|
||||
})->after(function () {
|
||||
})->after(function (): void {
|
||||
expect($this->count)->toBe(5);
|
||||
|
||||
$this->count++;
|
||||
})->after(function () {
|
||||
})->after(function (): void {
|
||||
expect($this->count)->toBe(6);
|
||||
|
||||
$this->count++;
|
||||
});
|
||||
})->after(function () {
|
||||
})->after(function (): void {
|
||||
expect($this->count)->toBe(4);
|
||||
|
||||
$this->count++;
|
||||
});
|
||||
|
||||
describe('something 2', function () {
|
||||
it('can run after test', function () {
|
||||
describe('something 2', function (): void {
|
||||
it('can run after test', function (): void {
|
||||
expect($this->count)->toBe(0);
|
||||
|
||||
$this->count++;
|
||||
})->after(function () {
|
||||
})->after(function (): void {
|
||||
expect($this->count)->toBe(6);
|
||||
|
||||
$this->count++;
|
||||
});
|
||||
})->after(function () {
|
||||
})->after(function (): void {
|
||||
expect($this->count)->toBe(4);
|
||||
|
||||
$this->count++;
|
||||
})->after(function () {
|
||||
})->after(function (): void {
|
||||
expect($this->count)->toBe(5);
|
||||
|
||||
$this->count++;
|
||||
@@ -136,7 +136,7 @@ describe('something 2', function () {
|
||||
test('high order test')
|
||||
->defer(fn () => $this->count++)
|
||||
->expect(fn () => $this->count)->toBe(1)
|
||||
->after(function () {
|
||||
->after(function (): void {
|
||||
expect($this->count)->toBe(4);
|
||||
|
||||
$this->count++;
|
||||
@@ -146,7 +146,7 @@ test('high order test with skip')
|
||||
->skip()
|
||||
->defer(fn () => $this->count++)
|
||||
->expect(fn () => $this->count)->toBe(1)
|
||||
->after(function () {
|
||||
->after(function (): void {
|
||||
dd('This should not run 5');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$file = __DIR__.DIRECTORY_SEPARATOR.'after-all-test';
|
||||
|
||||
beforeAll(function () use ($file) {
|
||||
beforeAll(function () use ($file): void {
|
||||
@unlink($file);
|
||||
});
|
||||
|
||||
afterAll(function () use ($file) {
|
||||
afterAll(function () use ($file): void {
|
||||
@unlink($file);
|
||||
});
|
||||
|
||||
test('deletes file after all', function () use ($file) {
|
||||
test('deletes file after all', function () use ($file): void {
|
||||
file_put_contents($file, 'foo');
|
||||
$this->assertFileExists($file);
|
||||
register_shutdown_function(function () {
|
||||
expect($file)->toBeFile();
|
||||
register_shutdown_function(function (): void {
|
||||
// $this->assertFileDoesNotExist($file);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,99 +2,99 @@
|
||||
|
||||
$state = new stdClass;
|
||||
|
||||
beforeEach(function () use ($state) {
|
||||
beforeEach(function () use ($state): void {
|
||||
$this->state = $state;
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
afterEach(function (): void {
|
||||
$this->state->bar = 1;
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
afterEach(function (): void {
|
||||
unset($this->state->bar);
|
||||
});
|
||||
|
||||
it('does not get executed before the test', function () {
|
||||
it('does not get executed before the test', function (): void {
|
||||
expect($this->state)->not->toHaveProperty('bar');
|
||||
});
|
||||
|
||||
it('gets executed after the test', function () {
|
||||
expect($this->state)->toHaveProperty('bar');
|
||||
expect($this->state->bar)->toBe(2);
|
||||
it('gets executed after the test', function (): void {
|
||||
expect($this->state)->toHaveProperty('bar')
|
||||
->and($this->state->bar)->toBe(2);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
afterEach(function (): void {
|
||||
$this->state->bar = 2;
|
||||
});
|
||||
|
||||
describe('outer', function () {
|
||||
afterEach(function () {
|
||||
describe('outer', function (): void {
|
||||
afterEach(function (): void {
|
||||
$this->state->bar++;
|
||||
});
|
||||
|
||||
describe('inner', function () {
|
||||
afterEach(function () {
|
||||
describe('inner', function (): void {
|
||||
afterEach(function (): void {
|
||||
$this->state->bar++;
|
||||
});
|
||||
|
||||
it('does not get executed before the test', function () {
|
||||
expect($this->state)->toHaveProperty('bar');
|
||||
expect($this->state->bar)->toBe(2);
|
||||
it('does not get executed before the test', function (): void {
|
||||
expect($this->state)->toHaveProperty('bar')
|
||||
->and($this->state->bar)->toBe(2);
|
||||
});
|
||||
|
||||
it('should call all parent afterEach functions', function () {
|
||||
expect($this->state)->toHaveProperty('bar');
|
||||
expect($this->state->bar)->toBe(4);
|
||||
it('should call all parent afterEach functions', function (): void {
|
||||
expect($this->state)->toHaveProperty('bar')
|
||||
->and($this->state->bar)->toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('matching describe block names', function () {
|
||||
afterEach(function () {
|
||||
describe('matching describe block names', function (): void {
|
||||
afterEach(function (): void {
|
||||
$this->state->foo = 1;
|
||||
});
|
||||
|
||||
describe('outer', function () {
|
||||
afterEach(function () {
|
||||
describe('outer', function (): void {
|
||||
afterEach(function (): void {
|
||||
$this->state->foo++;
|
||||
});
|
||||
|
||||
describe('middle', function () {
|
||||
afterEach(function () {
|
||||
describe('middle', function (): void {
|
||||
afterEach(function (): void {
|
||||
$this->state->foo++;
|
||||
});
|
||||
|
||||
describe('inner', function () {
|
||||
afterEach(function () {
|
||||
describe('inner', function (): void {
|
||||
afterEach(function (): void {
|
||||
$this->state->foo++;
|
||||
});
|
||||
|
||||
it('does not get executed before the test', function () {
|
||||
it('does not get executed before the test', function (): void {
|
||||
expect($this)->not->toHaveProperty('foo');
|
||||
});
|
||||
|
||||
it('should call all parent afterEach functions', function () {
|
||||
it('should call all parent afterEach functions', function (): void {
|
||||
expect($this->state->foo)->toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('middle', function () {
|
||||
it('does not get executed before the test', function () {
|
||||
describe('middle', function (): void {
|
||||
it('does not get executed before the test', function (): void {
|
||||
expect($this)->not->toHaveProperty('foo');
|
||||
});
|
||||
|
||||
it('should not call afterEach functions for sibling describe blocks with the same name', function () {
|
||||
it('should not call afterEach functions for sibling describe blocks with the same name', function (): void {
|
||||
expect($this)->not->toHaveProperty('foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inner', function () {
|
||||
it('does not get executed before the test', function () {
|
||||
describe('inner', function (): void {
|
||||
it('does not get executed before the test', function (): void {
|
||||
expect($this)->not->toHaveProperty('foo');
|
||||
});
|
||||
|
||||
it('should not call afterEach functions for descendent of sibling describe blocks with the same name', function () {
|
||||
it('should not call afterEach functions for descendent of sibling describe blocks with the same name', function (): void {
|
||||
expect($this)->not->toHaveProperty('foo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<?php
|
||||
|
||||
beforeEach(function () {
|
||||
beforeEach(function (): void {
|
||||
expect(true)->toBeTrue();
|
||||
})->assignee('nunomaduro');
|
||||
|
||||
it('may be associated with an assignee', function () {
|
||||
it('may be associated with an assignee', function (): void {
|
||||
expect(true)->toBeTrue();
|
||||
})->assignee('taylorotwell');
|
||||
|
||||
describe('nested', function () {
|
||||
it('may be associated with an assignee', function () {
|
||||
describe('nested', function (): void {
|
||||
it('may be associated with an assignee', function (): void {
|
||||
expect(true)->toBeTrue();
|
||||
})->assignee('taylorotwell');
|
||||
})->assignee('nunomaduro')->note('an note between an the assignee')->assignee(['jamesbrooks', 'joedixon']);
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
$foo = new stdClass;
|
||||
$foo->bar = 0;
|
||||
|
||||
beforeAll(function () use ($foo) {
|
||||
beforeAll(function () use ($foo): void {
|
||||
$foo->bar++;
|
||||
});
|
||||
|
||||
it('gets executed before tests', function () use ($foo) {
|
||||
it('gets executed before tests', function () use ($foo): void {
|
||||
expect($foo)->bar->toBe(1);
|
||||
|
||||
$foo->bar = 'changed';
|
||||
});
|
||||
|
||||
it('do not get executed before each test', function () use ($foo) {
|
||||
it('do not get executed before each test', function () use ($foo): void {
|
||||
expect($foo)->bar->toBe('changed');
|
||||
});
|
||||
|
||||
@@ -1,91 +1,91 @@
|
||||
<?php
|
||||
|
||||
beforeEach(function () {
|
||||
beforeEach(function (): void {
|
||||
$this->bar = 2;
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
beforeEach(function (): void {
|
||||
$this->bar++;
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
beforeEach(function (): void {
|
||||
$this->bar = 0;
|
||||
});
|
||||
|
||||
it('gets executed before each test', function () {
|
||||
it('gets executed before each test', function (): void {
|
||||
expect($this->bar)->toBe(1);
|
||||
|
||||
$this->bar = 'changed';
|
||||
});
|
||||
|
||||
it('gets executed before each test once again', function () {
|
||||
it('gets executed before each test once again', function (): void {
|
||||
expect($this->bar)->toBe(1);
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
beforeEach(function (): void {
|
||||
$this->bar++;
|
||||
});
|
||||
|
||||
describe('outer', function () {
|
||||
beforeEach(function () {
|
||||
describe('outer', function (): void {
|
||||
beforeEach(function (): void {
|
||||
$this->bar++;
|
||||
});
|
||||
|
||||
describe('inner', function () {
|
||||
beforeEach(function () {
|
||||
describe('inner', function (): void {
|
||||
beforeEach(function (): void {
|
||||
$this->bar++;
|
||||
});
|
||||
|
||||
it('should call all parent beforeEach functions', function () {
|
||||
it('should call all parent beforeEach functions', function (): void {
|
||||
expect($this->bar)->toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with expectations', function () {
|
||||
describe('with expectations', function (): void {
|
||||
beforeEach()->expect(true)->toBeTrue();
|
||||
|
||||
describe('nested block', function () {
|
||||
test('test', function () {});
|
||||
describe('nested block', function (): void {
|
||||
test('test', function (): void {});
|
||||
});
|
||||
|
||||
test('test', function () {});
|
||||
test('test', function (): void {});
|
||||
});
|
||||
|
||||
describe('matching describe block names', function () {
|
||||
beforeEach(function () {
|
||||
describe('matching describe block names', function (): void {
|
||||
beforeEach(function (): void {
|
||||
$this->foo = 1;
|
||||
});
|
||||
|
||||
describe('outer', function () {
|
||||
beforeEach(function () {
|
||||
describe('outer', function (): void {
|
||||
beforeEach(function (): void {
|
||||
$this->foo++;
|
||||
});
|
||||
|
||||
describe('middle', function () {
|
||||
beforeEach(function () {
|
||||
describe('middle', function (): void {
|
||||
beforeEach(function (): void {
|
||||
$this->foo++;
|
||||
});
|
||||
|
||||
describe('inner', function () {
|
||||
beforeEach(function () {
|
||||
describe('inner', function (): void {
|
||||
beforeEach(function (): void {
|
||||
$this->foo++;
|
||||
});
|
||||
|
||||
it('should call all parent beforeEach functions', function () {
|
||||
it('should call all parent beforeEach functions', function (): void {
|
||||
expect($this->foo)->toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('middle', function () {
|
||||
it('should not call beforeEach functions for sibling describe blocks with the same name', function () {
|
||||
describe('middle', function (): void {
|
||||
it('should not call beforeEach functions for sibling describe blocks with the same name', function (): void {
|
||||
expect($this->foo)->toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inner', function () {
|
||||
it('should not call beforeEach functions for descendent of sibling describe blocks with the same name', function () {
|
||||
describe('inner', function (): void {
|
||||
it('should not call beforeEach functions for descendent of sibling describe blocks with the same name', function (): void {
|
||||
expect($this->foo)->toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -93,36 +93,36 @@ describe('matching describe block names', function () {
|
||||
});
|
||||
|
||||
$matchingNameCalls = 0;
|
||||
describe('matching name', function () use (&$matchingNameCalls) {
|
||||
beforeEach(function () use (&$matchingNameCalls) {
|
||||
describe('matching name', function () use (&$matchingNameCalls): void {
|
||||
beforeEach(function () use (&$matchingNameCalls): void {
|
||||
$matchingNameCalls++;
|
||||
});
|
||||
|
||||
it('should call the before each', function () use (&$matchingNameCalls) {
|
||||
it('should call the before each', function () use (&$matchingNameCalls): void {
|
||||
expect($matchingNameCalls)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matching name', function () use (&$matchingNameCalls) {
|
||||
it('should not call the before each on the describe block with the same name', function () use (&$matchingNameCalls) {
|
||||
describe('matching name', function () use (&$matchingNameCalls): void {
|
||||
it('should not call the before each on the describe block with the same name', function () use (&$matchingNameCalls): void {
|
||||
expect($matchingNameCalls)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
beforeEach(function (): void {
|
||||
$this->baz = 1;
|
||||
});
|
||||
|
||||
describe('called on all tests', function () {
|
||||
beforeEach(function () {
|
||||
describe('called on all tests', function (): void {
|
||||
beforeEach(function (): void {
|
||||
$this->baz++;
|
||||
});
|
||||
|
||||
test('beforeEach should be called', function () {
|
||||
test('beforeEach should be called', function (): void {
|
||||
expect($this->baz)->toBe(2);
|
||||
});
|
||||
|
||||
test('beforeEach should be called for all tests', function () {
|
||||
test('beforeEach should be called for all tests', function (): void {
|
||||
expect($this->baz)->toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
beforeEach()->expect(true)->toBeTrue();
|
||||
|
||||
test('runs 1', function () {
|
||||
test('runs 1', function (): void {
|
||||
// This test did performs assertions...
|
||||
});
|
||||
|
||||
test('runs 2', function () {
|
||||
test('runs 2', function (): void {
|
||||
// This test did performs assertions...
|
||||
});
|
||||
|
||||
test('runs 3', function () {
|
||||
test('runs 3', function (): void {
|
||||
// This test did performs assertions...
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
beforeEach()->skip();
|
||||
|
||||
test('does not run 1', function () {
|
||||
test('does not run 1', function (): void {
|
||||
$this->fail('This test should not run');
|
||||
});
|
||||
|
||||
test('does not run 2', function () {
|
||||
test('does not run 2', function (): void {
|
||||
$this->fail('This test should not run');
|
||||
});
|
||||
|
||||
test('does not run 3', function () {
|
||||
test('does not run 3', function (): void {
|
||||
$this->fail('This test should not run');
|
||||
});
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
beforeEach()->todo();
|
||||
|
||||
test('is marked as todo 1', function () {
|
||||
test('is marked as todo 1', function (): void {
|
||||
$this->fail('This test should not run');
|
||||
});
|
||||
|
||||
test('is marked as todo 2', function () {
|
||||
test('is marked as todo 2', function (): void {
|
||||
$this->fail('This test should not run');
|
||||
});
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
it('has plugin')->assertTrue(class_exists(CoveragePlugin::class));
|
||||
|
||||
it('adds coverage if --coverage exist', function () {
|
||||
it('adds coverage if --coverage exist', function (): void {
|
||||
$plugin = new CoveragePlugin(new ConsoleOutput);
|
||||
|
||||
expect($plugin->coverage)->toBeFalse();
|
||||
$arguments = $plugin->handleArguments([]);
|
||||
expect($arguments)->toEqual([])
|
||||
expect($arguments)->toBeEmpty()
|
||||
->and($plugin->coverage)->toBeFalse();
|
||||
|
||||
$arguments = $plugin->handleArguments(['--coverage']);
|
||||
@@ -19,7 +19,7 @@ it('adds coverage if --coverage exist', function () {
|
||||
->and($plugin->coverage)->toBeTrue();
|
||||
})->skip(! Coverage::isAvailable() || ! function_exists('xdebug_info') || ! in_array('coverage', xdebug_info('mode'), true), 'Coverage is not available');
|
||||
|
||||
it('adds coverage if --min exist', function () {
|
||||
it('adds coverage if --min exist', function (): void {
|
||||
$plugin = new CoveragePlugin(new ConsoleOutput);
|
||||
expect($plugin->coverageMin)->toEqual(0.0)
|
||||
->and($plugin->coverage)->toBeFalse();
|
||||
@@ -34,7 +34,7 @@ it('adds coverage if --min exist', function () {
|
||||
expect($plugin->coverageMin)->toEqual(2.4);
|
||||
});
|
||||
|
||||
it('generates coverage based on file input', function () {
|
||||
it('generates coverage based on file input', function (): void {
|
||||
expect(Coverage::getMissingCoverage(new class
|
||||
{
|
||||
public function lineCoverageData(): array
|
||||
|
||||
@@ -5,9 +5,9 @@ use Tests\Fixtures\Covers\CoversClass1;
|
||||
|
||||
covers([CoversClass1::class]);
|
||||
|
||||
it('uses the correct PHPUnit attribute for class', function () {
|
||||
$attributes = (new ReflectionClass($this))->getAttributes();
|
||||
it('uses the correct PHPUnit attribute for class', function (): void {
|
||||
$attributes = new ReflectionClass($this)->getAttributes();
|
||||
|
||||
expect($attributes[1]->getName())->toBe(CoversClass::class);
|
||||
expect($attributes[1]->getArguments()[0])->toBe('Tests\Fixtures\Covers\CoversClass1');
|
||||
expect($attributes[1]->getName())->toBe(CoversClass::class)
|
||||
->and($attributes[1]->getArguments()[0])->toBe(CoversClass1::class);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user