Compare commits

...

147 Commits

Author SHA1 Message Date
99cc4e0146 wip 2026-05-02 19:33:09 +01:00
a47e6f8fef wip 2026-05-02 19:30:14 +01:00
536d79f765 wip 2026-05-02 19:20:55 +01:00
65c0fbc528 wip 2026-05-02 19:07:41 +01:00
9e4cf4b665 wip 2026-05-02 18:58:42 +01:00
7bea819978 wip 2026-05-02 18:47:26 +01:00
4280233b40 wip 2026-05-02 18:37:24 +01:00
d6db3a8a20 wip 2026-05-02 18:32:05 +01:00
51c8ce4df6 wip 2026-05-02 18:31:32 +01:00
5b8393b925 wip 2026-05-02 18:25:41 +01:00
e4d9b61fdf wip 2026-05-02 18:25:27 +01:00
e2d940cd53 wip 2026-05-02 18:25:21 +01:00
380ccd30b4 wip 2026-05-02 18:03:25 +01:00
31c200716d wip 2026-05-02 18:03:14 +01:00
6add4da543 wip 2026-05-02 18:02:20 +01:00
8ddcd3e853 wip 2026-05-02 18:02:13 +01:00
e3e178fd94 wip 2026-05-02 17:59:21 +01:00
7b1ec9f003 wip 2026-05-02 17:59:13 +01:00
1e48c5d473 wip 2026-05-02 17:59:00 +01:00
d00ec95dd9 wip 2026-05-02 17:58:55 +01:00
89f3d6cb39 wip 2026-05-02 17:45:54 +01:00
a07a2e512a wip 2026-05-02 17:39:15 +01:00
57eecb2b3d wip 2026-05-02 17:38:12 +01:00
9f804dc954 wip 2026-05-02 17:38:08 +01:00
7cbad4c589 wip 2026-05-02 17:38:01 +01:00
5cae93b059 wip 2026-05-02 17:37:56 +01:00
df829ad19d wip 2026-05-02 17:37:47 +01:00
635460653c wip 2026-05-02 17:37:34 +01:00
1aa80dc398 wip 2026-05-02 17:18:35 +01:00
8a14056111 wip 2026-05-02 17:15:46 +01:00
f247dd8e7b wip 2026-05-02 17:11:49 +01:00
1c7c9754fd wip 2026-05-02 17:07:08 +01:00
5f37939fda wip 2026-05-02 17:02:11 +01:00
28305fcb7a wip 2026-05-02 16:35:52 +01:00
5242803694 wip 2026-05-02 15:54:00 +01:00
925935a7e8 wip 2026-05-02 15:33:38 +01:00
460401c379 wip 2026-05-02 15:26:58 +01:00
348b439172 wip 2026-05-02 15:15:53 +01:00
a4e77766c5 wip 2026-05-02 15:07:51 +01:00
4a8c2d7d78 wip 2026-05-02 15:03:44 +01:00
7d51601120 wip 2026-05-02 14:15:37 +01:00
631bbe318b wip 2026-05-02 13:43:32 +01:00
9b7c15d5b6 wip 2026-05-02 12:03:35 +01:00
872796bd9b wip 2026-05-02 12:00:47 +01:00
c38d32ae86 wip 2026-05-02 09:49:33 +01:00
6407c4f78f wip 2026-05-02 01:58:39 +01:00
6e1bf63f6a wip 2026-05-02 01:40:35 +01:00
1d3e8bb5dd wip 2026-05-02 01:03:06 +01:00
3cc9b169e3 wip 2026-05-02 00:52:57 +01:00
c4911d046b wip 2026-05-02 00:06:04 +01:00
d0295f6168 wip 2026-05-01 23:59:25 +01:00
21efbc3107 wip 2026-05-01 22:55:38 +01:00
e59b99cd73 wip 2026-05-01 22:51:55 +01:00
bf48e20880 wip 2026-05-01 22:36:15 +01:00
53db68e005 wip 2026-05-01 22:31:00 +01:00
34f1e9a7f2 fix 2026-05-01 21:51:09 +01:00
57fd5ce042 wip 2026-05-01 21:50:56 +01:00
3bcabfb63b fix 2026-05-01 21:50:52 +01:00
aa3a7c303a wip 2026-05-01 21:32:59 +01:00
5c08a135f7 wip 2026-05-01 21:30:44 +01:00
6e0e030d71 wip 2026-05-01 21:22:33 +01:00
b2c07561e7 wip 2026-05-01 20:54:24 +01:00
97600b6f0b wip 2026-05-01 20:53:40 +01:00
8a51f15d65 wip 2026-05-01 20:45:51 +01:00
a349f53964 wip 2026-05-01 20:42:14 +01:00
a725e774c0 wip 2026-05-01 20:28:39 +01:00
bed5e5b54a wip 2026-05-01 20:02:46 +01:00
45b1d4ce20 wip 2026-05-01 19:50:54 +01:00
d106b70766 wip 2026-05-01 17:24:22 +01:00
6ac6c1518e wip 2026-05-01 17:17:33 +01:00
fda515a17f wip 2026-05-01 16:42:01 +01:00
0a97d3a288 asd 2026-05-01 15:33:06 +01:00
3802fa80e6 asd 2026-05-01 15:19:19 +01:00
5c3cbc14d2 wip 2026-05-01 15:07:10 +01:00
6b9c768172 wip 2026-05-01 14:39:23 +01:00
4a2fc179ae asd 2026-05-01 13:54:25 +01:00
b5bb2139dc wqdqwd 2026-05-01 12:57:12 +01:00
07416a3c61 wip 2026-05-01 03:30:28 +01:00
30b94e3034 qdw 2026-05-01 02:10:08 +01:00
be34eecb2f wip 2026-05-01 01:55:18 +01:00
5d9f95f8d4 qwdqwd 2026-05-01 01:44:08 +01:00
48b70a03d5 wip 2026-05-01 01:32:48 +01:00
4b8642b972 wip 2026-05-01 00:48:31 +01:00
8711d51eac fix 2026-05-01 00:19:44 +01:00
58dfb6da64 wip 2026-04-30 22:12:53 +01:00
d7735d1faa wip 2026-04-30 22:00:56 +01:00
6b59166f3c wip 2026-04-30 21:08:00 +01:00
3a26028d17 wip 2026-04-30 20:58:06 +01:00
3c91bf4ad2 wip 2026-04-30 20:51:57 +01:00
6a434be0f6 wip 2026-04-30 20:45:36 +01:00
f355b99bbf wip 2026-04-29 22:59:56 +01:00
95a00341e9 wip for now 2026-04-29 02:22:37 +01:00
466259646d wip 2026-04-28 22:12:42 +01:00
00f8d56083 wip 2026-04-28 21:41:20 +01:00
ca2dca592d wup 2026-04-28 21:34:40 +01:00
405d8d4406 wip 2026-04-28 21:28:46 +01:00
b944ee5841 wip 2026-04-27 19:15:42 +01:00
f4e22dcafe wip 2026-04-27 18:57:41 +01:00
339c1e8cac wip 2026-04-27 18:14:10 +01:00
d4c7362132 wip 2026-04-27 16:56:27 +01:00
81bfdbf8fe wip 2026-04-27 13:16:05 +01:00
f45cbf43c5 wip 2026-04-27 13:11:48 +01:00
b9088d23fb wip 2026-04-27 13:03:07 +01:00
7250185423 wip 2026-04-27 12:22:05 +01:00
e457eb0e9c wip 2026-04-27 11:15:59 +01:00
48357c6f30 wip 2026-04-27 10:30:08 +01:00
b46f051550 wip 2026-04-23 17:32:27 -07:00
3d3c5d41ac wip 2026-04-23 12:29:24 -07:00
caabebf2a1 wip 2026-04-23 10:56:17 -07:00
470a5833d4 wip 2026-04-23 10:30:44 -07:00
c1feefbb9e wip 2026-04-23 09:44:12 -07:00
e876dba8ba wip 2026-04-23 09:29:56 -07:00
d9c18f9c02 wip 2026-04-22 09:03:10 -07:00
660b57b365 wip 2026-04-22 08:42:32 -07:00
68527c996f wip 2026-04-22 08:25:38 -07:00
c6a42a2b28 wip 2026-04-22 08:07:52 -07:00
856a370032 style 2026-04-21 09:44:26 -07:00
e24882c486 wip 2026-04-21 09:41:19 -07:00
51fc380789 wip 2026-04-21 09:40:01 -07:00
f6609f4039 wip 2026-04-21 08:36:41 -07:00
2941f9821f wip 2026-04-21 08:15:24 -07:00
ed399af43e wip 2026-04-21 07:41:50 -07:00
0d66dc4322 chore: removes https 2026-04-21 07:26:19 -07:00
7e4280bf83 chore: improves feedback 2026-04-21 07:13:08 -07:00
a5915b16ab wip 2026-04-20 20:58:38 -07:00
1476b529a1 wip 2026-04-20 19:56:23 -07:00
2892341c28 wip 2026-04-20 14:28:18 -07:00
59e781e77b wip 2026-04-20 13:48:05 -07:00
55a3394f8c wip 2026-04-20 13:31:43 -07:00
0d99c33c4e wip 2026-04-20 13:16:59 -07:00
adc5aae6f8 wip 2026-04-20 13:00:41 -07:00
980667e845 wip 2026-04-20 12:25:39 -07:00
8c849c5f40 fix: missing warning 2026-04-20 12:18:32 -07:00
47f1fc2d94 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
9c8033d60c feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
42d1092a9e feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
c7e32f5d33 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
d379128cc4 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
f09d6f2064 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
494cc6e2a4 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
f52a455773 fix 2026-04-20 10:09:07 -07:00
184f5d2742 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
1d81069a2a feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
4b9bb77b54 feat(tia): adds poc 2026-04-20 10:09:07 -07:00
c440031e28 feat(tia): adds poc 2026-04-20 10:09:07 -07:00
bff44562a9 release: v4.6.3 2026-04-18 06:51:25 -07:00
9ebb990f96 chore: bumps phpunit 2026-04-18 06:51:17 -07:00
70 changed files with 9002 additions and 24 deletions

View File

@ -3,8 +3,10 @@
declare(strict_types=1); declare(strict_types=1);
use Pest\Contracts\Restarter;
use Pest\Kernel; use Pest\Kernel;
use Pest\Panic; use Pest\Panic;
use Pest\Support\Container;
use Pest\TestCaseFilters\GitDirtyTestCaseFilter; use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter; use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
use Pest\TestCaseMethodFilters\IssueTestCaseFilter; use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
@ -142,6 +144,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
// Get $rootPath based on $autoloadPath // Get $rootPath based on $autoloadPath
$rootPath = dirname($autoloadPath, 2); $rootPath = dirname($autoloadPath, 2);
$input = new ArgvInput; $input = new ArgvInput;
$testSuite = TestSuite::getInstance( $testSuite = TestSuite::getInstance(
@ -192,6 +195,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
try { try {
$kernel = Kernel::boot($testSuite, $input, $output); $kernel = Kernel::boot($testSuite, $input, $output);
$container = Container::getInstance();
foreach (Kernel::RESTARTERS as $restarterClass) {
$restarter = $container->get($restarterClass);
assert($restarter instanceof Restarter);
$restarter->maybeRestart($rootPath, $originalArguments);
}
$result = $kernel->handle($originalArguments, $arguments); $result = $kernel->handle($originalArguments, $arguments);
$kernel->terminate(); $kernel->terminate();

239
bin/pest-tia-vite-deps.mjs Normal file
View File

@ -0,0 +1,239 @@
#!/usr/bin/env node
import { readdir, readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { resolve, relative, extname, sep, join } from 'node:path'
import { pathToFileURL } from 'node:url'
const PAGE_EXTENSIONS = new Set([
'.vue', '.svelte',
'.tsx', '.jsx',
'.ts', '.js',
'.mts', '.cts', '.mjs', '.cjs',
])
const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
const PAGE_DIR_CANDIDATES = [
'resources/js/Pages',
'resources/js/pages',
'assets/js/Pages',
'assets/js/pages',
'assets/Pages',
'assets/pages',
]
async function loadRolldown() {
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
const path = projectRequire.resolve('rolldown')
return await import(pathToFileURL(path).href)
}
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)
}
async function loadAliasFromTsconfig() {
const alias = {}
for (const name of ['tsconfig.json', 'jsconfig.json']) {
const p = join(PROJECT_ROOT, name)
if (!existsSync(p)) continue
let cfg
try { cfg = await readJsonWithComments(p) } catch { continue }
const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.')
const paths = cfg?.compilerOptions?.paths ?? {}
for (const [key, targets] of Object.entries(paths)) {
if (!key.endsWith('/*')) continue
const t0 = Array.isArray(targets) ? targets[0] : null
if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue
const aliasKey = key.slice(0, -2)
if (alias[aliasKey] !== undefined) continue
alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2))
}
}
return alias
}
async function listPageFiles(pagesDir) {
if (!existsSync(pagesDir)) return []
const out = []
const walk = async (dir) => {
let entries
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
for (const entry of entries) {
const full = resolve(dir, entry.name)
if (entry.isDirectory()) { await walk(full); continue }
if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full)
}
}
await walk(pagesDir)
return out
}
async function discoverPagesDir() {
const override = process.env.TIA_VITE_PAGES_DIR
if (override && override.length > 0) {
return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
}
for (const rel of PAGE_DIR_CANDIDATES) {
const abs = resolve(PROJECT_ROOT, rel)
if (!existsSync(abs)) continue
const files = await listPageFiles(abs)
if (files.length > 0) return abs
}
return null
}
function componentNameFor(pageAbs, pagesDir) {
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
const ext = extname(rel)
return rel.slice(0, rel.length - ext.length)
}
function isLocalSpecifier(source, aliasKeys) {
if (source.startsWith('.') || source.startsWith('/')) return true
for (const key of aliasKeys) {
if (source === key || source.startsWith(key + '/')) return true
}
return false
}
async function main() {
const pagesDir = await discoverPagesDir()
if (pagesDir === null) {
process.stdout.write('{}')
return
}
const pages = await listPageFiles(pagesDir)
if (pages.length === 0) {
process.stdout.write('{}')
return
}
const { rolldown } = await loadRolldown()
const alias = await loadAliasFromTsconfig()
const aliasKeys = Object.keys(alias)
const graph = new Map()
const collector = {
name: 'pest-tia-collector',
moduleParsed(info) {
const id = info.id
if (!id || id.startsWith('\0')) return
const deps = new Set()
for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i)
for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i)
graph.set(id, deps)
},
}
const externalBare = {
name: 'pest-tia-external-bare',
resolveId(source) {
if (!source) return null
if (isLocalSpecifier(source, aliasKeys)) return null
return { id: source, external: true }
},
}
const assetStub = {
name: 'pest-tia-asset-stub',
load(id) {
if (!id) return null
if (ASSET_EXT_RE.test(id)) {
return { code: 'export default null', moduleSideEffects: false }
}
return null
},
}
const input = Object.create(null)
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
const bundle = await rolldown({
input,
cwd: PROJECT_ROOT,
resolve: {
alias,
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
},
transform: { jsx: 'preserve' },
treeshake: false,
plugins: [externalBare, assetStub, collector],
logLevel: 'silent',
onLog: () => {},
})
try {
await bundle.generate({ format: 'esm' })
} finally {
await bundle.close()
}
const reverse = new Map()
const transitiveCache = new Map()
const computeTransitive = (id, stack) => {
const cached = transitiveCache.get(id)
if (cached) return cached
if (stack.has(id)) return null
stack.add(id)
const acc = new Set()
const deps = graph.get(id)
if (deps) {
for (const dep of deps) {
if (!dep || dep.startsWith('\0')) continue
if (dep.startsWith(PROJECT_ROOT)) {
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
acc.add(rel)
}
if (stack.has(dep)) continue
const child = computeTransitive(dep, stack)
if (child) for (const r of child) acc.add(r)
}
}
stack.delete(id)
transitiveCache.set(id, acc)
return acc
}
for (const page of pages) {
const pageComponent = componentNameFor(page, pagesDir)
const reachable = computeTransitive(page, new Set())
if (!reachable) continue
for (const rel of reachable) {
const bucket = reverse.get(rel) ?? new Set()
bucket.add(pageComponent)
reverse.set(rel, bucket)
}
}
const payload = Object.create(null)
const keys = [...reverse.keys()].sort()
for (const key of keys) {
payload[key] = [...reverse.get(key)].sort()
}
process.stdout.write(JSON.stringify(payload))
}
try {
void pathToFileURL
await main()
} catch (err) {
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
process.exit(1)
}

View File

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

View File

@ -19,18 +19,19 @@
"require": { "require": {
"php": "^8.3.0", "php": "^8.3.0",
"brianium/paratest": "^7.20.0", "brianium/paratest": "^7.20.0",
"nunomaduro/collision": "^8.9.3", "composer/xdebug-handler": "^3.0.5",
"nunomaduro/collision": "^8.9.4",
"nunomaduro/termwind": "^2.4.0", "nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin": "^4.0.0",
"pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-arch": "^4.0.2",
"pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.1", "pestphp/pest-plugin-profanity": "^4.2.1",
"phpunit/phpunit": "^12.5.22", "phpunit/phpunit": "^12.5.24",
"symfony/process": "^7.4.8|^8.0.8" "symfony/process": "^7.4.8|^8.0.8"
}, },
"conflict": { "conflict": {
"filp/whoops": "<2.18.3", "filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.22", "phpunit/phpunit": ">12.5.24",
"sebastian/exporter": "<7.0.0", "sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0" "webmozart/assert": "<1.11.0"
}, },
@ -58,7 +59,7 @@
] ]
}, },
"require-dev": { "require-dev": {
"mrpunyapal/peststan": "^0.2.5", "mrpunyapal/peststan": "^0.2.9",
"pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-browser": "^4.3.1",
"pestphp/pest-plugin-type-coverage": "^4.0.4", "pestphp/pest-plugin-type-coverage": "^4.0.4",
@ -123,6 +124,7 @@
"Pest\\Plugins\\Verbose", "Pest\\Plugins\\Verbose",
"Pest\\Plugins\\Version", "Pest\\Plugins\\Version",
"Pest\\Plugins\\Shard", "Pest\\Plugins\\Shard",
"Pest\\Plugins\\Tia",
"Pest\\Plugins\\Parallel" "Pest\\Plugins\\Parallel"
] ]
}, },

View File

@ -5,6 +5,8 @@
[$bgBadgeColor, $bgBadgeText] = match ($type) { [$bgBadgeColor, $bgBadgeText] = match ($type) {
'INFO' => ['blue', 'INFO'], 'INFO' => ['blue', 'INFO'],
'ERROR' => ['red', 'ERROR'], 'ERROR' => ['red', 'ERROR'],
'WARN' => ['yellow', 'WARN'],
'SUCCESS' => ['green', 'SUCCESS'],
}; };
?> ?>

View File

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

View File

@ -25,6 +25,17 @@ final readonly class BootSubscribers implements Bootstrapper
Subscribers\EnsureIgnorableTestCasesAreIgnored::class, Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class, Subscribers\EnsureKernelDumpIsFlushed::class,
Subscribers\EnsureTeamCityEnabled::class, Subscribers\EnsureTeamCityEnabled::class,
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
Subscribers\EnsureTiaStarts::class,
Subscribers\EnsureTiaEnds::class,
Subscribers\EnsureTiaResultsAreCollected::class,
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
Subscribers\EnsureTiaResultIsRecordedOnErrored::class,
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
]; ];
/** /**

View File

@ -7,12 +7,18 @@ namespace Pest\Concerns;
use Closure; use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Collectors;
use Pest\Plugins\Tia\Enums\ReplayType;
use Pest\Plugins\Tia\Recorder;
use Pest\Preset; use Pest\Preset;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\Container;
use Pest\Support\ExceptionTrace; use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection; use Pest\Support\Reflection;
use Pest\Support\Shell; use Pest\Support\Shell;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\IncompleteTest; use PHPUnit\Framework\IncompleteTest;
use PHPUnit\Framework\SkippedTest; use PHPUnit\Framework\SkippedTest;
@ -75,6 +81,17 @@ trait Testable
*/ */
public bool $__ran = false; public bool $__ran = false;
/**
* The active replay mode for this test, set in `setUp()` and checked
* in `__runTest()` / `tearDown()` to skip the body and after-each.
*/
private ReplayType $__replay = ReplayType::None;
/**
* The cached assertion count to replay, captured when entering replay mode.
*/
private int $__replayAssertions = 0;
/** /**
* The test's test closure. * The test's test closure.
*/ */
@ -259,8 +276,35 @@ trait Testable
self::$__latestIssues = $method->issues; self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs; self::$__latestPrs = $method->prs;
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
$replay = ReplayType::fromStatus($status);
if ($replay !== ReplayType::None) {
assert($status !== null);
match ($replay) {
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
ReplayType::Skipped => $this->markTestSkipped($status->message()),
ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
};
return;
}
$recorder = Container::getInstance()->get(Recorder::class);
assert($recorder instanceof Recorder);
if ($recorder->isActive()) {
$recorder->beginTest($this::class, $this->name(), self::$__filename);
}
parent::setUp(); parent::setUp();
Collectors::armAll($recorder);
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
if ($this->__beforeEach instanceof Closure) { if ($this->__beforeEach instanceof Closure) {
@ -270,6 +314,13 @@ trait Testable
$this->__callClosure($beforeEach, $arguments); $this->__callClosure($beforeEach, $arguments);
} }
private function __beginReplay(ReplayType $replay, Tia $tia): void
{
$this->__replay = $replay;
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
$this->__ran = true;
}
/** /**
* Initialize test case properties from TestSuite. * Initialize test case properties from TestSuite.
*/ */
@ -302,6 +353,12 @@ trait Testable
*/ */
protected function tearDown(...$arguments): void protected function tearDown(...$arguments): void
{ {
if ($this->__replay !== ReplayType::None) {
TestSuite::getInstance()->test = null;
return;
}
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename); $afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
if ($this->__afterEach instanceof Closure) { if ($this->__afterEach instanceof Closure) {
@ -327,6 +384,16 @@ trait Testable
*/ */
private function __runTest(Closure $closure, ...$args): mixed private function __runTest(Closure $closure, ...$args): mixed
{ {
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
$this->expectNotToPerformAssertions();
}
$this->addToAssertionCount($this->__replayAssertions);
return null;
}
$arguments = $this->__resolveTestArguments($args); $arguments = $this->__resolveTestArguments($args);
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments); $this->__ensureDatasetArgumentNameAndNumberMatches($arguments);

View File

@ -119,6 +119,14 @@ final readonly class Configuration
return new Browser\Configuration; return new Browser\Configuration;
} }
/**
* Gets the TIA (Test Impact Analysis) configuration.
*/
public function tia(): Plugins\Tia\Configuration
{
return new Plugins\Tia\Configuration;
}
/** /**
* Proxies calls to the uses method. * Proxies calls to the uses method.
* *

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
/**
* @internal
*/
interface Restarter
{
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void;
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Pest\Support\View;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct(
private readonly string $headline,
private readonly string $hint,
private readonly bool $hasAnchor = false,
) {
parent::__construct($headline);
}
public function render(OutputInterface $output): void
{
View::renderUsing($output);
if (! $this->hasAnchor) {
View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
$output->writeln('');
return;
}
$this->renderChild($output, $this->headline);
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
$output->writeln('');
}
public function exitCode(): int
{
return 1;
}
private function renderChild(OutputInterface $output, string $text): void
{
$output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No affected tests found.',
'',
]);
}
public function exitCode(): int
{
return 0;
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct(private readonly string $className, string $filename)
{
parent::__construct(sprintf(
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',
$className,
$filename,
));
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires Pest tests.',
'',
sprintf(' Encountered PHPUnit class <fg=yellow>%s</>', $this->className),
sprintf(' in <fg=gray>%s</>.', $this->file),
'',
' Convert it to a Pest test, or run without Tia.',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}

View File

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

View File

@ -27,8 +27,13 @@ use Whoops\Exception\Inspector;
/** /**
* @internal * @internal
*/ */
final readonly class Kernel final class Kernel
{ {
/**
* Either the kernel is terminated or not.
*/
private bool $terminated = false;
/** /**
* The Kernel bootstrappers. * The Kernel bootstrappers.
* *
@ -36,6 +41,8 @@ final readonly class Kernel
*/ */
private const array BOOTSTRAPPERS = [ private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class, Bootstrappers\BootOverrides::class,
Bootstrappers\BootPhpUnitConfiguration::class,
Plugins\Tia\Bootstrapper::class,
Bootstrappers\BootSubscribers::class, Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class, Bootstrappers\BootFiles::class,
Bootstrappers\BootView::class, Bootstrappers\BootView::class,
@ -43,15 +50,22 @@ final readonly class Kernel
Bootstrappers\BootExcludeList::class, Bootstrappers\BootExcludeList::class,
]; ];
/**
* The Kernel restarters — resolved and invoked from `bin/pest`
* before any other Pest class is touched, so the list is exposed
* on the Kernel rather than driven from `bin/pest` directly.
*
* @var array<int, class-string<Contracts\Restarter>>
*/
public const array RESTARTERS = [
Restarters\XdebugRestarter::class,
Restarters\PcovRestarter::class,
];
/** /**
* Creates a new Kernel instance. * Creates a new Kernel instance.
*/ */
public function __construct( public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
private Application $application,
private OutputInterface $output,
) {
//
}
/** /**
* Boots the Kernel. * Boots the Kernel.
@ -112,9 +126,13 @@ final readonly class Kernel
$configuration = Registry::get(); $configuration = Registry::get();
$result = Facade::result(); $result = Facade::result();
return CallsAddsOutput::execute( $result = CallsAddsOutput::execute(
Result::exitCode($configuration, $result), Result::exitCode($configuration, $result),
); );
$this->terminate();
return $result;
} }
/** /**
@ -122,6 +140,12 @@ final readonly class Kernel
*/ */
public function terminate(): void public function terminate(): void
{ {
if ($this->terminated) {
return;
}
$this->terminated = true;
$preBufferOutput = Container::getInstance()->get(KernelDump::class); $preBufferOutput = Container::getInstance()->get(KernelDump::class);
assert($preBufferOutput instanceof KernelDump); assert($preBufferOutput instanceof KernelDump);

View File

@ -12,7 +12,9 @@ use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable; use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Test\AfterLastTestMethodErrored; use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored; use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\ConsideredRisky; use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed; use PHPUnit\Event\Test\Failed;
@ -255,9 +257,11 @@ final readonly class Converter
$numberOfNotPassedTests = count( $numberOfNotPassedTests = count(
array_unique( array_unique(
array_map( array_map(
function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string { function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
if ($event instanceof BeforeFirstTestMethodErrored if ($event instanceof BeforeFirstTestMethodErrored
|| $event instanceof AfterLastTestMethodErrored) { || $event instanceof AfterLastTestMethodErrored
|| $event instanceof BeforeFirstTestMethodFailed
|| $event instanceof AfterLastTestMethodFailed) {
return $event->testClassName(); return $event->testClassName();
} }

View File

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

1723
src/Plugins/Tia.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,636 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Exceptions\BaselineFetchFailed;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final readonly class BaselineSync
{
private const string WORKFLOW_FILE = 'tia-baseline.yml';
private const string ARTIFACT_NAME = 'pest-tia-baseline';
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
private const string DOWNLOAD_CACHE_DIR = 'artifacts';
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
private const int FETCH_COOLDOWN_SECONDS = 86400;
private const array DIAGNOSES = [
'network' => [
'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i',
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
],
'gh-auth' => [
'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i',
'message' => 'authentication failed — run `gh auth login` and retry.',
],
'rate-limit' => [
'pattern' => '/rate limit|too many requests|secondary rate limit/i',
'message' => 'GitHub API rate limit hit — try again later.',
],
'not-found' => [
'pattern' => '/404|not found|repository not found/i',
'message' => 'workflow or artifact not found in repo.',
],
'forbidden' => [
'pattern' => '/403|forbidden|access denied/i',
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
],
];
public function __construct(
private State $state,
private OutputInterface $output,
) {}
private function renderBadge(string $type, string $content): void
{
View::render('components.badge', ['type' => $type, 'content' => $content]);
}
private function renderChild(string $text): void
{
$this->output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
}
public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
{
$repo = $this->detectGitHubRepo($projectRoot);
if ($repo === null) {
return false;
}
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
$this->renderBadge('WARN', sprintf(
'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
$this->formatDuration($remaining),
));
return false;
}
$result = $this->download($repo, $projectRoot, $hasAnchor);
$payload = $result['payload'];
$failureKind = $result['failureKind'];
if ($payload === null) {
if ($failureKind === 'no-runs' || $failureKind === null) {
$this->startCooldown();
$this->emitPublishInstructions();
}
return false;
}
if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) {
return false;
}
if ($payload['coverage'] !== null) {
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
}
$this->clearCooldown();
$this->renderBadge('INFO', sprintf(
'Baseline ready (%s).',
$this->formatSize($payload['sizeOnDisk']),
));
return true;
}
private function cooldownRemaining(): ?int
{
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) {
return null;
}
$remaining = $decoded['until'] - time();
return $remaining > 0 ? $remaining : null;
}
private function startCooldown(): void
{
$this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([
'until' => time() + self::FETCH_COOLDOWN_SECONDS,
]));
}
private function clearCooldown(): void
{
$this->state->delete(Tia::KEY_FETCH_COOLDOWN);
}
private function formatDuration(int $seconds): string
{
if ($seconds >= 3600) {
return (int) round($seconds / 3600).'h';
}
if ($seconds >= 60) {
return (int) round($seconds / 60).'m';
}
return $seconds.'s';
}
private function emitPublishInstructions(): void
{
if ($this->isCi()) {
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
return;
}
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
}
private function isCi(): bool
{
return getenv('GITHUB_ACTIONS') === 'true'
|| getenv('GITLAB_CI') === 'true'
|| getenv('CIRCLECI') === 'true';
}
private function detectGitHubRepo(string $projectRoot): ?string
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
if (! is_file($gitConfig)) {
return null;
}
$content = @file_get_contents($gitConfig);
if ($content === false) {
return null;
}
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
return null;
}
$url = $match[1];
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
return $m[1];
}
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return $m[1];
}
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return $m[1];
}
return null;
}
/**
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
*/
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
{
$this->validateGhDependencies($hasAnchor);
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
if ($listError !== null) {
$this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Failed to query baseline runs — %s',
$listError['message'],
));
return ['payload' => null, 'failureKind' => $listError['kind']];
}
if ($runId === null) {
return ['payload' => null, 'failureKind' => 'no-runs'];
}
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
@touch($runCacheDir);
$this->renderBadge('INFO', sprintf(
'Using cached baseline from %s (run %s).',
$repo,
$runId,
));
return ['payload' => $this->readArtifact($runCacheDir), 'failureKind' => null];
}
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
return ['payload' => null, 'failureKind' => null];
}
$download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor);
if (! $download['success']) {
return ['payload' => null, 'failureKind' => $download['failureKind']];
}
$payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor);
$this->trimDownloadCache($projectRoot);
return ['payload' => $payload, 'failureKind' => null];
}
/**
* @param array{kind: string, message: string} $diagnosis
*/
private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void
{
if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) {
return;
}
Panic::with(new BaselineFetchFailed(
sprintf('%s — %s', $contextPrefix, $diagnosis['message']),
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
));
}
private function validateGhDependencies(bool $hasAnchor): void
{
if (! $this->commandExists('gh')) {
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) not found — cannot fetch baseline.',
'Install it from https://cli.github.com.',
$hasAnchor,
));
}
if (! $this->ghAuthenticated()) {
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
'Run `gh auth login` and retry.',
$hasAnchor,
));
}
}
/**
* @return array{success: bool, failureKind: ?string}
*/
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
{
$artifactSize = $this->artifactSize($repo, $runId);
$this->renderBadge('INFO', $artifactSize !== null
? sprintf(
'Fetching baseline (%s) from %s…',
$this->formatSize($artifactSize),
$repo,
)
: sprintf(
'Fetching baseline from %s…',
$repo,
));
$process = new Process([
'gh', 'run', 'download', $runId,
'-R', $repo,
'-n', self::ARTIFACT_NAME,
'-D', $runCacheDir,
]);
$process->setTimeout(900.0);
$process->start();
$startedAt = microtime(true);
while ($process->isRunning()) {
$this->renderDownloadProgress($runCacheDir, $artifactSize, $startedAt);
usleep(250_000);
}
$process->wait();
$this->clearProgressLine();
if ($process->isSuccessful()) {
return ['success' => true, 'failureKind' => null];
}
$this->cleanup($runCacheDir);
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
$this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Baseline download failed — %s',
$diagnosis['message'],
));
return ['success' => false, 'failureKind' => $diagnosis['kind']];
}
/**
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}
*/
private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
{
$payload = $this->readArtifact($runCacheDir);
if ($payload === null) {
$this->cleanup($runCacheDir);
Panic::with(new BaselineFetchFailed(
'Baseline downloaded but the artifact is missing expected files (graph.json).',
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
$hasAnchor,
));
}
return $payload;
}
private function artifactSize(string $repo, string $runId): ?int
{
$process = new Process([
'gh', 'api',
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
'--jq', sprintf(
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
self::ARTIFACT_NAME,
),
]);
$process->setTimeout(30.0);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$size = trim($process->getOutput());
return is_numeric($size) ? (int) $size : null;
}
private function renderDownloadProgress(string $dir, ?int $totalBytes, float $startedAt): void
{
$current = $this->dirSize($dir);
$elapsed = max(0.001, microtime(true) - $startedAt);
$speed = (int) ($current / $elapsed);
if ($totalBytes !== null && $totalBytes > 0) {
$percent = min(99, (int) floor(($current / $totalBytes) * 100));
$message = sprintf(
' <fg=cyan>Downloading</> %s / %s (%d%%, %s/s)',
$this->formatSize($current),
$this->formatSize($totalBytes),
$percent,
$this->formatSize($speed),
);
} else {
$message = sprintf(
' <fg=cyan>Downloading</> %s (%s/s)',
$this->formatSize($current),
$this->formatSize($speed),
);
}
$this->output->write("\r\033[K".$message);
}
private function clearProgressLine(): void
{
$this->output->write("\r\033[K");
}
private function dirSize(string $dir): int
{
if (! is_dir($dir)) {
return 0;
}
$total = 0;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
);
/** @var \SplFileInfo $entry */
foreach ($iterator as $entry) {
if ($entry->isFile()) {
$total += $entry->getSize();
}
}
return $total;
}
/**
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
*/
private function readArtifact(string $dir): ?array
{
$graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
$coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
if ($graph === false) {
return null;
}
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
return [
'graph' => $graph,
'coverage' => $coverage === false ? null : $coverage,
'sizeOnDisk' => $this->dirSize($dir),
];
}
private function downloadCacheDir(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
}
private function safeRunId(string $runId): string
{
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
return $sanitised === '' ? 'unknown' : $sanitised;
}
private function trimDownloadCache(string $projectRoot): void
{
$root = $this->downloadCacheDir($projectRoot);
if (! is_dir($root)) {
return;
}
$entries = @scandir($root);
if ($entries === false) {
return;
}
$candidates = [];
foreach ($entries as $entry) {
if (in_array($entry, ['.', '..'], true)) {
continue;
}
$path = $root.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($path)) {
continue;
}
$mtime = @filemtime($path);
$candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime];
}
if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) {
return;
}
usort(
$candidates,
static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'],
);
foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) {
$this->cleanup($stale['path']);
}
}
/**
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
*/
private function latestSuccessfulRunIdWithError(string $repo): array
{
$process = new Process([
'gh', 'run', 'list',
'-R', $repo,
'--workflow', self::WORKFLOW_FILE,
'--status', 'success',
'--limit', '1',
'--json', 'databaseId',
'--jq', '.[0].databaseId // empty',
]);
$process->setTimeout(30.0);
$process->run();
if (! $process->isSuccessful()) {
return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())];
}
$runId = trim($process->getOutput());
return [$runId === '' ? null : $runId, null];
}
private function ghAuthenticated(): bool
{
$process = new Process(['gh', 'auth', 'status']);
$process->setTimeout(10.0);
$process->run();
return $process->isSuccessful();
}
/**
* @return array{kind: string, message: string}
*/
private function classifyGhError(string $output): array
{
$output = trim($output);
if ($output === '') {
return ['kind' => 'unknown', 'message' => 'unknown error'];
}
foreach (self::DIAGNOSES as $kind => $diagnosis) {
if (preg_match($diagnosis['pattern'], $output) === 1) {
return ['kind' => $kind, 'message' => $diagnosis['message']];
}
}
return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))];
}
private function commandExists(string $cmd): bool
{
$process = new Process(['which', $cmd]);
$process->run();
return $process->isSuccessful();
}
private function cleanup(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
/** @var \SplFileInfo $entry */
foreach ($iterator as $entry) {
if ($entry->isDir()) {
@rmdir($entry->getPathname());
} else {
@unlink($entry->getPathname());
}
}
@rmdir($dir);
}
private function formatSize(int $bytes): string
{
if ($bytes >= 1024 * 1024) {
return sprintf('%.1f MB', $bytes / 1024 / 1024);
}
if ($bytes >= 1024) {
return sprintf('%.1f KB', $bytes / 1024);
}
return $bytes.' B';
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Contracts\Bootstrapper as BootstrapperContract;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
use Pest\TestSuite;
/**
* @internal
*/
final readonly class Bootstrapper implements BootstrapperContract
{
public function __construct(private Container $container) {}
public function boot(): void
{
$testSuite = $this->container->get(TestSuite::class);
assert($testSuite instanceof TestSuite);
$tempDir = Storage::tempDir($testSuite->rootPath);
$this->container->add(State::class, new FileState($tempDir));
}
}

View File

@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Exceptions\MissingDependency;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final readonly class ChangedFiles
{
public function __construct(private string $projectRoot) {}
/**
* @param array<int, string> $files project-relative paths.
* @param array<string, string> $lastRunTree path → content hash from last run.
* @return array<int, string>
*/
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
{
if ($lastRunTree === []) {
return $files;
}
$candidates = array_fill_keys($files, true);
foreach (array_keys($lastRunTree) as $snapshotted) {
$candidates[$snapshotted] = true;
}
$remaining = [];
foreach (array_keys($candidates) as $file) {
$snapshot = $lastRunTree[$file] ?? null;
$current = $this->currentHash($file);
if ($snapshot === null || $current === null || $current !== $snapshot) {
$remaining[] = $file;
}
}
return $remaining;
}
private function currentHash(string $relativePath): ?string
{
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
if (! is_file($absolute)) {
return null;
}
$hash = ContentHash::of($absolute);
return $hash === false ? null : $hash;
}
/**
* @param array<int, string> $files
* @return array<string, string> path → xxh128 content hash
*/
public function snapshotTree(array $files): array
{
$out = [];
foreach ($files as $file) {
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) {
$out[$file] = '';
continue;
}
$hash = ContentHash::of($absolute);
if ($hash !== false) {
$out[$file] = $hash;
}
}
return $out;
}
/**
* @return array<int, string>|null `null` when git is unavailable, or when
*/
public function since(?string $sha): ?array
{
$files = [];
if ($sha !== null && $sha !== '') {
if (! $this->shaIsReachable($sha)) {
return null;
}
$files = array_merge($files, $this->diffSinceSha($sha));
}
$files = array_merge($files, $this->workingTreeChanges());
$unique = [];
foreach ($files as $file) {
if ($file === '') {
continue;
}
$unique[$file] = true;
}
$candidates = array_keys($this->filterIgnored($unique));
if ($sha !== null && $sha !== '') {
return $this->filterBehaviourallyUnchanged($candidates, $sha);
}
return $candidates;
}
/**
* @param array<int, string> $files
* @return array<int, string>
*/
private function filterBehaviourallyUnchanged(array $files, string $sha): array
{
$remaining = [];
foreach ($files as $file) {
$currentHash = $this->currentHash($file);
if ($currentHash === null) {
$remaining[] = $file;
continue;
}
$baselineContent = $this->contentAtSha($sha, $file);
if ($baselineContent === null) {
$remaining[] = $file;
continue;
}
if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
$remaining[] = $file;
}
}
return $remaining;
}
private function contentAtSha(string $sha, string $path): ?string
{
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
$process->setTimeout(5.0);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
return $process->getOutput();
}
/**
* @param array<string, true> $candidates
* @return array<string, true>
*/
private function filterIgnored(array $candidates): array
{
if ($candidates === []) {
return $candidates;
}
$process = new Process(
['git', 'check-ignore', '--no-index', '-z', '--stdin'],
$this->projectRoot,
);
$process->setTimeout(5.0);
$process->setInput(implode("\x00", array_keys($candidates)));
$process->run();
$exitCode = $process->getExitCode();
if ($exitCode !== 0 && $exitCode !== 1) {
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
if ($output === '') {
return $candidates;
}
foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
if ($ignored !== '') {
unset($candidates[$ignored]);
}
}
return $candidates;
}
public function currentBranch(): ?string
{
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
$branch = trim($process->getOutput());
return $branch === '' || $branch === 'HEAD' ? null : $branch;
}
private function shaIsReachable(string $sha): bool
{
$process = new Process(
['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
$this->projectRoot,
);
$process->run();
return $process->getExitCode() === 0;
}
/**
* @return array<int, string>
*/
private function diffSinceSha(string $sha): array
{
$process = new Process(
['git', 'diff', '--name-only', $sha.'..HEAD'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
return $this->splitLines($process->getOutput());
}
/**
* @return array<int, string>
*/
private function workingTreeChanges(): array
{
$process = new Process(
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
if ($output === '') {
return [];
}
$records = explode("\x00", rtrim($output, "\x00"));
$files = [];
$count = count($records);
for ($i = 0; $i < $count; $i++) {
$record = $records[$i];
if (strlen($record) < 4) {
continue;
}
$status = substr($record, 0, 2);
$path = substr($record, 3);
if ($status[0] === 'R' || $status[0] === 'C') {
$files[] = $path;
if (isset($records[$i + 1]) && $records[$i + 1] !== '') {
$files[] = $records[$i + 1];
$i++;
}
continue;
}
$files[] = $path;
}
return $files;
}
public function currentSha(): ?string
{
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
$sha = trim($process->getOutput());
return $sha === '' ? null : $sha;
}
/**
* @return array<int, string>
*/
private function splitLines(string $output): array
{
$lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY);
return $lines === false ? [] : $lines;
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Edges\BladeEdges;
use Pest\Plugins\Tia\Edges\InertiaEdges;
/**
* @internal
*/
final class Collectors
{
/** @var list<class-string> */
private const array COLLECTORS = [
BladeEdges::class,
TableTracker::class,
InertiaEdges::class,
];
public static function armAll(Recorder $recorder): void
{
foreach (self::COLLECTORS as $collector) {
$collector::arm($recorder);
}
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Support\Container;
/**
* @internal
*/
final class Configuration
{
/**
* @return $this
*/
public function always(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markEnabled();
return $this;
}
/**
* @return $this
*/
public function locally(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markEnabled();
$watchPatterns->markLocally();
return $this;
}
/**
* @return $this
*/
public function filtered(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markFiltered();
return $this;
}
/**
* @return $this
*/
public function baselined(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markBaselined();
return $this;
}
/**
* @param array<string, string> $patterns glob → project-relative test dir
* @return $this
*/
public function watch(array $patterns): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->add($patterns);
return $this;
}
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class ContentHash
{
public static function of(string $absolute): string|false
{
$raw = @file_get_contents($absolute);
if ($raw === false) {
return false;
}
return self::ofContent($absolute, $raw);
}
public static function ofContent(string $path, string $raw): string
{
$lower = strtolower($path);
if (str_ends_with($lower, '.blade.php')) {
return self::hashBladeContent($raw);
}
if (str_ends_with($lower, '.php')) {
return self::hashPhpContent($raw);
}
foreach (['.vue', '.tsx', '.jsx', '.svelte', '.ts', '.js', '.mjs', '.cjs', '.mts'] as $extension) {
if (str_ends_with($lower, $extension)) {
return self::hashJsContent($raw);
}
}
return hash('xxh128', $raw);
}
private static function hashPhpContent(string $raw): string
{
$tokens = @token_get_all($raw);
if ($tokens === []) {
return hash('xxh128', $raw);
}
$normalised = '';
foreach ($tokens as $token) {
if (is_array($token)) {
if ($token[0] === T_WHITESPACE) {
continue;
}
if ($token[0] === T_COMMENT) {
continue;
}
if ($token[0] === T_DOC_COMMENT) {
continue;
}
$normalised .= $token[1];
} else {
$normalised .= $token;
}
}
return hash('xxh128', $normalised);
}
private static function hashBladeContent(string $raw): string
{
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
return hash('xxh128', trim($stripped));
}
private static function hashJsContent(string $raw): string
{
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;
$stripped = preg_replace('/^\s*\/\*.*?\*\/\s*$/sm', '', $stripped) ?? $stripped;
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
return hash('xxh128', trim($stripped));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts;
/**
* @internal
*/
interface State
{
public function read(string $key): ?string;
public function write(string $key, string $content): bool;
public function delete(string $key): bool;
public function exists(string $key): bool;
/**
* @return list<string>
*/
public function keysWithPrefix(string $prefix): array;
}

View File

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

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
use Throwable;
/**
* @internal
*/
final class CoverageCollector
{
/**
* @var array<string, string|null>
*/
private array $classFileCache = [];
/**
* @return array<string, array<int, string>>
*/
public function perTestFiles(): array
{
if (! PhpUnitCodeCoverage::instance()->isActive()) {
return [];
}
try {
$lineCoverage = PhpUnitCodeCoverage::instance()
->codeCoverage()
->getData()
->lineCoverage();
} catch (Throwable) {
return [];
}
/** @var array<string, array<string, true>> $edges */
$edges = [];
foreach ($lineCoverage as $sourceFile => $lines) {
$testIds = [];
foreach ($lines as $hits) {
if ($hits === null) {
continue;
}
foreach ($hits as $id) {
$testIds[$id] = true;
}
}
foreach (array_keys($testIds) as $testId) {
$testFile = $this->testIdToFile($testId);
if ($testFile === null) {
continue;
}
$edges[$testFile][$sourceFile] = true;
}
}
$out = [];
foreach ($edges as $testFile => $sources) {
$out[$testFile] = array_keys($sources);
}
return $out;
}
public function reset(): void
{
$this->classFileCache = [];
}
private function testIdToFile(string $testId): ?string
{
$hash = strpos($testId, '#');
$identifier = $hash === false ? $testId : substr($testId, 0, $hash);
if (! str_contains($identifier, '::')) {
return null;
}
[$className] = explode('::', $identifier, 2);
if (array_key_exists($className, $this->classFileCache)) {
return $this->classFileCache[$className];
}
$file = $this->resolveClassFile($className);
$this->classFileCache[$className] = $file;
return $file;
}
private function resolveClassFile(string $className): ?string
{
if (! class_exists($className, false)) {
return null;
}
assert(property_exists($className, '__filename') && is_string($className::$__filename));
return $className::$__filename;
}
}

View File

@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use Throwable;
/**
* @internal
*/
final class CoverageMerger
{
public static function applyIfMarked(string $reportPath): void
{
$state = self::state();
if (! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
return;
}
$state->delete(Tia::KEY_COVERAGE_MARKER);
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
if ($cachedBytes === null) {
$current = self::requireCoverage($reportPath);
if ($current instanceof CodeCoverage) {
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
}
return;
}
$decoded = self::decompress($cachedBytes);
if ($decoded === null) {
$state->delete(Tia::KEY_COVERAGE_CACHE);
return;
}
$cached = self::unserializeCoverage($decoded);
$current = self::requireCoverage($reportPath);
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
return;
}
self::stripCurrentTestsFromCached($cached, $current);
$cached->merge($current);
$serialised = serialize($cached);
@file_put_contents(
$reportPath,
'<?php return unserialize('.var_export($serialised, true).");\n",
);
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress($serialised));
}
private static function compress(string $bytes): string
{
$compressed = @gzencode($bytes);
return $compressed === false ? $bytes : $compressed;
}
private static function decompress(string $bytes): ?string
{
$decoded = @gzdecode($bytes);
return $decoded === false ? null : $decoded;
}
private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
{
$currentIds = self::collectTestIds($current);
if ($currentIds === []) {
return;
}
$cachedData = $cached->getData();
$lineCoverage = $cachedData->lineCoverage();
foreach ($lineCoverage as $file => $lines) {
foreach ($lines as $line => $ids) {
if ($ids === null) {
continue;
}
if ($ids === []) {
continue;
}
$filtered = array_values(array_diff($ids, $currentIds));
if ($filtered !== $ids) {
$lineCoverage[$file][$line] = $filtered;
}
}
}
$cachedData->setLineCoverage($lineCoverage);
}
/**
* @return array<int, string>
*/
private static function collectTestIds(CodeCoverage $coverage): array
{
$ids = [];
foreach ($coverage->getData()->lineCoverage() as $lines) {
foreach ($lines as $hits) {
if ($hits === null) {
continue;
}
foreach ($hits as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
private static function state(): State
{
$state = Container::getInstance()->get(State::class);
assert($state instanceof State);
return $state;
}
private static function requireCoverage(string $reportPath): ?CodeCoverage
{
if (! is_file($reportPath)) {
return null;
}
try {
/** @var mixed $value */
$value = require $reportPath;
} catch (Throwable) {
return null;
}
return $value instanceof CodeCoverage ? $value : null;
}
private static function unserializeCoverage(string $bytes): ?CodeCoverage
{
try {
$value = @unserialize($bytes);
} catch (Throwable) {
return null;
}
return $value instanceof CodeCoverage ? $value : null;
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Edges;
use Pest\Plugins\Tia\Recorder;
/**
* @internal
*/
final class BladeEdges
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string MARKER = 'pest.tia.blade-edges-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->isActive()) {
return;
}
$containerClass = self::CONTAINER_CLASS;
if (! class_exists($containerClass)) {
return;
}
/** @var object $app */
$app = $containerClass::getInstance();
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
return;
}
if ($app->bound(self::MARKER) || ! $app->bound('view')) {
return;
}
$app->instance(self::MARKER, true);
$factory = $app->make('view');
if (! is_object($factory) || ! method_exists($factory, 'composer')) {
return;
}
$factory->composer('*', static function (object $view) use ($recorder): void {
if (! method_exists($view, 'getPath')) {
return;
}
/** @var mixed $path */
$path = $view->getPath();
if (is_string($path) && $path !== '') {
$recorder->linkSource($path);
}
});
}
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Edges;
use Pest\Plugins\Tia\Recorder;
/**
* @internal
*/
final class InertiaEdges
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
private const string MARKER = 'pest.tia.inertia-edges-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->isActive()) {
return;
}
$containerClass = self::CONTAINER_CLASS;
if (! class_exists($containerClass)) {
return;
}
/** @var object $app */
$app = $containerClass::getInstance();
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
return;
}
if ($app->bound(self::MARKER) || ! $app->bound('events')) {
return;
}
$app->instance(self::MARKER, true);
/** @var object $events */
$events = $app->make('events');
if (! method_exists($events, 'listen')) {
return;
}
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
if (! property_exists($event, 'response') || ! is_object($event->response)) {
return;
}
$component = self::extractComponent($event->response);
if ($component !== null) {
$recorder->linkInertiaComponent($component);
}
});
}
private static function extractComponent(object $response): ?string
{
$content = self::readContent($response);
if ($content === null) {
return null;
}
if (self::isInertiaJsonResponse($response)) {
return self::componentFromJson($content);
}
if (str_contains($content, 'type="application/json"')
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1]));
if ($component !== null) {
return $component;
}
}
if (str_contains($content, 'data-page=')
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
return self::componentFromJson(html_entity_decode($match[1]));
}
return null;
}
private static function isInertiaJsonResponse(object $response): bool
{
if (! property_exists($response, 'headers') || ! is_object($response->headers)) {
return false;
}
$headers = $response->headers;
return method_exists($headers, 'has') && $headers->has('X-Inertia') === true;
}
private static function componentFromJson(string $json): ?string
{
/** @var mixed $decoded */
$decoded = json_decode($json, true);
if (is_array($decoded)
&& isset($decoded['component'])
&& is_string($decoded['component'])
&& $decoded['component'] !== '') {
return $decoded['component'];
}
return null;
}
private static function readContent(object $response): ?string
{
if (! method_exists($response, 'getContent')) {
return null;
}
/** @var mixed $content */
$content = $response->getContent();
return is_string($content) ? $content : null;
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Enums;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* @internal
*/
enum ReplayType
{
case None;
case Pass;
case Risky;
case Skipped;
case Incomplete;
case Failure;
public static function fromStatus(?TestStatus $status): self
{
if (! $status instanceof TestStatus) {
return self::None;
}
return match (true) {
$status->isSuccess() => self::Pass,
$status->isRisky() => self::Risky,
$status->isSkipped() => self::Skipped,
$status->isIncomplete() => self::Incomplete,
default => self::Failure,
};
}
}

View File

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
/**
* @internal
*/
final class FileState implements State
{
private readonly string $rootDir;
private ?string $resolvedRoot = null;
public function __construct(string $rootDir)
{
$this->rootDir = rtrim($rootDir, DIRECTORY_SEPARATOR);
}
public function read(string $key): ?string
{
$path = $this->pathFor($key);
if (! is_file($path)) {
return null;
}
$bytes = @file_get_contents($path);
return $bytes === false ? null : $bytes;
}
public function write(string $key, string $content): bool
{
if (! $this->ensureRoot()) {
return false;
}
$path = $this->pathFor($key);
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
if (@file_put_contents($tmp, $content) === false) {
return false;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
}
public function delete(string $key): bool
{
$path = $this->pathFor($key);
if (! is_file($path)) {
return true;
}
return @unlink($path);
}
public function exists(string $key): bool
{
return is_file($this->pathFor($key));
}
public function keysWithPrefix(string $prefix): array
{
$root = $this->resolvedRoot();
if ($root === null) {
return [];
}
$pattern = $root.DIRECTORY_SEPARATOR.$prefix.'*';
$matches = glob($pattern);
if ($matches === false) {
return [];
}
$keys = [];
foreach ($matches as $path) {
$keys[] = basename($path);
}
return $keys;
}
public function pathFor(string $key): string
{
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
}
private function resolvedRoot(): ?string
{
if ($this->resolvedRoot !== null) {
return $this->resolvedRoot;
}
$resolved = @realpath($this->rootDir);
if ($resolved === false) {
return null;
}
return $this->resolvedRoot = $resolved;
}
private function ensureRoot(): bool
{
if (is_dir($this->rootDir)) {
return true;
}
if (@mkdir($this->rootDir, 0755, true)) {
return true;
}
return is_dir($this->rootDir);
}
}

View File

@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final readonly class Fingerprint
{
private const int SCHEMA_VERSION = 15;
/**
* @return array{
* structural: array<string, int|string|null>,
* environmental: array<string, int|string|null>,
* }
*/
public static function compute(string $projectRoot): array
{
return [
'structural' => [
'schema' => self::SCHEMA_VERSION,
'composer_lock' => self::composerLockHash($projectRoot),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
'vite_config' => self::viteConfigHash($projectRoot),
'package_json' => self::packageJsonHash($projectRoot),
'package_lock' => self::packageLockHash($projectRoot),
'js_config' => self::jsConfigHash($projectRoot),
'composer_json' => self::composerJsonHash($projectRoot),
],
'environmental' => [
'php_minor' => PHP_MAJOR_VERSION,
// 'extensions' => self::extensionsFingerprint($projectRoot),
// 'env_files' => self::envFilesHash($projectRoot),
],
];
}
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
*/
public static function structuralMatches(array $a, array $b): bool
{
$aStructural = self::structuralOnly($a);
$bStructural = self::structuralOnly($b);
ksort($aStructural);
ksort($bStructural);
return $aStructural === $bStructural;
}
/**
* @param array<string, mixed> $stored
* @param array<string, mixed> $current
* @return list<string>
*/
public static function structuralDrift(array $stored, array $current): array
{
return self::detectDrift(
self::structuralOnly($stored),
self::structuralOnly($current),
'schema',
);
}
/**
* @param array<string, mixed> $stored
* @param array<string, mixed> $current
* @return list<string>
*/
public static function environmentalDrift(array $stored, array $current): array
{
return self::detectDrift(
self::environmentalOnly($stored),
self::environmentalOnly($current),
);
}
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
* @return list<string>
*/
private static function detectDrift(array $a, array $b, ?string $skipKey = null): array
{
$drifts = [];
foreach ($a as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (($b[$key] ?? null) !== $value) {
$drifts[] = $key;
}
}
foreach ($b as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (! array_key_exists($key, $a) && $value !== null) {
$drifts[] = $key;
}
}
return array_values(array_unique($drifts));
}
/**
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function structuralOnly(array $fingerprint): array
{
return self::bucket($fingerprint, 'structural');
}
/**
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function environmentalOnly(array $fingerprint): array
{
return self::bucket($fingerprint, 'environmental');
}
/**
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function bucket(array $fingerprint, string $key): array
{
$raw = $fingerprint[$key] ?? null;
if (! is_array($raw)) {
return [];
}
$normalised = [];
foreach ($raw as $k => $v) {
if (is_string($k)) {
$normalised[$k] = $v;
}
}
return $normalised;
}
private static function viteConfigHash(string $projectRoot): ?string
{
$parts = [];
foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function jsConfigHash(string $projectRoot): ?string
{
$parts = [];
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
$hash = self::hashIfExists($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function packageJsonHash(string $projectRoot): ?string
{
$path = $projectRoot.'/package.json';
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
$relevant = [
'type' => $data['type'] ?? null,
'packageManager' => $data['packageManager'] ?? null,
'dependencies' => $data['dependencies'] ?? null,
'devDependencies' => $data['devDependencies'] ?? null,
'optionalDependencies' => $data['optionalDependencies'] ?? null,
'peerDependencies' => $data['peerDependencies'] ?? null,
'overrides' => $data['overrides'] ?? null,
'resolutions' => $data['resolutions'] ?? null,
'imports' => $data['imports'] ?? null,
'exports' => $data['exports'] ?? null,
'browser' => $data['browser'] ?? null,
];
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
}
private static function composerLockHash(string $projectRoot): ?string
{
return self::hashIfExists($projectRoot.'/composer.lock');
}
private static function packageLockHash(string $projectRoot): ?string
{
$parts = [];
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
$hash = self::hashIfExists($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function composerJsonHash(string $projectRoot): ?string
{
$path = $projectRoot.'/composer.json';
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
$relevantConfig = array_intersect_key($config, [
'platform' => true,
'allow-plugins' => true,
]);
$relevant = [
'autoload' => $data['autoload'] ?? null,
'autoload-dev' => $data['autoload-dev'] ?? null,
'require' => $data['require'] ?? null,
'require-dev' => $data['require-dev'] ?? null,
'extra' => $data['extra'] ?? null,
'repositories' => $data['repositories'] ?? null,
'minimum-stability' => $data['minimum-stability'] ?? null,
'prefer-stable' => $data['prefer-stable'] ?? null,
'config' => $relevantConfig === [] ? null : $relevantConfig,
];
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
}
private static function sortRecursively(mixed &$value): void
{
if (! is_array($value)) {
return;
}
$isAssoc = ! array_is_list($value);
if ($isAssoc) {
ksort($value);
}
foreach ($value as &$child) {
self::sortRecursively($child);
}
}
private static function contentHashOrNull(string $path): ?string
{
if (! is_file($path)) {
return null;
}
$hash = ContentHash::of($path);
return $hash === false ? null : $hash;
}
private static function hashIfExists(string $path): ?string
{
if (! is_file($path)) {
return null;
}
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
}

1446
src/Plugins/Tia/Graph.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,397 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final class JsModuleGraph
{
private const int NODE_TIMEOUT_SECONDS = 180;
private const string CACHE_FILE = 'js-module-graph.cache.json';
/**
* @var list<string>
*/
public const array VITE_CONFIG_NAMES = [
'vite.config.ts',
'vite.config.js',
'vite.config.mjs',
'vite.config.cjs',
'vite.config.mts',
];
/**
* Candidate page directories, in priority order. Must stay in sync with
* `PAGE_DIR_CANDIDATES` in bin/pest-tia-vite-deps.mjs.
*
* @var list<string>
*/
private const array PAGE_DIR_CANDIDATES = [
'resources/js/Pages',
'resources/js/pages',
'assets/js/Pages',
'assets/js/pages',
'assets/Pages',
'assets/pages',
];
/**
* @var list<string>
*/
private const array PAGE_EXTENSIONS = [
'vue', 'svelte',
'tsx', 'jsx',
'ts', 'js',
'mts', 'cts', 'mjs', 'cjs',
];
/**
* @return array<string, list<string>>
*/
public static function build(string $projectRoot): array
{
$result = self::resolve($projectRoot);
return $result ?? [];
}
/**
* @return array<string, list<string>>|null
*/
public static function buildStrict(string $projectRoot): ?array
{
return self::resolve($projectRoot);
}
public static function isApplicable(string $projectRoot): bool
{
if (! self::hasViteConfig($projectRoot)) {
return false;
}
return self::firstExistingPagesDir($projectRoot) !== null;
}
private static function firstExistingPagesDir(string $projectRoot): ?string
{
foreach (self::PAGE_DIR_CANDIDATES as $rel) {
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (! is_dir($abs)) {
continue;
}
if (self::dirHasPageFile($abs)) {
return $abs;
}
}
return null;
}
private static function dirHasPageFile(string $dir): bool
{
try {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
);
} catch (\UnexpectedValueException) {
return false;
}
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
if (in_array(strtolower($file->getExtension()), self::PAGE_EXTENSIONS, true)) {
return true;
}
}
return false;
}
/**
* @return array<string, list<string>>|null
*/
private static function resolve(string $projectRoot): ?array
{
$fingerprint = self::fingerprint($projectRoot);
if ($fingerprint !== null) {
$cached = self::readCache($projectRoot, $fingerprint);
if ($cached !== null) {
return $cached;
}
}
$process = self::buildNodeProcess($projectRoot);
if (! $process instanceof Process) {
return null;
}
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$result = self::parseNodeOutput($process->getOutput());
if ($result !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
}
private static function buildNodeProcess(string $projectRoot): ?Process
{
if (! self::hasViteConfig($projectRoot)) {
return null;
}
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
return null;
}
$nodeBinary = (new ExecutableFinder)->find('node');
if ($nodeBinary === null) {
return null;
}
$helperPath = dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-tia-vite-deps.mjs';
if (! is_file($helperPath)) {
return null;
}
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
return $process;
}
/**
* @return array<string, list<string>>|null
*/
private static function parseNodeOutput(string $output): ?array
{
/** @var mixed $decoded */
$decoded = json_decode($output, true);
if (! is_array($decoded)) {
return null;
}
$out = [];
foreach ($decoded as $path => $components) {
if (! is_string($path)) {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
sort($names);
$out[$path] = $names;
}
}
ksort($out);
return $out;
}
private static function fingerprint(string $projectRoot): ?string
{
$parts = [];
foreach (self::VITE_CONFIG_NAMES as $name) {
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (! is_file($path)) {
continue;
}
$stat = @stat($path);
$bytes = @file_get_contents($path);
$parts[] = 'config:'.$name
.':'.($stat === false ? '0' : (string) $stat['mtime'])
.':'.($stat === false ? '0' : (string) $stat['size'])
.':'.($bytes === false ? '' : hash('sha256', $bytes));
}
if ($parts === []) {
return null;
}
$override = getenv('TIA_VITE_PAGES_DIR');
if (is_string($override) && $override !== '') {
$parts[] = 'pagesDirOverride:'.$override;
}
$pagesDir = self::firstExistingPagesDir($projectRoot);
if ($pagesDir !== null) {
$parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
}
$jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
if ($jsRoot !== null && is_dir($jsRoot)) {
$entries = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$entries[] = $file->getPathname()
.':'.$file->getSize()
.':'.$file->getMTime();
}
sort($entries);
$parts[] = 'js:'.hash('sha256', implode("\n", $entries));
}
return hash('sha256', implode('|', $parts));
}
/**
* @return array<string, list<string>>|null
*/
private static function readCache(string $projectRoot, string $fingerprint): ?array
{
$path = self::cachePath($projectRoot);
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
/** @var mixed $decoded */
$decoded = json_decode($raw, true);
if (! is_array($decoded)) {
return null;
}
if (($decoded['fingerprint'] ?? null) !== $fingerprint) {
return null;
}
$graph = $decoded['graph'] ?? null;
if (! is_array($graph)) {
return null;
}
$out = [];
foreach ($graph as $key => $value) {
if (! is_string($key)) {
continue;
}
if (! is_array($value)) {
continue;
}
$names = [];
foreach ($value as $name) {
if (is_string($name) && $name !== '') {
$names[] = $name;
}
}
$out[$key] = $names;
}
return $out;
}
/**
* @param array<string, list<string>> $graph
*/
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
{
$path = self::cachePath($projectRoot);
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return;
}
$payload = json_encode([
'fingerprint' => $fingerprint,
'graph' => $graph,
]);
if ($payload === false) {
return;
}
$tmp = $path.'.tmp.'.bin2hex(random_bytes(4));
if (@file_put_contents($tmp, $payload) === false) {
return;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
}
}
private static function cachePath(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE;
}
private static function hasViteConfig(string $projectRoot): bool
{
foreach (self::VITE_CONFIG_NAMES as $name) {
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,355 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\TestSuite;
use ReflectionClass;
/**
* @internal
*/
final class Recorder
{
private ?string $currentTestFile = null;
/** @var array<string, array<string, true>> */
private array $perTestFiles = [];
/** @var array<string, array<string, true>> */
private array $perTestTables = [];
/** @var array<string, array<string, true>> */
private array $perTestInertiaComponents = [];
/** @var array<string, true> */
private array $perTestUsesDatabase = [];
/** @var array<string, string|null> */
private array $classFileCache = [];
/** @var array<string, bool> */
private array $classUsesDatabaseCache = [];
private bool $active = false;
private bool $driverChecked = false;
private bool $driverAvailable = false;
private string $driver = 'none';
private ?SourceScope $sourceScope = null;
public function activate(): void
{
$this->active = true;
}
public function isActive(): bool
{
return $this->active;
}
public function driverAvailable(): bool
{
if (! $this->driverChecked) {
if (function_exists('pcov\\start')) {
$this->driver = 'pcov';
$this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
$modes = \xdebug_info('mode');
if (is_array($modes) && in_array('coverage', $modes, true)) {
$this->driver = 'xdebug';
$this->driverAvailable = true;
}
}
$this->driverChecked = true;
}
return $this->driverAvailable;
}
public function beginTest(string $className, string $methodName, string $fallbackFile): void
{
if (! $this->active || ! $this->driverAvailable()) {
return;
}
if ($this->currentTestFile !== null) {
return;
}
$file = $this->resolveTestFile($className, $fallbackFile);
if ($file === null) {
return;
}
$this->currentTestFile = $file;
if ($this->classUsesDatabase($className)) {
$this->perTestUsesDatabase[$file] = true;
}
if ($this->driver === 'pcov') {
\pcov\clear();
\pcov\start();
return;
}
\xdebug_start_code_coverage();
}
public function endTest(): void
{
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
return;
}
if ($this->driver === 'pcov') {
\pcov\stop();
$scope = $this->sourceScope();
$filesToCollectCoverageFor = [];
foreach (\pcov\waiting() as $file) {
if (is_string($file) && $scope->contains($file)) {
$filesToCollectCoverageFor[] = $file;
}
}
/** @var array<string, mixed> $data */
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
$coveredFiles = $this->filesWithExecutedLines($data);
} else {
/** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage();
\xdebug_stop_code_coverage(true);
$coveredFiles = array_keys($data);
}
foreach ($coveredFiles as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
$this->currentTestFile = null;
}
public function linkSource(string $sourceFile): void
{
if (! $this->active) {
return;
}
if ($this->currentTestFile === null) {
return;
}
if ($sourceFile === '') {
return;
}
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
private function classUsesDatabase(string $className): bool
{
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
return $this->classUsesDatabaseCache[$className];
}
if (! class_exists($className, false)) {
return $this->classUsesDatabaseCache[$className] = false;
}
static $needles = [
'Illuminate\\Foundation\\Testing\\RefreshDatabase' => true,
'Illuminate\\Foundation\\Testing\\DatabaseMigrations' => true,
'Illuminate\\Foundation\\Testing\\DatabaseTransactions' => true,
];
$reflection = new ReflectionClass($className);
do {
foreach (array_keys($reflection->getTraits()) as $traitName) {
if (isset($needles[$traitName])) {
return $this->classUsesDatabaseCache[$className] = true;
}
}
$reflection = $reflection->getParentClass();
} while ($reflection !== false && ! $reflection->isInternal());
return $this->classUsesDatabaseCache[$className] = false;
}
public function linkTable(string $table): void
{
if (! $this->active) {
return;
}
if ($this->currentTestFile === null) {
return;
}
if ($table === '') {
return;
}
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
}
public function linkInertiaComponent(string $component): void
{
if (! $this->active) {
return;
}
if ($this->currentTestFile === null) {
return;
}
if ($component === '') {
return;
}
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
}
/** @return array<string, array<int, string>> */
public function perTestFiles(): array
{
$out = [];
foreach ($this->perTestFiles as $testFile => $sources) {
$out[$testFile] = array_keys($sources);
}
return $out;
}
/** @return array<string, array<int, string>> */
public function perTestTables(): array
{
$out = [];
foreach ($this->perTestTables as $testFile => $tables) {
$names = array_keys($tables);
sort($names);
$out[$testFile] = $names;
}
return $out;
}
/** @return array<string, array<int, string>> */
public function perTestInertiaComponents(): array
{
$out = [];
foreach ($this->perTestInertiaComponents as $testFile => $components) {
$names = array_keys($components);
sort($names);
$out[$testFile] = $names;
}
return $out;
}
/** @return array<string, true> */
public function perTestUsesDatabase(): array
{
return $this->perTestUsesDatabase;
}
private function resolveTestFile(string $className, string $fallbackFile): ?string
{
if (array_key_exists($className, $this->classFileCache)) {
$file = $this->classFileCache[$className];
} else {
$file = $this->readPestFilename($className);
$this->classFileCache[$className] = $file;
}
if ($file !== null) {
return $file;
}
if ($fallbackFile !== '' && $fallbackFile !== 'unknown' && ! str_contains($fallbackFile, "eval()'d")) {
return $fallbackFile;
}
return null;
}
private function readPestFilename(string $className): ?string
{
if (! class_exists($className, false)) {
return null;
}
assert(property_exists($className, '__filename') && is_string($className::$__filename));
return $className::$__filename;
}
/**
* @param array<string, mixed> $data
* @return list<string>
*/
private function filesWithExecutedLines(array $data): array
{
$out = [];
foreach ($data as $file => $lines) {
if (! is_array($lines)) {
continue;
}
$covered = [];
foreach ($lines as $line => $count) {
if (is_int($count) && $count > 0) {
$covered[] = $line;
}
}
if ($covered === []) {
continue;
}
$lineKeys = array_keys($lines);
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
continue;
}
$out[] = $file;
}
return $out;
}
private function sourceScope(): SourceScope
{
return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath);
}
public function reset(): void
{
$this->currentTestFile = null;
$this->perTestFiles = [];
$this->perTestTables = [];
$this->perTestInertiaComponents = [];
$this->perTestUsesDatabase = [];
$this->classFileCache = [];
$this->classUsesDatabaseCache = [];
$this->sourceScope = null;
$this->active = false;
}
}

View File

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* @internal
*/
final class ResultCollector
{
/**
* @var array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
*/
private array $results = [];
private ?string $currentTestId = null;
private ?string $currentTestFile = null;
private ?float $startTime = null;
public function testPrepared(string $testId, ?string $testFile = null): void
{
$this->currentTestId = $testId;
$this->currentTestFile = $testFile;
$this->startTime = microtime(true);
}
public function testPassed(): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::success());
}
public function testFailed(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::failure($message));
}
public function testErrored(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::error($message));
}
public function testSkipped(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::skipped($message));
}
public function testIncomplete(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::incomplete($message));
}
public function testRisky(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::risky($message));
}
/**
* @return array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
*/
public function all(): array
{
return $this->results;
}
public function recordAssertions(string $testId, int $assertions): void
{
if (isset($this->results[$testId])) {
$this->results[$testId]['assertions'] = $assertions;
}
}
/**
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
*/
public function merge(array $results): void
{
foreach ($results as $testId => $result) {
$this->results[$testId] = $result;
}
}
public function reset(): void
{
$this->results = [];
$this->currentTestId = null;
$this->currentTestFile = null;
$this->startTime = null;
}
public function finishTest(): void
{
$this->currentTestId = null;
$this->currentTestFile = null;
$this->startTime = null;
}
private function record(TestStatus $status): void
{
if ($this->currentTestId === null) {
return;
}
$time = $this->startTime !== null
? round(microtime(true) - $this->startTime, 3)
: 0.0;
$existing = $this->results[$this->currentTestId] ?? null;
$this->results[$this->currentTestId] = [
'status' => $status->asInt(),
'message' => $status->message(),
'time' => $time,
'assertions' => $existing['assertions'] ?? 0,
];
if ($this->currentTestFile !== null) {
$this->results[$this->currentTestId]['file'] = $this->currentTestFile;
}
}
}

View File

@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\TextUI\Configuration\Registry;
use Throwable;
/**
* @internal
*/
final class SourceScope
{
/** @var array<string, bool> */
private array $containsCache = [];
private const array TOP_LEVEL_NOISE = [
'vendor',
'node_modules',
'.git',
'.idea',
'.vscode',
'.github',
'.pest',
'.phpunit.cache',
'.cache',
];
private const array NESTED_NOISE = [
'storage/framework',
'storage/logs',
'bootstrap/cache',
];
/**
* @param list<string> $includes Absolute, normalised directory paths.
* @param list<string> $excludes Absolute, normalised directory paths.
*/
public function __construct(
private readonly array $includes,
private readonly array $excludes,
) {}
public static function fromProjectRoot(string $projectRoot): self
{
$phpunitIncludes = [];
$phpunitExcludes = [];
try {
$source = Registry::get()->source();
foreach ($source->includeDirectories() as $dir) {
$phpunitIncludes[] = self::normalise($dir->path());
}
foreach ($source->excludeDirectories() as $dir) {
$phpunitExcludes[] = self::normalise($dir->path());
}
} catch (Throwable) {
// Registry not initialized — fall back to project-root scanning.
}
$rootIncludes = self::topLevelProjectDirs($projectRoot);
$includes = array_values(array_unique([...$phpunitIncludes, ...$rootIncludes]));
$excludes = array_values(array_unique([
...$phpunitExcludes,
...self::nestedNoiseDirs($projectRoot),
]));
if ($includes === []) {
$includes = [self::normalise($projectRoot)];
}
return new self($includes, $excludes);
}
/**
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
*/
public static function testPaths(): array
{
try {
$suites = Registry::get()->testSuite();
} catch (Throwable) {
return [];
}
$out = [];
foreach ($suites as $suite) {
foreach ($suite->directories() as $directory) {
$out[] = self::normalise($directory->path());
}
foreach ($suite->files() as $file) {
$out[] = self::normalise($file->path());
}
}
return array_values(array_unique($out));
}
public function contains(string $absoluteFile): bool
{
if (isset($this->containsCache[$absoluteFile])) {
return $this->containsCache[$absoluteFile];
}
$real = @realpath($absoluteFile);
$candidate = $real === false ? $absoluteFile : $real;
$candidate = self::normalise($candidate);
foreach ($this->excludes as $excluded) {
if ($this->startsWithDir($candidate, $excluded)) {
return $this->containsCache[$absoluteFile] = false;
}
}
foreach ($this->includes as $included) {
if ($this->startsWithDir($candidate, $included)) {
return $this->containsCache[$absoluteFile] = true;
}
}
return $this->containsCache[$absoluteFile] = false;
}
/**
* @return list<string>
*/
private static function topLevelProjectDirs(string $projectRoot): array
{
$entries = @scandir($projectRoot);
if ($entries === false) {
return [];
}
$out = [];
foreach ($entries as $entry) {
if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue;
}
if (in_array($entry, self::TOP_LEVEL_NOISE, true)) {
continue;
}
if ($entry !== '' && $entry[0] === '.') {
continue;
}
$abs = $projectRoot.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($abs)) {
continue;
}
$out[] = self::normalise(@realpath($abs) ?: $abs);
}
return $out;
}
/**
* @return list<string>
*/
private static function nestedNoiseDirs(string $projectRoot): array
{
$out = [];
foreach (self::NESTED_NOISE as $relative) {
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
$out[] = self::normalise(@realpath($abs) ?: $abs);
}
return $out;
}
private static function normalise(string $path): string
{
return rtrim($path, '/\\');
}
private function startsWithDir(string $candidate, string $dir): bool
{
if ($candidate === $dir) {
return true;
}
return str_starts_with($candidate, $dir.DIRECTORY_SEPARATOR);
}
}

146
src/Plugins/Tia/Storage.php Normal file
View File

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class Storage
{
public static function tempDir(string $projectRoot): string
{
$home = self::homeDir();
if ($home === null) {
return $projectRoot
.DIRECTORY_SEPARATOR.'.pest'
.DIRECTORY_SEPARATOR.'tia';
}
return $home
.DIRECTORY_SEPARATOR.'.pest'
.DIRECTORY_SEPARATOR.'tia'
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
}
public static function purge(string $projectRoot): void
{
$dir = self::tempDir($projectRoot);
if (! is_dir($dir)) {
return;
}
self::removeRecursive($dir);
}
private static function removeRecursive(string $dir): void
{
$entries = @scandir($dir);
if ($entries === false) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue;
}
$path = $dir.DIRECTORY_SEPARATOR.$entry;
if (is_dir($path) && ! is_link($path)) {
self::removeRecursive($path);
continue;
}
@unlink($path);
}
@rmdir($dir);
}
private static function homeDir(): ?string
{
foreach (['HOME', 'USERPROFILE'] as $key) {
$value = getenv($key);
if (is_string($value) && $value !== '' && is_dir($value)) {
return rtrim($value, '/\\');
}
}
return null;
}
/**
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar`
*/
private static function projectKey(string $projectRoot): string
{
$origin = self::originIdentity($projectRoot);
$realpath = @realpath($projectRoot);
$input = $origin ?? ($realpath === false ? $projectRoot : $realpath);
$hash = substr(hash('sha256', $input), 0, 16);
$slug = self::slug(basename($projectRoot));
return $slug === '' ? $hash : $slug.'-'.$hash;
}
private static function originIdentity(string $projectRoot): ?string
{
$url = self::rawOriginUrl($projectRoot);
if ($url === null) {
return null;
}
// git@host:org/repo(.git)
if (preg_match('#^[\w.-]+@([\w.-]+):([\w./-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return strtolower($m[1].'/'.$m[2]);
}
// scheme://[user@]host[:port]/org/repo(.git) — https, ssh, git, file
if (preg_match('#^[a-z]+://(?:[^@/]+@)?([^/:]+)(?::\d+)?/([\w./-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return strtolower($m[1].'/'.$m[2]);
}
return strtolower($url);
}
private static function rawOriginUrl(string $projectRoot): ?string
{
$config = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
if (! is_file($config)) {
return null;
}
$raw = @file_get_contents($config);
if ($raw === false) {
return null;
}
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $raw, $match) === 1) {
return trim($match[1]);
}
return null;
}
private static function slug(string $name): string
{
$slug = strtolower($name);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
return trim($slug, '-');
}
}

View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class TableExtractor
{
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
/**
* @return list<string> Sorted, deduped table names referenced by the
*/
public static function fromSql(string $sql): array
{
$trimmed = ltrim($sql);
if ($trimmed === '') {
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) {
return [];
}
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
if (preg_match_all($pattern, $sql, $matches) === false) {
return [];
}
$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]));
if ($name === '') {
continue;
}
if (self::isSchemaMeta($name)) {
continue;
}
$tables[strtolower($name)] = true;
}
$out = array_keys($tables);
sort($out);
return $out;
}
/**
* @return list<string> Table names referenced by `Schema::` calls,
*/
public static function fromMigrationSource(string $php): array
{
$tables = [];
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
foreach ($matches[1] as $i => $primary) {
$tables[strtolower($primary)] = true;
$secondary = $matches[2][$i] ?? '';
if ($secondary !== '') {
$tables[strtolower($secondary)] = true;
}
}
}
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
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*\)/',
];
foreach ($dmlPatterns as $pattern) {
if (preg_match_all($pattern, $php, $matches) === false) {
continue;
}
foreach ($matches[1] as $name) {
$lower = strtolower($name);
if (! self::isSchemaMeta($lower)) {
$tables[$lower] = true;
}
}
}
$out = array_keys($tables);
sort($out);
return $out;
}
private static function isSchemaMeta(string $name): bool
{
$lower = strtolower($name);
return in_array($lower, ['sqlite_master', 'sqlite_sequence', 'migrations'], true)
|| str_starts_with($lower, 'pg_')
|| str_starts_with($lower, 'information_schema');
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class TableTracker
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string MARKER = 'pest.tia.table-tracker-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->isActive()) {
return;
}
$containerClass = self::CONTAINER_CLASS;
if (! class_exists($containerClass)) {
return;
}
/** @var object $app */
$app = $containerClass::getInstance();
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
return;
}
if ($app->bound(self::MARKER)) {
return;
}
if (! $app->bound('db')) {
return;
}
$app->instance(self::MARKER, true);
$listener = static function (object $query) use ($recorder): void {
if (! property_exists($query, 'sql')) {
return;
}
/** @var mixed $sql */
$sql = $query->sql;
if (! is_string($sql) || $sql === '') {
return;
}
foreach (TableExtractor::fromSql($sql) as $table) {
$recorder->linkTable($table);
}
};
/** @var object $db */
$db = $app->make('db');
if (is_callable([$db, 'listen'])) {
/** @var callable $listen */
$listen = [$db, 'listen'];
$listen($listener);
return;
}
if (! $app->bound('events')) {
return;
}
/** @var object $events */
$events = $app->make('events');
if (! method_exists($events, 'listen')) {
return;
}
$events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
}
}

View File

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\TestSuite;
use PHPUnit\TextUI\Configuration\Registry;
use Throwable;
/**
* Resolves the set of project-relative paths that are considered test files,
* driven by phpunit.xml's <testsuites>. Falls back to the runtime TestSuite
* configuration when no config file is present.
*
* @internal
*/
final readonly class TestPaths
{
/**
* @param list<string> $directories Project-relative directory prefixes (no trailing slash).
* @param list<string> $files Project-relative file paths.
* @param list<string> $suffixes Filename suffixes (e.g. '.php').
*/
public function __construct(
private array $directories,
private array $files,
private array $suffixes,
) {}
public static function fromProjectRoot(string $projectRoot): self
{
$directories = [];
$files = [];
$suffixes = [];
try {
$configuration = Registry::get();
foreach ($configuration->testSuite() as $suite) {
foreach ($suite->directories() as $directory) {
$rel = self::toRelative($directory->path(), $projectRoot);
if ($rel !== null) {
$directories[] = $rel;
}
$suffix = $directory->suffix();
if ($suffix !== '') {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
}
}
foreach ($suite->files() as $file) {
$rel = self::toRelative($file->path(), $projectRoot);
if ($rel !== null) {
$files[] = $rel;
}
}
}
if ($suffixes === []) {
foreach ($configuration->testSuffixes() as $suffix) {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
}
}
} catch (Throwable) {
// Registry not initialized — fall through to defaults.
}
if ($suffixes === []) {
$suffixes = ['.php'];
}
if ($directories === [] && $files === []) {
$fallback = self::testSuiteFallback($projectRoot);
if ($fallback !== null) {
$directories[] = $fallback;
}
}
return new self(
array_values(array_unique($directories)),
array_values(array_unique($files)),
array_values(array_unique($suffixes)),
);
}
public function isTestFile(string $relativePath): bool
{
if (in_array($relativePath, $this->files, true)) {
return true;
}
$matchesSuffix = false;
foreach ($this->suffixes as $suffix) {
if (str_ends_with($relativePath, $suffix)) {
$matchesSuffix = true;
break;
}
}
if (! $matchesSuffix) {
return false;
}
foreach ($this->directories as $dir) {
if ($dir === '') {
continue;
}
if (str_starts_with($relativePath, $dir.'/')) {
return true;
}
}
return false;
}
private static function toRelative(string $value, string $projectRoot): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$real = @realpath($value);
$resolved = $real === false ? $value : $real;
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
if (! str_starts_with($resolved.'/', $root)) {
return null;
}
return rtrim(substr($resolved, strlen($root)), '/');
}
private static function testSuiteFallback(string $projectRoot): ?string
{
try {
$testPath = TestSuite::getInstance()->testPath;
} catch (Throwable) {
return null;
}
$real = @realpath($testPath);
$resolved = $real === false ? $testPath : $real;
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
if (! str_starts_with($resolved.'/', $root)) {
return null;
}
return rtrim(substr($resolved, strlen($root)), '/');
}
}

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Browser\Support\BrowserTestIdentifier;
use Pest\Factories\TestCaseFactory;
use Pest\Plugins\Tia\Contracts\WatchDefault;
use Pest\TestSuite;
/**
* @internal
*/
final readonly class Browser implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
}
public function defaults(string $projectRoot, string $testPath): array
{
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
$globs = [
'resources/js/** !*.php',
'resources/css/** !*.php',
'public/hot !*.php',
'public/** !*.php',
];
$patterns = [];
foreach ($globs as $glob) {
$patterns[$glob] = $browserTargets;
}
return $patterns;
}
/**
* @return array<int, string>
*/
public static function detectBrowserTestTargets(string $projectRoot, string $testPath): array
{
$targets = [];
$candidate = $testPath.'/Browser';
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
$targets[] = $candidate;
}
if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests;
foreach ($repo->getFilenames() as $filename) {
$factory = $repo->get($filename);
if (! $factory instanceof TestCaseFactory) {
continue;
}
foreach ($factory->methods as $method) {
if (BrowserTestIdentifier::isBrowserTest($method)) {
$rel = self::fileRelative($projectRoot, $filename);
if ($rel !== null) {
$targets[] = $rel;
}
break;
}
}
}
}
return array_values(array_unique($targets));
}
private static function fileRelative(string $projectRoot, string $path): ?string
{
$real = @realpath($path);
if ($real === false) {
$real = $path;
}
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (! str_starts_with($real, $root)) {
return null;
}
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Inertia implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& (InstalledVersions::isInstalled('inertiajs/inertia-laravel')
|| InstalledVersions::isInstalled('rompetomp/inertia-bundle'));
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'resources/js/** !*.php' => [$testPath],
];
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Laravel implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('laravel/framework');
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'database/migrations/**/*.php' => [$testPath],
'storage/fixtures/**/*' => [$testPath],
'app/** !*.php' => [$testPath],
'resources/views/**' => [$testPath],
'lang/**' => [$testPath],
'resources/lang/**' => [$testPath],
'vite.config.* !*.php' => [$testPath],
'webpack.mix.* !*.php' => [$testPath],
'tailwind.config.* !*.php' => [$testPath],
'postcss.config.* !*.php' => [$testPath],
];
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Livewire implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('livewire/livewire');
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'resources/views/livewire/**/*.blade.php' => [$testPath],
'resources/views/components/**/*.blade.php' => [$testPath],
'resources/views/pages/**/*.blade.php' => [$testPath],
'resources/js/**/*.js' => [$testPath],
'resources/js/**/*.ts' => [$testPath],
];
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Php implements WatchDefault
{
public function applicable(): bool
{
return true;
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'.env' => [$testPath],
'.env.testing' => [$testPath],
'.env.local' => [$testPath],
'.env.*.local' => [$testPath],
'docker-compose.yml' => [$testPath],
'docker-compose.yaml' => [$testPath],
'phpunit.xml*' => [$testPath],
$testPath.'/Fixtures/**/*' => [$testPath],
$testPath.'/**/Fixtures/**/*' => [$testPath],
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
];
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Symfony implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('symfony/framework-bundle');
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'config/** !*.php' => [$testPath],
'config/routes/** !*.php' => [$testPath],
'migrations/**/*.php' => [$testPath],
'src/Migrations/**/*.php' => [$testPath],
'templates/** !*.php' => [$testPath],
'translations/** !*.php' => [$testPath],
'config/doctrine/**/*.xml' => [$testPath],
'config/doctrine/**/*.yaml' => [$testPath],
'webpack.config.js' => [$testPath],
'importmap.php' => [$testPath],
'assets/** !*.php' => [$testPath],
];
}
}

View File

@ -0,0 +1,331 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\WatchDefault;
use Pest\TestSuite;
/**
* @internal
*/
final class WatchPatterns
{
/**
* @var array<int, class-string<WatchDefault>>
*/
private const array DEFAULTS = [
WatchDefaults\Php::class,
WatchDefaults\Laravel::class,
WatchDefaults\Symfony::class,
WatchDefaults\Livewire::class,
WatchDefaults\Inertia::class,
WatchDefaults\Browser::class,
];
private const array VCS_DIRS = ['.git', '.svn', '.hg'];
/**
* @var array<string, array<int, string>> raw pattern key → list of project-relative test dirs/files
*/
private array $patterns = [];
/**
* @var array<string, array{include: string, excludes: array<int, string>, allowDotfiles: bool}>
*/
private array $parsed = [];
private bool $enabled = false;
private bool $locally = false;
private bool $filtered = false;
private bool $baselined = false;
public function useDefaults(string $projectRoot): void
{
$testPath = TestSuite::getInstance()->testPath;
foreach (self::DEFAULTS as $class) {
$default = new $class;
if (! $default->applicable()) {
continue;
}
foreach ($default->defaults($projectRoot, $testPath) as $key => $dirs) {
$this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$key] ?? [], $dirs),
));
}
}
}
/**
* @param array<string, string> $patterns pattern key → project-relative test dir/file
*/
public function add(array $patterns): void
{
foreach ($patterns as $key => $dir) {
$this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$key] ?? [], [$dir]),
));
}
}
/**
* @param string $projectRoot Absolute path.
* @param array<int, string> $changedFiles Project-relative paths.
* @return array<int, string> Project-relative test dirs/files.
*/
public function matchedDirectories(string $projectRoot, array $changedFiles): array
{
if ($this->patterns === []) {
return [];
}
$matched = [];
foreach ($changedFiles as $file) {
foreach ($this->patterns as $key => $dirs) {
if (! $this->keyMatches($key, $file)) {
continue;
}
foreach ($dirs as $dir) {
$matched[$dir] = true;
}
}
}
return array_keys($matched);
}
/**
* @param array<int, string> $directories Project-relative dirs/files.
* @param array<int, string> $allTestFiles Project-relative test files from graph.
* @return array<int, string>
*/
public function testsUnderDirectories(array $directories, array $allTestFiles): array
{
if ($directories === []) {
return [];
}
$affected = [];
foreach ($allTestFiles as $testFile) {
foreach ($directories as $target) {
if ($testFile === $target) {
$affected[] = $testFile;
break;
}
$prefix = rtrim($target, '/').'/';
if (str_starts_with($testFile, $prefix)) {
$affected[] = $testFile;
break;
}
}
}
return $affected;
}
public function markEnabled(): void
{
$this->enabled = true;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function markLocally(): void
{
$this->locally = true;
}
public function isLocally(): bool
{
return $this->locally;
}
public function markFiltered(): void
{
$this->filtered = true;
}
public function isFiltered(): bool
{
return $this->filtered;
}
public function markBaselined(): void
{
$this->baselined = true;
}
public function isBaselined(): bool
{
return $this->baselined;
}
public function reset(): void
{
$this->patterns = [];
$this->parsed = [];
$this->enabled = false;
$this->locally = false;
$this->filtered = false;
$this->baselined = false;
}
private function keyMatches(string $key, string $file): bool
{
$rule = $this->parse($key);
if (! $this->globMatches($rule['include'], $file)) {
return false;
}
$file = str_replace('\\', '/', $file);
if ($this->touchesVcs($file)) {
return false;
}
if (! $rule['allowDotfiles'] && $this->touchesDotfile($file)) {
return false;
}
foreach ($rule['excludes'] as $exclude) {
if ($this->excludeMatches($exclude, $file)) {
return false;
}
}
return true;
}
/**
* @return array{include: string, excludes: array<int, string>, allowDotfiles: bool}
*/
private function parse(string $key): array
{
if (isset($this->parsed[$key])) {
return $this->parsed[$key];
}
$tokens = preg_split('/\s+/', trim($key)) ?: [];
$include = '';
$excludes = [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if ($token[0] === '!') {
$excludes[] = substr($token, 1);
continue;
}
if ($include === '') {
$include = $token;
}
}
return $this->parsed[$key] = [
'include' => $include,
'excludes' => $excludes,
'allowDotfiles' => $this->patternTargetsDotfiles($include),
];
}
private function patternTargetsDotfiles(string $pattern): bool
{
foreach (explode('/', str_replace('\\', '/', $pattern)) as $segment) {
if ($segment !== '' && $segment[0] === '.') {
return true;
}
}
return false;
}
private function touchesVcs(string $file): bool
{
foreach (explode('/', $file) as $segment) {
if (in_array($segment, self::VCS_DIRS, true)) {
return true;
}
}
return false;
}
private function touchesDotfile(string $file): bool
{
foreach (explode('/', $file) as $segment) {
if ($segment !== '' && $segment[0] === '.') {
return true;
}
}
return false;
}
private function excludeMatches(string $exclude, string $file): bool
{
$pattern = str_contains($exclude, '/') ? $exclude : '**/'.$exclude;
if ($this->globMatches($pattern, $file)) {
return true;
}
return $this->globMatches($exclude, basename($file));
}
private function globMatches(string $pattern, string $file): bool
{
$pattern = str_replace('\\', '/', $pattern);
$file = str_replace('\\', '/', $file);
$regex = '';
$len = strlen($pattern);
$i = 0;
while ($i < $len) {
$c = $pattern[$i];
if ($c === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') {
$regex .= '.*';
$i += 2;
if (isset($pattern[$i]) && $pattern[$i] === '/') {
$i++;
}
} elseif ($c === '*') {
$regex .= '[^/]*';
$i++;
} elseif ($c === '?') {
$regex .= '[^/]';
$i++;
} else {
$regex .= preg_quote($c, '#');
$i++;
}
}
return (bool) preg_match('#^'.$regex.'$#', $file);
}
}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Pest\Restarters;
use Pest\Contracts\Restarter;
use Pest\Plugins\Tia;
/**
* @internal
*/
final class PcovRestarter implements Restarter
{
private const string ENV_RESTARTED = 'PEST_PCOV_RESTARTER_RESTARTED';
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void
{
if (! extension_loaded('pcov')) {
return;
}
if (getenv(self::ENV_RESTARTED) === '1') {
return;
}
if (! Tia::isEnabledForRun($arguments)) {
return;
}
$desired = $this->normalise($projectRoot);
$current = $this->normalise((string) ini_get('pcov.directory'));
if ($current === $desired) {
return;
}
$this->restart($projectRoot, $arguments);
}
/**
* @param array<int, string> $arguments
*/
private function restart(string $projectRoot, array $arguments): void
{
$env = $this->inheritEnv();
$env[self::ENV_RESTARTED] = '1';
$command = array_merge(
[PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
array_values($arguments),
);
$proc = @proc_open(
$command,
[STDIN, STDOUT, STDERR],
$pipes,
null,
$env,
);
if (! is_resource($proc)) {
return;
}
$exitCode = proc_close($proc);
exit($exitCode === -1 ? 1 : $exitCode);
}
/**
* @return array<string, string>
*/
private function inheritEnv(): array
{
$env = [];
foreach (getenv() as $name => $value) {
$env[$name] = $value;
}
return $env;
}
private function normalise(string $path): string
{
return rtrim($path, '/\\');
}
}

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Pest\Restarters;
use Composer\XdebugHandler\XdebugHandler;
use Pest\Contracts\Restarter;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage;
/**
* @internal
*/
final class XdebugRestarter implements Restarter
{
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void
{
if (! class_exists(XdebugHandler::class)) {
return;
}
if (! extension_loaded('xdebug')) {
return;
}
if (! $this->xdebugIsCoverageOnly()) {
return;
}
if (! $this->runLooksDroppable($arguments, $projectRoot)) {
return;
}
(new XdebugHandler('pest'))->check();
}
private function xdebugIsCoverageOnly(): bool
{
if (! function_exists('xdebug_info')) {
return false;
}
$modes = @xdebug_info('mode');
if (! is_array($modes)) {
return false;
}
$modes = array_values(array_filter($modes, is_string(...)));
if ($modes === []) {
return true;
}
return $modes === ['coverage'];
}
/**
* @param array<int, string> $arguments
*/
private function runLooksDroppable(array $arguments, string $projectRoot): bool
{
foreach ($arguments as $value) {
if ($value === '--coverage'
|| str_starts_with($value, '--coverage=')
|| str_starts_with($value, '--coverage-')) {
return false;
}
if ($value === '--fresh') {
return false;
}
}
if (! Tia::isEnabledForRun($arguments)) {
return false;
}
return $this->tiaWillReplay($projectRoot);
}
private function tiaWillReplay(string $projectRoot): bool
{
$path = Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
if (! is_file($path)) {
return false;
}
$json = @file_get_contents($path);
if ($json === false) {
return false;
}
$graph = Graph::decode($json, $projectRoot);
if (! $graph instanceof Graph) {
return false;
}
return Fingerprint::structuralMatches(
$graph->fingerprint(),
Fingerprint::compute($projectRoot),
);
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(Finished $event): void
{
$test = $event->test();
if ($test instanceof TestMethod) {
$this->collector->recordAssertions(
$test->className().'::'.$test->methodName(),
$event->numberOfAssertionsPerformed(),
);
}
$this->collector->finishTest();
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaEnds implements FinishedSubscriber
{
public function __construct(private Recorder $recorder) {}
public function notify(Finished $event): void
{
$this->recorder->endTest();
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Exceptions\TiaRequiresPestTests;
use Pest\Panic;
use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaIsRunningPestTestsOnly implements PreparedSubscriber
{
public function __construct(private Recorder $recorder) {}
public function notify(Prepared $event): void
{
if (! $this->recorder->isActive()) {
return;
}
$test = $event->test();
if (! $test instanceof TestMethod) {
return;
}
$className = $test->className();
if (! class_exists($className, false)) {
return;
}
if (method_exists($className, '__initializeTestCase')) {
return;
}
Panic::with(new TiaRequiresPestTests($className, $test->file()));
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\ErroredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnErrored implements ErroredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(Errored $event): void
{
$this->collector->testErrored($event->throwable()->message());
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\FailedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnFailed implements FailedSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(Failed $event): void
{
$this->collector->testFailed($event->throwable()->message());
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnIncomplete implements MarkedIncompleteSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(MarkedIncomplete $event): void
{
$this->collector->testIncomplete($event->throwable()->message());
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\Passed;
use PHPUnit\Event\Test\PassedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnPassed implements PassedSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(Passed $event): void
{
$this->collector->testPassed();
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\ConsideredRiskySubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnRisky implements ConsideredRiskySubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(ConsideredRisky $event): void
{
$this->collector->testRisky($event->message());
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\SkippedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnSkipped implements SkippedSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(Skipped $event): void
{
$this->collector->testSkipped($event->message());
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\PreparationStarted;
use PHPUnit\Event\Test\PreparationStartedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultsAreCollected implements PreparationStartedSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(PreparationStarted $event): void
{
$test = $event->test();
if ($test instanceof TestMethod) {
$this->collector->testPrepared($test->className().'::'.$test->methodName(), $test->file());
}
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaStarts implements PreparedSubscriber
{
public function __construct(private Recorder $recorder) {}
public function notify(Prepared $event): void
{
if (! $this->recorder->isActive()) {
return;
}
$test = $event->test();
if (! $test instanceof TestMethod) {
return;
}
$this->recorder->beginTest($test->className(), $test->methodName(), $test->file());
}
}

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Support; namespace Pest\Support;
use Pest\Exceptions\ShouldNotHappen; use Pest\Exceptions\ShouldNotHappen;
use Pest\Plugins\Tia\CoverageMerger;
use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Node\Directory; use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File; use SebastianBergmann\CodeCoverage\Node\File;
@ -88,6 +89,8 @@ final class Coverage
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath)); throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
} }
CoverageMerger::applyIfMarked($reportPath);
/** @var CodeCoverage $codeCoverage */ /** @var CodeCoverage $codeCoverage */
$codeCoverage = require $reportPath; $codeCoverage = require $reportPath;
unlink($reportPath); unlink($reportPath);

View File

@ -11,6 +11,7 @@ use PHPUnit\Event\Code\TestDoxBuilder;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\ThrowableBuilder; use PHPUnit\Event\Code\ThrowableBuilder;
use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\PhpunitDeprecationTriggered; use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
use PHPUnit\Event\Test\PhpunitErrorTriggered; use PHPUnit\Event\Test\PhpunitErrorTriggered;
use PHPUnit\Event\Test\PhpunitNoticeTriggered; use PHPUnit\Event\Test\PhpunitNoticeTriggered;
@ -40,11 +41,16 @@ final class StateGenerator
} }
foreach ($testResult->testFailedEvents() as $testResultEvent) { foreach ($testResult->testFailedEvents() as $testResultEvent) {
$state->add(TestResult::fromPestParallelTestCase( if ($testResultEvent instanceof Failed) {
$testResultEvent->test(), $state->add(TestResult::fromPestParallelTestCase(
TestResult::FAIL, $testResultEvent->test(),
$testResultEvent->throwable() TestResult::FAIL,
)); $testResultEvent->throwable()
));
} else {
// @phpstan-ignore-next-line
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
}
} }
$this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL); $this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL);

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Pest\TestCaseFilters;
use Pest\Contracts\TestCaseFilter;
use Pest\Plugins\Tia\Graph;
/**
* @internal
*/
final readonly class TiaTestCaseFilter implements TestCaseFilter
{
/**
* @param array<string, true> $affectedTestFiles Keys are project-relative test file paths.
*/
public function __construct(
private string $projectRoot,
private Graph $graph,
private array $affectedTestFiles,
) {}
public function accept(string $testCaseFilename): bool
{
$rel = $this->relative($testCaseFilename);
if ($rel === null) {
return true;
}
if (! $this->graph->knowsTest($rel)) {
return true;
}
return isset($this->affectedTestFiles[$rel]);
}
private function relative(string $path): ?string
{
$real = @realpath($path);
if ($real === false) {
$real = $path;
}
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (! str_starts_with($real, $root)) {
return null;
}
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
}
}

View File

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

View File

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

View File

@ -1,7 +1,7 @@
PASS Tests\Arch PASS Tests\Arch
✓ preset → php → ignoring ['Pest\Expectation', 'debug_backtrace', 'var_export', …] ✓ preset → php → ignoring ['Pest\Expectation', 'debug_backtrace', 'var_export', …]
✓ preset → strict → ignoring ['usleep'] ✓ preset → strict → ignoring ['Pest\Plugins\Tia\BaselineSync', 'usleep']
✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …] ✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …]
✓ globals ✓ globals
✓ contracts ✓ contracts

View File

@ -1,15 +1,20 @@
<?php <?php
use Pest\Expectation; use Pest\Expectation;
use Pest\Plugins\Tia\BaselineSync;
arch()->preset()->php()->ignoring([ arch()->preset()->php()->ignoring([
Expectation::class, Expectation::class,
'debug_backtrace', 'debug_backtrace',
'var_export', 'var_export',
'xdebug_info', 'xdebug_info',
'xdebug_start_code_coverage',
'xdebug_stop_code_coverage',
'xdebug_get_code_coverage',
]); ]);
arch()->preset()->strict()->ignoring([ arch()->preset()->strict()->ignoring([
BaselineSync::class,
'usleep', 'usleep',
]); ]);

View File

@ -0,0 +1,261 @@
<?php
use Pest\Plugins\Tia\ContentHash;
describe('of()', function () {
it('returns false when file does not exist', function () {
expect(ContentHash::of('/path/that/does/not/exist.php'))->toBeFalse();
});
it('hashes an existing file', function () {
$path = tempnam(sys_get_temp_dir(), 'pest_').'.php';
file_put_contents($path, "<?php echo 'hi';");
try {
expect(ContentHash::of($path))->toBeString()->not->toBeEmpty();
} finally {
@unlink($path);
}
});
});
describe('PHP files', function () {
it('produces the same hash regardless of whitespace differences', function () {
$a = ContentHash::ofContent('a.php', "<?php \$foo = 1;\n\necho \$foo;");
$b = ContentHash::ofContent('a.php', '<?php $foo=1; echo $foo;');
expect($a)->toBe($b);
});
it('ignores single-line comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n// this is a comment\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
it('ignores hash-style comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n# hash comment\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
it('ignores multi-line comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n/* a multi\n line comment */\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
it('ignores doc comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n/**\n * @return int\n */\nfunction foo() { return 1; }");
$b = ContentHash::ofContent('a.php', "<?php\nfunction foo() { return 1; }");
expect($a)->toBe($b);
});
it('detects code changes', function () {
$a = ContentHash::ofContent('a.php', '<?php $foo = 1;');
$b = ContentHash::ofContent('a.php', '<?php $foo = 2;');
expect($a)->not->toBe($b);
});
it('preserves whitespace inside string literals', function () {
$a = ContentHash::ofContent('a.php', "<?php \$foo = 'hello world';");
$b = ContentHash::ofContent('a.php', "<?php \$foo = 'helloworld';");
expect($a)->not->toBe($b);
});
it('treats variable renames as a change', function () {
$a = ContentHash::ofContent('a.php', '<?php $foo = 1;');
$b = ContentHash::ofContent('a.php', '<?php $bar = 1;');
expect($a)->not->toBe($b);
});
it('falls back to a raw hash for unparseable PHP', function () {
$hash = ContentHash::ofContent('a.php', 'not valid php at all');
expect($hash)->toBeString()->not->toBeEmpty();
});
it('is case-insensitive on the file extension', function () {
$a = ContentHash::ofContent('a.PHP', "<?php\n// comment\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
});
describe('Blade files', function () {
it('strips blade comments', function () {
$a = ContentHash::ofContent('a.blade.php', '<div>{{-- a comment --}}Hello</div>');
$b = ContentHash::ofContent('a.blade.php', '<div>Hello</div>');
expect($a)->toBe($b);
});
it('strips multi-line blade comments', function () {
$a = ContentHash::ofContent('a.blade.php', "<div>\n{{--\n multi\n line\n--}}\nHello\n</div>");
$b = ContentHash::ofContent('a.blade.php', '<div> Hello </div>');
expect($a)->toBe($b);
});
it('collapses whitespace', function () {
$a = ContentHash::ofContent('a.blade.php', "<div>\n Hello\n World\n</div>");
$b = ContentHash::ofContent('a.blade.php', '<div> Hello World </div>');
expect($a)->toBe($b);
});
it('detects content changes', function () {
$a = ContentHash::ofContent('a.blade.php', '<div>Hello</div>');
$b = ContentHash::ofContent('a.blade.php', '<div>Goodbye</div>');
expect($a)->not->toBe($b);
});
it('keeps blade directives intact', function () {
$a = ContentHash::ofContent('a.blade.php', '@if($user)Hi @endif');
$b = ContentHash::ofContent('a.blade.php', '@if($user)Bye @endif');
expect($a)->not->toBe($b);
});
it('does not use the PHP tokenizer for blade files', function () {
$a = ContentHash::ofContent('a.blade.php', '<?php // not stripped ?> hello');
$b = ContentHash::ofContent('a.blade.php', '<?php ?> hello');
expect($a)->not->toBe($b);
});
});
describe('JavaScript-like files', function () {
it('strips line comments', function () {
$a = ContentHash::ofContent('a.js', "// a comment\nconst foo = 1;");
$b = ContentHash::ofContent('a.js', 'const foo = 1;');
expect($a)->toBe($b);
});
it('strips block comments on their own lines', function () {
$a = ContentHash::ofContent('a.js', "/* block */\nconst foo = 1;");
$b = ContentHash::ofContent('a.js', 'const foo = 1;');
expect($a)->toBe($b);
});
it('collapses whitespace', function () {
$a = ContentHash::ofContent('a.js', "const foo = 1;\n\nconst bar = 2;");
$b = ContentHash::ofContent('a.js', 'const foo = 1; const bar = 2;');
expect($a)->toBe($b);
});
it('detects code changes', function () {
$a = ContentHash::ofContent('a.js', 'const foo = 1;');
$b = ContentHash::ofContent('a.js', 'const foo = 2;');
expect($a)->not->toBe($b);
});
it('does not strip inline trailing comments', function () {
$a = ContentHash::ofContent('a.js', 'const foo = 1; // inline');
$b = ContentHash::ofContent('a.js', 'const foo = 1;');
expect($a)->not->toBe($b);
});
it('applies the same rules to .ts files', function () {
$a = ContentHash::ofContent('a.ts', "// comment\nconst foo: number = 1;");
$b = ContentHash::ofContent('a.ts', 'const foo: number = 1;');
expect($a)->toBe($b);
});
it('applies the same rules to .tsx files', function () {
$a = ContentHash::ofContent('a.tsx', "// comment\nconst Foo = () => <div/>;");
$b = ContentHash::ofContent('a.tsx', 'const Foo = () => <div/>;');
expect($a)->toBe($b);
});
it('applies the same rules to .jsx files', function () {
$a = ContentHash::ofContent('a.jsx', "// comment\nconst Foo = () => <div/>;");
$b = ContentHash::ofContent('a.jsx', 'const Foo = () => <div/>;');
expect($a)->toBe($b);
});
it('applies the same rules to .vue files', function () {
$a = ContentHash::ofContent('a.vue', "<script>\n// comment\nexport default {}\n</script>");
$b = ContentHash::ofContent('a.vue', '<script> export default {} </script>');
expect($a)->toBe($b);
});
it('applies the same rules to .svelte files', function () {
$a = ContentHash::ofContent('a.svelte', "<script>\n// comment\nlet foo = 1;\n</script>");
$b = ContentHash::ofContent('a.svelte', '<script> let foo = 1; </script>');
expect($a)->toBe($b);
});
it('applies the same rules to .mjs, .cjs, and .mts files', function () {
foreach (['mjs', 'cjs', 'mts'] as $ext) {
$a = ContentHash::ofContent("a.$ext", "// comment\nexport const foo = 1;");
$b = ContentHash::ofContent("a.$ext", 'export const foo = 1;');
expect($a)->toBe($b);
}
});
});
describe('unknown extensions', function () {
it('hashes the raw content for unknown extensions', function () {
$a = ContentHash::ofContent('a.txt', 'hello world');
$b = ContentHash::ofContent('a.txt', 'hello world');
expect($a)->toBe($b);
});
it('does not normalise whitespace for unknown extensions', function () {
$a = ContentHash::ofContent('a.txt', 'hello world');
$b = ContentHash::ofContent('a.txt', 'hello world');
expect($a)->not->toBe($b);
});
it('does not strip comments for unknown extensions', function () {
$a = ContentHash::ofContent('a.txt', "// not a comment here\nhello");
$b = ContentHash::ofContent('a.txt', 'hello');
expect($a)->not->toBe($b);
});
it('hashes files with no extension as raw content', function () {
$a = ContentHash::ofContent('Makefile', "all:\n\techo hi");
$b = ContentHash::ofContent('Makefile', "all:\n\techo hi");
expect($a)->toBe($b);
});
});
describe('output format', function () {
it('returns a 32-character hex xxh128 hash', function () {
$hash = ContentHash::ofContent('a.php', '<?php $foo = 1;');
expect($hash)->toMatch('/^[a-f0-9]{32}$/');
});
it('returns a stable hash for empty content', function () {
$a = ContentHash::ofContent('a.php', '');
$b = ContentHash::ofContent('a.php', '');
expect($a)->toBe($b);
});
});