diff --git a/.gitattributes b/.gitattributes index 5c2beb7174..eeb80b93de 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,4 +21,5 @@ phpstan.dist.neon export-ignore phpunit.bat export-ignore phpunit.dist.xml export-ignore SECURITY.md export-ignore +testbench.yaml export-ignore translator export-ignore diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 0000000000..126288f437 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,44 @@ +# Everything the test suite is known to write into the testbench skeleton +# (vendor/orchestra/testbench-core/laravel). Tests/TestCase clears these once per +# process before snapshotting the skeleton, so a suite run never inherits leftovers +# from a previous one. It's also what `vendor/bin/testbench package:purge-skeleton` +# removes. +purge: + directories: + - addons + - app/Actions + - app/Dictionaries + - app/Fieldtypes + - app/Modifiers + - app/Scopes + - app/Tags + - app/Widgets + - config/statamic + - public/diskimgroot + - public/glide + - public/imgcache + - public/static + - public/testimages + - public/vendor + - resources/addons + - resources/blueprints + - resources/content + - resources/css + - resources/dictionaries + - resources/fieldsets + - resources/forms + - resources/js + - resources/users + - storage/framework/testing/disks + - storage/statamic + files: + - app/Providers/AppServiceProvider.php + - composer.json.bak + - composer.lock + - package.json + - public/*.jpg + - resources/*.svg + - resources/preferences.yaml + - resources/sites.yaml + - storage/logs/*.log + - vite-cp.config.js diff --git a/tests/Actions/DuplicateFormTest.php b/tests/Actions/DuplicateFormTest.php index 2b44565a8f..4ce7839e21 100644 --- a/tests/Actions/DuplicateFormTest.php +++ b/tests/Actions/DuplicateFormTest.php @@ -7,11 +7,13 @@ use Statamic\Facades\Form; use Statamic\Facades\User; use Tests\FakesRoles; +use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; class DuplicateFormTest extends TestCase { use FakesRoles; + use PreventSavingStacheItemsToDisk; public function setUp(): void { diff --git a/tests/Antlers/ParserTestCase.php b/tests/Antlers/ParserTestCase.php index d6dacb7eeb..2e23b70b4f 100644 --- a/tests/Antlers/ParserTestCase.php +++ b/tests/Antlers/ParserTestCase.php @@ -2,6 +2,7 @@ namespace Tests\Antlers; +use Statamic\Contracts\View\Antlers\Parser as ParserContract; use Statamic\Facades\YAML; use Statamic\Fields\Blueprint; use Statamic\Fields\BlueprintRepository; @@ -46,6 +47,14 @@ protected function setUp(): void parent::setUp(); GlobalRuntimeState::resetGlobalState(); + + // The guarded/allowed path lists on GlobalRuntimeState are only populated as a side + // effect of resolving the real parser, and resetGlobalState() doesn't clear them. The + // tests here build their own parsers, so without this they'd run against whatever the + // last test to resolve one happened to leave behind - or against empty lists, which + // silently drop every modifier in user content, if nothing has resolved one yet. + app(ParserContract::class); + GlobalRuntimeState::$throwErrorOnAccessViolation = false; GlobalRuntimeState::$allowPhpInContent = false; GlobalRuntimeState::$allowMethodsInContent = false; diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php index 516df9febd..de52a40a57 100644 --- a/tests/Assets/AssetTest.php +++ b/tests/Assets/AssetTest.php @@ -2112,6 +2112,10 @@ public function it_doesnt_process_or_error_when_uploading_non_glideable_file_wit $this->container->sourcePreset('small'); + // Glide only creates its temp directory when it actually processes an image, so + // create it up front. Otherwise there'd be nothing for the assertion below to check. + app('files')->makeDirectory($glideDir = storage_path('statamic/glide/tmp'), 0777, true, true); + $asset = (new Asset)->container($this->container)->path("path/to/file.{$extension}")->syncOriginal(); Facades\AssetContainer::shouldReceive('findByHandle')->with('test_container')->andReturn($this->container); @@ -2123,7 +2127,6 @@ public function it_doesnt_process_or_error_when_uploading_non_glideable_file_wit $return = $asset->upload(UploadedFile::fake()->createWithContent("file.{$extension}", '')); $this->assertEquals($asset, $return); - $this->assertDirectoryExists($glideDir = storage_path('statamic/glide/tmp')); $this->assertEmpty(app('files')->allFiles($glideDir)); // no temp files Storage::disk('test')->assertExists("path/to/file.{$extension}"); $this->assertEquals("path/to/file.{$extension}", $asset->path()); diff --git a/tests/CP/Navigation/NavTest.php b/tests/CP/Navigation/NavTest.php index 9c8cced489..6a109a39a1 100644 --- a/tests/CP/Navigation/NavTest.php +++ b/tests/CP/Navigation/NavTest.php @@ -27,9 +27,6 @@ public function setUp(): void Route::any('wordpress-importer', ['as' => 'statamic.cp.wordpress-importer.index']); Route::any('security-droids', ['as' => 'statamic.cp.security-droids.index']); - - // TODO: Other tests are leaving behind forms without titles that are causing failures here? - Facades\Form::shouldReceive('all')->andReturn(collect()); } #[Test] diff --git a/tests/Console/Commands/MakeAddonTest.php b/tests/Console/Commands/MakeAddonTest.php index d2e195cbfe..90c551f3f8 100644 --- a/tests/Console/Commands/MakeAddonTest.php +++ b/tests/Console/Commands/MakeAddonTest.php @@ -3,6 +3,7 @@ namespace Tests\Console\Commands; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Facades\Process; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -19,6 +20,10 @@ public function setUp(): void $this->markTestSkippedInWindows(); + // Without this, the addon's `npm install` runs for real. Since the testbench app + // has no package.json, npm walks up and installs against this repo's own one. + Process::fake(); + $this->files = app(Filesystem::class); $this->fakeSuccessfulComposerRequire(); } diff --git a/tests/Feature/Blueprints/ViewBlueprintListingTest.php b/tests/Feature/Blueprints/ViewBlueprintListingTest.php index 86feed329f..f1394c48ec 100644 --- a/tests/Feature/Blueprints/ViewBlueprintListingTest.php +++ b/tests/Feature/Blueprints/ViewBlueprintListingTest.php @@ -51,6 +51,8 @@ public function it_lets_you_edit_a_custom_namespace_blueprint() Facades\Blueprint::addNamespace($namespace, 'resources/content/'.$namespace); + $this->createBlueprint($namespace, $handle)->save(); + $this ->actingAs($user) ->get(cp_route('blueprints.additional.edit', [$namespace, $handle])) @@ -58,8 +60,8 @@ public function it_lets_you_edit_a_custom_namespace_blueprint() ->assertInertia(fn ($page) => $page->component('blueprints/Edit')); } - private function createBlueprint($handle) + private function createBlueprint($namespace, $handle) { - return tap(new Blueprint)->setHandle($handle); + return tap(new Blueprint)->setHandle($handle)->setNamespace($namespace); } } diff --git a/tests/Modifiers/ArrayAccessType.php b/tests/Modifiers/ArrayAccessType.php new file mode 100644 index 0000000000..8fba776770 --- /dev/null +++ b/tests/Modifiers/ArrayAccessType.php @@ -0,0 +1,39 @@ +data = $data; + } + + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->data[$offset]; + } + + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + return isset($this->data[$offset]); + } + + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + // + } + + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + // + } +} diff --git a/tests/Modifiers/Item.php b/tests/Modifiers/Item.php new file mode 100644 index 0000000000..42d72cfb7d --- /dev/null +++ b/tests/Modifiers/Item.php @@ -0,0 +1,18 @@ +data($data); + } +} diff --git a/tests/Modifiers/ItemWithOrigin.php b/tests/Modifiers/ItemWithOrigin.php new file mode 100644 index 0000000000..c46248d485 --- /dev/null +++ b/tests/Modifiers/ItemWithOrigin.php @@ -0,0 +1,30 @@ +data($data); + $this->origin = $origin; + } + + public function origin($origin = null) + { + // Bypass the logic to load the origin. Just use what was passed in. + return $this->origin; + } + + public function getOriginByString($origin) + { + // Required by trait + } +} diff --git a/tests/Modifiers/PluckTest.php b/tests/Modifiers/PluckTest.php index a5a48d5cb3..b49d8ada05 100644 --- a/tests/Modifiers/PluckTest.php +++ b/tests/Modifiers/PluckTest.php @@ -2,16 +2,12 @@ namespace Tests\Modifiers; -use ArrayAccess; use Illuminate\Support\Collection; use Mockery; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Query\Builder; -use Statamic\Data\ContainsData; -use Statamic\Data\HasOrigin; use Statamic\Entries\EntryCollection; use Statamic\Modifiers\Modify; -use Statamic\Support\Traits\FluentlyGetsAndSets; use Tests\TestCase; class PluckTest extends TestCase @@ -168,72 +164,3 @@ private function modify($value, $key) return Modify::value($value)->pluck([$key])->fetch(); } } - -// Represents an object that doesn't have origins and therefore wouldn't have a "value" method. -// So a "get" method would need to be used. e.g. a form Submission. -class Item -{ - use ContainsData, FluentlyGetsAndSets; - - public function __construct($data) - { - $this->data($data); - } -} - -// Represents an object that could have an origin and therefore a "value" method. e.g. an Entry. -class ItemWithOrigin -{ - use ContainsData, FluentlyGetsAndSets, HasOrigin; - - public function __construct($data, $origin = null) - { - $this->data($data); - $this->origin = $origin; - } - - public function origin($origin = null) - { - // Bypass the logic to load the origin. Just use what was passed in. - return $this->origin; - } - - public function getOriginByString($origin) - { - // Required by trait - } -} - -class ArrayAccessType implements ArrayAccess -{ - private $data; - - public function __construct($data) - { - $this->data = $data; - } - - #[\ReturnTypeWillChange] - public function offsetGet($offset) - { - return $this->data[$offset]; - } - - #[\ReturnTypeWillChange] - public function offsetExists($offset) - { - return isset($this->data[$offset]); - } - - #[\ReturnTypeWillChange] - public function offsetSet($offset, $value) - { - // - } - - #[\ReturnTypeWillChange] - public function offsetUnset($offset) - { - // - } -} diff --git a/tests/RestoresTestbenchSkeleton.php b/tests/RestoresTestbenchSkeleton.php new file mode 100644 index 0000000000..98444e89dc --- /dev/null +++ b/tests/RestoresTestbenchSkeleton.php @@ -0,0 +1,162 @@ +purgeTestbenchSkeleton(); + + self::$skeletonSnapshot = $this->scanTestbenchSkeleton(); + } + + /** + * The snapshot only stops tests within a process from leaking into each other. A process + * starting against a skeleton dirtied by an earlier run would bake that dirt into its + * snapshot, so clear the known offenders first. + */ + private function purgeTestbenchSkeleton(): void + { + $files = new Filesystem; + + $purge = Config::loadFromYaml(__DIR__.'/..')->getPurgeAttributes(); + + $expand = fn ($paths) => (new Collection($paths)) + ->map(fn ($path) => default_skeleton_path().'/'.$path) + ->flatMap(fn ($path) => str_contains($path, '*') ? $files->glob($path) : [$path]); + + foreach ($expand($purge['files']) as $file) { + $files->delete($file); + } + + foreach ($expand($purge['directories']) as $directory) { + $this->deleteDirectory($directory); + } + } + + protected function restoreTestbenchSkeleton(): void + { + if (self::$skeletonSnapshot === null) { + return; + } + + $added = array_diff_key($this->scanTestbenchSkeleton(), self::$skeletonSnapshot); + + // Deepest first, so directories are empty by the time we get to them. + uksort($added, fn ($a, $b) => substr_count($b, '/') <=> substr_count($a, '/')); + + foreach ($added as $path => $ignored) { + $absolute = self::$skeletonPath.'/'.$path; + + // Same reasoning as DeletesDirectories: never ask whether the path is a file, a + // directory or a link, because a junction gives contradictory answers. unlink() + // takes files and file links, rmdir() takes empty directories, directory links + // and junctions without following them. + @unlink($absolute) || @rmdir($absolute); + } + } + + private function scanTestbenchSkeleton(): array + { + $paths = []; + + $scan = function ($relative) use (&$scan, &$paths) { + $absolute = self::$skeletonPath.($relative ? '/'.$relative : ''); + + foreach (scandir($absolute) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $relative ? $relative.'/'.$entry : $entry; + + if (in_array($path, self::$skeletonExclusions)) { + continue; + } + + if ($this->isRealSkeletonDirectory($absolute.'/'.$entry)) { + $scan($path); + } + + $paths[$path] = true; + } + }; + + $scan(''); + + return $paths; + } + + /** + * Whether the scan may descend into a path. Recursing through a link would record its + * target's contents as skeleton paths, and the restore would then delete files that can + * live anywhere on disk, so anything we can't positively place inside the skeleton is + * left alone. Resolving the path is what settles it: a junction whose lstat mode makes + * is_dir() and is_link() disagree still resolves to wherever it points. + */ + private function isRealSkeletonDirectory(string $path): bool + { + clearstatcache(true, $path); + + if (is_link($path) || ! is_dir($path)) { + return false; + } + + $resolved = realpath($path); + + return $resolved !== false + && str_starts_with($resolved.DIRECTORY_SEPARATOR, self::$skeletonRealPath.DIRECTORY_SEPARATOR); + } +} diff --git a/tests/RestoresTestbenchSkeletonTest.php b/tests/RestoresTestbenchSkeletonTest.php new file mode 100644 index 0000000000..87c116f273 --- /dev/null +++ b/tests/RestoresTestbenchSkeletonTest.php @@ -0,0 +1,75 @@ +target = dirname(base_path()).'/restores-testbench-skeleton-tmp'; + } + + public function tearDown(): void + { + $this->deleteDirectory($this->target); + + parent::tearDown(); + } + + #[Test] + public function it_removes_files_and_directories_a_test_added() + { + File::put($file = base_path('added.html'), ''); + File::put($nested = base_path('added-dir/nested/deep.html'), ''); + + $this->restoreTestbenchSkeleton(); + + clearstatcache(); + + $this->assertFileDoesNotExist($file); + $this->assertFileDoesNotExist($nested); + $this->assertDirectoryDoesNotExist(base_path('added-dir')); + } + + #[Test] + public function it_removes_links_without_following_them() + { + File::put($this->target.'/kept.html', ''); + File::put($targetFile = $this->target.'/kept-file.html', ''); + + app('files')->link($this->target, $linkedDir = base_path('linked-dir')); + app('files')->link($targetFile, $linkedFile = base_path('linked-file.html')); + + // Filesystem::link() shells out on Windows and throws away exec()'s result, so a link + // that never got created would leave every assertion below passing on a path that + // isn't there. Fail loudly instead of silently testing nothing. + $this->assertDirectoryExists($linkedDir); + $this->assertFileExists($linkedFile); + + $this->restoreTestbenchSkeleton(); + + clearstatcache(); + + $this->assertFalse(is_link($linkedDir)); + $this->assertDirectoryDoesNotExist($linkedDir); + $this->assertFalse(is_link($linkedFile)); + $this->assertFileDoesNotExist($linkedFile); + + // If the scan had descended into the link, the restore would have deleted the + // target's contents along with it. + $this->assertFileExists($this->target.'/kept.html'); + $this->assertFileExists($targetFile); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index cc9f0f401a..569eb24fa9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -12,7 +12,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase { - use WindowsHelpers; + use RestoresTestbenchSkeleton, WindowsHelpers; protected $shouldFakeVersion = true; protected $shouldPreventNavBeingBuilt = true; @@ -20,6 +20,8 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase protected function setUp(): void { + $this->prepareTestbenchSkeleton(); + parent::setUp(); $this->withoutVite(); @@ -57,7 +59,14 @@ public function tearDown(): void $this->deleteFakeStacheDirectory(); } - parent::tearDown(); + // Mockery verifies its expectations inside parent::tearDown() and throws when they + // aren't met, which would otherwise skip the restore and leak the failing test's files + // into the next one - right when you're already trying to work out what went wrong. + try { + parent::tearDown(); + } finally { + $this->restoreTestbenchSkeleton(); + } } protected function getPackageProviders($app)