From 5fcbb3e6c0fa44febad0ca075c67560c1916df01 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sat, 8 Aug 2026 10:21:02 -0400 Subject: [PATCH 01/15] Restore the testbench skeleton between tests The skeleton at vendor/orchestra/testbench-core/laravel persists for the life of a process, so anything a test writes there is visible to every test that follows it. 198 of our test files leave files behind, which is how a form file containing only {} ends up crashing CoreNavTest - it only passes today because of lucky ordering, and that luck runs out as soon as the suite is split across processes. Snapshot the skeleton once per process and delete anything new after each test. Directories the framework owns (bootstrap/cache, storage/framework/views and friends) are left alone, both because deleting them breaks the app and because walking them gets expensive. A process that starts against an already dirty skeleton would bake that dirt into its snapshot, so testbench.yaml declares the paths our tests are known to write and those get cleared before the first boot - which also means vendor/bin/testbench package:purge-skeleton cleans up after us. --- testbench.yaml | 44 ++++++++++ tests/RestoresTestbenchSkeleton.php | 126 ++++++++++++++++++++++++++++ tests/TestCase.php | 8 +- 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 testbench.yaml create mode 100644 tests/RestoresTestbenchSkeleton.php 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/RestoresTestbenchSkeleton.php b/tests/RestoresTestbenchSkeleton.php new file mode 100644 index 0000000000..4d4c852323 --- /dev/null +++ b/tests/RestoresTestbenchSkeleton.php @@ -0,0 +1,126 @@ +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) { + $files->deleteDirectory($directory); + } + } + + protected function snapshotTestbenchSkeleton(): void + { + if (self::$skeletonSnapshot !== null) { + return; + } + + self::$skeletonPath = $this->app->basePath(); + self::$skeletonSnapshot = $this->scanTestbenchSkeleton(); + } + + 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 => $isDir) { + $absolute = self::$skeletonPath.'/'.$path; + + $isDir ? @rmdir($absolute) : @unlink($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; + } + + $isDir = is_dir($absolute.'/'.$entry) && ! is_link($absolute.'/'.$entry); + + if ($isDir) { + $scan($path); + } + + $paths[$path] = $isDir; + } + }; + + $scan(''); + + return $paths; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index cc9f0f401a..95ea9a3bb2 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,8 +20,12 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase protected function setUp(): void { + $this->purgeTestbenchSkeleton(); + parent::setUp(); + $this->snapshotTestbenchSkeleton(); + $this->withoutVite(); $this->withoutMiddleware(AuthenticateSession::class); @@ -58,6 +62,8 @@ public function tearDown(): void } parent::tearDown(); + + $this->restoreTestbenchSkeleton(); } protected function getPackageProviders($app) From d67d8420a5ffd617229e90cbf0f27a13c1726621 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sat, 8 Aug 2026 10:21:07 -0400 Subject: [PATCH 02/15] Stop MakeAddonTest running npm install against this repo Making an addon with a fieldtype runs 'npm install' from the testbench app's base path. That app has no package.json, so npm walks up and installs against ours, rewriting package-lock.json in the working tree. The other commands that trigger this already fake the process. --- tests/Console/Commands/MakeAddonTest.php | 5 +++++ 1 file changed, 5 insertions(+) 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(); } From a72607ab21268cfbe0f8fecb0c7522e7c838a879 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sat, 8 Aug 2026 10:21:07 -0400 Subject: [PATCH 03/15] Stop DuplicateFormTest writing users into the fixtures directory The users it makes have no id, so saving them writes tests/__fixtures__/users/.yaml into the repo. Point the stache stores at the throwaway directory like the other tests that save users do. --- tests/Actions/DuplicateFormTest.php | 2 ++ 1 file changed, 2 insertions(+) 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 { From 2072e5762853b02d97406561aaf890e5bfeae53e Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sat, 8 Aug 2026 10:21:12 -0400 Subject: [PATCH 04/15] Create the blueprint ViewBlueprintListingTest needs It was asserting a custom namespace blueprint could be edited without ever creating one, and only passed because StoreCustomBlueprintTest had left one behind in the testbench skeleton. --- tests/Feature/Blueprints/ViewBlueprintListingTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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); } } From c403c7dbccbe07235d049ea2dc9f47910da97532 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sat, 8 Aug 2026 10:21:12 -0400 Subject: [PATCH 05/15] Create glide's temp directory in the non-glideable upload test Glide only makes the directory when it actually processes an image, which by definition never happens here. The test was relying on an earlier one in the file having made it, so make it up front and keep the assertion that nothing lands in it. --- tests/Assets/AssetTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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()); From 2d3fda60d2df0ad01809d03538481e5d6bdcd72c Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sat, 8 Aug 2026 23:04:53 -0400 Subject: [PATCH 06/15] Give the modifier test helper classes their own files Item, ItemWithOrigin and ArrayAccessType were declared at the bottom of PluckTest.php, so SelectTest only found them if PluckTest.php happened to be loaded into the same process first. Tests\ is autoloaded from tests/, so a file each is all they need. --- tests/Modifiers/ArrayAccessType.php | 39 +++++++++++++++ tests/Modifiers/Item.php | 18 +++++++ tests/Modifiers/ItemWithOrigin.php | 30 ++++++++++++ tests/Modifiers/PluckTest.php | 73 ----------------------------- 4 files changed, 87 insertions(+), 73 deletions(-) create mode 100644 tests/Modifiers/ArrayAccessType.php create mode 100644 tests/Modifiers/Item.php create mode 100644 tests/Modifiers/ItemWithOrigin.php 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) - { - // - } -} From ca8408c31fcbea171f5326bbc613a54881c25923 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sat, 8 Aug 2026 23:09:36 -0400 Subject: [PATCH 07/15] Apply the real Antlers runtime configuration in ParserTestCase The guarded and allowed path lists on GlobalRuntimeState are only populated as a side effect of resolving the parser out of the container, and resetGlobalState() leaves them alone. These tests build parsers by hand, so they were running against whatever the last test to resolve one left behind. First Antlers test in a process got empty allow lists, which silently drops every modifier in user content - so {{ now format="Y" }} rendered a full datetime and a preparsed | upper did nothing. --- tests/Antlers/ParserTestCase.php | 9 +++++++++ 1 file changed, 9 insertions(+) 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; From 2030d41e7014c66162196a581341378dba4c89e7 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sat, 8 Aug 2026 23:33:19 -0400 Subject: [PATCH 08/15] Stop mocking away leftover forms in NavTest The mock was compensating for form files other tests had left in the testbench app. Any form adds children to the Forms nav item, which is what broke the assertions - not the missing titles the TODO guessed at. Nothing leaves forms behind now. --- tests/CP/Navigation/NavTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/CP/Navigation/NavTest.php b/tests/CP/Navigation/NavTest.php index 29ce26e972..ee4dfe9bad 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] From b5340089cd0e4fe5da5fbdb7331afea7ef4d66a5 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sun, 9 Aug 2026 18:02:31 -0400 Subject: [PATCH 09/15] Harden skeleton deletion against Windows reparse points Three changes, all following DeletesDirectories from #15145. The purge deletes whole trees, so it uses the trait directly. The restore deletes a computed diff of individual paths rather than a tree, so it takes the technique instead: unlink() then rmdir(), never asking what the path is, since a junction reports an lstat mode that makes is_dir() and is_link() contradict each other. The scan mattered most. It used the same ambiguous answer to decide whether to recurse, so a junction that said "directory" would have had its target's contents recorded as skeleton paths - and the restore would then have deleted files from wherever that junction pointed. It now descends only into paths that resolve to somewhere inside the skeleton. --- tests/RestoresTestbenchSkeleton.php | 46 ++++++++++++++---- tests/RestoresTestbenchSkeletonTest.php | 64 +++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 tests/RestoresTestbenchSkeletonTest.php diff --git a/tests/RestoresTestbenchSkeleton.php b/tests/RestoresTestbenchSkeleton.php index 4d4c852323..63b8acc3bd 100644 --- a/tests/RestoresTestbenchSkeleton.php +++ b/tests/RestoresTestbenchSkeleton.php @@ -10,14 +10,18 @@ trait RestoresTestbenchSkeleton { + use DeletesDirectories; + /** - * The skeleton's contents before any test in this process had a chance to touch it, - * keyed by path relative to the skeleton root so lookups are hash based. + * The set of paths, relative to the skeleton root, that existed before any test in this + * process had a chance to touch it. Paths are keys so lookups are hash based. */ private static ?array $skeletonSnapshot = null; private static ?string $skeletonPath = null; + private static ?string $skeletonRealPath = null; + private static bool $skeletonPurged = false; /** @@ -59,7 +63,7 @@ protected function purgeTestbenchSkeleton(): void } foreach ($expand($purge['directories']) as $directory) { - $files->deleteDirectory($directory); + $this->deleteDirectory($directory); } } @@ -70,6 +74,7 @@ protected function snapshotTestbenchSkeleton(): void } self::$skeletonPath = $this->app->basePath(); + self::$skeletonRealPath = realpath(self::$skeletonPath) ?: self::$skeletonPath; self::$skeletonSnapshot = $this->scanTestbenchSkeleton(); } @@ -84,10 +89,14 @@ protected function restoreTestbenchSkeleton(): void // 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 => $isDir) { + foreach ($added as $path => $ignored) { $absolute = self::$skeletonPath.'/'.$path; - $isDir ? @rmdir($absolute) : @unlink($absolute); + // 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); } } @@ -109,13 +118,11 @@ private function scanTestbenchSkeleton(): array continue; } - $isDir = is_dir($absolute.'/'.$entry) && ! is_link($absolute.'/'.$entry); - - if ($isDir) { + if ($this->isRealSkeletonDirectory($absolute.'/'.$entry)) { $scan($path); } - $paths[$path] = $isDir; + $paths[$path] = true; } }; @@ -123,4 +130,25 @@ private function scanTestbenchSkeleton(): array 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..0cfe013cf7 --- /dev/null +++ b/tests/RestoresTestbenchSkeletonTest.php @@ -0,0 +1,64 @@ +target = __DIR__.'/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')); + + $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); + } +} From d4c9c76c12888f941b5061ce945218a0f4deae71 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sun, 9 Aug 2026 18:42:04 -0400 Subject: [PATCH 10/15] Snapshot the skeleton before the app boots The snapshot was taken after parent::setUp(), so anything the first boot of a process created was already there and became part of the baseline - invisible to the restore for the rest of that process. GlideTest roots a filesystem disk at public/glide and Flysystem creates the root when the disk resolves, so a process starting with that file left the directory behind. resources/sites.yaml and storage/statamic likewise survived every run. Taking the snapshot before the first boot instead means a run now leaves the skeleton byte for byte as it found it. Costs roughly twenty seconds across the suite, since those boot artifacts are now recreated per test rather than once. --- tests/RestoresTestbenchSkeleton.php | 39 +++++++++++++++-------------- tests/TestCase.php | 4 +-- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/tests/RestoresTestbenchSkeleton.php b/tests/RestoresTestbenchSkeleton.php index 63b8acc3bd..e52803edb5 100644 --- a/tests/RestoresTestbenchSkeleton.php +++ b/tests/RestoresTestbenchSkeleton.php @@ -22,8 +22,6 @@ trait RestoresTestbenchSkeleton private static ?string $skeletonRealPath = null; - private static bool $skeletonPurged = false; - /** * Subtrees the framework owns and rebuilds on demand. Deleting these breaks * subsequent tests ("Please provide a valid cache path"), and walking them gets @@ -38,18 +36,32 @@ trait RestoresTestbenchSkeleton ]; /** - * The snapshot below 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 before the app is ever booted. + * Runs once per process, before the first app is booted. Booting first would mean the + * directories the boot creates - a disk's root, say - were already there when the + * snapshot was taken, making them part of the baseline and invisible to the restore for + * the rest of the process. */ - protected function purgeTestbenchSkeleton(): void + protected function prepareTestbenchSkeleton(): void { - if (self::$skeletonPurged) { + if (self::$skeletonSnapshot !== null) { return; } - self::$skeletonPurged = true; + self::$skeletonPath = default_skeleton_path(); + self::$skeletonRealPath = realpath(self::$skeletonPath) ?: self::$skeletonPath; + + $this->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(); @@ -67,17 +79,6 @@ protected function purgeTestbenchSkeleton(): void } } - protected function snapshotTestbenchSkeleton(): void - { - if (self::$skeletonSnapshot !== null) { - return; - } - - self::$skeletonPath = $this->app->basePath(); - self::$skeletonRealPath = realpath(self::$skeletonPath) ?: self::$skeletonPath; - self::$skeletonSnapshot = $this->scanTestbenchSkeleton(); - } - protected function restoreTestbenchSkeleton(): void { if (self::$skeletonSnapshot === null) { diff --git a/tests/TestCase.php b/tests/TestCase.php index 95ea9a3bb2..0389eef271 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -20,12 +20,10 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase protected function setUp(): void { - $this->purgeTestbenchSkeleton(); + $this->prepareTestbenchSkeleton(); parent::setUp(); - $this->snapshotTestbenchSkeleton(); - $this->withoutVite(); $this->withoutMiddleware(AuthenticateSession::class); From 74449916f1f5570836a1433ee591ce0b379fcba9 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sun, 9 Aug 2026 23:38:38 -0400 Subject: [PATCH 11/15] Restore the skeleton even when tearDown throws Mockery verifies its expectations inside parent::tearDown() and throws when they aren't met, so the restore never ran for a test that failed that way and its files leaked into the next one - exactly when you're already trying to work out what went wrong. --- tests/TestCase.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index 0389eef271..569eb24fa9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -59,9 +59,14 @@ public function tearDown(): void $this->deleteFakeStacheDirectory(); } - parent::tearDown(); - - $this->restoreTestbenchSkeleton(); + // 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) From 72cff026f494b172f482f92d67431cef15b8ac4a Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sun, 9 Aug 2026 23:38:38 -0400 Subject: [PATCH 12/15] Say what the skeleton restore doesn't cover Two things the comments claimed more than the code delivers. bootstrap/cache is excluded as framework-owned, but Statamic's addon manifest lands there too and so survives a run. And only added paths get restored - overwriting or deleting something the skeleton shipped would still carry across, which nothing does today. --- tests/RestoresTestbenchSkeleton.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/RestoresTestbenchSkeleton.php b/tests/RestoresTestbenchSkeleton.php index e52803edb5..98444e89dc 100644 --- a/tests/RestoresTestbenchSkeleton.php +++ b/tests/RestoresTestbenchSkeleton.php @@ -15,6 +15,9 @@ trait RestoresTestbenchSkeleton /** * The set of paths, relative to the skeleton root, that existed before any test in this * process had a chance to touch it. Paths are keys so lookups are hash based. + * + * Only paths a test adds get restored. A test that overwrites or deletes something the + * skeleton already shipped still affects the ones after it. Nothing does that today. */ private static ?array $skeletonSnapshot = null; @@ -23,9 +26,13 @@ trait RestoresTestbenchSkeleton private static ?string $skeletonRealPath = null; /** - * Subtrees the framework owns and rebuilds on demand. Deleting these breaks - * subsequent tests ("Please provide a valid cache path"), and walking them gets - * expensive once thousands of compiled views have piled up. + * Subtrees left alone because something else rebuilds them on demand. Deleting them breaks + * subsequent tests ("Please provide a valid cache path"), and walking storage/framework/views + * gets expensive once thousands of compiled views have piled up. + * + * Note bootstrap/cache isn't only the framework's: Statamic's addon manifest lands there as + * addons.php, so it survives a run and the next one starts with it. Empty in practice, but + * it sits outside the guarantee the rest of this trait makes. */ private static array $skeletonExclusions = [ 'bootstrap/cache', From 876328d6e72670e26e0ce189d25a11c3a57f4a00 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sun, 9 Aug 2026 23:38:38 -0400 Subject: [PATCH 13/15] Put the skeleton test's link target in the temp directory It was writing into tests/. Cleaned up afterwards, but it's the exact thing the trait under test exists to prevent. Temp is still outside the skeleton, which is all the "doesn't follow links" assertions need. --- tests/RestoresTestbenchSkeletonTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/RestoresTestbenchSkeletonTest.php b/tests/RestoresTestbenchSkeletonTest.php index 0cfe013cf7..b04a89fc44 100644 --- a/tests/RestoresTestbenchSkeletonTest.php +++ b/tests/RestoresTestbenchSkeletonTest.php @@ -13,7 +13,9 @@ public function setUp(): void { parent::setUp(); - $this->target = __DIR__.'/restores-testbench-skeleton-tmp'; + // Has to live outside the skeleton for the "doesn't follow links" assertions to mean + // anything, and outside the repo so this test doesn't do what the trait exists to stop. + $this->target = sys_get_temp_dir().'/restores-testbench-skeleton-tmp'; } public function tearDown(): void From d7cc9f0ec5dca1bd7591a39b00247a78736d0a71 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Sun, 9 Aug 2026 23:38:38 -0400 Subject: [PATCH 14/15] Export-ignore testbench.yaml The other dev-only root files are already ignored, so it was shipping in the package for no reason. --- .gitattributes | 1 + 1 file changed, 1 insertion(+) 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 From 9396e5691de8c283a27a5b18912e41cfeb0099f7 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 10 Aug 2026 00:09:07 -0400 Subject: [PATCH 15/15] Make the link test fail when a link isn't created Moving the target to sys_get_temp_dir() was wrong on Windows. Filesystem::link() hard links the file case there and hard links can't cross volumes, so on a runner whose temp directory is on another drive the link would never be created - and every assertion in that test is "doesn't exist", so it would have passed while testing nothing. Put the target on the same volume, still outside the skeleton and still untracked, and assert both links exist before restoring so the test can't pass without them. --- tests/RestoresTestbenchSkeletonTest.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/RestoresTestbenchSkeletonTest.php b/tests/RestoresTestbenchSkeletonTest.php index b04a89fc44..87c116f273 100644 --- a/tests/RestoresTestbenchSkeletonTest.php +++ b/tests/RestoresTestbenchSkeletonTest.php @@ -13,9 +13,12 @@ public function setUp(): void { parent::setUp(); - // Has to live outside the skeleton for the "doesn't follow links" assertions to mean - // anything, and outside the repo so this test doesn't do what the trait exists to stop. - $this->target = sys_get_temp_dir().'/restores-testbench-skeleton-tmp'; + // Outside the skeleton, so descending into a link would be a genuine delete beyond it. + // Not in the repo's tracked tree, so this test doesn't do what the trait exists to stop. + // And on the same volume as the checkout: Filesystem::link() hard links the file case on + // Windows, and hard links can't cross volumes, so a temp dir on another drive wouldn't be + // linkable at all. dirname(base_path()) is the skeleton's parent, inside gitignored vendor. + $this->target = dirname(base_path()).'/restores-testbench-skeleton-tmp'; } public function tearDown(): void @@ -49,6 +52,12 @@ public function it_removes_links_without_following_them() 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();