diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index d97a671d..ac952c0d 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -367,6 +367,15 @@ YAML; return $m[1]; } + // SSH URL form: ssh://[user@]github.com[:port]/org/repo(.git). + // Some teams configure this explicitly to pin the SSH port; the + // colon-separated form above doesn't match. Mirrors the parser + // in `Storage::originIdentity` so the same remote produces the + // same project key for both storage and remote-fetch. + if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) { + return $m[1]; + } + return null; } diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index f533b244..308eb36a 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -6,6 +6,7 @@ namespace Pest\Plugins\Tia; use Pest\Support\Container; use PHPUnit\Framework\TestStatus\TestStatus; +use Symfony\Component\Console\Output\OutputInterface; /** * File-level Test Impact Analysis graph. @@ -319,7 +320,21 @@ final class Graph if ($newJsFiles !== []) { $freshMap = JsModuleGraph::buildStrict($this->projectRoot); - if ($freshMap !== null) { + if ($freshMap === null) { + // Vite resolver was unavailable (Node missing, cold-start + // timeout, vite.config refused to load). Falling back to + // the broad watch pattern is the correct call, but + // doing so silently can make a slow replay feel + // inexplicable — surface a single line so the user + // knows precision was downgraded for these files. + $output = Container::getInstance()->get(OutputInterface::class); + if ($output instanceof OutputInterface) { + $output->writeln(sprintf( + ' TIA Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).', + count($newJsFiles), + )); + } + } else { foreach ($newJsFiles as $rel) { $pages = $freshMap[$rel] ?? []; diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index bdbbec51..ade27d97 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -100,21 +100,19 @@ final class Recorder if (function_exists('pcov\\start')) { $this->driver = 'pcov'; $this->driverAvailable = true; - } elseif (function_exists('xdebug_start_code_coverage')) { - // Xdebug is loaded. Probe whether coverage mode is active by - // attempting a start — it emits E_WARNING when the mode is off. - // We capture the warning via a temporary error handler. - $probeOk = true; - set_error_handler(static function () use (&$probeOk): bool { - $probeOk = false; + } elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) { + // Xdebug 3+ exposes the active mode set via `xdebug_info`, + // so we can ask directly instead of probing with a + // start/stop pair. The probe approach used to emit + // E_WARNING when coverage mode was off; with monitoring + // agents (Sentry, Bugsnag) hooked into the error + // handler stack that warning could be reported as a + // real error. `xdebug_info('mode')` is silent and + // returns the active modes as a list, so a presence + // check is enough. + $modes = \xdebug_info('mode'); - return true; - }); - \xdebug_start_code_coverage(); - restore_error_handler(); - - if ($probeOk) { - \xdebug_stop_code_coverage(false); + if (is_array($modes) && in_array('coverage', $modes, true)) { $this->driver = 'xdebug'; $this->driverAvailable = true; } diff --git a/src/Plugins/Tia/TableExtractor.php b/src/Plugins/Tia/TableExtractor.php index da67a1ab..c2f00cfd 100644 --- a/src/Plugins/Tia/TableExtractor.php +++ b/src/Plugins/Tia/TableExtractor.php @@ -105,21 +105,27 @@ final class TableExtractor } /** - * @return list Table names referenced by `Schema::` calls - * OR raw DDL statements in the given migration + * @return list Table names referenced by `Schema::` calls, + * raw DDL, or DML inside the given migration * file contents. Empty when nothing matches — * callers treat that as "fall back to the * broad watch pattern". * - * Two passes: + * Three passes: * 1. `Schema::create|table|drop|dropIfExists|dropColumn[s]|rename` * captures the conventional Laravel migration shape. * 2. Raw DDL fallback: scans for `CREATE / ALTER / DROP / * TRUNCATE / RENAME TABLE ` patterns inside string * literals (i.e. `DB::statement('CREATE TABLE …')`, - * `DB::unprepared('ALTER TABLE …')`). False positives possible - * if the same syntax appears in a comment or unrelated string, - * but over-attribution is correctness-safe. + * `DB::unprepared('ALTER TABLE …')`). + * 3. DML inside migration bodies — `INSERT INTO`, `UPDATE … SET`, + * `DELETE FROM`, and Laravel's fluent `DB::table('foo')`. + * Catches the seeded-lookup-table case where a migration + * populates rows that tests later read. + * + * False positives possible when the same syntax appears in a + * comment or unrelated string, but over-attribution is + * correctness-safe. */ public static function fromMigrationSource(string $php): array { @@ -157,6 +163,33 @@ final class TableExtractor } } + // Pass 3: DML inside migration bodies. Migrations that seed + // lookup tables via `DB::statement('INSERT INTO roles …')`, + // `DB::table('statuses')->insert(…)`, `UPDATE foo SET …`, or + // `DELETE FROM bar` are common in Laravel. Without picking + // these up, an edit to the seed payload would route through + // only the schema'd tables and silently skip every test that + // reads from the populated table. Fluent-builder calls + // (`DB::table('x')`) and raw SQL strings are both covered. + $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); diff --git a/src/Plugins/Tia/WatchDefaults/Inertia.php b/src/Plugins/Tia/WatchDefaults/Inertia.php index 14065780..aebf80c7 100644 --- a/src/Plugins/Tia/WatchDefaults/Inertia.php +++ b/src/Plugins/Tia/WatchDefaults/Inertia.php @@ -43,12 +43,18 @@ final readonly class Inertia implements WatchDefault 'resources/js/Pages/**/*.tsx' => [$browserDir], 'resources/js/Pages/**/*.jsx' => [$browserDir], 'resources/js/Pages/**/*.svelte' => [$browserDir], + 'resources/js/Pages/**/*.ts' => [$browserDir], + 'resources/js/Pages/**/*.js' => [$browserDir], // Shared layouts / components consumed by pages. 'resources/js/Layouts/**/*.vue' => [$browserDir], 'resources/js/Layouts/**/*.tsx' => [$browserDir], + 'resources/js/Layouts/**/*.ts' => [$browserDir], + 'resources/js/Layouts/**/*.js' => [$browserDir], 'resources/js/Components/**/*.vue' => [$browserDir], 'resources/js/Components/**/*.tsx' => [$browserDir], + 'resources/js/Components/**/*.ts' => [$browserDir], + 'resources/js/Components/**/*.js' => [$browserDir], // SSR entry point. 'resources/js/ssr.js' => [$browserDir],