diff --git a/bin/pest-tia-vite-deps.mjs b/bin/pest-tia-vite-deps.mjs index 49133249..32fb6fd8 100644 --- a/bin/pest-tia-vite-deps.mjs +++ b/bin/pest-tia-vite-deps.mjs @@ -29,22 +29,54 @@ async function loadRolldown() { return await import(pathToFileURL(path).href) } -async function readJsonWithComments(path) { - const raw = await readFile(path, 'utf8') - const stripped = raw - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/[^\n]*/g, '$1') - return JSON.parse(stripped) +export function stripJsonComments(raw) { + let out = '' + let inString = false + let quote = '' + let inLine = false + let inBlock = false + + for (let i = 0; i < raw.length; i++) { + const c = raw[i] + const n = raw[i + 1] + + if (inLine) { + if (c === '\n') { inLine = false; out += c } + continue + } + if (inBlock) { + if (c === '*' && n === '/') { inBlock = false; i++ } + continue + } + if (inString) { + out += c + if (c === '\\') { out += n ?? ''; i++; continue } + if (c === quote) inString = false + continue + } + if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue } + if (c === '/' && n === '/') { inLine = true; i++; continue } + if (c === '/' && n === '*') { inBlock = true; i++; continue } + if (c === '}' || c === ']') out = out.replace(/,\s*$/, '') + out += c + } + + return out } -async function loadAliasFromTsconfig() { +async function readJsonWithComments(path) { + const raw = await readFile(path, 'utf8') + return JSON.parse(stripJsonComments(raw)) +} + +export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) { const alias = {} for (const name of ['tsconfig.json', 'jsconfig.json']) { - const p = join(PROJECT_ROOT, name) + const p = join(projectRoot, name) if (!existsSync(p)) continue let cfg try { cfg = await readJsonWithComments(p) } catch { continue } - const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.') + const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.') const paths = cfg?.compilerOptions?.paths ?? {} for (const [key, targets] of Object.entries(paths)) { if (!key.endsWith('/*')) continue @@ -230,10 +262,14 @@ async function main() { process.stdout.write(JSON.stringify(payload)) } -try { - void pathToFileURL - await main() -} catch (err) { - process.stderr.write(String(err?.stack ?? err ?? 'unknown error')) - process.exit(1) +const invokedDirectly = process.argv[1] !== undefined + && import.meta.url === pathToFileURL(process.argv[1]).href + +if (invokedDirectly) { + try { + await main() + } catch (err) { + process.stderr.write(String(err?.stack ?? err ?? 'unknown error')) + process.exit(1) + } } diff --git a/composer.json b/composer.json index 8ad33b39..5b5ce2de 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ ], "require": { "php": "^8.4", - "brianium/paratest": "^7.22.4", + "brianium/paratest": "^7.23.0", "nunomaduro/collision": "^8.9.4", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^5.0.0", @@ -59,7 +59,7 @@ }, "require-dev": { "mrpunyapal/peststan": "^0.2.11", - "laravel/pao": "^1.1.1", + "laravel/pao": "^1.1.2", "pestphp/pest-dev-tools": "^5.0.0", "pestphp/pest-plugin-browser": "^5.0.0", "pestphp/pest-plugin-type-coverage": "^5.0.0", diff --git a/src/Configuration.php b/src/Configuration.php index a0ee2c40..46f2d118 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -122,9 +122,9 @@ final readonly class Configuration /** * Gets the evals configuration. */ - public function evals(): Evals\Configuration + public function evals(): Evals\Configuration // @phpstan-ignore-line { - return new Evals\Configuration; + return new Evals\Configuration; // @phpstan-ignore-line } /** diff --git a/src/Exceptions/TiaRequiresRepositoryRoot.php b/src/Exceptions/TiaRequiresRepositoryRoot.php new file mode 100644 index 00000000..5a122197 --- /dev/null +++ b/src/Exceptions/TiaRequiresRepositoryRoot.php @@ -0,0 +1,44 @@ +subdirectoryPrefix, + )); + } + + public function render(OutputInterface $output): void + { + $output->writeln([ + '', + ' ERROR Tia mode requires the git repository root.', + '', + sprintf(' This project sits in a subdirectory of a larger repo %s.', $this->subdirectoryPrefix), + '', + ' Give the project its own git repository to use Tia.', + '', + ]); + } + + public function exitCode(): int + { + return 1; + } +} diff --git a/src/Expectations/HigherOrderExpectation.php b/src/Expectations/HigherOrderExpectation.php index 0feb886f..abf521de 100644 --- a/src/Expectations/HigherOrderExpectation.php +++ b/src/Expectations/HigherOrderExpectation.php @@ -112,7 +112,7 @@ final class HigherOrderExpectation * Dynamically calls methods on the class with the given arguments. * * @param array $arguments - * @return self|self + * @return self */ public function __call(string $name, array $arguments): self { @@ -127,7 +127,7 @@ final class HigherOrderExpectation /** * Accesses properties in the value or in the expectation. * - * @return self|self + * @return self */ public function __get(string $name): self { diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index e04237e3..09928439 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -9,6 +9,7 @@ use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\Terminable; use Pest\Exceptions\NoAffectedTestsFound; +use Pest\Exceptions\TiaRequiresRepositoryRoot; use Pest\Panic; use Pest\Plugins\Concerns\HandleArguments; use Pest\Plugins\Tia\BaselineSync; @@ -627,6 +628,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array { $this->watchPatterns->useDefaults($projectRoot); + + $subdirectoryPrefix = $this->gitSubdirectoryPrefix($projectRoot); + + if ($subdirectoryPrefix !== null) { + Panic::with(new TiaRequiresRepositoryRoot($subdirectoryPrefix)); + } + $this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? 'main'; $fingerprint = Fingerprint::compute($projectRoot); @@ -1643,6 +1651,27 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return implode(', ', array_keys($seen)); } + /** + * The path from the git repository root down to $projectRoot (e.g. + * `laravel-app`) when the project is nested inside a larger repo, or `null` + * when the project root is itself the repo root (or git is unavailable). + * TIA requires the two to coincide: git reports and addresses paths + * relative to the repo root, while the dependency graph is project-relative. + */ + private function gitSubdirectoryPrefix(string $projectRoot): ?string + { + $process = new Process(['git', 'rev-parse', '--show-prefix'], $projectRoot); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + $prefix = trim($process->getOutput()); + + return $prefix === '' ? null : rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/'); + } + private function composerLockDelta(string $projectRoot, string $sha): string { $current = @file_get_contents($projectRoot.'/composer.lock'); diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 5b6ab145..8c32f763 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -1820,6 +1820,9 @@ ✓ does not throw when an integer --random-order-seed is passed as a separate argv element ✓ still detects --tia when an integer argument is present + PASS Tests\Unit\Plugins\Tia\ViteDepsHelper + ✓ loadAliasFromTsconfig() → it resolves an @/* alias from a commented tsconfig with glob includes + PASS Tests\Unit\Preset ✓ preset invalid name ✓ preset → myFramework @@ -2006,4 +2009,4 @@ ✓ pass with dataset with ('my-datas-set-value') ✓ within describe → pass with dataset with ('my-datas-set-value') - Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1389 passed (3097 assertions) \ No newline at end of file + Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1390 passed (3099 assertions) \ No newline at end of file diff --git a/tests/Unit/Plugins/Tia/ViteDepsHelper.php b/tests/Unit/Plugins/Tia/ViteDepsHelper.php new file mode 100644 index 00000000..8b02921f --- /dev/null +++ b/tests/Unit/Plugins/Tia/ViteDepsHelper.php @@ -0,0 +1,300 @@ + ['{"a":1,"b":2}', ['a' => 1, 'b' => 2]], + 'plain-array' => ['[1,2,3]', [1, 2, 3]], + 'nested' => ['{"a":{"b":[1,2]}}', ['a' => ['b' => [1, 2]]]], + 'empty-object' => ['{}', []], + 'empty-array' => ['[]', []], + + 'line-comment-own-line' => ["{\n// comment\n\"a\":1\n}", ['a' => 1]], + 'line-comment-trailing' => ["{\"a\":1 // trailing\n}", ['a' => 1]], + 'leading-line-comments' => ["// a\n// b\n{\"a\":1}", ['a' => 1]], + 'line-comment-eof-no-newline' => ['{"a":1}// end', ['a' => 1]], + + 'block-comment-leading' => ['{/* c */"a":1}', ['a' => 1]], + 'block-comment-inline' => ['{"a":1,/* c */"b":2}', ['a' => 1, 'b' => 2]], + 'block-comment-multiline' => ["{\n/* line1\nline2 */\n\"a\":1\n}", ['a' => 1]], + 'block-comment-eof' => ['{"a":1}/* end */', ['a' => 1]], + 'jsdoc-style-block' => ["{\n/**\n * doc\n */\n\"a\":1\n}", ['a' => 1]], + 'block-contains-double-slash' => ['{/* // inside */"a":1}', ['a' => 1]], + 'line-then-block' => ["{// l\n/* b */\"a\":1}", ['a' => 1]], + 'comment-with-quotes' => ['{/* "b":2 */"a":1}', ['a' => 1]], + 'comment-with-braces-commas' => ['{"a":1 /* },{ */,"b":2}', ['a' => 1, 'b' => 2]], + + 'trailing-comma-object' => ['{"a":1,}', ['a' => 1]], + 'trailing-comma-array' => ['[1,2,]', [1, 2]], + 'trailing-comma-nested' => ['{"a":[1,2,],"b":{"c":3,},}', ['a' => [1, 2], 'b' => ['c' => 3]]], + 'trailing-comma-newline' => ["{\n\"a\":1,\n}", ['a' => 1]], + 'trailing-comma-then-block' => ['{"a":1, /* x */ }', ['a' => 1]], + 'trailing-comma-then-line' => ["{\"a\":1, // x\n}", ['a' => 1]], + + 'crlf-line-comment' => ["{\r\n\"a\":1 // c\r\n}", ['a' => 1]], + 'tabs-whitespace' => ["{\n\t\"a\":\t1\n}", ['a' => 1]], + + 'url-in-string' => [tiaJson(['u' => 'https://example.com//x']), ['u' => 'https://example.com//x']], + 'glob-in-string' => [tiaJson(['g' => 'resources/js/**/*.ts']), ['g' => 'resources/js/**/*.ts']], + 'path-alias-in-string' => [tiaJson(['p' => '@/*']), ['p' => '@/*']], + 'block-open-in-string' => [tiaJson(['s' => 'a/*b']), ['s' => 'a/*b']], + 'block-close-in-string' => [tiaJson(['s' => 'b*/c']), ['s' => 'b*/c']], + 'block-both-in-string' => [tiaJson(['s' => 'x/*y*/z']), ['s' => 'x/*y*/z']], + 'star-in-string' => [tiaJson(['s' => '5 * 3']), ['s' => '5 * 3']], + 'single-slashes-in-string' => [tiaJson(['s' => 'a/b/c']), ['s' => 'a/b/c']], + + 'escaped-quote-in-string' => [tiaJson(['s' => 'a"b']), ['s' => 'a"b']], + 'escaped-backslash-in-string' => [tiaJson(['s' => 'a\\']), ['s' => 'a\\']], + 'backslash-then-slash' => [tiaJson(['s' => 'a\\/b']), ['s' => 'a\\/b']], + 'newline-escape-in-string' => [tiaJson(['s' => "a\nb"]), ['s' => "a\nb"]], + 'unicode-in-string' => [tiaJson(['s' => 'café ☕']), ['s' => 'café ☕']], + + 'block-secret' => ['{"a":1/*SECRET*/}', ['a' => 1]], + ]; +} + +function tiaStripResults(): array +{ + static $cache = null; + if ($cache !== null) { + return $cache; + } + + $payload = []; + foreach (tiaStripFixtures() as $name => [$raw]) { + $payload[] = ['name' => $name, 'raw' => $raw]; + } + + $inputFile = tempnam(sys_get_temp_dir(), 'tia-strip-'); + file_put_contents($inputFile, json_encode($payload)); + + $helper = str_replace('\\', '/', tiaViteHelperPath()); + $input = str_replace('\\', '/', $inputFile); + + $script = <<mustRun(); + + @unlink($inputFile); + + return $cache = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); +} + +function tiaAliasFixtures(): array +{ + $config = fn (array $compilerOptions): array => ['tsconfig.json' => tiaJson(['compilerOptions' => $compilerOptions])]; + + return [ + 'basic' => [ + $config(['baseUrl' => '.', 'paths' => ['@/*' => ['./resources/js/*']]]), + ['@' => 'resources/js'], + ], + 'multiple-aliases' => [ + $config(['baseUrl' => '.', 'paths' => ['@/*' => ['./resources/js/*'], '~/*' => ['./src/*']]]), + ['@' => 'resources/js', '~' => 'src'], + ], + 'default-baseUrl' => [ + $config(['paths' => ['@/*' => ['./resources/js/*']]]), + ['@' => 'resources/js'], + ], + 'baseUrl-subdir' => [ + $config(['baseUrl' => 'src', 'paths' => ['@/*' => ['lib/*']]]), + ['@' => 'src/lib'], + ], + 'jsconfig-fallback' => [ + ['jsconfig.json' => tiaJson(['compilerOptions' => ['baseUrl' => '.', 'paths' => ['@/*' => ['./resources/js/*']]]])], + ['@' => 'resources/js'], + ], + 'first-target-wins' => [ + $config(['baseUrl' => '.', 'paths' => ['@/*' => ['./a/*', './b/*']]]), + ['@' => 'a'], + ], + 'multiple-keys-mixed' => [ + $config(['baseUrl' => '.', 'paths' => [ + '@/*' => ['./resources/js/*'], + 'bad' => ['./x/*'], + '~/*' => ['./nope'], + ]]), + ['@' => 'resources/js'], + ], + 'no-paths' => [ + $config(['baseUrl' => '.']), + [], + ], + 'no-compiler-options' => [ + ['tsconfig.json' => '{}'], + [], + ], + 'key-without-glob' => [ + $config(['baseUrl' => '.', 'paths' => ['@' => ['./resources/js/*']]]), + [], + ], + 'target-without-glob' => [ + $config(['baseUrl' => '.', 'paths' => ['@/*' => ['./resources/js']]]), + [], + ], + 'target-not-array' => [ + $config(['baseUrl' => '.', 'paths' => ['@/*' => './resources/js/*']]), + [], + ], + 'target-empty-array' => [ + $config(['baseUrl' => '.', 'paths' => ['@/*' => []]]), + [], + ], + 'commented-tsconfig' => [ + ['tsconfig.json' => <<<'JSON' + { + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more. */ + "target": "ESNext", // language target + "baseUrl": ".", + "paths": { + "@/*": ["./resources/js/*"], + }, + }, + } + JSON], + ['@' => 'resources/js'], + ], + 'trailing-comma-paths' => [ + ['tsconfig.json' => '{"compilerOptions":{"baseUrl":".","paths":{"@/*":["./resources/js/*",],},},}'], + ['@' => 'resources/js'], + ], + 'malformed-json' => [ + ['tsconfig.json' => '{ this is not json'], + [], + ], + 'missing-config' => [ + [], + [], + ], + 'tsconfig-precedence' => [ + [ + 'tsconfig.json' => tiaJson(['compilerOptions' => ['baseUrl' => '.', 'paths' => ['@/*' => ['./a/*']]]]), + 'jsconfig.json' => tiaJson(['compilerOptions' => ['baseUrl' => '.', 'paths' => ['@/*' => ['./b/*']]]]), + ], + ['@' => 'a'], + ], + ]; +} + +function tiaAliasResults(): array +{ + static $cache = null; + if ($cache !== null) { + return $cache; + } + + $roots = []; + $written = []; + $payload = []; + + foreach (tiaAliasFixtures() as $name => [$files]) { + $root = sys_get_temp_dir().'/pest-tia-'.bin2hex(random_bytes(6)); + mkdir($root, 0755, true); + foreach ($files as $fname => $content) { + file_put_contents($root.'/'.$fname, $content); + $written[] = $root.'/'.$fname; + } + $root = str_replace('\\', '/', $root); + $roots[$name] = $root; + $payload[] = ['name' => $name, 'root' => $root]; + } + + $inputFile = tempnam(sys_get_temp_dir(), 'tia-alias-'); + file_put_contents($inputFile, json_encode($payload)); + + $helper = str_replace('\\', '/', tiaViteHelperPath()); + $input = str_replace('\\', '/', $inputFile); + + $script = <<mustRun(); + + $aliases = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + + @unlink($inputFile); + foreach ($written as $f) { + @unlink($f); + } + foreach ($roots as $r) { + @rmdir($r); + } + + return $cache = ['roots' => $roots, 'aliases' => $aliases]; +} + +beforeEach(function () { + if ((new ExecutableFinder)->find('node') === null) { + $this->markTestSkipped('node is not available.'); + } +}); + +it('strips JSONC down to something JSON.parse accepts', function (string $name) { + $result = tiaStripResults()[$name]; + [, $expected] = tiaStripFixtures()[$name]; + + expect($result['ok'])->toBeTrue( + "[{$name}] did not parse after stripping: ".($result['error'] ?? 'unknown').PHP_EOL. + 'stripped: '.$result['stripped'], + ); + expect($result['parsed'])->toEqual($expected); +})->with(array_keys(tiaStripFixtures())); + +it('never touches comment-looking sequences inside string values', function () { + expect(tiaStripResults()['url-in-string']['stripped'])->toContain('https://example.com//x') + ->and(tiaStripResults()['glob-in-string']['stripped'])->toContain('resources/js/**/*.ts') + ->and(tiaStripResults()['block-both-in-string']['stripped'])->toContain('x/*y*/z'); +}); + +it('removes the comment body entirely', function () { + expect(tiaStripResults()['block-secret']['stripped'])->not->toContain('SECRET'); +}); + +it('builds the expected alias map from a tsconfig', function (string $name) { + $results = tiaAliasResults(); + [, $expectedRelative] = tiaAliasFixtures()[$name]; + + $root = $results['roots'][$name]; + $expected = []; + foreach ($expectedRelative as $key => $relative) { + $expected[$key] = $root.'/'.$relative; + } + + expect($results['aliases'][$name])->toEqual($expected); +})->with(array_keys(tiaAliasFixtures())); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index 78df799f..f6f758c0 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -24,13 +24,13 @@ test('parallel', function () use ($run) { $file = file_get_contents(__FILE__); $file = preg_replace( '/\$expected = \'.*?\';/', - "\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1372 passed (3044 assertions)';", + "\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1373 passed (3046 assertions)';", $file, ); file_put_contents(__FILE__, $file); } - $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1372 passed (3044 assertions)'; + $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1373 passed (3046 assertions)'; expect($output) ->toContain("Tests: {$expected}")