From 2e26705451a8ad8c45cb0fd167a0848ab42eba05 Mon Sep 17 00:00:00 2001 From: roble Date: Tue, 21 Jul 2026 19:51:29 +0100 Subject: [PATCH] feat: Add tests for NewCommand to validate skeleton package resolution --- src/Console/Commands/NewCommand.php | 29 +++++++-- tests/Feature/NewCommandTest.php | 94 +++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/NewCommandTest.php diff --git a/src/Console/Commands/NewCommand.php b/src/Console/Commands/NewCommand.php index 582ba58..21fd0b1 100644 --- a/src/Console/Commands/NewCommand.php +++ b/src/Console/Commands/NewCommand.php @@ -18,6 +18,14 @@ class NewCommand extends Command { use DisplaysBanner; + /** + * Constraint used only when Packagist can't be reached to resolve the newest + * skeleton release. Must stay numeric — `@stable` does not override the + * `--stability=dev` that laravel/installer forces, so anything non-numeric + * silently installs dev-main. Bump this when the skeleton changes major. + */ + private const SKELETON_FALLBACK = '^2.0'; + protected $signature = 'new {name? : The name of the new Saucebase application} {--driver= : Environment driver (docker, native) — prompted if omitted} @@ -192,9 +200,14 @@ protected function skeletonPackage(): string return $package; } - $major = (int) explode('.', ltrim(Application::version(), 'v'))[0]; + // laravel/installer hardcodes --stability=dev when it shells out to + // `composer create-project`, so a bare package name resolves to dev-main. + // Verified: `@stable` does NOT override that flag — only a numeric + // constraint excludes the dev branch. So resolve the newest published + // release and pin it, rather than coupling to the installer's own major. + $latest = $this->latestVersion($package); - return $major > 0 ? "{$package}:^{$major}.0" : $package; + return "{$package}:".($latest ?? self::SKELETON_FALLBACK); } /** @@ -276,16 +289,22 @@ protected function checkForUpdates(): ?int return null; } - protected function latestVersion(): ?string + /** + * The newest published release of a package, or null when offline. + * + * Packagist's p2 endpoint lists tagged releases only (dev branches live in a + * separate ~dev.json), so index 0 is always the latest stable. + */ + protected function latestVersion(string $package = 'saucebase/installer'): ?string { try { - $response = Http::timeout(2)->get('https://repo.packagist.org/p2/saucebase/installer.json'); + $response = Http::timeout(2)->get("https://repo.packagist.org/p2/{$package}.json"); if (! $response->ok()) { return null; } - $version = $response->json('packages.saucebase/installer.0.version'); + $version = $response->json("packages.{$package}.0.version"); return is_string($version) ? ltrim($version, 'v') : null; } catch (\Throwable) { diff --git a/tests/Feature/NewCommandTest.php b/tests/Feature/NewCommandTest.php new file mode 100644 index 0000000..7ce1c0b --- /dev/null +++ b/tests/Feature/NewCommandTest.php @@ -0,0 +1,94 @@ +fakeOptions = $options; + $command->fakeLatest = $latest; + + return $command; + } + + public function test_skeleton_is_pinned_to_the_newest_published_release(): void + { + $this->assertSame( + 'saucebase/saucebase:2.9.4', + $this->command()->exposedSkeletonPackage() + ); + } + + public function test_skeleton_looks_up_the_skeleton_not_the_installer(): void + { + $command = $this->command(); + $command->exposedSkeletonPackage(); + + $this->assertSame('saucebase/saucebase', $command->queriedPackage); + } + + /** + * laravel/installer forces --stability=dev, and `@stable` does not override it, + * so the constraint must always be numeric or new apps silently get dev-main. + */ + public function test_falls_back_to_a_numeric_constraint_when_offline(): void + { + $package = $this->command(latest: null)->exposedSkeletonPackage(); + + $this->assertSame('saucebase/saucebase:^2.0', $package); + $this->assertDoesNotMatchRegularExpression('/@(stable|dev)/', $package); + } + + public function test_dev_mode_uses_the_unconstrained_package(): void + { + $this->assertSame( + 'saucebase/saucebase', + $this->command(['dev' => true])->exposedSkeletonPackage() + ); + } + + public function test_using_option_overrides_the_skeleton(): void + { + $this->assertSame( + 'vendor/other-skeleton', + $this->command(['using' => 'vendor/other-skeleton'])->exposedSkeletonPackage() + ); + } +} + +class TestableNewCommand extends NewCommand +{ + /** @var array Fake option values for tests that bypass CLI input. */ + public array $fakeOptions = []; + + public function option($key = null): string|array|bool|null + { + if (! empty($this->fakeOptions)) { + return $key !== null ? ($this->fakeOptions[$key] ?? false) : $this->fakeOptions; + } + + return false; + } + + /** Stubbed Packagist lookup — tests must not hit the network. */ + public ?string $fakeLatest = null; + + public ?string $queriedPackage = null; + + protected function latestVersion(string $package = 'saucebase/installer'): ?string + { + $this->queriedPackage = $package; + + return $this->fakeLatest; + } + + public function exposedSkeletonPackage(): string + { + return $this->skeletonPackage(); + } +}