diff --git a/CHANGELOG.md b/CHANGELOG.md index 648d84f..8dd4502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ All notable changes are documented here. The format is based on [Keep a Changelo - Added `Crest\Command\Make\NamedArtifactCommand`: base of `make:command`, `make:middleware`, `make:provider` and `make:responder`. - Added `serve` (alias `server`): runs `php -S 127.0.0.1:8080 -t public .htrouter.php` in the project root. Port: `--port`, then `APP_PORT` (environment or `.env`), then 8080. Fails if `.htrouter.php` or `vendor/autoload.php` is missing. [#10](https://github.com/phalcon/crest/issues/10) - Added a package hint for unknown commands: `unknown command 'migration:run'; provided by phalcon/migrations`. No hint when a command with that prefix is registered. The map is set with `Crest\Console\Registry::withProviders()`. [#19](https://github.com/phalcon/crest/issues/19) +- Added the hand-off: in a project, a global crest passes each project command to the project's `vendor/bin/crest` and returns its exit status. `new`, `up`, `down`, `install` and `--version` stay in the global crest. A project that requires `phalcon/crest` without `vendor/bin/crest` gives an error. [#27](https://github.com/phalcon/crest/issues/27) +- Added `Crest\Project\Locator::project()`: the nearest directory with a `composer.json`. [#27](https://github.com/phalcon/crest/issues/27) ### Changed diff --git a/README.md b/README.md index cba3115..30e0fce 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,19 @@ directory must be in your `PATH`: ## Usage -In a project, use the crest in `vendor/`. The commands that work on the project need -the project autoloader and its Phalcon: - - vendor/bin/crest list available commands - vendor/bin/crest about environment and version report - vendor/bin/crest make:action GET /company/all +Run `crest` in a project. The commands that work on the project need the project +autoloader and its Phalcon, so a global crest passes them to the project's +`vendor/bin/crest` and returns its exit status: + + crest list available commands + crest about environment and version report + crest make:action GET /company/all + +`new`, `up`, `down`, `install` and `--version` always run in the crest that you type. +Without a global crest, run `vendor/bin/crest` in the project. crest finds the project +from a subdirectory, or from `--directory`. If the project requires `phalcon/crest` but +has no `vendor/bin/crest` yet, crest stops and tells you to run `crest install` or +`composer install`. To create a project, use the global crest. The new project requires `phalcon/crest`, so after `composer install` it has its own `vendor/bin/crest`: diff --git a/bin/crest b/bin/crest index fbd164f..1b656df 100755 --- a/bin/crest +++ b/bin/crest @@ -14,6 +14,8 @@ declare(strict_types=1); use Crest\Commands; use Crest\Console\Kernel; +use Crest\Console\Output; +use Crest\HandOff; // Dev checkout: bin/../vendor; installed dependency: vendor/phalcon/crest/bin -> vendor. foreach ([dirname(__DIR__) . '/vendor/autoload.php', dirname(__DIR__, 3) . '/autoload.php'] as $autoload) { @@ -29,6 +31,16 @@ if (!class_exists(Kernel::class)) { exit(1); } +$arguments = $_SERVER['argv'] ?? []; + +// In a project, a global crest passes the project commands to the crest of +// the project. +$status = (new HandOff())->run($arguments, new Output()); + +if (null !== $status) { + exit($status); +} + $kernel = new Kernel(Commands::NAME, Commands::registry(), Commands::PACKAGE); -exit($kernel->handle($_SERVER['argv'] ?? [])); +exit($kernel->handle($arguments)); diff --git a/docs/index.md b/docs/index.md index 4c258fd..a9116a2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,9 +64,9 @@ same router and document root as the container. The port is `--port`, else as docker compose does: the last `APP_PORT` line wins, and `export`, `APP_PORT: `, quotes and `#` comments are allowed. It does not expand `${...}`. Such a value stops `serve` with an error. `serve` finds the root -from a subdirectory, and it does not need the crest in `vendor/`, so the -global crest runs it. It stops before PHP starts if `.htrouter.php` or -`vendor/autoload.php` is missing. +from a subdirectory. A global crest passes it to the crest in `vendor/`, as +it does the other project commands. It stops before PHP starts if +`.htrouter.php` or `vendor/autoload.php` is missing. The server uses the PHP that runs crest. PHP options on the command line, for example `-d extension=phalcon.so`, do not reach it. Put such settings in @@ -86,11 +86,21 @@ them with a crest outside the project, for example one that you install with The generated project requires `phalcon/crest` as a dev dependency. The commands that work on the project, for example `make:action` and `route:list`, need the autoloader and the Phalcon of the project. After -`crest install` or `composer install`, run them with the crest in `vendor/`: +`crest install` or `composer install`, the project has its own crest in +`vendor/`. A global crest passes these commands to it, with the same +arguments, and returns its exit status. In the container, there is no global +crest: - vendor/bin/crest make:action GET /hello + crest make:action GET /hello docker compose exec app vendor/bin/crest make:action GET /hello +`new`, `up`, `down`, `install` and `--version` always run in the crest that +you type. crest finds the project from the working directory, or from +`--directory`, and goes up to the nearest `composer.json`. If that project +requires `phalcon/crest` but has no `vendor/bin/crest`, crest stops: + + crest: /path/to/my-app has no vendor/bin/crest; run 'crest install' or 'composer install' first + The files come from the `project-*` stubs. To change them, publish them by name in the directory that the project goes into (the working directory, or `--directory`), then edit the copies: @@ -170,8 +180,8 @@ reads the filesystem and keeps working on a project that does not currently run. `make:action` takes an HTTP method and a route path: - vendor/bin/crest make:action GET /company/all - vendor/bin/crest make:action GET /company/{id} + crest make:action GET /company/all + crest make:action GET /company/{id} The class name comes from the framework's routing convention, so the file lands where the router will look for it. Placeholders must come last: `/album/{id}/edit` diff --git a/resources/infection.json5 b/resources/infection.json5 index 41f5e21..5668134 100644 --- a/resources/infection.json5 +++ b/resources/infection.json5 @@ -32,6 +32,8 @@ "Crest\\Generator\\Stub::render", "Crest\\Project\\Config::psr4Map", "Crest\\Command\\ServeCommand::envValue", + // Locator::project() found the composer.json first. + "Crest\\HandOff::missing", // Same guarantee from further off: sources() only ever yields // paths it has confirmed with is_file(), or glob() results. "Crest\\Command\\Stub\\PublishCommand::handle", diff --git a/resources/stubs/adr/project-readme.stub b/resources/stubs/adr/project-readme.stub index a503ebc..0b01f15 100644 --- a/resources/stubs/adr/project-readme.stub +++ b/resources/stubs/adr/project-readme.stub @@ -27,13 +27,13 @@ Then open http://localhost:8080/. ## Next steps -Use the crest in `vendor/` for these commands. It uses the autoloader and -the Phalcon of this project. On the host: +These commands use the crest in `vendor/`, with the autoloader and the +Phalcon of this project. On the host, a global crest passes them to it: - vendor/bin/crest make:action GET /hello - vendor/bin/crest route:list + crest make:action GET /hello + crest route:list -With docker: +Without a global crest, use `vendor/bin/crest`. With docker: docker compose exec {{ service }} vendor/bin/crest make:action GET /hello docker compose exec {{ service }} vendor/bin/crest route:list diff --git a/src/Commands.php b/src/Commands.php index 5271b2e..f0a2ea8 100644 --- a/src/Commands.php +++ b/src/Commands.php @@ -38,6 +38,12 @@ */ final class Commands { + /** + * The commands that run before a project exists. A global crest runs + * them itself, and passes the other commands to the crest of the project. + */ + public const HOST = ['down', 'install', 'new', 'up']; + /** * Composer `extra` key packages use to contribute commands. */ diff --git a/src/HandOff.php b/src/HandOff.php new file mode 100644 index 0000000..a5b68d4 --- /dev/null +++ b/src/HandOff.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest; + +use Crest\Console\Output; +use Crest\Process\Runner; +use Crest\Process\ShellRunner; +use Crest\Project\Locator; + +use function array_slice; +use function file_get_contents; +use function in_array; +use function is_file; +use function json_decode; +use function realpath; +use function sprintf; +use function str_starts_with; +use function strlen; +use function substr; + +use const PHP_BINARY; + +/** + * Passes a project command from a global crest to the crest of the project. + * + * A global crest loads its own autoloader. The project commands need the + * autoloader, the Phalcon and the crest version of the project. Thus, in a + * project, a global crest runs vendor/bin/crest of the project with the same + * arguments, and returns its exit status. + * + * The host commands (Commands::HOST) and --version stay in the crest that the + * user typed. + */ +final class HandOff +{ + /** + * Where composer puts the crest of a project that requires it. + */ + public const BINARY = 'vendor/bin/crest'; + + private readonly Runner $runner; + + private readonly string $self; + + /** + * @param string|null $self The root of the running crest package. Null + * for this package. A test gives another root. + */ + public function __construct(?Runner $runner = null, ?string $self = null) + { + $this->runner = $runner ?? new ShellRunner(); + $this->self = $self ?? Paths::root(); + } + + /** + * @param list $argv + * + * @return int|null The exit status of the crest of the project. Null when + * this crest runs the call. + */ + public function run(array $argv, Output $output): ?int + { + $tokens = array_slice($argv, 1); + + if (false === $this->isProjectCall($tokens)) { + return null; + } + + $start = realpath($this->directory($tokens)); + $root = false === $start ? null : Locator::project($start); + + if (null === $root) { + return null; + } + + $binary = $root . '/' . self::BINARY; + + if (false === is_file($binary)) { + return $this->missing($root, $output); + } + + if (true === $this->isSelf($root)) { + return null; + } + + return $this->runner->run([PHP_BINARY, $binary, ...$tokens]); + } + + /** + * Where the walk up starts: the --directory value, or the working + * directory. The last value wins, as in the parser. After `--`, tokens + * are values, not options. An empty value reads as absent. + * + * @param list $tokens + */ + private function directory(array $tokens): string + { + $directory = ''; + + foreach ($tokens as $index => $token) { + if ('--' === $token) { + break; + } + + if (true === str_starts_with($token, '--directory=')) { + $directory = substr($token, strlen('--directory=')); + } + + if ('--directory' === $token) { + $directory = $tokens[$index + 1] ?? ''; + } + } + + return '' === $directory ? '.' : $directory; + } + + /** + * The listing (no command, or an option first) and each command that is + * not a host command belong to the project. --version gives the version + * of the crest that the user typed. + * + * @param list $tokens + */ + private function isProjectCall(array $tokens): bool + { + $first = $tokens[0] ?? null; + + if (null === $first || true === str_starts_with($first, '-')) { + return false === in_array($first, ['--version', '-V'], true); + } + + return false === in_array($first, Commands::HOST, true); + } + + /** + * The crest of the project is this package. Without this check, the + * crest of the project passes the call to itself for ever. + */ + private function isSelf(string $root): bool + { + return realpath($root . '/vendor/' . Commands::PACKAGE) === realpath($this->self); + } + + /** + * No vendor/bin/crest. A project that requires crest has no vendor/ yet: + * no crest can run a project command there, so this is an error. Other + * projects do not use crest: this crest runs the call, as before. + */ + private function missing(string $root, Output $output): ?int + { + /** @var array{require?: array, require-dev?: array}|null $composer */ + $composer = json_decode((string) file_get_contents($root . '/composer.json'), true); + + if ( + false === isset($composer['require'][Commands::PACKAGE]) + && false === isset($composer['require-dev'][Commands::PACKAGE]) + ) { + return null; + } + + $output->error( + sprintf( + "%s: %s has no %s; run 'crest install' or 'composer install' first", + Commands::NAME, + $root, + self::BINARY + ) + ); + + return 1; + } +} diff --git a/src/Project/Locator.php b/src/Project/Locator.php index e0ef3d3..995d33e 100644 --- a/src/Project/Locator.php +++ b/src/Project/Locator.php @@ -17,19 +17,40 @@ use function is_file; /** - * Finds the nearest crest.php by walking up from a starting directory, so - * crest works from anywhere inside a project. + * Finds a file by walking up from a starting directory, so crest works from + * anywhere inside a project. */ final class Locator { public const FILENAME = 'crest.php'; + private const MANIFEST = 'composer.json'; + + /** + * The nearest crest.php. + */ public static function locate(string $from): ?string + { + return self::nearest($from, self::FILENAME); + } + + /** + * The project root: the nearest directory with a composer.json. The + * vendor/ directory of the project is next to that file. + */ + public static function project(string $from): ?string + { + $manifest = self::nearest($from, self::MANIFEST); + + return null === $manifest ? null : dirname($manifest); + } + + private static function nearest(string $from, string $name): ?string { $current = $from; while (true) { - $candidate = $current . '/' . self::FILENAME; + $candidate = $current . '/' . $name; if (true === is_file($candidate)) { return $candidate; diff --git a/tests/Unit/BinaryTest.php b/tests/Unit/BinaryTest.php new file mode 100644 index 0000000..e39bcd8 --- /dev/null +++ b/tests/Unit/BinaryTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit; + +use Crest\Paths; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +use function fclose; +use function file_put_contents; +use function json_encode; +use function proc_close; +use function proc_open; +use function stream_get_contents; + +use const PHP_BINARY; +use const PHP_EOL; + +/** + * Runs bin/crest as a child process, as a user does. The scratch root is a + * project with a fake vendor/bin/crest that prints its arguments and exits + * with status 7. + */ +final class BinaryTest extends TestCase +{ + use ScratchDirectory; + + protected function setUp(): void + { + $this->makeScratchDirectory('binary', 'vendor/bin'); + + file_put_contents( + $this->root . '/composer.json', + (string) json_encode(['require-dev' => ['phalcon/crest' => 'dev-master']]) + ); + file_put_contents( + $this->root . '/vendor/bin/crest', + "removeScratchDirectory(); + } + + public function testAProjectCommandRunsInTheCrestOfTheProject(): void + { + [$status, $stdout] = $this->crest(['route:list', '--trace']); + + $this->assertSame(7, $status); + $this->assertSame('project crest: route:list --trace' . PHP_EOL, $stdout); + } + + public function testTheVersionComesFromThisCrest(): void + { + [$status, $stdout] = $this->crest(['--version']); + + $this->assertSame(0, $status); + $this->assertStringContainsString('crest', $stdout); + $this->assertStringNotContainsString('project crest', $stdout); + } + + /** + * @param list $arguments + * + * @return array{int, string} The exit status and stdout. + */ + private function crest(array $arguments): array + { + $process = proc_open( + [PHP_BINARY, Paths::root() . '/bin/crest', ...$arguments], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $this->root + ); + + if (false === $process) { + throw new RuntimeException('could not start bin/crest'); + } + + $stdout = (string) stream_get_contents($pipes[1]); + + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($process), $stdout]; + } +} diff --git a/tests/Unit/HandOffTest.php b/tests/Unit/HandOffTest.php new file mode 100644 index 0000000..21bc2eb --- /dev/null +++ b/tests/Unit/HandOffTest.php @@ -0,0 +1,324 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Unit; + +use Crest\Console\Output; +use Crest\HandOff; +use Crest\Tests\Support\CapturesOutput; +use Crest\Tests\Support\Process\FakeRunner; +use Crest\Tests\Support\ScratchDirectory; +use PHPUnit\Framework\TestCase; + +use function chdir; +use function file_put_contents; +use function getcwd; +use function json_encode; +use function mkdir; +use function unlink; + +use const PHP_BINARY; +use const PHP_EOL; + +/** + * The scratch root has three directories: app/ is a project that requires + * crest and has vendor/bin/crest, other/ is not a project, and self/ is the + * running crest. + * + * A walk up from other/ finds the composer.json of this repository. It has no + * vendor/bin/crest, and it does not require phalcon/crest, so the call stays. + */ +final class HandOffTest extends TestCase +{ + use CapturesOutput; + use ScratchDirectory; + + private string $previousCwd = ''; + + private FakeRunner $runner; + + protected function setUp(): void + { + $this->makeScratchDirectory('hand-off', 'app/src/Action', 'app/vendor/bin', 'other', 'self'); + $this->captureStreams(); + $this->requiring('require-dev'); + + file_put_contents($this->root . '/app/vendor/bin/crest', "runner = new FakeRunner(); + + // Most calls start in the project, as the calls of a user who works + // in it. + $this->previousCwd = (string) getcwd(); + chdir($this->root . '/app'); + } + + protected function tearDown(): void + { + chdir($this->previousCwd); + + $this->closeStreams(); + $this->removeScratchDirectory(); + } + + /** + * @return iterable + */ + public static function hostCommands(): iterable + { + yield 'down' => ['down']; + yield 'install' => ['install']; + yield 'new' => ['new']; + yield 'up' => ['up']; + } + + /** + * @return iterable}> + */ + public static function listings(): iterable + { + yield 'no command' => [[]]; + yield 'an option first' => [['--help']]; + } + + /** + * @return iterable + */ + public static function requirements(): iterable + { + yield 'require' => ['require']; + yield 'require-dev' => ['require-dev']; + } + + /** + * @return iterable + */ + public static function versionFlags(): iterable + { + yield 'long' => ['--version']; + yield 'short' => ['-V']; + } + + public function testACallFromASubdirectoryIsPassedOn(): void + { + chdir($this->root . '/app/src/Action'); + + $this->handOff(['route:list']); + + $this->assertPassedOn(['route:list']); + } + + public function testADirectoryOptionAfterTheDoubleDashIsAValue(): void + { + chdir($this->root . '/other'); + + $this->assertStays($this->handOff(['route:list', '--', '--directory=' . $this->root . '/app'])); + } + + public function testADirectoryOptionWithoutAValueMeansTheWorkingDirectory(): void + { + // The project crest reports the missing value. + $this->handOff(['route:list', '--directory']); + + $this->assertPassedOn(['route:list', '--directory']); + } + + public function testADirectoryThatDoesNotExistRunsTheCallHere(): void + { + // This crest reports the directory, as it did before the hand-off. + $this->assertStays($this->handOff(['route:list', '--directory=' . $this->root . '/missing'])); + } + + public function testAnEmptyDirectoryOptionMeansTheWorkingDirectory(): void + { + $this->handOff(['route:list', '--directory=']); + + $this->assertPassedOn(['route:list', '--directory=']); + } + + public function testAProjectCommandIsPassedOn(): void + { + $status = $this->handOff(['route:list', '--trace'], 3); + + $this->assertSame(3, $status); + $this->assertPassedOn(['route:list', '--trace']); + } + + public function testAProjectThatDoesNotRequireCrestRunsTheCallHere(): void + { + // Not a crest project: this crest runs the call, as before. + file_put_contents($this->root . '/app/composer.json', '{"require": {"php": "^8.1"}}'); + unlink($this->root . '/app/vendor/bin/crest'); + + $this->assertStays($this->handOff(['route:list'])); + } + + /** + * @dataProvider requirements + */ + public function testAProjectThatRequiresCrestButHasNoBinaryIsReported(string $section): void + { + // No crest can run a project command before composer installs the + // project. + $this->requiring($section); + unlink($this->root . '/app/vendor/bin/crest'); + + $status = $this->handOff(['route:list']); + + $this->assertSame(1, $status); + $this->assertSame([], $this->runner->calls); + $this->assertSame( + 'crest: ' . $this->root . "/app has no vendor/bin/crest; run 'crest install' or 'composer install' first" + . PHP_EOL, + $this->readStderr() + ); + } + + public function testARelativeDirectoryOptionStartsInTheWorkingDirectory(): void + { + // The project crest reads the same path from the same working + // directory. + chdir($this->root . '/other'); + + $this->handOff(['route:list', '--directory=../app']); + + $this->assertPassedOn(['route:list', '--directory=../app']); + } + + public function testArgumentsReachTheProjectCrestUnchanged(): void + { + $tokens = ['make:action', 'GET', '/company/{id}', '--force', '--', '--help']; + + $this->handOff($tokens); + + $this->assertPassedOn($tokens); + } + + /** + * @dataProvider hostCommands + */ + public function testHostCommandsRunHere(string $command): void + { + $this->assertStays($this->handOff([$command, 'my-app'])); + } + + public function testOutsideAProjectTheCallRunsHere(): void + { + chdir($this->root . '/other'); + + $this->assertStays($this->handOff(['route:list'])); + } + + public function testTheCrestOfTheProjectRunsTheCallItself(): void + { + // vendor/bin/crest, run directly. A hand-off here would never stop. + mkdir($this->root . '/app/vendor/phalcon/crest', 0o775, true); + + $this->assertStays($this->handOff(['route:list'], 0, $this->root . '/app/vendor/phalcon/crest')); + } + + public function testTheDirectoryOptionFindsTheProject(): void + { + chdir($this->root . '/other'); + + $this->handOff(['route:list', '--directory', $this->root . '/app']); + + $this->assertPassedOn(['route:list', '--directory', $this->root . '/app']); + } + + public function testTheDirectoryOptionWithAnEqualsSignFindsTheProject(): void + { + chdir($this->root . '/other'); + + $this->handOff(['route:list', '--directory=' . $this->root . '/app']); + + $this->assertPassedOn(['route:list', '--directory=' . $this->root . '/app']); + } + + public function testTheLastDirectoryOptionWins(): void + { + // As in the parser, which keeps the last value. + chdir($this->root . '/other'); + + $tokens = [ + 'route:list', + '--directory=' . $this->root . '/other', + '--directory', + $this->root . '/app', + ]; + + $this->handOff($tokens); + + $this->assertPassedOn($tokens); + } + + /** + * @dataProvider listings + * + * @param list $tokens + */ + public function testTheListingIsPassedOn(array $tokens): void + { + // The listing of the project also shows the commands of its packages. + $this->handOff($tokens); + + $this->assertPassedOn($tokens); + } + + /** + * @dataProvider versionFlags + */ + public function testTheVersionIsTheVersionOfThisCrest(string $flag): void + { + $this->assertStays($this->handOff([$flag])); + } + + /** + * @param list $tokens + */ + private function assertPassedOn(array $tokens): void + { + $this->assertSame( + [[[PHP_BINARY, $this->root . '/app/vendor/bin/crest', ...$tokens], null]], + $this->runner->calls + ); + } + + private function assertStays(?int $status): void + { + $this->assertNull($status); + $this->assertSame([], $this->runner->calls); + } + + /** + * @param list $tokens The arguments after `crest`. + * @param string|null $self The root of the running crest. + */ + private function handOff(array $tokens, int $status = 0, ?string $self = null): ?int + { + $this->runner = new FakeRunner($status); + + return (new HandOff($this->runner, $self ?? $this->root . '/self'))->run( + ['crest', ...$tokens], + new Output($this->stdout, $this->stderr, false) + ); + } + + private function requiring(string $section): void + { + file_put_contents( + $this->root . '/app/composer.json', + (string) json_encode([$section => ['phalcon/crest' => 'dev-master']]) + ); + } +} diff --git a/tests/Unit/Project/LocatorTest.php b/tests/Unit/Project/LocatorTest.php index d9e6cff..20df4ae 100644 --- a/tests/Unit/Project/LocatorTest.php +++ b/tests/Unit/Project/LocatorTest.php @@ -40,6 +40,28 @@ public function testFindsTheFileInTheStartingDirectory(): void $this->assertSame($this->root . '/crest.php', Locator::locate($this->root)); } + public function testProjectIsNullWhenNoDirectoryHasAComposerJson(): void + { + // From the filesystem root: a walk from the scratch directory finds + // the composer.json of this repository. + $this->assertNull(Locator::project('/')); + } + + public function testProjectIsTheNearestDirectoryWithAComposerJson(): void + { + file_put_contents($this->root . '/composer.json', '{}'); + file_put_contents($this->root . '/src/composer.json', '{}'); + + $this->assertSame($this->root . '/src', Locator::project($this->root . '/src/Action/Deep')); + } + + public function testProjectWalksUpToTheComposerJson(): void + { + file_put_contents($this->root . '/composer.json', '{}'); + + $this->assertSame($this->root, Locator::project($this->root . '/src/Action/Deep')); + } + public function testReturnsNullOnceTheFilesystemRootIsPassed(): void { // Nothing is written, so the walk runs all the way to '/' and has to