mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
Compare commits
9 Commits
07416a3c61
...
d106b70766
| Author | SHA1 | Date | |
|---|---|---|---|
| d106b70766 | |||
| 6ac6c1518e | |||
| fda515a17f | |||
| 0a97d3a288 | |||
| 3802fa80e6 | |||
| 5c3cbc14d2 | |||
| 6b9c768172 | |||
| 4a2fc179ae | |||
| b5bb2139dc |
18
.github/workflows/tests.yml
vendored
18
.github/workflows/tests.yml
vendored
@ -76,21 +76,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Integration Tests
|
- name: Integration Tests
|
||||||
run: composer test:integration
|
run: composer test:integration
|
||||||
|
|
||||||
# tests-tia records coverage inside its sandbox, which requires
|
|
||||||
# pcov (or xdebug) in the process PHP. The main setup-php step is
|
|
||||||
# `coverage: none` for speed — re-enable pcov here just for the
|
|
||||||
# TIA step. Cheap: pcov startup is near-zero.
|
|
||||||
- name: Enable pcov for TIA
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: ${{ matrix.php }}
|
|
||||||
tools: composer:v2
|
|
||||||
coverage: pcov
|
|
||||||
extensions: sockets
|
|
||||||
|
|
||||||
- name: TIA End-to-End Tests
|
|
||||||
# Black-box tests drive Pest `--tia` against a throw-away sandbox.
|
|
||||||
# First scenario takes ~60s (composer-installs the host Pest into a
|
|
||||||
# cached template); subsequent clones are cheap.
|
|
||||||
run: composer test:tia
|
|
||||||
|
|||||||
6
bin/pest
6
bin/pest
@ -151,6 +151,12 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
|||||||
// mode gates).
|
// mode gates).
|
||||||
\Pest\Support\XdebugGuard::maybeDrop($rootPath);
|
\Pest\Support\XdebugGuard::maybeDrop($rootPath);
|
||||||
|
|
||||||
|
// Restarts PHP with `pcov.directory=<root>` when `--tia` is active and
|
||||||
|
// pcov is loaded, so the driver never instruments anything outside the
|
||||||
|
// project (vendor, system includes). Idempotent — guarded by an env
|
||||||
|
// sentinel so a single round-trip is enough.
|
||||||
|
\Pest\Support\PcovGuard::maybeRestart($rootPath);
|
||||||
|
|
||||||
$input = new ArgvInput;
|
$input = new ArgvInput;
|
||||||
|
|
||||||
$testSuite = TestSuite::getInstance(
|
$testSuite = TestSuite::getInstance(
|
||||||
|
|||||||
@ -1,32 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
/**
|
|
||||||
* TIA Vite dependency resolver.
|
|
||||||
*
|
|
||||||
* Spins up a throwaway headless Vite dev server using the project's
|
|
||||||
* `vite.config.*`, walks every `resources/js/Pages/**` entry to warm
|
|
||||||
* up the module graph, then serializes the graph as a reverse map:
|
|
||||||
*
|
|
||||||
* { "<abs source path>": ["<page component name>", ...], ... }
|
|
||||||
*
|
|
||||||
* The resulting JSON is written to stdout. Stderr is silent on
|
|
||||||
* success so Pest can parse stdout without stripping.
|
|
||||||
*
|
|
||||||
* Why this exists: at TIA record time we need to know which Inertia
|
|
||||||
* page components depend on each shared source file (Button.vue,
|
|
||||||
* Layouts/*.vue, etc.) so a later edit to one of those files can
|
|
||||||
* invalidate only the tests that rendered an affected page. Vite
|
|
||||||
* already knows this via its module graph — we borrow it.
|
|
||||||
*
|
|
||||||
* Called from `Pest\Plugins\Tia\JsModuleGraph::build()` as:
|
|
||||||
*
|
|
||||||
* node bin/pest-tia-vite-deps.mjs <absoluteProjectRoot>
|
|
||||||
*
|
|
||||||
* Environment:
|
|
||||||
* TIA_VITE_PAGES_DIR override the `resources/js/Pages` default.
|
|
||||||
* TIA_VITE_TIMEOUT_MS override the 20s internal watchdog.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readdir } from 'node:fs/promises'
|
import { readdir } from 'node:fs/promises'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
@ -38,11 +11,6 @@ const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
|
|||||||
const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/')
|
const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/')
|
||||||
const TIMEOUT_MS = Number.parseInt(process.env.TIA_VITE_TIMEOUT_MS ?? '20000', 10)
|
const TIMEOUT_MS = Number.parseInt(process.env.TIA_VITE_TIMEOUT_MS ?? '20000', 10)
|
||||||
|
|
||||||
// Resolve Vite from the project's own `node_modules`, not from this
|
|
||||||
// helper's location (which lives under `vendor/pestphp/pest/bin/` and
|
|
||||||
// has no `node_modules`). `createRequire` anchored at the project
|
|
||||||
// root walks up from there, matching the resolution behaviour any
|
|
||||||
// project-local script would see.
|
|
||||||
async function loadVite() {
|
async function loadVite() {
|
||||||
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||||
const vitePath = projectRequire.resolve('vite')
|
const vitePath = projectRequire.resolve('vite')
|
||||||
@ -84,9 +52,6 @@ async function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boot Vite in middleware mode (no port binding, no HMR server).
|
|
||||||
// We only need the module graph; transformRequest per page warms
|
|
||||||
// it without running a bundle.
|
|
||||||
const server = await createServer({
|
const server = await createServer({
|
||||||
configFile: undefined, // auto-detect vite.config.*
|
configFile: undefined, // auto-detect vite.config.*
|
||||||
root: PROJECT_ROOT,
|
root: PROJECT_ROOT,
|
||||||
@ -101,12 +66,10 @@ async function main() {
|
|||||||
optimizeDeps: { disabled: true },
|
optimizeDeps: { disabled: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watchdog — don't let a pathological config hang the record run.
|
|
||||||
const killer = setTimeout(() => {
|
const killer = setTimeout(() => {
|
||||||
server.close().catch(() => {}).finally(() => process.exit(2))
|
server.close().catch(() => {}).finally(() => process.exit(2))
|
||||||
}, TIMEOUT_MS)
|
}, TIMEOUT_MS)
|
||||||
|
|
||||||
// Reverse map: depSourcePath → Set<component name>.
|
|
||||||
const reverse = new Map()
|
const reverse = new Map()
|
||||||
|
|
||||||
const pageComponentCache = new Map()
|
const pageComponentCache = new Map()
|
||||||
@ -125,15 +88,12 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
await server.transformRequest(pageUrl, { ssr: false })
|
await server.transformRequest(pageUrl, { ssr: false })
|
||||||
} catch {
|
} catch {
|
||||||
// Transform errors (missing deps, syntax issues) shouldn't
|
|
||||||
// poison the whole graph — skip this page and continue.
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false)
|
const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false)
|
||||||
if (!pageModule) continue
|
if (!pageModule) continue
|
||||||
|
|
||||||
// BFS over importedModules, scoped to files inside the project.
|
|
||||||
const visited = new Set()
|
const visited = new Set()
|
||||||
const queue = [pageModule]
|
const queue = [pageModule]
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
@ -143,8 +103,6 @@ async function main() {
|
|||||||
if (!id || visited.has(id)) continue
|
if (!id || visited.has(id)) continue
|
||||||
visited.add(id)
|
visited.add(id)
|
||||||
|
|
||||||
// Skip files outside the project root (node_modules, etc.)
|
|
||||||
// and virtual modules (`\0`-prefixed ids from plugins).
|
|
||||||
if (id.startsWith('\0')) continue
|
if (id.startsWith('\0')) continue
|
||||||
if (!id.startsWith(PROJECT_ROOT)) continue
|
if (!id.startsWith(PROJECT_ROOT)) continue
|
||||||
|
|
||||||
@ -172,8 +130,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Node 20 dynamic-import path — some environments are pickier than others.
|
void pathToFileURL
|
||||||
void pathToFileURL // retained to silence tree-shakers referencing the import
|
|
||||||
await main()
|
await main()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
||||||
|
|||||||
48
src/Exceptions/BaselineFetchFailed.php
Normal file
48
src/Exceptions/BaselineFetchFailed.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when fetching the team-shared TIA baseline hits an error
|
||||||
|
* that's actionable rather than transient — missing `gh`, broken
|
||||||
|
* auth, scope/perms misconfiguration, or a CI publish that produced
|
||||||
|
* an unreadable artifact. Silently falling through to a full record
|
||||||
|
* would paper over the bug and waste minutes; better to stop, tell
|
||||||
|
* the user what to fix, and offer the `--fresh` escape hatch.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $headline,
|
||||||
|
private readonly string $hint,
|
||||||
|
) {
|
||||||
|
parent::__construct($headline);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(OutputInterface $output): void
|
||||||
|
{
|
||||||
|
$output->writeln([
|
||||||
|
'',
|
||||||
|
' <fg=white;options=bold;bg=red> TIA </> '.$this->headline,
|
||||||
|
' <fg=gray>'.$this->hint.'</>',
|
||||||
|
' <fg=gray>Bypass with</> <fg=cyan>--fresh</> <fg=gray>to record locally and skip the baseline fetch.</>',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exitCode(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -789,7 +789,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
$affected = $changed === [] ? [] : $graph->affected($changed);
|
$affectedFromChanges = $changed === [] ? [] : $graph->affected($changed);
|
||||||
|
$failedFromCache = [];
|
||||||
|
|
||||||
if ($this->filteredMode) {
|
if ($this->filteredMode) {
|
||||||
// `failedOrErroredTestFiles()` only yields failures that have a
|
// `failedOrErroredTestFiles()` only yields failures that have a
|
||||||
@ -798,12 +799,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
// is no longer expected. If one slips through, doing the best
|
// is no longer expected. If one slips through, doing the best
|
||||||
// we can with the located ones is strictly better than bailing
|
// we can with the located ones is strictly better than bailing
|
||||||
// to a full suite.
|
// to a full suite.
|
||||||
$affected = array_values(array_unique([
|
$failedFromCache = $graph->failedOrErroredTestFiles($this->branch);
|
||||||
...$affected,
|
|
||||||
...$graph->failedOrErroredTestFiles($this->branch),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$affected = array_values(array_unique([
|
||||||
|
...$affectedFromChanges,
|
||||||
|
...$failedFromCache,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->reportAffectedSummary($changed, $affectedFromChanges, $failedFromCache, $affected);
|
||||||
|
|
||||||
$affectedSet = array_fill_keys($affected, true);
|
$affectedSet = array_fill_keys($affected, true);
|
||||||
$canRefreshReplayEdges = $affected !== [] && $coverageAvailable;
|
$canRefreshReplayEdges = $affected !== [] && $coverageAvailable;
|
||||||
|
|
||||||
@ -855,6 +860,97 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surfaces what TIA decided to run and why, before the suite
|
||||||
|
* starts. Two pieces a developer wants at a glance:
|
||||||
|
*
|
||||||
|
* 1. *How many* tests are about to run — the deciding factor for
|
||||||
|
* whether they wait for the run or kick off something else.
|
||||||
|
* 2. *Why* — which changed files drove the affected set, and how
|
||||||
|
* many came in via cached failures (filtered mode).
|
||||||
|
*
|
||||||
|
* Stays quiet when nothing is affected: the existing
|
||||||
|
* `NoAffectedTestsFound` panic / recap line covers that path.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $changedFiles
|
||||||
|
* @param array<int, string> $affectedFromChanges
|
||||||
|
* @param array<int, string> $failedFromCache
|
||||||
|
* @param array<int, string> $affected
|
||||||
|
*/
|
||||||
|
private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $failedFromCache, array $affected): void
|
||||||
|
{
|
||||||
|
if ($affected === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failures that overlap with the change-driven set are already
|
||||||
|
// pulled in by edges — don't double-count them as a separate
|
||||||
|
// reason in the breakdown.
|
||||||
|
$newFailures = $failedFromCache === []
|
||||||
|
? 0
|
||||||
|
: count(array_diff($failedFromCache, $affectedFromChanges));
|
||||||
|
|
||||||
|
$reasons = [];
|
||||||
|
$singleReason = (int) ($affectedFromChanges !== []) + (int) ($newFailures > 0) === 1;
|
||||||
|
|
||||||
|
if ($affectedFromChanges !== []) {
|
||||||
|
$reasons[] = $singleReason
|
||||||
|
? sprintf(
|
||||||
|
'from %d changed file%s',
|
||||||
|
count($changedFiles),
|
||||||
|
count($changedFiles) === 1 ? '' : 's',
|
||||||
|
)
|
||||||
|
: sprintf(
|
||||||
|
'%d from %d changed file%s',
|
||||||
|
count($affectedFromChanges),
|
||||||
|
count($changedFiles),
|
||||||
|
count($changedFiles) === 1 ? '' : 's',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newFailures > 0) {
|
||||||
|
$reasons[] = $singleReason
|
||||||
|
? sprintf(
|
||||||
|
'from %d previous failure%s',
|
||||||
|
$newFailures,
|
||||||
|
$newFailures === 1 ? '' : 's',
|
||||||
|
)
|
||||||
|
: sprintf(
|
||||||
|
'%d from previous failure%s',
|
||||||
|
$newFailures,
|
||||||
|
$newFailures === 1 ? '' : 's',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->output->writeln(sprintf(
|
||||||
|
' <fg=cyan>TIA</> %d affected test file%s%s.',
|
||||||
|
count($affected),
|
||||||
|
count($affected) === 1 ? '' : 's',
|
||||||
|
$reasons === [] ? '' : ' ('.implode(', ', $reasons).')',
|
||||||
|
));
|
||||||
|
|
||||||
|
// List the first few affected test files so the developer can see
|
||||||
|
// *which* tests are about to run, not just the count. Capped at 10
|
||||||
|
// to keep the line tight on large impact sets.
|
||||||
|
$previewLimit = 10;
|
||||||
|
$sorted = $affected;
|
||||||
|
sort($sorted);
|
||||||
|
$preview = array_slice($sorted, 0, $previewLimit);
|
||||||
|
|
||||||
|
foreach ($preview as $file) {
|
||||||
|
$this->output->writeln(sprintf(' <fg=gray> • %s</>', $file));
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainder = count($sorted) - count($preview);
|
||||||
|
|
||||||
|
if ($remainder > 0) {
|
||||||
|
$this->output->writeln(sprintf(
|
||||||
|
' <fg=gray> … +%d more</>',
|
||||||
|
$remainder,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, string> $affected Project-relative paths.
|
* @param array<int, string> $affected Project-relative paths.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
|
use Pest\Exceptions\BaselineFetchFailed;
|
||||||
|
use Pest\Panic;
|
||||||
use Pest\Plugins\Tia;
|
use Pest\Plugins\Tia;
|
||||||
use Pest\Plugins\Tia\Contracts\State;
|
use Pest\Plugins\Tia\Contracts\State;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
@ -69,16 +71,19 @@ final readonly class BaselineSync
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->output->writeln(sprintf(
|
$failureKind = null;
|
||||||
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
|
$payload = $this->download($repo, $projectRoot, $failureKind);
|
||||||
$repo,
|
|
||||||
));
|
|
||||||
|
|
||||||
$payload = $this->download($repo, $projectRoot);
|
|
||||||
|
|
||||||
if ($payload === null) {
|
if ($payload === null) {
|
||||||
|
// Genuine "no baseline published yet" → cool down and show
|
||||||
|
// the publish-instructions YAML so the user can wire CI.
|
||||||
|
// Anything else (missing gh, auth, network, mid-download
|
||||||
|
// error) is transient and gets a one-line diagnostic
|
||||||
|
// instead — no cooldown, no noisy YAML.
|
||||||
|
if ($failureKind === 'no-runs' || $failureKind === null) {
|
||||||
$this->startCooldown();
|
$this->startCooldown();
|
||||||
$this->emitPublishInstructions($repo);
|
$this->emitPublishInstructions($repo);
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -299,16 +304,58 @@ YAML;
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{graph: string, coverage: ?string}|null */
|
/**
|
||||||
private function download(string $repo, string $projectRoot): ?array
|
* @param-out string|null $failureKind
|
||||||
|
*
|
||||||
|
* @return array{graph: string, coverage: ?string}|null
|
||||||
|
*/
|
||||||
|
private function download(string $repo, string $projectRoot, ?string &$failureKind = null): ?array
|
||||||
{
|
{
|
||||||
|
$failureKind = null;
|
||||||
|
|
||||||
if (! $this->commandExists('gh')) {
|
if (! $this->commandExists('gh')) {
|
||||||
|
Panic::with(new BaselineFetchFailed(
|
||||||
|
'GitHub CLI (gh) not found — cannot fetch baseline.',
|
||||||
|
'Install it from https://cli.github.com.',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->ghAuthenticated()) {
|
||||||
|
Panic::with(new BaselineFetchFailed(
|
||||||
|
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
|
||||||
|
'Run `gh auth login` and retry.',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
|
||||||
|
|
||||||
|
if ($listError !== null) {
|
||||||
|
$failureKind = $listError['kind'];
|
||||||
|
|
||||||
|
// Tier 1 — actionable misconfiguration. Stop the suite and
|
||||||
|
// tell the user what to fix; a silent fall-through to a
|
||||||
|
// full record would just paper over the bug.
|
||||||
|
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
||||||
|
Panic::with(new BaselineFetchFailed(
|
||||||
|
sprintf('Failed to query baseline runs — %s', $listError['message']),
|
||||||
|
'Check the workflow file name (tia-baseline.yml), artifact name (pest-tia-baseline), and your gh token scope.',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2 — transient (network, rate-limit, unknown). Surface
|
||||||
|
// the diagnostic but let the suite fall through to record mode.
|
||||||
|
$this->output->writeln(sprintf(
|
||||||
|
' <fg=yellow>TIA</> failed to query baseline runs — %s',
|
||||||
|
$listError['message'],
|
||||||
|
));
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$runId = $this->latestSuccessfulRunId($repo);
|
|
||||||
|
|
||||||
if ($runId === null) {
|
if ($runId === null) {
|
||||||
|
// Genuine missing baseline — caller emits publish instructions.
|
||||||
|
$failureKind = 'no-runs';
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -322,6 +369,12 @@ YAML;
|
|||||||
// id as recently used and doesn't evict it later.
|
// id as recently used and doesn't evict it later.
|
||||||
@touch($runCacheDir);
|
@touch($runCacheDir);
|
||||||
|
|
||||||
|
$this->output->writeln(sprintf(
|
||||||
|
' <fg=cyan>TIA</> using cached baseline from <fg=white>%s</> (run %s).',
|
||||||
|
$repo,
|
||||||
|
$runId,
|
||||||
|
));
|
||||||
|
|
||||||
return $this->readArtifact($runCacheDir);
|
return $this->readArtifact($runCacheDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -329,6 +382,19 @@ YAML;
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$artifactSize = $this->artifactSize($repo, $runId);
|
||||||
|
|
||||||
|
$this->output->writeln($artifactSize !== null
|
||||||
|
? sprintf(
|
||||||
|
' <fg=cyan>TIA</> fetching baseline (%s) from <fg=white>%s</>…',
|
||||||
|
$this->formatSize($artifactSize),
|
||||||
|
$repo,
|
||||||
|
)
|
||||||
|
: sprintf(
|
||||||
|
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
|
||||||
|
$repo,
|
||||||
|
));
|
||||||
|
|
||||||
$process = new Process([
|
$process = new Process([
|
||||||
'gh', 'run', 'download', $runId,
|
'gh', 'run', 'download', $runId,
|
||||||
'-R', $repo,
|
'-R', $repo,
|
||||||
@ -336,11 +402,38 @@ YAML;
|
|||||||
'-D', $runCacheDir,
|
'-D', $runCacheDir,
|
||||||
]);
|
]);
|
||||||
$process->setTimeout(900.0);
|
$process->setTimeout(900.0);
|
||||||
$process->run();
|
$process->start();
|
||||||
|
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
|
||||||
|
while ($process->isRunning()) {
|
||||||
|
$this->renderDownloadProgress($runCacheDir, $artifactSize, $startedAt);
|
||||||
|
usleep(250_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$process->wait();
|
||||||
|
$this->clearProgressLine();
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if (! $process->isSuccessful()) {
|
||||||
$this->cleanup($runCacheDir);
|
$this->cleanup($runCacheDir);
|
||||||
|
|
||||||
|
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
|
||||||
|
$failureKind = $diagnosis['kind'];
|
||||||
|
|
||||||
|
// Tier 1 — actionable. Stop hard with a clear diagnostic.
|
||||||
|
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
||||||
|
Panic::with(new BaselineFetchFailed(
|
||||||
|
sprintf('Baseline download failed — %s', $diagnosis['message']),
|
||||||
|
'Check the workflow file name (tia-baseline.yml), artifact name (pest-tia-baseline), and your gh token scope.',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2 — transient. Diagnostic + fall through to record mode.
|
||||||
|
$this->output->writeln(sprintf(
|
||||||
|
' <fg=yellow>TIA</> baseline download failed — %s',
|
||||||
|
$diagnosis['message'],
|
||||||
|
));
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -349,7 +442,13 @@ YAML;
|
|||||||
if ($payload === null) {
|
if ($payload === null) {
|
||||||
$this->cleanup($runCacheDir);
|
$this->cleanup($runCacheDir);
|
||||||
|
|
||||||
return null;
|
// Artifact present but malformed — CI's publish step is
|
||||||
|
// broken. Falling through would silently waste the next
|
||||||
|
// run; surface the bug instead.
|
||||||
|
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.',
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->trimDownloadCache($projectRoot);
|
$this->trimDownloadCache($projectRoot);
|
||||||
@ -357,6 +456,93 @@ YAML;
|
|||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up the artifact's compressed size so the progress bar has a
|
||||||
|
* denominator. Returns null on any failure — callers fall back to a
|
||||||
|
* size-less spinner.
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
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) {
|
||||||
|
// gh extracts as it downloads, so disk size can briefly exceed
|
||||||
|
// the compressed `size_in_bytes` for multi-file artifacts. Cap
|
||||||
|
// the percentage at 99% until the process actually exits — the
|
||||||
|
// cleared line + completion message take care of the final
|
||||||
|
// "100%" message naturally.
|
||||||
|
$percent = min(99, (int) floor(($current / $totalBytes) * 100));
|
||||||
|
$message = sprintf(
|
||||||
|
' <fg=cyan>TIA</> downloading %s / %s (%d%%, %s/s)',
|
||||||
|
$this->formatSize($current),
|
||||||
|
$this->formatSize($totalBytes),
|
||||||
|
$percent,
|
||||||
|
$this->formatSize($speed),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$message = sprintf(
|
||||||
|
' <fg=cyan>TIA</> downloading %s (%s/s)',
|
||||||
|
$this->formatSize($current),
|
||||||
|
$this->formatSize($speed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// \r returns to start of line, \033[K erases from cursor to end —
|
||||||
|
// safe regardless of message length, no ANSI-aware padding needed.
|
||||||
|
$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}|null
|
* @return array{graph: string, coverage: ?string}|null
|
||||||
*/
|
*/
|
||||||
@ -448,7 +634,16 @@ YAML;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function latestSuccessfulRunId(string $repo): ?string
|
/**
|
||||||
|
* Returns `[runId|null, errorOrNull]`. Distinguishes "no runs yet"
|
||||||
|
* (runId null, error null) from "couldn't ask GitHub" (error
|
||||||
|
* populated with kind + message). Lets the caller pick between
|
||||||
|
* showing publish instructions and emitting a transient-failure
|
||||||
|
* diagnostic.
|
||||||
|
*
|
||||||
|
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
|
||||||
|
*/
|
||||||
|
private function latestSuccessfulRunIdWithError(string $repo): array
|
||||||
{
|
{
|
||||||
$process = new Process([
|
$process = new Process([
|
||||||
'gh', 'run', 'list',
|
'gh', 'run', 'list',
|
||||||
@ -463,12 +658,79 @@ YAML;
|
|||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if (! $process->isSuccessful()) {
|
||||||
return null;
|
return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())];
|
||||||
}
|
}
|
||||||
|
|
||||||
$runId = trim($process->getOutput());
|
$runId = trim($process->getOutput());
|
||||||
|
|
||||||
return $runId === '' ? null : $runId;
|
return [$runId === '' ? null : $runId, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ghAuthenticated(): bool
|
||||||
|
{
|
||||||
|
$process = new Process(['gh', 'auth', 'status']);
|
||||||
|
$process->setTimeout(10.0);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
return $process->isSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a chunk of `gh` stderr/stdout to a coarse kind + a short,
|
||||||
|
* actionable message. Falls back to the first non-empty line of
|
||||||
|
* the output so even unrecognised errors aren't reduced to "unknown".
|
||||||
|
*
|
||||||
|
* @return array{kind: string, message: string}
|
||||||
|
*/
|
||||||
|
private function classifyGhError(string $output): array
|
||||||
|
{
|
||||||
|
$output = trim($output);
|
||||||
|
|
||||||
|
if ($output === '') {
|
||||||
|
return ['kind' => 'unknown', 'message' => 'unknown error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/(could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo)/i', $output) === 1) {
|
||||||
|
return [
|
||||||
|
'kind' => 'network',
|
||||||
|
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/(authentication failed|not logged in|requires authentication|bad credentials|401)/i', $output) === 1) {
|
||||||
|
return [
|
||||||
|
'kind' => 'gh-auth',
|
||||||
|
'message' => 'authentication failed — run `gh auth login` and retry.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/(rate limit|too many requests|secondary rate limit)/i', $output) === 1) {
|
||||||
|
return [
|
||||||
|
'kind' => 'rate-limit',
|
||||||
|
'message' => 'GitHub API rate limit hit — try again later.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/(404|not found|repository not found)/i', $output) === 1) {
|
||||||
|
return [
|
||||||
|
'kind' => 'not-found',
|
||||||
|
'message' => 'workflow or artifact not found in repo.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/(403|forbidden|access denied)/i', $output) === 1) {
|
||||||
|
return [
|
||||||
|
'kind' => 'forbidden',
|
||||||
|
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown — surface the first informative line so the user has
|
||||||
|
// *something* to act on.
|
||||||
|
$first = strtok($output, "\n");
|
||||||
|
$message = is_string($first) ? trim($first) : 'unknown error';
|
||||||
|
|
||||||
|
return ['kind' => 'unknown', 'message' => $message];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function commandExists(string $cmd): bool
|
private function commandExists(string $cmd): bool
|
||||||
|
|||||||
@ -61,6 +61,8 @@ final class Recorder
|
|||||||
|
|
||||||
private string $driver = 'none';
|
private string $driver = 'none';
|
||||||
|
|
||||||
|
private ?SourceScope $sourceScope = null;
|
||||||
|
|
||||||
public function activate(): void
|
public function activate(): void
|
||||||
{
|
{
|
||||||
$this->active = true;
|
$this->active = true;
|
||||||
@ -148,19 +150,28 @@ final class Recorder
|
|||||||
|
|
||||||
if ($this->driver === 'pcov') {
|
if ($this->driver === 'pcov') {
|
||||||
\pcov\stop();
|
\pcov\stop();
|
||||||
/** @var array<string, mixed> $data */
|
|
||||||
$data = \pcov\collect(\pcov\all);
|
|
||||||
|
|
||||||
// pcov returns every executable line in every file it
|
// pcov\waiting() lists every file pcov has tracked but not
|
||||||
// tracked: positive values for executed lines, `-1` for
|
// yet collected for. Filter that list down to the project's
|
||||||
// executable-but-not-run. A file with no positives was
|
// source scope (phpunit.xml's `<source>` plus other
|
||||||
// loaded but nothing in it ran during this test's window
|
// top-level project dirs, minus vendor / caches), then ask
|
||||||
// — typically a declaration-only file (Mailables, Enums,
|
// pcov to collect *only* for those — `pcov\inclusive`
|
||||||
// DTOs) pulled in by some service-provider's static `use`
|
// narrows the result set at the driver level instead of us
|
||||||
// at framework boot. Including those attributes every
|
// post-filtering after a full collect. Anything pcov saw
|
||||||
// globally-bootstrapped class to whichever test triggered
|
// outside the scope is dropped before any line counts come
|
||||||
// the boot, blowing up the affected set on edits to those
|
// back.
|
||||||
// files.
|
$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 = self::filesWithExecutedLines($data);
|
$coveredFiles = self::filesWithExecutedLines($data);
|
||||||
} else {
|
} else {
|
||||||
/** @var array<string, mixed> $data */
|
/** @var array<string, mixed> $data */
|
||||||
@ -656,11 +667,16 @@ final class Recorder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters pcov's `file => line => executionCount` map to the files
|
* Filters pcov's `file => line => executionCount` map to files that
|
||||||
* that actually had at least one executed line. pcov reports `-1`
|
* actually had executed code AND live inside the configured source
|
||||||
* for "executable but not run" and a positive count for executed
|
* scope (`phpunit.xml`'s `<source>` block, or the project root with
|
||||||
* lines; a file with no positives was loaded but contributed no
|
* vendor/etc. excluded as fallback).
|
||||||
* executed code to this test.
|
*
|
||||||
|
* pcov reports `-1` for "executable but not run" and a positive
|
||||||
|
* count for executed lines. We also skip files where the *only*
|
||||||
|
* positive line is the implicit `ZEND_RETURN` at end-of-file: pcov
|
||||||
|
* surfaces that as a one-line artifact for files that were merely
|
||||||
|
* included (autoloaded) without any real code running.
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
@ -674,18 +690,35 @@ final class Recorder
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($lines as $count) {
|
$covered = [];
|
||||||
|
foreach ($lines as $line => $count) {
|
||||||
if (is_int($count) && $count > 0) {
|
if (is_int($count) && $count > 0) {
|
||||||
$out[] = $file;
|
$covered[] = $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
continue 2;
|
if ($covered === []) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip files where the only "executed" line is the implicit
|
||||||
|
// ZEND_RETURN at end-of-file (pcov artifact from being included
|
||||||
|
// but never actually run).
|
||||||
|
if (count($covered) === 1 && max($covered) === max(array_keys($lines))) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$out[] = $file;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function sourceScope(): SourceScope
|
||||||
|
{
|
||||||
|
return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath);
|
||||||
|
}
|
||||||
|
|
||||||
public function reset(): void
|
public function reset(): void
|
||||||
{
|
{
|
||||||
$this->currentTestFile = null;
|
$this->currentTestFile = null;
|
||||||
@ -698,6 +731,7 @@ final class Recorder
|
|||||||
$this->fileToClassNames = [];
|
$this->fileToClassNames = [];
|
||||||
$this->indexedClassNames = [];
|
$this->indexedClassNames = [];
|
||||||
$this->classDependencyCache = [];
|
$this->classDependencyCache = [];
|
||||||
|
$this->sourceScope = null;
|
||||||
$this->active = false;
|
$this->active = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
273
src/Plugins/Tia/SourceScope.php
Normal file
273
src/Plugins/Tia/SourceScope.php
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scopes coverage collection to project source — the directories
|
||||||
|
* declared in `phpunit.xml`'s `<source>` config plus any other
|
||||||
|
* top-level project directories that aren't on a hard-coded noise
|
||||||
|
* list (vendor, caches, IDE/git metadata).
|
||||||
|
*
|
||||||
|
* Used by `Recorder` as the per-test filter passed to
|
||||||
|
* `\pcov\collect(\pcov\inclusive, …)` — pcov tracks every file PHP
|
||||||
|
* loads, but we only ask for coverage on files inside the project
|
||||||
|
* source scope, so anything outside (vendor / caches / etc.) is
|
||||||
|
* dropped before any line counts come back.
|
||||||
|
*
|
||||||
|
* Falls back to "every top-level project dir minus the noise list"
|
||||||
|
* when no `phpunit.xml` / `phpunit.xml.dist` is present or it has no
|
||||||
|
* `<source>` block — Pest projects without explicit phpunit config
|
||||||
|
* still get sensible scoping.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class SourceScope
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Top-level directory names always treated as out-of-scope. These
|
||||||
|
* mirror what a Laravel app considers "not source": dependencies,
|
||||||
|
* editor metadata, framework artefacts, the TIA state itself.
|
||||||
|
*/
|
||||||
|
private const array TOP_LEVEL_NOISE = [
|
||||||
|
'vendor',
|
||||||
|
'node_modules',
|
||||||
|
'.git',
|
||||||
|
'.idea',
|
||||||
|
'.vscode',
|
||||||
|
'.github',
|
||||||
|
'.pest',
|
||||||
|
'.phpunit.cache',
|
||||||
|
'.cache',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nested paths (relative to project root) that must be excluded
|
||||||
|
* even when their top-level parent is in scope. Laravel writes
|
||||||
|
* compiled views, route caches, and packaged manifests here on
|
||||||
|
* every framework boot — instrumenting them would burn cycles
|
||||||
|
* and create noisy edges.
|
||||||
|
*/
|
||||||
|
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 string $projectRoot,
|
||||||
|
private readonly array $includes,
|
||||||
|
private readonly array $excludes,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromProjectRoot(string $projectRoot): self
|
||||||
|
{
|
||||||
|
$configPath = self::configPath($projectRoot);
|
||||||
|
|
||||||
|
$phpunitIncludes = [];
|
||||||
|
$phpunitExcludes = [];
|
||||||
|
|
||||||
|
if ($configPath !== null) {
|
||||||
|
$xml = @simplexml_load_file($configPath);
|
||||||
|
|
||||||
|
if ($xml !== false) {
|
||||||
|
$configDir = dirname($configPath);
|
||||||
|
$phpunitIncludes = self::extractDirectories($xml, 'source/include/directory', $configDir);
|
||||||
|
$phpunitExcludes = self::extractDirectories($xml, 'source/exclude/directory', $configDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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($projectRoot, $includes, $excludes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the absolute file path is inside an `<include>`
|
||||||
|
* directory and not under any exclude. Symlinks are resolved on
|
||||||
|
* the input so a `realpath()`'d coverage entry still matches a
|
||||||
|
* config that pointed at the unresolved tree.
|
||||||
|
*/
|
||||||
|
public function contains(string $absoluteFile): bool
|
||||||
|
{
|
||||||
|
$real = @realpath($absoluteFile);
|
||||||
|
$candidate = $real === false ? $absoluteFile : $real;
|
||||||
|
$candidate = self::normalise($candidate);
|
||||||
|
|
||||||
|
foreach ($this->excludes as $excluded) {
|
||||||
|
if (self::startsWithDir($candidate, $excluded)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->includes as $included) {
|
||||||
|
if (self::startsWithDir($candidate, $included)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project-relative directories the resolver considers in scope.
|
||||||
|
* Useful for setting `pcov.directory` (a single common ancestor)
|
||||||
|
* or `\pcov\collect()`'s file filter.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function includes(): array
|
||||||
|
{
|
||||||
|
return $this->includes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function configPath(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) {
|
||||||
|
$candidate = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
||||||
|
|
||||||
|
if (is_file($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function extractDirectories(\SimpleXMLElement $xml, string $xpath, string $configDir): array
|
||||||
|
{
|
||||||
|
$nodes = $xml->xpath($xpath);
|
||||||
|
|
||||||
|
if (! is_array($nodes)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($nodes as $node) {
|
||||||
|
$value = trim((string) $node);
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$absolute = self::resolveRelative($value, $configDir);
|
||||||
|
|
||||||
|
if ($absolute === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[] = $absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($out));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every top-level directory under `$projectRoot` except those on
|
||||||
|
* the noise list. Hidden entries (dotdirs) are skipped unless
|
||||||
|
* they're explicitly project source — keeping `.git/`, `.idea/`
|
||||||
|
* etc. out without an explicit allowlist.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function topLevelProjectDirs(string $projectRoot): array
|
||||||
|
{
|
||||||
|
$entries = @scandir($projectRoot);
|
||||||
|
|
||||||
|
if ($entries === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $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 resolveRelative(string $path, string $configDir): ?string
|
||||||
|
{
|
||||||
|
$isAbsolute = $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || $path[0] === '/'
|
||||||
|
|| (strlen($path) >= 2 && $path[1] === ':'));
|
||||||
|
|
||||||
|
$combined = $isAbsolute ? $path : $configDir.DIRECTORY_SEPARATOR.$path;
|
||||||
|
|
||||||
|
$real = @realpath($combined);
|
||||||
|
|
||||||
|
if ($real === false) {
|
||||||
|
// Directory may not exist yet (e.g. generated source) — keep
|
||||||
|
// the unresolved path so a future file under it still matches.
|
||||||
|
return self::normalise($combined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::normalise($real);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalise(string $path): string
|
||||||
|
{
|
||||||
|
return rtrim($path, '/\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function startsWithDir(string $candidate, string $dir): bool
|
||||||
|
{
|
||||||
|
if ($candidate === $dir) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_starts_with($candidate, $dir.DIRECTORY_SEPARATOR);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/Support/PcovGuard.php
Normal file
182
src/Support/PcovGuard.php
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-execs the PHP process with `pcov.directory` pinned to the project
|
||||||
|
* root so pcov never instruments anything outside it (vendor, system
|
||||||
|
* includes, etc.).
|
||||||
|
*
|
||||||
|
* pcov reads `pcov.directory` once, on the first file it instruments —
|
||||||
|
* setting it via `ini_set()` from inside the test runner is too late
|
||||||
|
* for files already compiled by Composer's autoloader. Restarting the
|
||||||
|
* process with `-dpcov.directory=<root>` from the very top of `bin/pest`
|
||||||
|
* means *every* file pcov sees is filtered correctly.
|
||||||
|
*
|
||||||
|
* Only fires when ALL of these hold:
|
||||||
|
* 1. The pcov extension is loaded.
|
||||||
|
* 2. `--tia` is present in argv (plain `pest` runs are unaffected).
|
||||||
|
* 3. The current `pcov.directory` differs from the project root.
|
||||||
|
* 4. We are not already the restarted process — guarded by an env
|
||||||
|
* sentinel so a single round-trip is enough.
|
||||||
|
*
|
||||||
|
* Modelled after {@see XdebugGuard}: the same "check before doing real
|
||||||
|
* work in `bin/pest`" position, the same conservative gating around
|
||||||
|
* `--tia`. They are independent — both can fire on the same invocation
|
||||||
|
* (the user has pcov *and* xdebug loaded), in which case Xdebug is
|
||||||
|
* dropped first and the pcov restart inherits the slimmer process.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class PcovGuard
|
||||||
|
{
|
||||||
|
private const string ENV_RESTARTED = 'PEST_PCOV_GUARD_RESTARTED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call as early as possible after Composer autoload, before any
|
||||||
|
* Pest class beyond the autoloader is touched. Idempotent and
|
||||||
|
* defensive — returns silently when pcov isn't installed, when the
|
||||||
|
* INI is already correct, or when we've already restarted.
|
||||||
|
*/
|
||||||
|
public static function maybeRestart(string $projectRoot): void
|
||||||
|
{
|
||||||
|
if (! extension_loaded('pcov')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getenv(self::ENV_RESTARTED) === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$argv = is_array($_SERVER['argv'] ?? null) ? $_SERVER['argv'] : [];
|
||||||
|
|
||||||
|
if (! self::hasTiaFlag($argv)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$desired = self::normalise($projectRoot);
|
||||||
|
$current = self::normalise((string) ini_get('pcov.directory'));
|
||||||
|
|
||||||
|
if ($current === $desired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::restart($projectRoot, $argv);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $argv
|
||||||
|
*/
|
||||||
|
private static function hasTiaFlag(array $argv): bool
|
||||||
|
{
|
||||||
|
foreach ($argv as $value) {
|
||||||
|
if (is_string($value) && $value === '--tia') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns a child PHP process inheriting our stdin/stdout/stderr and
|
||||||
|
* exits with its status. `pcntl_exec` would be the cleanest path
|
||||||
|
* (replaces the current process, no double-buffering) but it isn't
|
||||||
|
* available on Windows or in environments that disable it; the
|
||||||
|
* `proc_open` fallback works everywhere PHP runs.
|
||||||
|
*
|
||||||
|
* @param array<int, mixed> $argv
|
||||||
|
*/
|
||||||
|
private static function restart(string $projectRoot, array $argv): void
|
||||||
|
{
|
||||||
|
$script = self::scriptArgv($argv);
|
||||||
|
|
||||||
|
if ($script === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$env = self::inheritEnv();
|
||||||
|
$env[self::ENV_RESTARTED] = '1';
|
||||||
|
|
||||||
|
$command = array_merge(
|
||||||
|
[PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
|
||||||
|
$script,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (function_exists('pcntl_exec')) {
|
||||||
|
// `pcntl_exec` returns false on failure and replaces the
|
||||||
|
// process on success — no `exit` needed in the success path.
|
||||||
|
// Pass the env explicitly because pcntl_exec doesn't inherit
|
||||||
|
// by default.
|
||||||
|
$binary = array_shift($command);
|
||||||
|
|
||||||
|
if (is_string($binary)) {
|
||||||
|
@pcntl_exec($binary, $command, $env);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're still here, pcntl_exec failed; fall through.
|
||||||
|
}
|
||||||
|
|
||||||
|
$proc = @proc_open(
|
||||||
|
$command,
|
||||||
|
[STDIN, STDOUT, STDERR],
|
||||||
|
$pipes,
|
||||||
|
null,
|
||||||
|
$env,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! is_resource($proc)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exitCode = proc_close($proc);
|
||||||
|
|
||||||
|
exit($exitCode === -1 ? 1 : $exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstructs the argv we want the child process to receive: the
|
||||||
|
* script path followed by every original argument. Returns null
|
||||||
|
* when argv is malformed and we can't safely restart.
|
||||||
|
*
|
||||||
|
* @param array<int, mixed> $argv
|
||||||
|
* @return list<string>|null
|
||||||
|
*/
|
||||||
|
private static function scriptArgv(array $argv): ?array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($argv as $value) {
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out === [] ? null : $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private static function inheritEnv(): array
|
||||||
|
{
|
||||||
|
$env = [];
|
||||||
|
|
||||||
|
foreach (getenv() as $name => $value) {
|
||||||
|
if (is_string($name) && is_string($value)) {
|
||||||
|
$env[$name] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $env;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalise(string $path): string
|
||||||
|
{
|
||||||
|
return rtrim($path, '/\\');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Pest\TestsTia\Support\Sandbox;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Mutating a source file should narrow replay to the tests that depend
|
|
||||||
* on it. Untouched areas of the suite keep cache-hitting.
|
|
||||||
*/
|
|
||||||
|
|
||||||
test('editing a source file marks only its dependents as affected', function () {
|
|
||||||
tiaScenario(function (Sandbox $sandbox) {
|
|
||||||
$sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
$sandbox->write('src/Math.php', <<<'PHP'
|
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App;
|
|
||||||
|
|
||||||
final class Math
|
|
||||||
{
|
|
||||||
public static function add(int $a, int $b): int
|
|
||||||
{
|
|
||||||
return $a + $b;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function sub(int $a, int $b): int
|
|
||||||
{
|
|
||||||
return $a - $b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PHP);
|
|
||||||
|
|
||||||
$process = $sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
|
||||||
expect(tiaOutput($process))->toMatch('/2 affected,\s*1 replayed/');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('adding a new test file runs the new test + replays the rest', function () {
|
|
||||||
tiaScenario(function (Sandbox $sandbox) {
|
|
||||||
$sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
$sandbox->write('tests/ExtraTest.php', <<<'PHP'
|
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
test('extra smoke', function () {
|
|
||||||
expect(true)->toBeTrue();
|
|
||||||
});
|
|
||||||
PHP);
|
|
||||||
|
|
||||||
$process = $sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
|
||||||
expect(tiaOutput($process))->toMatch('/1 affected,\s*3 replayed/');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Pest\TestsTia\Support\Sandbox;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Fingerprint splits into structural vs environmental. Hand-forge each
|
|
||||||
* drift flavour on a valid graph and assert the right branch fires.
|
|
||||||
*/
|
|
||||||
|
|
||||||
test('structural drift discards the graph entirely', function () {
|
|
||||||
tiaScenario(function (Sandbox $sandbox) {
|
|
||||||
$sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
$graphPath = $sandbox->path().'/.pest/tia/graph.json';
|
|
||||||
$graph = json_decode((string) file_get_contents($graphPath), true);
|
|
||||||
$graph['fingerprint']['structural']['composer_lock'] = str_repeat('0', 32);
|
|
||||||
file_put_contents($graphPath, json_encode($graph));
|
|
||||||
|
|
||||||
$process = $sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
|
||||||
expect(tiaOutput($process))->toContain('graph structure outdated');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('environmental drift keeps edges, drops results', function () {
|
|
||||||
tiaScenario(function (Sandbox $sandbox) {
|
|
||||||
$sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
$graphPath = $sandbox->path().'/.pest/tia/graph.json';
|
|
||||||
$graph = json_decode((string) file_get_contents($graphPath), true);
|
|
||||||
|
|
||||||
$edgeCountBefore = count($graph['edges']);
|
|
||||||
|
|
||||||
$graph['fingerprint']['environmental']['php_minor'] = '7.4';
|
|
||||||
$graph['fingerprint']['environmental']['extensions'] = str_repeat('0', 32);
|
|
||||||
file_put_contents($graphPath, json_encode($graph));
|
|
||||||
|
|
||||||
$process = $sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
|
||||||
expect(tiaOutput($process))->toContain('env differs from baseline');
|
|
||||||
expect(tiaOutput($process))->toContain('results dropped, edges reused');
|
|
||||||
|
|
||||||
$graphAfter = $sandbox->graph();
|
|
||||||
expect(count($graphAfter['edges']))->toBe($edgeCountBefore);
|
|
||||||
expect($graphAfter['fingerprint']['environmental']['php_minor'])
|
|
||||||
->not()->toBe('7.4');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "pest/tia-sample-project",
|
|
||||||
"type": "project",
|
|
||||||
"description": "Throw-away fixture used by tests-tia to exercise TIA end-to-end.",
|
|
||||||
"require": {
|
|
||||||
"php": "^8.3"
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"sort-packages": true,
|
|
||||||
"allow-plugins": {
|
|
||||||
"pestphp/pest-plugin": true,
|
|
||||||
"php-http/discovery": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"minimum-stability": "dev",
|
|
||||||
"prefer-stable": true
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
|
||||||
bootstrap="vendor/autoload.php"
|
|
||||||
colors="true"
|
|
||||||
cacheDirectory=".phpunit.cache"
|
|
||||||
executionOrder="depends,defects"
|
|
||||||
failOnRisky="false"
|
|
||||||
failOnWarning="false"
|
|
||||||
displayDetailsOnTestsThatTriggerWarnings="true"
|
|
||||||
displayDetailsOnTestsThatTriggerNotices="true">
|
|
||||||
<testsuites>
|
|
||||||
<testsuite name="default">
|
|
||||||
<directory>tests</directory>
|
|
||||||
</testsuite>
|
|
||||||
</testsuites>
|
|
||||||
<source>
|
|
||||||
<include>
|
|
||||||
<directory>src</directory>
|
|
||||||
</include>
|
|
||||||
</source>
|
|
||||||
</phpunit>
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App;
|
|
||||||
|
|
||||||
final class Greeter
|
|
||||||
{
|
|
||||||
public static function greet(string $name): string
|
|
||||||
{
|
|
||||||
return sprintf('Hello, %s!', $name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App;
|
|
||||||
|
|
||||||
final class Math
|
|
||||||
{
|
|
||||||
public static function add(int $a, int $b): int
|
|
||||||
{
|
|
||||||
return $a + $b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Greeter;
|
|
||||||
|
|
||||||
test('greeter greets', function () {
|
|
||||||
expect(Greeter::greet('Nuno'))->toBe('Hello, Nuno!');
|
|
||||||
});
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Math;
|
|
||||||
|
|
||||||
test('math add', function () {
|
|
||||||
expect(Math::add(2, 3))->toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('math add negative', function () {
|
|
||||||
expect(Math::add(-1, 1))->toBe(0);
|
|
||||||
});
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// Intentionally minimal — tests-tia exercises TIA against the simplest
|
|
||||||
// possible Pest harness. Anything more and we end up debugging the
|
|
||||||
// fixture instead of the feature under test.
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Pest\TestsTia\Support\Sandbox;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* `--tia --fresh` short-circuits whatever graph is on disk and records
|
|
||||||
* from scratch. Used when the user knows the cache is wrong.
|
|
||||||
*/
|
|
||||||
|
|
||||||
test('--tia --fresh forces record mode even with a valid graph', function () {
|
|
||||||
tiaScenario(function (Sandbox $sandbox) {
|
|
||||||
$sandbox->pest(['--tia']);
|
|
||||||
expect($sandbox->hasGraph())->toBeTrue();
|
|
||||||
|
|
||||||
$graphBefore = $sandbox->graph();
|
|
||||||
|
|
||||||
$process = $sandbox->pest(['--tia', '--fresh']);
|
|
||||||
|
|
||||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
|
||||||
expect(tiaOutput($process))->toContain('recording dependency graph');
|
|
||||||
|
|
||||||
$graphAfter = $sandbox->graph();
|
|
||||||
expect(array_keys($graphAfter['edges']))
|
|
||||||
->toEqualCanonicalizing(array_keys($graphBefore['edges']));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Pest\TestsTia\Support\Sandbox;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The canonical cycle:
|
|
||||||
* 1. Cold `--tia` run → record mode → graph written, tests pass.
|
|
||||||
* 2. Subsequent `--tia` runs → replay mode, every test cache-hits.
|
|
||||||
*/
|
|
||||||
|
|
||||||
test('cold run records the graph', function () {
|
|
||||||
tiaScenario(function (Sandbox $sandbox) {
|
|
||||||
$process = $sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
|
||||||
expect(tiaOutput($process))->toContain('recording dependency graph');
|
|
||||||
expect($sandbox->hasGraph())->toBeTrue();
|
|
||||||
|
|
||||||
$graph = $sandbox->graph();
|
|
||||||
expect($graph)->toHaveKey('edges');
|
|
||||||
expect(array_keys($graph['edges']))->toContain('tests/MathTest.php');
|
|
||||||
expect(array_keys($graph['edges']))->toContain('tests/GreeterTest.php');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('warm run replays every test', function () {
|
|
||||||
tiaScenario(function (Sandbox $sandbox) {
|
|
||||||
// Cold pass: records edges AND snapshots results (series mode
|
|
||||||
// runs `snapshotTestResults` in the same `addOutput` pass).
|
|
||||||
$sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
$process = $sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
|
||||||
// Zero changes → only the `replayed` fragment appears in the
|
|
||||||
// recap; the `affected` fragment is omitted when count is 0.
|
|
||||||
expect(tiaOutput($process))->toMatch('/3 replayed/');
|
|
||||||
expect(tiaOutput($process))->not()->toMatch('/\d+ affected/');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Pest\TestsTia\Support\Sandbox;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Edit a source file, run TIA (tests re-run), revert to the original
|
|
||||||
* bytes, run again — the revert is itself a change vs the previous
|
|
||||||
* snapshot, so the affected tests re-execute rather than replaying the
|
|
||||||
* stale bad-version cache.
|
|
||||||
*/
|
|
||||||
|
|
||||||
test('reverting a modified file re-triggers its affected tests', function () {
|
|
||||||
tiaScenario(function (Sandbox $sandbox) {
|
|
||||||
$sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
$original = (string) file_get_contents($sandbox->path().'/src/Math.php');
|
|
||||||
|
|
||||||
$sandbox->write('src/Math.php', <<<'PHP'
|
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App;
|
|
||||||
|
|
||||||
final class Math
|
|
||||||
{
|
|
||||||
public static function add(int $a, int $b): int
|
|
||||||
{
|
|
||||||
return 999; // broken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PHP);
|
|
||||||
|
|
||||||
$broken = $sandbox->pest(['--tia']);
|
|
||||||
expect($broken->isSuccessful())->toBeFalse();
|
|
||||||
|
|
||||||
$sandbox->write('src/Math.php', $original);
|
|
||||||
|
|
||||||
$recovered = $sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
expect($recovered->isSuccessful())->toBeTrue(tiaOutput($recovered));
|
|
||||||
expect(tiaOutput($recovered))->toMatch('/2 affected,\s*1 replayed/');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Pest\TestsTia\Support\Sandbox;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Cached statuses + assertion counts should survive replay.
|
|
||||||
*/
|
|
||||||
|
|
||||||
test('assertion counts survive replay', function () {
|
|
||||||
tiaScenario(function (Sandbox $sandbox) {
|
|
||||||
$sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
$process = $sandbox->pest(['--tia']);
|
|
||||||
$output = tiaOutput($process);
|
|
||||||
|
|
||||||
// MathTest has 2 assertions, GreeterTest has 1 → 3 total.
|
|
||||||
// The "Tests: … (N assertions, … replayed)" banner should show 3.
|
|
||||||
expect($output)->toMatch('/\(3 assertions/');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('breaking a test replays as a failure on the next run', function () {
|
|
||||||
tiaScenario(function (Sandbox $sandbox) {
|
|
||||||
// Prime.
|
|
||||||
$sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
// Break the test. Its test file's edge map still points at
|
|
||||||
// `src/Math.php`; editing the test file counts as a change
|
|
||||||
// and the test re-executes.
|
|
||||||
$sandbox->write('tests/MathTest.php', <<<'PHP'
|
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Math;
|
|
||||||
|
|
||||||
test('math add', function () {
|
|
||||||
expect(Math::add(2, 3))->toBe(999); // wrong
|
|
||||||
});
|
|
||||||
|
|
||||||
test('math add negative', function () {
|
|
||||||
expect(Math::add(-1, 1))->toBe(0);
|
|
||||||
});
|
|
||||||
PHP);
|
|
||||||
|
|
||||||
$process = $sandbox->pest(['--tia']);
|
|
||||||
|
|
||||||
expect($process->isSuccessful())->toBeFalse();
|
|
||||||
expect(tiaOutput($process))->toContain('math add');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,447 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\TestsTia\Support;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Throw-away sandbox for a TIA end-to-end scenario.
|
|
||||||
*
|
|
||||||
* On first call in a test run, a shared "template" sandbox is created
|
|
||||||
* under the system temp dir and composer-installed against the host
|
|
||||||
* Pest source. Subsequent `::create()` calls clone the template — cheap
|
|
||||||
* (rcopy + git init) vs. running composer install per test.
|
|
||||||
*
|
|
||||||
* Each test owns its own clone; no cross-test state.
|
|
||||||
*
|
|
||||||
* Set `PEST_TIA_KEEP=1` to skip teardown so a failing scenario can be
|
|
||||||
* reproduced manually — the path is emitted to STDERR.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class Sandbox
|
|
||||||
{
|
|
||||||
private static ?string $templatePath = null;
|
|
||||||
|
|
||||||
private function __construct(private readonly string $path) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Eagerly provision the shared template. Call once from the harness
|
|
||||||
* bootstrap so parallel workers don't race on first `create()`.
|
|
||||||
*/
|
|
||||||
public static function warmTemplate(): void
|
|
||||||
{
|
|
||||||
self::ensureTemplate();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function create(): self
|
|
||||||
{
|
|
||||||
$template = self::ensureTemplate();
|
|
||||||
|
|
||||||
$path = sys_get_temp_dir()
|
|
||||||
.DIRECTORY_SEPARATOR
|
|
||||||
.'pest-tia-sandbox-'
|
|
||||||
.bin2hex(random_bytes(4));
|
|
||||||
|
|
||||||
self::rcopy($template, $path);
|
|
||||||
self::bootstrapGit($path);
|
|
||||||
|
|
||||||
return new self($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function path(): string
|
|
||||||
{
|
|
||||||
return $this->path;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function write(string $relative, string $content): void
|
|
||||||
{
|
|
||||||
$absolute = $this->path.DIRECTORY_SEPARATOR.$relative;
|
|
||||||
$dir = dirname($absolute);
|
|
||||||
|
|
||||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
|
||||||
throw new RuntimeException("Cannot create {$dir}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (@file_put_contents($absolute, $content) === false) {
|
|
||||||
throw new RuntimeException("Cannot write {$absolute}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(string $relative): void
|
|
||||||
{
|
|
||||||
$absolute = $this->path.DIRECTORY_SEPARATOR.$relative;
|
|
||||||
|
|
||||||
if (is_file($absolute)) {
|
|
||||||
@unlink($absolute);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $flags
|
|
||||||
*/
|
|
||||||
public function pest(array $flags = []): Process
|
|
||||||
{
|
|
||||||
// Invoke Pest's bin script through PHP directly rather than the
|
|
||||||
// `vendor/bin/pest` symlink — `rcopy()` loses the `+x` bit when
|
|
||||||
// cloning the template. Going through `php` bypasses the exec
|
|
||||||
// check. Use `PHP_BINARY` (not a bare `php`) so the sandbox
|
|
||||||
// executes under the same interpreter that launched the outer
|
|
||||||
// test suite — otherwise macOS multi-version setups (Herd, brew,
|
|
||||||
// asdf, …) fall back to the first `php` on `$PATH`, which often
|
|
||||||
// lacks the coverage driver TIA's record mode needs.
|
|
||||||
$process = new Process(
|
|
||||||
[PHP_BINARY, 'vendor/pestphp/pest/bin/pest', ...$flags],
|
|
||||||
$this->path,
|
|
||||||
[
|
|
||||||
// Strip any CI signal so TIA doesn't suppress instructions.
|
|
||||||
'GITHUB_ACTIONS' => '',
|
|
||||||
'GITLAB_CI' => '',
|
|
||||||
'CIRCLECI' => '',
|
|
||||||
// Force TIA's Storage to fall back to the sandbox-local
|
|
||||||
// `.pest/tia/` layout. Without this, every sandbox run
|
|
||||||
// would dump state into the developer's real home dir
|
|
||||||
// (`~/.pest/tia/`), polluting it and making tests
|
|
||||||
// non-hermetic.
|
|
||||||
'HOME' => '',
|
|
||||||
'USERPROFILE' => '',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
$process->setTimeout(120.0);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
return $process;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
public function graph(): ?array
|
|
||||||
{
|
|
||||||
$path = $this->path.'/.pest/tia/graph.json';
|
|
||||||
|
|
||||||
if (! is_file($path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = @file_get_contents($path);
|
|
||||||
|
|
||||||
if ($raw === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode($raw, true);
|
|
||||||
|
|
||||||
return is_array($decoded) ? $decoded : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasGraph(): bool
|
|
||||||
{
|
|
||||||
return $this->graph() !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $args
|
|
||||||
*/
|
|
||||||
public function git(array $args): Process
|
|
||||||
{
|
|
||||||
$process = new Process(['git', ...$args], $this->path);
|
|
||||||
$process->setTimeout(30.0);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
return $process;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(): void
|
|
||||||
{
|
|
||||||
if (getenv('PEST_TIA_KEEP') === '1') {
|
|
||||||
fwrite(STDERR, "[PEST_TIA_KEEP] sandbox: {$this->path}\n");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_dir($this->path)) {
|
|
||||||
self::rrmdir($this->path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily provisions a once-per-process template with composer already
|
|
||||||
* installed against the host Pest source. Every sandbox clone copies
|
|
||||||
* from here, avoiding a ~30s composer install per test.
|
|
||||||
*/
|
|
||||||
private static function ensureTemplate(): string
|
|
||||||
{
|
|
||||||
if (self::$templatePath !== null && is_dir(self::$templatePath.'/vendor')) {
|
|
||||||
return self::$templatePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache key includes a fingerprint of the host Pest source tree —
|
|
||||||
// when we edit Pest internals, the key changes, old templates
|
|
||||||
// become orphaned, the new template rebuilds. Without this, a
|
|
||||||
// stale template with yesterday's Pest code silently masks today's
|
|
||||||
// code under test.
|
|
||||||
$template = sys_get_temp_dir()
|
|
||||||
.DIRECTORY_SEPARATOR
|
|
||||||
.'pest-tia-template-'
|
|
||||||
.self::hostFingerprint();
|
|
||||||
|
|
||||||
// Serialise template creation across parallel paratest workers.
|
|
||||||
// Without the lock, three workers hitting `ensureTemplate()`
|
|
||||||
// simultaneously each see "no vendor yet → rebuild", stomp on
|
|
||||||
// each other's composer install, and produce half-written
|
|
||||||
// fixtures. `flock` on a sibling lockfile keeps it to one
|
|
||||||
// builder; the others block, then observe the finished
|
|
||||||
// template and skip straight to the fast path.
|
|
||||||
$lockPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-template.lock';
|
|
||||||
$lock = fopen($lockPath, 'c');
|
|
||||||
|
|
||||||
if ($lock === false) {
|
|
||||||
throw new RuntimeException('Cannot open template lock at '.$lockPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
flock($lock, LOCK_EX);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Re-check after acquiring the lock — another worker may have
|
|
||||||
// just finished the build while we were waiting.
|
|
||||||
if (is_dir($template.'/vendor')) {
|
|
||||||
self::$templatePath = $template;
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Garbage-collect every older template keyed by a different
|
|
||||||
// fingerprint so /tmp doesn't accumulate a 200 MB graveyard
|
|
||||||
// over a month of edits.
|
|
||||||
foreach (glob(sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-template-*') ?: [] as $orphan) {
|
|
||||||
if ($orphan !== $template) {
|
|
||||||
self::rrmdir($orphan);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_dir($template)) {
|
|
||||||
self::rrmdir($template);
|
|
||||||
}
|
|
||||||
|
|
||||||
$fixture = __DIR__.'/../Fixtures/sample-project';
|
|
||||||
|
|
||||||
if (! is_dir($fixture)) {
|
|
||||||
throw new RuntimeException('Missing fixture at '.$fixture);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! @mkdir($template, 0755, true) && ! is_dir($template)) {
|
|
||||||
throw new RuntimeException('Cannot create template at '.$template);
|
|
||||||
}
|
|
||||||
|
|
||||||
self::rcopy($fixture, $template);
|
|
||||||
self::wireHostPest($template);
|
|
||||||
self::composerInstall($template);
|
|
||||||
|
|
||||||
self::$templatePath = $template;
|
|
||||||
|
|
||||||
return $template;
|
|
||||||
} finally {
|
|
||||||
flock($lock, LOCK_UN);
|
|
||||||
fclose($lock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function wireHostPest(string $path): void
|
|
||||||
{
|
|
||||||
$hostRoot = realpath(__DIR__.'/../..');
|
|
||||||
|
|
||||||
if ($hostRoot === false) {
|
|
||||||
throw new RuntimeException('Cannot resolve host Pest root');
|
|
||||||
}
|
|
||||||
|
|
||||||
$composerJson = $path.'/composer.json';
|
|
||||||
$decoded = json_decode((string) file_get_contents($composerJson), true);
|
|
||||||
|
|
||||||
$decoded['repositories'] = [
|
|
||||||
['type' => 'path', 'url' => $hostRoot, 'options' => ['symlink' => false]],
|
|
||||||
];
|
|
||||||
$decoded['require']['pestphp/pest'] = '*@dev';
|
|
||||||
|
|
||||||
file_put_contents(
|
|
||||||
$composerJson,
|
|
||||||
json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function composerInstall(string $path): void
|
|
||||||
{
|
|
||||||
// Invoke composer via the *same* PHP binary that's running this
|
|
||||||
// process. On macOS multi-version setups (Herd, brew, asdf, etc.)
|
|
||||||
// the `composer` shebang often points at the system PHP, which
|
|
||||||
// may not match the version the test suite booted with — leading
|
|
||||||
// to "your PHP version does not satisfy the requirement" errors
|
|
||||||
// even when the interpreter in use would satisfy it. Going
|
|
||||||
// through `PHP_BINARY` + the located composer binary/phar
|
|
||||||
// sidesteps that entirely.
|
|
||||||
$composer = self::locateComposer();
|
|
||||||
$args = $composer === null
|
|
||||||
? ['composer', 'install']
|
|
||||||
: [PHP_BINARY, $composer, 'install'];
|
|
||||||
|
|
||||||
$process = new Process(
|
|
||||||
[...$args, '--no-interaction', '--prefer-dist', '--no-progress', '--quiet'],
|
|
||||||
$path,
|
|
||||||
);
|
|
||||||
$process->setTimeout(600.0);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
"composer install failed in template:\n".$process->getOutput().$process->getErrorOutput(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the composer binary to a real path PHP can execute. Returns
|
|
||||||
* `null` when composer isn't findable, in which case the caller falls
|
|
||||||
* back to invoking plain `composer` via `$PATH` (and hopes for the
|
|
||||||
* best — usually fine on CI Linux runners).
|
|
||||||
*/
|
|
||||||
private static function locateComposer(): ?string
|
|
||||||
{
|
|
||||||
$probe = new Process(['command', '-v', 'composer']);
|
|
||||||
$probe->run();
|
|
||||||
|
|
||||||
$path = trim($probe->getOutput());
|
|
||||||
|
|
||||||
if ($path === '' || ! is_file($path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `composer` may be a shell-script wrapper (Herd does this) —
|
|
||||||
// resolve the actual phar it invokes. Heuristic: parse out the
|
|
||||||
// last `.phar` argument from the wrapper, fall back to the file
|
|
||||||
// itself if no wrapper is detected.
|
|
||||||
$content = @file_get_contents($path);
|
|
||||||
|
|
||||||
if ($content !== false && preg_match('/\S+\.phar/', $content, $m) === 1) {
|
|
||||||
$phar = $m[0];
|
|
||||||
|
|
||||||
if (is_file($phar)) {
|
|
||||||
return $phar;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function bootstrapGit(string $path): void
|
|
||||||
{
|
|
||||||
// Each clone needs its own repo — TIA's SHA / branch / diff logic
|
|
||||||
// all rely on `.git/`. The template has no git dir so clones start
|
|
||||||
// from a clean slate.
|
|
||||||
$run = function (array $args) use ($path): void {
|
|
||||||
$process = new Process(['git', ...$args], $path);
|
|
||||||
$process->setTimeout(30.0);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
|
||||||
throw new RuntimeException('git '.implode(' ', $args).' failed: '.$process->getErrorOutput());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// `.git` may have been cloned from the template if we ever add one
|
|
||||||
// there — nuke it just in case so every sandbox starts fresh.
|
|
||||||
if (is_dir($path.'/.git')) {
|
|
||||||
self::rrmdir($path.'/.git');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep `vendor/` and composer lock out of the sandbox's git repo
|
|
||||||
// entirely. With ~thousands of files `git add .` takes tens of
|
|
||||||
// seconds; TIA also ignores vendor paths via `shouldIgnore()` so
|
|
||||||
// tracking them buys nothing except slowness.
|
|
||||||
file_put_contents($path.'/.gitignore', "vendor/\ncomposer.lock\n");
|
|
||||||
|
|
||||||
$run(['init', '-q', '-b', 'main']);
|
|
||||||
$run(['config', 'user.email', 'sandbox@pest.test']);
|
|
||||||
$run(['config', 'user.name', 'Pest Sandbox']);
|
|
||||||
$run(['config', 'commit.gpgsign', 'false']);
|
|
||||||
$run(['add', '.']);
|
|
||||||
$run(['commit', '-q', '-m', 'initial']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Short hash derived from the host Pest source that the template is
|
|
||||||
* built against. Hashing the newest mtime across `src/`, `overrides/`,
|
|
||||||
* and `composer.json` is cheap (one stat each) and catches every edit
|
|
||||||
* that could alter TIA behaviour.
|
|
||||||
*/
|
|
||||||
private static function hostFingerprint(): string
|
|
||||||
{
|
|
||||||
$hostRoot = realpath(__DIR__.'/../..');
|
|
||||||
|
|
||||||
if ($hostRoot === false) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
$newest = 0;
|
|
||||||
|
|
||||||
foreach ([$hostRoot.'/src', $hostRoot.'/overrides'] as $dir) {
|
|
||||||
if (! is_dir($dir)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$iter = new \RecursiveIteratorIterator(
|
|
||||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($iter as $file) {
|
|
||||||
if ($file->isFile()) {
|
|
||||||
$newest = max($newest, $file->getMTime());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_file($hostRoot.'/composer.json')) {
|
|
||||||
$newest = max($newest, (int) filemtime($hostRoot.'/composer.json'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return substr(sha1($hostRoot.'|'.PHP_VERSION.'|'.$newest), 0, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function rcopy(string $src, string $dest): void
|
|
||||||
{
|
|
||||||
if (! is_dir($dest) && ! @mkdir($dest, 0755, true) && ! is_dir($dest)) {
|
|
||||||
throw new RuntimeException("Cannot create {$dest}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$iter = new \RecursiveIteratorIterator(
|
|
||||||
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
|
|
||||||
\RecursiveIteratorIterator::SELF_FIRST,
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($iter as $item) {
|
|
||||||
$target = $dest.DIRECTORY_SEPARATOR.$iter->getSubPathname();
|
|
||||||
|
|
||||||
if ($item->isDir()) {
|
|
||||||
@mkdir($target, 0755, true);
|
|
||||||
} else {
|
|
||||||
copy($item->getPathname(), $target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function rrmdir(string $dir): void
|
|
||||||
{
|
|
||||||
if (! is_dir($dir)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `rm -rf` shells out but handles symlinks, read-only files, and
|
|
||||||
// the composer-vendor quirks (lock files, .bin symlinks) that
|
|
||||||
// PHP's own recursive delete stumbles on. Non-fatal on failure.
|
|
||||||
$process = new Process(['rm', '-rf', $dir]);
|
|
||||||
$process->setTimeout(60.0);
|
|
||||||
$process->run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* tests-tia bootstrap.
|
|
||||||
*
|
|
||||||
* Pest's automatic `Pest.php` loader scans the configured `testDirectory()`
|
|
||||||
* which defaults to `tests/` and is hard to override from a nested suite.
|
|
||||||
* So instead of relying on `tests-tia/Pest.php` being found, wire the
|
|
||||||
* helpers in via PHPUnit's explicit `bootstrap=` attribute — simpler,
|
|
||||||
* no config-search surprises.
|
|
||||||
*/
|
|
||||||
require __DIR__.'/../vendor/autoload.php';
|
|
||||||
require __DIR__.'/Support/Sandbox.php';
|
|
||||||
|
|
||||||
use Pest\TestsTia\Support\Sandbox;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
|
|
||||||
// tests-tia exercises the record path end-to-end, which means the
|
|
||||||
// sandbox PHP must expose a coverage driver (pcov or xdebug with
|
|
||||||
// coverage mode). Without one, `--tia` records zero edges and every
|
|
||||||
// scenario assertion fails with a useless "no coverage driver" banner.
|
|
||||||
// Bail out loudly at bootstrap so the failure mode is obvious.
|
|
||||||
if (! extension_loaded('pcov') && ! extension_loaded('xdebug')) {
|
|
||||||
fwrite(STDERR, "\n");
|
|
||||||
fwrite(STDERR, " \e[30;43m SKIP \e[0m tests-tia requires a coverage driver (pcov or xdebug).\n");
|
|
||||||
fwrite(STDERR, " Install one, then retry: composer test:tia\n\n");
|
|
||||||
|
|
||||||
// Exit 0 so CI doesn't fail when the driver is genuinely absent —
|
|
||||||
// the CI workflow adds pcov explicitly so this branch only fires on
|
|
||||||
// dev machines that haven't set one up.
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-warm the shared composer template once, up-front. Without this,
|
|
||||||
// parallel workers race on first use — whoever hits `ensureTemplate()`
|
|
||||||
// second gets a half-written template. A file-based lock + single
|
|
||||||
// bootstrap pre-warm sidesteps the problem entirely.
|
|
||||||
Sandbox::warmTemplate();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs `$body` inside a fresh sandbox, guaranteeing teardown even when the
|
|
||||||
* body throws. Keeps scenario tests tidy — one line per setup + destroy.
|
|
||||||
*/
|
|
||||||
function tiaScenario(Closure $body): void
|
|
||||||
{
|
|
||||||
$sandbox = Sandbox::create();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$body($sandbox);
|
|
||||||
} finally {
|
|
||||||
$sandbox->destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip ANSI escapes so assertions are terminal-agnostic.
|
|
||||||
*/
|
|
||||||
function tiaOutput(Process $process): string
|
|
||||||
{
|
|
||||||
$output = $process->getOutput().$process->getErrorOutput();
|
|
||||||
|
|
||||||
return preg_replace('/\e\[[0-9;]*m/', '', $output) ?? $output;
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:noNamespaceSchemaLocation="../vendor/phpunit/phpunit/phpunit.xsd"
|
|
||||||
bootstrap="bootstrap.php"
|
|
||||||
colors="true"
|
|
||||||
cacheDirectory="../.phpunit.cache/tests-tia"
|
|
||||||
executionOrder="default"
|
|
||||||
failOnRisky="false"
|
|
||||||
failOnWarning="false">
|
|
||||||
<testsuites>
|
|
||||||
<testsuite name="tia">
|
|
||||||
<directory>.</directory>
|
|
||||||
<exclude>Fixtures</exclude>
|
|
||||||
<exclude>Support</exclude>
|
|
||||||
</testsuite>
|
|
||||||
</testsuites>
|
|
||||||
</phpunit>
|
|
||||||
Reference in New Issue
Block a user