diff --git a/lang/en/messages.php b/lang/en/messages.php
index 9ed47bf6e81..0508f562edc 100644
--- a/lang/en/messages.php
+++ b/lang/en/messages.php
@@ -21,10 +21,19 @@
'blueprints_intro' => 'Blueprints define and organize fields into content models for collections, forms, and other data.',
'blueprints_title_instructions' => 'Use a singular noun, such as \'Article\' or \'Product\'',
'cache_utility_application_cache_description' => 'Laravel\'s unified cache used by Statamic, third party addons, and composer packages.',
+ 'cache_utility_asset_images_cleared' => '{1} Cleared :count cached image (:size) for :asset in :time.|[0,*] Cleared :count cached images (:size) for :asset in :time.',
+ 'cache_utility_coverage' => '{1} :cached of :total warmable URL cached.|[0,*] :cached of :total warmable URLs cached.',
'cache_utility_description' => 'Manage and view important information about Statamic\'s various caching layers.',
'cache_utility_image_cache_description' => 'The image cache stores copies of all transformed and resized images.',
+ 'cache_utility_images_cleared' => '{1} Cleared :count cached image (:size) from :container in :time.|[0,*] Cleared :count cached images (:size) from :container in :time.',
+ 'cache_utility_invalidated' => '{1} Cleared :count cached page in :time.|[0,*] Cleared :count cached pages in :time.',
+ 'cache_utility_invalid_wildcard' => 'Use a single wildcard at the end of a URL, such as /blog/*.',
+ 'cache_utility_matches' => '{0} No cached pages match these URLs.|{1} :count cached page matches.|[2,*] :count cached pages match.',
+ 'cache_utility_pages_cache_failed' => '{1} :count page could not be cached.|[0,*] :count pages could not be cached.',
+ 'cache_utility_pages_cached' => '{1} Cached :count page.|[0,*] Cached :count pages.',
'cache_utility_stache_description' => 'The Stache is Statamic\'s content store that functions much like a database. It is generated automatically from the content files.',
'cache_utility_static_cache_description' => 'Static pages bypass Statamic completely and are rendered directly from the server for maximum performance.',
+ 'cache_utility_urls_instructions' => 'One URL per line. Use /blog/* to include a section.',
'choose_entry_localization_deletion_behavior' => 'Choose the action you wish to perform on the localized entries.',
'collection_configure_date_behavior_private' => 'Private - Hidden from listings, URLs return 404',
'collection_configure_date_behavior_public' => 'Public - Always visible',
diff --git a/resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue b/resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue
index 63aef1f3230..65a1498aa96 100644
--- a/resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue
+++ b/resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue
@@ -8,7 +8,7 @@
* Select-all-matching uses A (always reserved so actions skip it).
*/
import { Motion } from 'motion-v';
-import { computed, onMounted, onUnmounted } from 'vue';
+import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Button, ButtonGroup, Icon } from '@ui';
// ——— Keyboard shortcut constants ———
@@ -38,6 +38,7 @@ const props = defineProps({
const hasSelections = computed(() => (props.selections?.length ?? 0) > 0);
const showSelectAllMatching = computed(() => !!props.canSelectAllMatching);
const isSelectingAllMatching = computed(() => !!props.selectingAllMatching);
+const toolbar = ref(null);
/** True if this action is the built-in delete (by handle or trash icon). */
function isDeleteAction(action) {
@@ -75,9 +76,11 @@ const actionsWithShortcuts = computed(() => {
// ——— Keyboard handler: skip when overlays or form controls have focus ———
function hasOpenOverlay() {
- return !!document.querySelector(
+ const overlays = document.querySelectorAll(
'[data-ui-modal-content], [data-ui-dropdown-menu], .stack-content, [role="dialog"]'
);
+
+ return Array.from(overlays).some((overlay) => !overlay.contains(toolbar.value));
}
function isInsideFormControl(event) {
@@ -143,6 +146,7 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown, true));
:transition="{ duration: 0.2, ease: 'easeInOut' }"
>
diff --git a/resources/js/components/utilities/CacheBulkActions.vue b/resources/js/components/utilities/CacheBulkActions.vue
new file mode 100644
index 00000000000..a437e6cbf52
--- /dev/null
+++ b/resources/js/components/utilities/CacheBulkActions.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
diff --git a/resources/js/components/utilities/CacheListing.vue b/resources/js/components/utilities/CacheListing.vue
new file mode 100644
index 00000000000..66ea4885c0b
--- /dev/null
+++ b/resources/js/components/utilities/CacheListing.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/utilities/ImageCacheAssets.vue b/resources/js/components/utilities/ImageCacheAssets.vue
new file mode 100644
index 00000000000..a237432b017
--- /dev/null
+++ b/resources/js/components/utilities/ImageCacheAssets.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ numberFormatter.format(row.count) }}
+
+
+ {{ row.size_formatted }}
+
+
+
+
+
+
+
diff --git a/resources/js/components/utilities/ImageCacheBreadcrumbs.vue b/resources/js/components/utilities/ImageCacheBreadcrumbs.vue
new file mode 100644
index 00000000000..e1b18764405
--- /dev/null
+++ b/resources/js/components/utilities/ImageCacheBreadcrumbs.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/utilities/ImageCacheExplorer.vue b/resources/js/components/utilities/ImageCacheExplorer.vue
new file mode 100644
index 00000000000..77350723ae0
--- /dev/null
+++ b/resources/js/components/utilities/ImageCacheExplorer.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ numberFormatter.format(row.count) }}
+
+
+ {{ row.size_formatted }}
+
+
+
+
+
+
+
diff --git a/resources/js/components/utilities/ImageCacheVariants.vue b/resources/js/components/utilities/ImageCacheVariants.vue
new file mode 100644
index 00000000000..17c105f1459
--- /dev/null
+++ b/resources/js/components/utilities/ImageCacheVariants.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ dimensions[row.id] || '—' }}
+
+
+ {{ row.size_formatted }}
+
+
+
+ {{ dateFormatter.format(row.generated_at, { relative: true }) }}
+
+
+
+ {{ row.hash }}
+
+
+
+
+
+
diff --git a/resources/js/components/utilities/StaticCacheExplorer.vue b/resources/js/components/utilities/StaticCacheExplorer.vue
new file mode 100644
index 00000000000..8dc267a4089
--- /dev/null
+++ b/resources/js/components/utilities/StaticCacheExplorer.vue
@@ -0,0 +1,257 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ dateFormatter.format(row.generated_at, { relative: true }) }}
+
+ —
+
+
+ {{ row.site_name || __('Unknown site') }}
+
+
+
+
+
+
+
+
diff --git a/resources/js/pages/utilities/Cache.vue b/resources/js/pages/utilities/Cache.vue
index b6c34b95792..0b324e3d6fc 100644
--- a/resources/js/pages/utilities/Cache.vue
+++ b/resources/js/pages/utilities/Cache.vue
@@ -1,237 +1,569 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- {{ __('Content Stache') }}
-
-
-
-
-
-
-
+
+
+
+
+ {{ section.title }}
+
+
-
-
- {{ __('statamic::messages.cache_utility_stache_description') }}
-
- {{ stache.records }}
- {{ stache.size }}
- {{ stache.time }}
- {{ stache.rebuilt }}
-
-
-
-
-
-
- {{ __('Static Page Cache') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ __('Specify the URLs you want to invalidate. One line per URL.') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ __('statamic::messages.cache_utility_static_cache_description') }}
-
- {{ static.strategy }}
- {{ static.count }}
-
-
-
-
-
-
- {{ __('Application Cache') }}
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
- {{ __('statamic::messages.cache_utility_application_cache_description') }}
-
- {{ cache.driver }}
-
-
-
-
-
-
- {{ __('Image Cache') }}
-
-
-
-
-
-
-
- {{ __('statamic::messages.cache_utility_image_cache_description') }}
-
-
-
-
-
- {{ __('Loading') }}
-
-
- {{ images.count }}
- {{ images.size }}
-
+
+
+
+
+
+ {{ metric.label }}
+
+
+
+ {{ metric.value }}
+
+
+
+
+
+ {{ metric.value }}
+
+
+
+ {{ __n('statamic::messages.cache_utility_coverage', staticCoverage.total, {
+ cached: numberFormatter.format(staticCoverage.cached),
+ total: numberFormatter.format(staticCoverage.total),
+ }) }}
+
-
+
+
+
+
+
+
+
+
+ {{ section.title }}
+
+
+
+
+
+
diff --git a/resources/js/tests/components/CacheBulkActions.test.js b/resources/js/tests/components/CacheBulkActions.test.js
new file mode 100644
index 00000000000..7f3fb584420
--- /dev/null
+++ b/resources/js/tests/components/CacheBulkActions.test.js
@@ -0,0 +1,102 @@
+import { enableAutoUnmount, mount } from '@vue/test-utils';
+import { afterEach, beforeEach, expect, test, vi } from 'vitest';
+import { defineComponent, h, ref } from 'vue';
+import { provideListingContext } from '@ui/Listing/Listing.vue';
+import CacheBulkActions from '@/components/utilities/CacheBulkActions.vue';
+import * as Globals from '@/bootstrap/globals';
+
+enableAutoUnmount(afterEach);
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.stubGlobal('ResizeObserver', class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ });
+ Object.assign(window, Globals);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+function mountActions({ inStack = false } = {}) {
+ const selections = ref(['first', 'second']);
+ const processing = ref(false);
+ const provider = defineComponent({
+ setup() {
+ provideListingContext({
+ selections,
+ clearSelections: () => selections.value = [],
+ });
+
+ return () => h('div', { class: inStack ? 'stack-content' : undefined }, [
+ h(CacheBulkActions, { label: 'Clear', processing: processing.value }),
+ ]);
+ },
+ });
+
+ const wrapper = mount(provider, {
+ attachTo: document.body,
+ global: {
+ stubs: { Icon: true },
+ mocks: { __: Globals.__, __n: Globals.__n },
+ },
+ });
+
+ return { wrapper, selections, processing };
+}
+
+test('uses the native floating toolbar to act on selections and deselect them', async () => {
+ const { wrapper, selections } = mountActions();
+ expect(wrapper.find('[data-floating-toolbar]').exists()).toBe(false);
+ await vi.advanceTimersByTimeAsync(300);
+
+ expect(wrapper.text()).toContain('Deselect 2 items');
+ const clear = wrapper.findAll('button').find((button) => button.text().startsWith('Clear'));
+ await clear.trigger('click');
+ expect(wrapper.findComponent(CacheBulkActions).emitted('run')).toEqual([[['first', 'second']]]);
+
+ const deselect = wrapper.findAll('button').find((button) => button.text().startsWith('Deselect'));
+ await deselect.trigger('click');
+ expect(selections.value).toEqual([]);
+ expect(wrapper.find('[data-floating-toolbar]').exists()).toBe(false);
+});
+
+test('supports native Escape deselection and blocks actions while processing', async () => {
+ const { wrapper, selections, processing } = mountActions();
+ await vi.advanceTimersByTimeAsync(300);
+ processing.value = true;
+ await vi.advanceTimersByTimeAsync(0);
+
+ await wrapper.findAll('button').find((button) => button.text().startsWith('Clear')).trigger('click');
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ expect(wrapper.findComponent(CacheBulkActions).emitted('run')).toBeUndefined();
+ expect(selections.value).toHaveLength(2);
+
+ processing.value = false;
+ await vi.advanceTimersByTimeAsync(0);
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ expect(selections.value).toEqual([]);
+});
+
+test('handles shortcuts inside its own stack and defers to a nested overlay', async () => {
+ const { wrapper, selections } = mountActions({ inStack: true });
+ await vi.advanceTimersByTimeAsync(300);
+
+ const overlay = document.createElement('div');
+ overlay.setAttribute('role', 'dialog');
+ wrapper.element.append(overlay);
+
+ overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ expect(selections.value).toHaveLength(2);
+ overlay.remove();
+
+ wrapper.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', bubbles: true, cancelable: true }));
+ expect(wrapper.findComponent(CacheBulkActions).emitted('run')).toEqual([[['first', 'second']]]);
+
+ wrapper.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ expect(selections.value).toEqual([]);
+});
diff --git a/resources/js/tests/components/ImageCacheExplorer.test.js b/resources/js/tests/components/ImageCacheExplorer.test.js
new file mode 100644
index 00000000000..2c817c6b653
--- /dev/null
+++ b/resources/js/tests/components/ImageCacheExplorer.test.js
@@ -0,0 +1,503 @@
+import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils';
+import { afterEach, beforeEach, expect, test, vi } from 'vitest';
+import { defineComponent, h } from 'vue';
+import axios from 'axios';
+import { Button } from '@ui';
+import CacheListing from '@/components/utilities/CacheListing.vue';
+import Explorer from '@/components/utilities/ImageCacheExplorer.vue';
+import ImageCacheAssets from '@/components/utilities/ImageCacheAssets.vue';
+import ImageCacheBreadcrumbs from '@/components/utilities/ImageCacheBreadcrumbs.vue';
+import ImageCacheVariants from '@/components/utilities/ImageCacheVariants.vue';
+import * as Globals from '@/bootstrap/globals';
+import { toast } from '@api';
+
+vi.mock('axios', () => ({ default: { post: vi.fn(), get: vi.fn(), isCancel: () => false } }));
+
+enableAutoUnmount(afterEach);
+
+const refresh = vi.fn();
+const containers = [
+ { id: 'blog', handle: 'blog', title: 'Editorial', count: 2, size: 10, size_formatted: '10 B' },
+ { id: 'empty', handle: 'empty', title: 'Empty', count: 0, size: 0, size_formatted: '0 B' },
+];
+const cachedAsset = {
+ id: 'blog::nested/photo.jpg',
+ path: 'nested/photo.jpg',
+ title: 'photo.jpg',
+ count: 2,
+ size: 10,
+ size_formatted: '10 B',
+ thumbnail: '/cached-image',
+ extension: 'jpg',
+};
+const ListingStub = defineComponent({
+ props: ['additionalParameters', 'selections'],
+ emits: ['requestCompleted', 'bulkAction', 'update:selections'],
+ setup(props, { expose, slots }) {
+ expose({ refresh });
+
+ return () => {
+ const rows = props.additionalParameters?.container ? [cachedAsset] : containers;
+
+ return h('div', [
+ slots.breadcrumbs?.(),
+ ...rows.flatMap((row) => [
+ slots['cell-title']?.({ row }),
+ slots['cell-path']?.({ row }),
+ slots['cell-count']({ row }),
+ slots['cell-size']({ row }),
+ slots['cell-actions']({ row }),
+ ]),
+ ]);
+ };
+ },
+});
+
+function mountExplorer() {
+ return mount(Explorer, {
+ props: {
+ url: '/image-containers',
+ clearUrl: '/clear-image-container',
+ assetsUrl: '/image-assets',
+ clearAssetUrl: '/clear-image-asset',
+ variantsUrl: '/image-variants',
+ clearVariantUrl: '/clear-image-variant',
+ },
+ global: {
+ stubs: { CacheListing: ListingStub, ImageCacheAssets: true, Icon: true, 'file-icon': true },
+ mocks: { __: Globals.__ },
+ },
+ });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(toast, 'success').mockImplementation(() => {});
+ vi.spyOn(toast, 'error').mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+test('renders counts and sizes and disables clearing empty containers', () => {
+ const wrapper = mountExplorer();
+ const buttons = wrapper.findAllComponents(Button).filter((button) => button.props('text') === 'Clear');
+
+ expect(wrapper.text()).toContain('10 B');
+ expect(buttons[0].attributes('aria-label')).toBe('Clear cached images in Editorial');
+ expect(buttons[0].props('disabled')).toBe(false);
+ expect(buttons[1].props('disabled')).toBe(true);
+});
+
+test('clears the chosen container once, shows a toast, and refreshes the listing', async () => {
+ let resolve;
+ axios.post.mockReturnValue(new Promise((done) => resolve = done));
+ const wrapper = mountExplorer();
+ const button = wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Clear');
+
+ await button.trigger('click');
+ await button.trigger('click');
+
+ expect(axios.post).toHaveBeenCalledExactlyOnceWith('/clear-image-container', { container: 'blog' });
+ expect(button.props('loading')).toBe(true);
+ expect(wrapper.findAllComponents(Button).every((button) => button.props('disabled'))).toBe(true);
+
+ const result = { count: 2, size: 10, message: 'Cleared 2 cached images (10 B) from Editorial in 3ms.' };
+ resolve({ data: result });
+ await flushPromises();
+
+ expect(toast.success).toHaveBeenCalledExactlyOnceWith(result.message);
+ expect(wrapper.text()).not.toContain(result.message);
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(wrapper.emitted('cleared')).toEqual([[result]]);
+ expect(wrapper.emitted('processing')).toEqual([[true], [false]]);
+ expect(button.props('disabled')).toBe(false);
+});
+
+test('shows errors and allows retrying without claiming a successful clear', async () => {
+ axios.post.mockRejectedValue({ response: { data: { errors: { container: ['Could not clear this container.'] } } } });
+ const wrapper = mountExplorer();
+ const button = wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Clear');
+
+ await button.trigger('click');
+ await flushPromises();
+
+ expect(toast.error).toHaveBeenCalledExactlyOnceWith('Could not clear this container.');
+ expect(button.props('disabled')).toBe(false);
+ expect(refresh).not.toHaveBeenCalled();
+ expect(wrapper.emitted('cleared')).toBeUndefined();
+
+ axios.post.mockResolvedValue({ data: { count: 2, size: 10, message: 'Cleared.' } });
+ await button.trigger('click');
+ await flushPromises();
+
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(wrapper.emitted('cleared')).toHaveLength(1);
+});
+
+test('bulk clears selected containers and assets and resets their selections', async () => {
+ const result = { count: 2, size: 10, message: 'Cleared.' };
+ axios.post.mockResolvedValue({ data: result });
+
+ const containers = mountExplorer();
+ const containerListing = containers.findComponent(CacheListing);
+ const containerIds = ['blog', 'empty'];
+ containerListing.vm.$emit('update:selections', containerIds);
+ containerListing.vm.$emit('bulkAction', containerIds);
+ await flushPromises();
+
+ expect(axios.post.mock.lastCall).toEqual(['/clear-image-container', { containers: containerIds }]);
+ expect(containerListing.props('selections')).toEqual([]);
+
+ const assets = mount(ImageCacheAssets, {
+ props: {
+ container: { id: 'blog', title: 'Editorial' },
+ url: '/image-assets',
+ clearUrl: '/clear-image-asset',
+ variantsUrl: '/image-variants',
+ clearVariantUrl: '/clear-image-variant',
+ },
+ global: {
+ stubs: { CacheListing: ListingStub, Icon: true, 'file-icon': true },
+ mocks: { __: Globals.__ },
+ },
+ });
+ const assetListing = assets.findComponent(CacheListing);
+ const assetIds = [cachedAsset.id, 'blog::other.jpg'];
+ assetListing.vm.$emit('update:selections', assetIds);
+ assetListing.vm.$emit('bulkAction', assetIds);
+ await flushPromises();
+
+ expect(axios.post.mock.lastCall).toEqual(['/clear-image-asset', { container: 'blog', assets: assetIds }]);
+ expect(assetListing.props('selections')).toEqual([]);
+ expect(assets.emitted('cleared')).toEqual([[result]]);
+});
+
+test('drills into a container, refreshes its totals after clearing, and preserves the container listing', async () => {
+ const wrapper = mountExplorer();
+ const listing = wrapper.findComponent(CacheListing);
+
+ listing.vm.$emit('requestCompleted', { response: { data: { meta: { container_count: 2, single_container: null } } } });
+ await wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Editorial').trigger('click');
+
+ const assets = wrapper.findComponent(ImageCacheAssets);
+ expect(assets.props()).toMatchObject({
+ container: containers[0],
+ url: '/image-assets',
+ clearUrl: '/clear-image-asset',
+ variantsUrl: '/image-variants',
+ clearVariantUrl: '/clear-image-variant',
+ });
+ expect(listing.element.style.display).toBe('none');
+
+ assets.vm.$emit('processing', true);
+ expect(wrapper.emitted('processing')).toEqual([[true]]);
+
+ const result = { count: 1, size: 10, message: 'Cleared.' };
+ assets.vm.$emit('cleared', result);
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(wrapper.emitted('cleared')).toEqual([[result]]);
+
+ assets.vm.$emit('processing', false);
+ await flushPromises();
+ await wrapper.findComponent(ImageCacheBreadcrumbs).findAllComponents(Button)[0].trigger('click');
+ await flushPromises();
+ expect(wrapper.findComponent(ImageCacheAssets).exists()).toBe(false);
+ expect(listing.element.style.display).toBe('');
+});
+
+test('opens the only container automatically without mistaking a filtered result for a single container', async () => {
+ const wrapper = mountExplorer();
+ const listing = wrapper.findComponent(CacheListing);
+
+ listing.vm.$emit('requestCompleted', { response: { data: { meta: { container_count: 3, single_container: null } } } });
+ await flushPromises();
+ expect(wrapper.findComponent(ImageCacheAssets).exists()).toBe(false);
+
+ listing.vm.$emit('requestCompleted', { response: { data: { meta: { container_count: 1, single_container: containers[0] } } } });
+ await flushPromises();
+
+ expect(wrapper.findComponent(ImageCacheAssets).props('container')).toEqual(containers[0]);
+ expect(wrapper.findComponent(ImageCacheBreadcrumbs).props('showContainers')).toBe(false);
+ expect(listing.element.style.display).toBe('none');
+});
+
+test('shows the breadcrumb hierarchy across levels and disables its navigation while clearing', async () => {
+ const wrapper = mountExplorer();
+ wrapper.findComponent(CacheListing).vm.$emit('requestCompleted', {
+ response: { data: { meta: { container_count: 2, single_container: null } } },
+ });
+ await flushPromises();
+
+ const breadcrumbs = wrapper.findComponent(ImageCacheBreadcrumbs);
+ await wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Editorial').trigger('click');
+
+ const assets = wrapper.findComponent(ImageCacheAssets);
+ assets.vm.$emit('update:selectedAsset', cachedAsset);
+ await flushPromises();
+
+ expect(breadcrumbs.findAllComponents(Button).map((button) => button.props('text'))).toEqual([
+ 'All containers', 'Editorial', 'photo.jpg',
+ ]);
+
+ assets.vm.$emit('processing', true);
+ await flushPromises();
+ expect(breadcrumbs.findAllComponents(Button).every((button) => button.props('disabled'))).toBe(true);
+
+ assets.vm.$emit('processing', false);
+ await flushPromises();
+ await breadcrumbs.findAllComponents(Button)[1].trigger('click');
+ expect(assets.props('selectedAsset')).toBe(null);
+});
+
+test('keeps the single-container view focused and allows retrying a failed asset clear', async () => {
+ axios.post.mockRejectedValue({ response: { data: { errors: { asset: ['Could not clear this asset.'] } } } });
+ const wrapper = mount(ImageCacheAssets, {
+ props: {
+ container: containers[0],
+ url: '/image-assets',
+ clearUrl: '/clear-image-asset',
+ variantsUrl: '/image-variants',
+ clearVariantUrl: '/clear-image-variant',
+ },
+ global: {
+ stubs: { CacheListing: ListingStub, Icon: true, 'file-icon': true },
+ mocks: { __: Globals.__ },
+ },
+ });
+
+ expect(wrapper.text()).not.toContain('All containers');
+ expect(wrapper.text()).toContain(cachedAsset.path);
+
+ const button = wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Clear');
+ await button.trigger('click');
+ await flushPromises();
+
+ expect(toast.error).toHaveBeenCalledExactlyOnceWith('Could not clear this asset.');
+ expect(refresh).not.toHaveBeenCalled();
+ expect(wrapper.emitted('cleared')).toBeUndefined();
+ expect(button.props('disabled')).toBe(false);
+
+ axios.post.mockResolvedValue({ data: { count: 2, size: 10, message: 'Cleared.' } });
+ await button.trigger('click');
+ await flushPromises();
+
+ expect(axios.post.mock.lastCall).toEqual(['/clear-image-asset', { container: 'blog', asset: cachedAsset.id }]);
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(wrapper.emitted('cleared')).toHaveLength(1);
+});
+
+test('shows asset thumbnails and opens their cached variations', async () => {
+ const wrapper = mount(ImageCacheAssets, {
+ props: {
+ container: containers[0],
+ url: '/image-assets',
+ clearUrl: '/clear-image-asset',
+ variantsUrl: '/image-variants',
+ clearVariantUrl: '/clear-image-variant',
+ },
+ global: {
+ stubs: { CacheListing: ListingStub, ImageCacheVariants: true, Icon: true, 'file-icon': true },
+ mocks: { __: Globals.__ },
+ },
+ });
+
+ expect(wrapper.find('img').attributes('src')).toBe('/cached-image');
+ expect(wrapper.find('img').attributes('alt')).toBe('');
+
+ const openAsset = () => wrapper.findAllComponents(Button).find((button) => button.props('text') === cachedAsset.path);
+ await openAsset().trigger('click');
+ const variants = wrapper.findComponent(ImageCacheVariants);
+
+ expect(variants.props()).toMatchObject({ asset: cachedAsset, container: containers[0], url: '/image-variants' });
+ expect(wrapper.findComponent(CacheListing).element.style.display).toBe('none');
+
+
+});
+
+test('refreshes asset totals after a variation clear and leaves the detail view only when the final variation is gone', async () => {
+ const wrapper = mount(ImageCacheAssets, {
+ props: {
+ container: containers[0],
+ selectedAsset: cachedAsset,
+ url: '/image-assets',
+ clearUrl: '/clear-image-asset',
+ variantsUrl: '/image-variants',
+ clearVariantUrl: '/clear-image-variant',
+ },
+ global: {
+ stubs: { CacheListing: ListingStub, ImageCacheVariants: true, Icon: true, 'file-icon': true },
+ mocks: { __: Globals.__ },
+ },
+ });
+
+ wrapper.findComponent(ImageCacheVariants).vm.$emit('cleared', { count: 1, remaining: 1, remaining_size: 512 });
+ await flushPromises();
+ expect(wrapper.findComponent(ImageCacheVariants).props('asset').count).toBe(1);
+ expect(refresh).toHaveBeenCalledOnce();
+
+ wrapper.findComponent(ImageCacheVariants).vm.$emit('cleared', { count: 1, remaining: 0, remaining_size: 0 });
+ await flushPromises();
+ expect(wrapper.findComponent(ImageCacheVariants).exists()).toBe(false);
+ expect(wrapper.findComponent(CacheListing).element.style.display).toBe('');
+ expect(refresh).toHaveBeenCalledTimes(2);
+ expect(wrapper.emitted('cleared')).toHaveLength(2);
+});
+
+test('uses native listings to search, sort, drill into assets, clear one, and return to updated container totals', async () => {
+ Object.assign(window, Globals);
+ window.Statamic = {
+ $config: {
+ get: (key, fallback) => {
+ if (key === 'paginationSizeOptions') return [15, 25, 50, 100];
+ if (key === 'paginationSize') return 15;
+ return fallback;
+ },
+ },
+ $progress: { loading: vi.fn(), complete: vi.fn() },
+ $preferences: { get: () => undefined },
+ $events: { $on: vi.fn(), $off: vi.fn(), $emit: vi.fn() },
+ $toast: { error: vi.fn() },
+ };
+
+ const containerResponse = {
+ data: {
+ data: containers,
+ meta: {
+ total: 2,
+ current_page: 1,
+ last_page: 1,
+ per_page: 15,
+ from: 1,
+ to: 2,
+ container_count: 2,
+ single_container: null,
+ columns: [
+ { field: 'title', label: 'Container', visible: true, sortable: true },
+ { field: 'handle', label: 'Handle', visible: false, sortable: true },
+ { field: 'count', label: 'Cached images', visible: true, sortable: true, numeric: true },
+ { field: 'size', label: 'Size', visible: true, sortable: true, numeric: true },
+ { field: 'actions', label: 'Actions', visible: true, sortable: false },
+ ],
+ },
+ },
+ };
+ axios.get.mockResolvedValue(containerResponse);
+
+ vi.useFakeTimers();
+ const wrapper = mount(Explorer, {
+ props: {
+ url: '/image-containers',
+ clearUrl: '/clear-image-container',
+ assetsUrl: '/image-assets',
+ clearAssetUrl: '/clear-image-asset',
+ variantsUrl: '/image-variants',
+ clearVariantUrl: '/clear-image-variant',
+ },
+ global: {
+ stubs: {
+ Icon: true, 'file-icon': true,
+ SortableList: { inheritAttrs: false, template: ' ' },
+ 'ui-heading': true,
+ 'ui-checkbox': true,
+ 'ui-drag-handle': true,
+ 'ui-skeleton': true,
+ 'ui-input-group': { template: '
' },
+ 'ui-input-group-prepend': true,
+ 'ui-input-group-append': true,
+ },
+ mocks: {
+ __: Globals.__,
+ $number: { formatRange: (from, to) => `${from}-${to}`, format: (value) => `${value}` },
+ },
+ },
+ });
+ await flushPromises();
+
+ expect(axios.get.mock.lastCall[1].params).toMatchObject({ sort: 'size', order: 'desc' });
+ expect(wrapper.text()).toContain('Editorial');
+ expect(wrapper.text()).toContain('10 B');
+ expect(wrapper.find('th[aria-sort="descending"]').text()).toBe('Size');
+ expect(wrapper.findAll('tbody [role="checkbox"]')).toHaveLength(2);
+ expect(wrapper.find('[aria-label="Customize Columns"]').exists()).toBe(true);
+
+ const countHeader = wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Cached images');
+ await countHeader.trigger('click');
+ await flushPromises();
+ expect(axios.get.mock.lastCall[1].params).toMatchObject({ sort: 'count', order: 'asc' });
+
+ await wrapper.find('input').setValue('Editorial');
+ await vi.advanceTimersByTimeAsync(300);
+ expect(axios.get.mock.lastCall[1].params.search).toBe('Editorial');
+
+ let cleared = false;
+ axios.get.mockImplementation(async (url) => {
+ if (url === '/image-assets') {
+ return {
+ data: {
+ data: cleared ? [] : [cachedAsset],
+ meta: {
+ total: cleared ? 0 : 1,
+ current_page: 1,
+ last_page: 1,
+ per_page: 15,
+ from: cleared ? null : 1,
+ to: cleared ? null : 1,
+ columns: [
+ { field: 'path', label: 'Asset', visible: true, sortable: true },
+ ...containerResponse.data.meta.columns.slice(2),
+ ],
+ },
+ },
+ };
+ }
+
+ const rows = containers.map((row) => {
+ if (cleared && row.id === 'blog') {
+ return { ...row, count: 0, size: 0, size_formatted: '0 B' };
+ }
+
+ return row;
+ });
+
+ return { data: { ...containerResponse.data, data: rows } };
+ });
+
+ await wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Editorial').trigger('click');
+ await flushPromises();
+
+ const assets = wrapper.findComponent(ImageCacheAssets);
+ expect(assets.text()).toContain('nested/photo.jpg');
+ expect(assets.text()).toContain('10 B');
+ expect(axios.get.mock.calls.some(([url, options]) => url === '/image-assets'
+ && options.params.container === 'blog' && options.params.sort === 'size')).toBe(true);
+
+ let resolve;
+ axios.post.mockReturnValue(new Promise((done) => resolve = done));
+ const clear = assets.findAllComponents(Button).find((button) => button.props('text') === 'Clear');
+ const back = wrapper.findComponent(ImageCacheBreadcrumbs).findAllComponents(Button).find((button) => button.props('text') === 'All containers');
+ await clear.trigger('click');
+ await clear.trigger('click');
+
+ expect(axios.post).toHaveBeenCalledExactlyOnceWith('/clear-image-asset', { container: 'blog', asset: cachedAsset.id });
+ expect(back.props('disabled')).toBe(true);
+ expect(clear.props('loading')).toBe(true);
+
+ const result = { count: 2, size: 10, message: 'Cleared 2 cached images for nested/photo.jpg.' };
+ cleared = true;
+ resolve({ data: result });
+ await flushPromises();
+
+ expect(toast.success).toHaveBeenCalledExactlyOnceWith(result.message);
+ expect(assets.text()).not.toContain(cachedAsset.path);
+ expect(wrapper.emitted('cleared')).toEqual([[result]]);
+ expect(wrapper.emitted('processing')).toEqual([[true], [false]]);
+
+ await back.trigger('click');
+ await flushPromises();
+ expect(wrapper.findComponent(ImageCacheAssets).exists()).toBe(false);
+ expect(wrapper.find('input').element.value).toBe('Editorial');
+ expect(wrapper.find('[aria-label="Clear cached images in Editorial"]').attributes('disabled')).toBeDefined();
+});
diff --git a/resources/js/tests/components/ImageCacheVariants.test.js b/resources/js/tests/components/ImageCacheVariants.test.js
new file mode 100644
index 00000000000..26b17551196
--- /dev/null
+++ b/resources/js/tests/components/ImageCacheVariants.test.js
@@ -0,0 +1,191 @@
+import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils';
+import { afterEach, beforeEach, expect, test, vi } from 'vitest';
+import { defineComponent, h } from 'vue';
+import axios from 'axios';
+import { Button } from '@ui';
+import CacheListing from '@/components/utilities/CacheListing.vue';
+import Variants from '@/components/utilities/ImageCacheVariants.vue';
+import { __ } from '@/bootstrap/globals';
+import { dateFormatter, toast } from '@api';
+
+vi.mock('axios', () => ({ default: { post: vi.fn() } }));
+
+enableAutoUnmount(afterEach);
+const refresh = vi.fn();
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(toast, 'success').mockImplementation(() => {});
+ vi.spyOn(toast, 'error').mockImplementation(() => {});
+});
+
+afterEach(() => vi.restoreAllMocks());
+
+const variant = {
+ id: 'containers/blog/photo.jpg/hash/photo.webp',
+ title: 'photo.webp',
+ hash: 'hash',
+ format: 'WEBP',
+ size: 1024,
+ size_formatted: '1 KB',
+ generated_at: '2026-11-09T12:00:00+00:00',
+ preview_url: '/cached-image',
+};
+
+function mountVariants(previewVisible = true) {
+ const ListingStub = defineComponent({
+ props: ['url', 'additionalParameters', 'preferencesPrefix', 'selections'],
+ emits: ['bulkAction', 'update:selections'],
+ setup(props, { expose, slots }) {
+ expose({ refresh });
+
+ return () => h('div', [
+ slots['panel-actions']?.(),
+ previewVisible ? slots['cell-preview']({ row: variant }) : null,
+ slots['cell-dimensions']({ row: variant, isColumnVisible: () => previewVisible }),
+ slots['cell-size']({ row: variant }),
+ slots['cell-generated_at']({ row: variant }),
+ slots['cell-actions']({ row: variant }),
+ ]);
+ },
+ });
+
+ return mount(Variants, {
+ props: {
+ container: { id: 'blog', title: 'Editorial' },
+ asset: { id: 'blog::photo.jpg', path: 'photo.jpg' },
+ url: '/image-variants',
+ clearUrl: '/clear-image-variant',
+ },
+ global: {
+ stubs: { CacheListing: ListingStub, Icon: true, FileIcon: true },
+ mocks: { __ },
+ },
+ });
+}
+
+test('shows existing previews, measured dimensions, sizes, and relative generation dates', async () => {
+ const wrapper = mountVariants();
+ const listing = wrapper.findComponent(CacheListing);
+
+ expect(listing.props('additionalParameters')).toEqual({ container: 'blog', asset: 'blog::photo.jpg' });
+ expect(listing.props('preferencesPrefix')).toBe('cache.image_variants');
+ expect(wrapper.find('a').attributes()).toMatchObject({ href: '/cached-image', target: '_blank', rel: 'noopener noreferrer' });
+ expect(wrapper.text()).toContain('1 KB');
+
+ const image = wrapper.find('img');
+ Object.defineProperties(image.element, {
+ naturalWidth: { value: 640 },
+ naturalHeight: { value: 480 },
+ });
+ await image.trigger('load');
+
+ expect(wrapper.text()).toContain('640 × 480');
+ expect(wrapper.find('time').text()).toBe(dateFormatter.format(variant.generated_at, { relative: true }));
+ expect(wrapper.find('time').attributes('title')).toBe(dateFormatter.format(variant.generated_at, {
+ preset: 'datetime',
+ second: '2-digit',
+ timeZoneName: 'short',
+ }));
+});
+
+test('can determine dimensions when the preview column is hidden', async () => {
+ const wrapper = mountVariants(false);
+ const image = wrapper.find('img');
+ expect(image.classes()).toContain('sr-only');
+
+ Object.defineProperties(image.element, {
+ naturalWidth: { value: 320 },
+ naturalHeight: { value: 240 },
+ });
+ await image.trigger('load');
+
+ expect(wrapper.text()).toContain('320 × 240');
+});
+
+test('handles missing previews and blocks clear actions during an operation', async () => {
+ const wrapper = mountVariants();
+ await wrapper.find('img').trigger('error');
+
+ expect(wrapper.find('img').exists()).toBe(false);
+ expect(wrapper.find('file-icon-stub').exists()).toBe(true);
+
+ const clear = wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Clear');
+ await wrapper.setProps({ processing: true });
+
+ expect(wrapper.findAllComponents(Button).every((button) => button.props('disabled'))).toBe(true);
+ await clear.trigger('click');
+ expect(axios.post).not.toHaveBeenCalled();
+});
+
+test('clears only the chosen variation once, then refreshes and reports updated totals', async () => {
+ let resolve;
+ axios.post.mockReturnValue(new Promise((done) => resolve = done));
+ const wrapper = mountVariants();
+ const clear = wrapper.findAllComponents(Button).find((button) => button.attributes('aria-label') === 'Clear cached variation hash');
+
+ await clear.trigger('click');
+ await clear.trigger('click');
+ expect(axios.post).toHaveBeenCalledExactlyOnceWith('/clear-image-variant', {
+ container: 'blog',
+ asset: 'blog::photo.jpg',
+ variant: variant.id,
+ });
+ expect(clear.props('loading')).toBe(true);
+ expect(wrapper.findAllComponents(Button).every((button) => button.props('disabled'))).toBe(true);
+
+ const result = { count: 1, size: 1024, remaining: 1, remaining_size: 512, message: 'Variation cleared.' };
+ resolve({ data: result });
+ await flushPromises();
+
+ expect(toast.success).toHaveBeenCalledExactlyOnceWith(result.message);
+ expect(wrapper.text()).not.toContain(result.message);
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(wrapper.emitted('cleared')).toEqual([[result]]);
+ expect(wrapper.emitted('processing')).toEqual([[true], [false]]);
+});
+
+test('allows retrying a failed clear and avoids reloading the variation list after clearing its final row', async () => {
+ axios.post.mockRejectedValue({ response: { data: { errors: { variant: ['Could not clear this variation.'] } } } });
+ const wrapper = mountVariants();
+ const clear = wrapper.findAllComponents(Button).find((button) => button.attributes('aria-label') === 'Clear cached variation hash');
+
+ await clear.trigger('click');
+ await flushPromises();
+ expect(toast.error).toHaveBeenCalledExactlyOnceWith('Could not clear this variation.');
+ expect(wrapper.emitted('cleared')).toBeUndefined();
+ expect(clear.props('disabled')).toBe(false);
+
+ const result = { count: 1, size: 1024, remaining: 0, remaining_size: 0, message: 'Variation cleared.' };
+ axios.post.mockResolvedValue({ data: result });
+ await clear.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.emitted('cleared')).toEqual([[result]]);
+ expect(refresh).not.toHaveBeenCalled();
+});
+
+test('bulk clears selected variations and preserves the selection after a failure', async () => {
+ axios.post.mockRejectedValue({ response: { data: { errors: { variants: ['Unable to clear these variations.'] } } } });
+ const wrapper = mountVariants();
+ const listing = wrapper.findComponent(CacheListing);
+ const selected = [variant.id, 'containers/blog/photo.jpg/other/photo.jpg'];
+ listing.vm.$emit('update:selections', selected);
+ listing.vm.$emit('bulkAction', selected);
+ await flushPromises();
+
+ expect(axios.post.mock.lastCall).toEqual(['/clear-image-variant', {
+ container: 'blog',
+ asset: 'blog::photo.jpg',
+ variants: selected,
+ }]);
+ expect(listing.props('selections')).toEqual(selected);
+ expect(refresh).not.toHaveBeenCalled();
+
+ axios.post.mockResolvedValue({ data: { count: 2, size: 2048, remaining: 1, remaining_size: 512, message: 'Cleared.' } });
+ listing.vm.$emit('bulkAction', selected);
+ await flushPromises();
+
+ expect(listing.props('selections')).toEqual([]);
+ expect(refresh).toHaveBeenCalledOnce();
+});
diff --git a/resources/js/tests/components/StaticCacheExplorer.test.js b/resources/js/tests/components/StaticCacheExplorer.test.js
new file mode 100644
index 00000000000..f916ec15507
--- /dev/null
+++ b/resources/js/tests/components/StaticCacheExplorer.test.js
@@ -0,0 +1,401 @@
+import { flushPromises, mount } from '@vue/test-utils';
+import { afterEach, beforeEach, expect, test, vi } from 'vitest';
+import { defineComponent, h, nextTick } from 'vue';
+import axios from 'axios';
+import { Button, Select } from '@ui';
+import CacheListing from '@/components/utilities/CacheListing.vue';
+import Explorer from '@/components/utilities/StaticCacheExplorer.vue';
+import * as Globals from '@/bootstrap/globals';
+import { dateFormatter, toast } from '@api';
+import { setTranslations } from '@/translations/translator';
+
+vi.mock('axios', () => ({ default: { post: vi.fn(), get: vi.fn(), isCancel: () => false } }));
+
+const url = 'https://example.com/blog/one';
+const refresh = vi.fn();
+const ListingStub = defineComponent({
+ props: {
+ url: String,
+ additionalParameters: Object,
+ selections: Array,
+ allowSelections: Boolean,
+ bulkAction: String,
+ sortColumn: String,
+ sortDirection: String,
+ },
+ emits: ['update:selections', 'update:searchQuery', 'requestCompleted', 'bulkAction'],
+ setup(props, { expose, slots }) {
+ expose({ refresh });
+
+ return () => h('div', slots['cell-actions']({ row: { url } }));
+ },
+});
+
+function mountExplorer() {
+ return mount(Explorer, {
+ props: {
+ url: '/cached-pages',
+ invalidateUrl: '/invalidate',
+ cacheUrl: '/cache-page',
+ sites: [{ value: 'en', label: 'English' }, { value: 'fr', label: 'French' }],
+ },
+ global: {
+ stubs: {
+ CacheListing: ListingStub,
+ Field: { template: '
' },
+ Select: true,
+ Icon: true,
+ },
+ },
+ });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ Object.assign(window, Globals);
+ setTranslations({
+ 'statamic::messages.cache_utility_pages_cached': '{1} Cached :count page.|[0,*] Cached :count pages.',
+ 'statamic::messages.cache_utility_pages_cache_failed': '{1} :count page could not be cached.|[0,*] :count pages could not be cached.',
+ });
+ vi.spyOn(toast, 'success').mockImplementation(() => {});
+ vi.spyOn(toast, 'error').mockImplementation(() => {});
+});
+
+afterEach(() => {
+ setTranslations({});
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+test('passes site and domain filters to the native listing and clears out-of-scope selections', async () => {
+ const wrapper = mountExplorer();
+ const listing = wrapper.findComponent(CacheListing);
+
+ listing.vm.$emit('requestCompleted', { response: { data: { meta: { domains: ['https://example.com'] } } } });
+ listing.vm.$emit('update:selections', [url]);
+ await nextTick();
+
+ const [, site, domain] = wrapper.findAllComponents(Select);
+ expect(domain.props('options')).toContainEqual({ value: 'https://example.com', label: 'https://example.com' });
+
+ site.vm.$emit('update:modelValue', 'fr');
+ domain.vm.$emit('update:modelValue', 'https://example.com');
+ await nextTick();
+
+ expect(listing.props('additionalParameters')).toEqual({ site: 'fr', domain: 'https://example.com', status: 'cached' });
+ expect(listing.props('selections')).toEqual([]);
+ wrapper.unmount();
+});
+
+test('invalidates an individual URL once, reports the actual result, and refreshes the list', async () => {
+ let resolve;
+ axios.post.mockReturnValue(new Promise((done) => resolve = done));
+ const wrapper = mountExplorer();
+ const rowButton = wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Clear');
+
+ await rowButton.trigger('click');
+ await rowButton.trigger('click');
+ expect(axios.post).toHaveBeenCalledExactlyOnceWith('/invalidate', { urls: [url], site: null, domain: null });
+ expect(rowButton.props('disabled')).toBe(true);
+
+ const result = { message: 'Cleared 2 cached pages in 3ms.', count: 2 };
+ resolve({ data: result });
+ await flushPromises();
+
+ expect(toast.success).toHaveBeenCalledExactlyOnceWith(result.message);
+ expect(wrapper.text()).not.toContain(result.message);
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(wrapper.emitted('invalidated')).toEqual([[result]]);
+ expect(wrapper.emitted('processing')).toEqual([[true], [false]]);
+ expect(rowButton.props('disabled')).toBe(false);
+ wrapper.unmount();
+});
+
+test('browses uncached URLs with Cache bulk actions and resets out-of-scope selections', async () => {
+ const wrapper = mountExplorer();
+ wrapper.findComponent(CacheListing).vm.$emit('update:selections', [url]);
+ await nextTick();
+
+ wrapper.findAllComponents(Select)[0].vm.$emit('update:modelValue', 'uncached');
+ await nextTick();
+
+ const listing = wrapper.findComponent(CacheListing);
+ expect(listing.props('additionalParameters')).toEqual({ site: null, domain: null, status: 'uncached' });
+ expect(listing.props('allowSelections')).toBe(true);
+ expect(listing.props('bulkAction')).toBe('Cache');
+ expect(listing.props('sortColumn')).toBe('url');
+ expect(listing.props('sortDirection')).toBe('asc');
+ expect(listing.props('selections')).toEqual([]);
+ expect(wrapper.findAllComponents(Button).some((button) => button.props('text') === 'Clear')).toBe(false);
+
+ expect(axios.post).not.toHaveBeenCalled();
+ wrapper.unmount();
+});
+
+test('caches selected URLs across pages and domains through the native bulk action', async () => {
+ const selected = [url, 'https://example.de/blog/two'];
+ axios.post.mockImplementation(async (endpoint, { url }) => ({ data: { url, message: 'Page cached.' } }));
+ const wrapper = mountExplorer();
+ wrapper.findAllComponents(Select)[0].vm.$emit('update:modelValue', 'uncached');
+ await nextTick();
+ const listing = wrapper.findComponent(CacheListing);
+ listing.vm.$emit('update:selections', selected);
+ await nextTick();
+
+ listing.vm.$emit('requestCompleted', { response: { data: { meta: { domains: ['https://example.de'] } } } });
+ expect(listing.props('selections')).toEqual(selected);
+ listing.vm.$emit('bulkAction', selected);
+ await flushPromises();
+
+ expect(axios.post.mock.calls).toEqual(selected.map((url) => ['/cache-page', { url }]));
+ expect(listing.props('selections')).toEqual([]);
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(toast.success).toHaveBeenCalledExactlyOnceWith('Cached 2 pages.');
+ expect(toast.error).not.toHaveBeenCalled();
+ expect(wrapper.emitted('cached')).toEqual([[{ count: 2, urls: selected }]]);
+ expect(wrapper.emitted('processing')).toEqual([[true], [false]]);
+ wrapper.unmount();
+});
+
+test('retains failed bulk cache selections and allows retrying only those URLs', async () => {
+ const failedUrl = 'https://example.de/blog/two';
+ axios.post
+ .mockResolvedValueOnce({ data: { url, message: 'Page cached.' } })
+ .mockRejectedValueOnce({ response: { data: { message: 'The page could not be cached.' } } });
+ const wrapper = mountExplorer();
+ wrapper.findAllComponents(Select)[0].vm.$emit('update:modelValue', 'uncached');
+ await nextTick();
+ const listing = wrapper.findComponent(CacheListing);
+ listing.vm.$emit('update:selections', [url, failedUrl]);
+ await nextTick();
+ listing.vm.$emit('bulkAction', [url, failedUrl]);
+ await flushPromises();
+
+ expect(listing.props('selections')).toEqual([failedUrl]);
+ expect(toast.success).toHaveBeenCalledExactlyOnceWith('Cached 1 page.');
+ expect(toast.error).toHaveBeenCalledExactlyOnceWith('1 page could not be cached.');
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(wrapper.vm.processing).toBe(false);
+
+ axios.post.mockResolvedValueOnce({ data: { url: failedUrl, message: 'Page cached.' } });
+ listing.vm.$emit('bulkAction', [failedUrl]);
+ await flushPromises();
+
+ expect(axios.post.mock.lastCall).toEqual(['/cache-page', { url: failedUrl }]);
+ expect(listing.props('selections')).toEqual([]);
+ expect(refresh).toHaveBeenCalledTimes(2);
+ wrapper.unmount();
+});
+
+test('caches an uncached URL once, refreshes after success, and permits retry after failure', async () => {
+ const wrapper = mountExplorer();
+ wrapper.findAllComponents(Select)[0].vm.$emit('update:modelValue', 'uncached');
+ await nextTick();
+
+ let resolve;
+ axios.post.mockReturnValueOnce(new Promise((done) => resolve = done));
+ const cache = wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Cache');
+ await cache.trigger('click');
+ await cache.trigger('click');
+
+ expect(axios.post).toHaveBeenCalledExactlyOnceWith('/cache-page', { url });
+ expect(cache.props('disabled')).toBe(true);
+ expect(cache.props('loading')).toBe(true);
+
+ const result = { message: 'Page cached in 30ms.', url };
+ resolve({ data: result });
+ await flushPromises();
+
+ expect(toast.success).toHaveBeenCalledExactlyOnceWith(result.message);
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(wrapper.emitted('cached')).toEqual([[result]]);
+ expect(wrapper.emitted('processing')).toEqual([[true], [false]]);
+ expect(cache.props('disabled')).toBe(false);
+
+ axios.post.mockRejectedValueOnce({ response: { data: { message: 'The page could not be cached.' } } });
+ await cache.trigger('click');
+ await flushPromises();
+ expect(toast.error).toHaveBeenCalledExactlyOnceWith('The page could not be cached.');
+ expect(refresh).toHaveBeenCalledOnce();
+ expect(cache.props('disabled')).toBe(false);
+
+ axios.post.mockResolvedValueOnce({ data: result });
+ await cache.trigger('click');
+ await flushPromises();
+ expect(refresh).toHaveBeenCalledTimes(2);
+ wrapper.unmount();
+});
+
+test('retains selections across pages and invalidates all selected URLs', async () => {
+ const selected = [url, 'https://example.de/blog/two'];
+ axios.post.mockResolvedValue({ data: { message: 'Cleared 2 cached pages.', count: 2 } });
+ const wrapper = mountExplorer();
+ const listing = wrapper.findComponent(CacheListing);
+
+ listing.vm.$emit('update:selections', selected);
+ listing.vm.$emit('requestCompleted', { response: { data: { meta: { domains: ['https://example.com', 'https://example.de'] } } } });
+ await nextTick();
+
+ expect(listing.props('selections')).toEqual(selected);
+ listing.vm.$emit('bulkAction', selected);
+ await flushPromises();
+
+ expect(axios.post).toHaveBeenCalledExactlyOnceWith('/invalidate', { urls: selected, site: null, domain: null });
+ expect(listing.props('selections')).toEqual([]);
+ wrapper.unmount();
+});
+
+test('keeps selections after a failed invalidation and allows retrying', async () => {
+ axios.post.mockRejectedValue({ response: { data: { errors: { urls: ['Could not clear these URLs.'] } } } });
+ const wrapper = mountExplorer();
+ const listing = wrapper.findComponent(CacheListing);
+
+ listing.vm.$emit('update:selections', [url]);
+ await nextTick();
+ listing.vm.$emit('bulkAction', [url]);
+ await flushPromises();
+
+ expect(toast.error).toHaveBeenCalledExactlyOnceWith('Could not clear these URLs.');
+ expect(listing.props('selections')).toEqual([url]);
+ expect(listing.props('selections')).toEqual([url]);
+ expect(refresh).not.toHaveBeenCalled();
+
+ axios.post.mockResolvedValue({ data: { message: 'Cleared 1 cached page.', count: 1 } });
+ listing.vm.$emit('bulkAction', [url]);
+ await flushPromises();
+
+ expect(listing.props('selections')).toEqual([]);
+ expect(refresh).toHaveBeenCalledOnce();
+ wrapper.unmount();
+});
+
+test('clears selections when the search changes', async () => {
+ const wrapper = mountExplorer();
+ const listing = wrapper.findComponent(CacheListing);
+ listing.vm.$emit('update:selections', [url]);
+ await nextTick();
+ listing.vm.$emit('update:searchQuery', 'other');
+ await nextTick();
+
+ expect(listing.props('selections')).toEqual([]);
+ wrapper.unmount();
+});
+
+test('uses the native listing for sorting, column preferences, search, pagination, and filters', async () => {
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
+ Object.assign(window, Globals);
+ window.Statamic = {
+ $config: {
+ get: (key, fallback) => {
+ if (key === 'paginationSizeOptions') return [15, 25, 50, 100];
+ if (key === 'paginationSize') return 15;
+ return fallback;
+ },
+ },
+ $progress: { loading: vi.fn(), complete: vi.fn() },
+ $preferences: { get: () => undefined, set: vi.fn().mockResolvedValue({}) },
+ $events: { $on: vi.fn(), $off: vi.fn(), $emit: vi.fn() },
+ $toast: { success: vi.fn(), error: vi.fn() },
+ };
+
+ const generatedAt = '2026-11-09T12:00:00+00:00';
+
+ axios.get.mockResolvedValue({
+ data: {
+ data: [{ id: url, title: url, url, generated_at: generatedAt, site_name: 'English' }],
+ meta: {
+ total: 45,
+ current_page: 1,
+ last_page: 3,
+ per_page: 15,
+ from: 1,
+ to: 15,
+ domains: ['https://example.com'],
+ columns: [
+ { field: 'url', label: 'URL', visible: true, sortable: true },
+ { field: 'path', label: 'Path', visible: false, sortable: true },
+ { field: 'generated_at', label: 'Generated', visible: true, sortable: true, fieldtype: 'date' },
+ { field: 'site_name', label: 'Site', visible: true, sortable: true },
+ { field: 'domain', label: 'Domain', visible: false, sortable: true },
+ { field: 'actions', label: 'Actions', visible: true, sortable: false },
+ ],
+ },
+ },
+ });
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-11-09T14:00:00Z'));
+ const wrapper = mount(Explorer, {
+ props: {
+ url: '/cached-pages',
+ invalidateUrl: '/invalidate',
+ cacheUrl: '/cache-page',
+ sites: [{ value: 'en', label: 'English' }, { value: 'fr', label: 'French' }],
+ },
+ global: {
+ stubs: {
+ Icon: true,
+ Select: true,
+ Modal: {
+ props: ['open'],
+ template: '
',
+ },
+ SortableList: { inheritAttrs: false, template: ' ' },
+ 'ui-heading': true,
+ 'ui-checkbox': true,
+ 'ui-drag-handle': true,
+ 'ui-skeleton': true,
+ 'ui-input-group': { template: '
' },
+ 'ui-input-group-prepend': true,
+ 'ui-input-group-append': true,
+ },
+ mocks: { $number: { formatRange: (from, to) => `${from}-${to}`, format: (value) => `${value}` } },
+ },
+ });
+ await flushPromises();
+
+ expect(axios.get.mock.lastCall[1].params).toMatchObject({ sort: 'generated_at', order: 'desc' });
+ expect(wrapper.find('th[aria-sort="descending"]').text()).toBe('Generated');
+ expect(wrapper.find(`a[href="${url}"]`).exists()).toBe(true);
+ const generated = wrapper.find('time');
+ expect(generated.text()).toBe('2 hours ago');
+ expect(generated.attributes('datetime')).toBe(generatedAt);
+ expect(generated.attributes('title')).toBe(dateFormatter.format(generatedAt, {
+ preset: 'datetime',
+ second: '2-digit',
+ timeZoneName: 'short',
+ }));
+
+ const generatedHeader = wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Generated');
+ await generatedHeader.trigger('click');
+ await flushPromises();
+ expect(axios.get.mock.lastCall[1].params).toMatchObject({ sort: 'generated_at', order: 'asc' });
+ expect(wrapper.find('th[aria-sort="ascending"]').text()).toBe('Generated');
+
+ await wrapper.find('[aria-label="Customize Columns"]').trigger('click');
+ expect(wrapper.find('ui-checkbox-stub[label="Path"]').exists()).toBe(true);
+ expect(wrapper.find('ui-checkbox-stub[label="Domain"]').exists()).toBe(true);
+
+ const saveColumns = wrapper.findAllComponents(Button).find((button) => button.props('text') === 'Save');
+ await saveColumns.trigger('click');
+ await flushPromises();
+ expect(Statamic.$preferences.set).toHaveBeenCalledExactlyOnceWith('cache.static_pages.columns', [
+ 'url', 'generated_at', 'site_name', 'actions',
+ ]);
+
+ await wrapper.find('input').setValue('blog');
+ await vi.advanceTimersByTimeAsync(300);
+ expect(axios.get.mock.lastCall[1].params.search).toBe('blog');
+
+ const next = wrapper.findAllComponents(Button).find((button) => button.props('icon') === 'chevron-right');
+ await next.trigger('click');
+ await flushPromises();
+ expect(axios.get.mock.lastCall[1].params.page).toBe(2);
+
+ wrapper.findAllComponents(Select)[1].vm.$emit('update:modelValue', 'fr');
+ await nextTick();
+ expect(axios.get.mock.lastCall[1].params.site).toBe('fr');
+
+ wrapper.unmount();
+ vi.useRealTimers();
+});
diff --git a/resources/js/tests/pages/CacheManager.test.js b/resources/js/tests/pages/CacheManager.test.js
new file mode 100644
index 00000000000..0d6800ce9e2
--- /dev/null
+++ b/resources/js/tests/pages/CacheManager.test.js
@@ -0,0 +1,208 @@
+import { enableAutoUnmount, shallowMount } from '@vue/test-utils';
+import { afterEach, beforeEach, expect, test, vi } from 'vitest';
+import { nextTick } from 'vue';
+import axios from 'axios';
+import { router } from '@inertiajs/vue3';
+import Cache from '@/pages/utilities/Cache.vue';
+import { toast } from '@api';
+import { Panel, Stack } from '@ui';
+import ImageCacheExplorer from '@/components/utilities/ImageCacheExplorer.vue';
+import StaticCacheExplorer from '@/components/utilities/StaticCacheExplorer.vue';
+
+vi.mock('axios', () => ({ default: { post: vi.fn() } }));
+vi.mock('@inertiajs/vue3', async (importOriginal) => ({
+ ...await importOriginal(),
+ router: { post: vi.fn(), reload: vi.fn() },
+}));
+
+enableAutoUnmount(afterEach);
+
+function mountCache() {
+ return shallowMount(Cache, {
+ props: {
+ stache: { records: 2, size: '1 KB', time: '2ms', rebuilt: 'just now' },
+ cache: { driver: 'file' },
+ static: { enabled: true, strategy: 'half', count: 2 },
+ staticCoverage: { cached: 2, total: 3, uncached: 1 },
+ images: { count: 4, assets: 2, size: '1 KB' },
+ cacheSites: [{ value: 'en', label: 'English' }, { value: 'fr', label: 'French' }],
+ previewStaticPagesUrl: '/preview',
+ invalidatePagesUrl: '/invalidate',
+ cacheStaticPageUrl: '/cache-page',
+ warmStaticUrl: '/warm',
+ imageContainersUrl: '/image-containers',
+ clearImageContainerUrl: '/clear-image-container',
+ imageAssetsUrl: '/image-assets',
+ clearImageAssetUrl: '/clear-image-asset',
+ imageVariantsUrl: '/image-variants',
+ clearImageVariantUrl: '/clear-image-variant',
+ },
+ global: {
+ renderStubDefaultSlot: true,
+ mocks: { __n: (key) => key },
+ stubs: {
+ Head: true,
+ Deferred: { template: '
' },
+ CommandPaletteItem: { template: '
' },
+ },
+ },
+ });
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.useFakeTimers();
+ vi.spyOn(toast, 'error').mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+test('opens the image explorer, protects an in-progress clear, and reloads image totals', async () => {
+ const wrapper = mountCache();
+ expect(wrapper.findComponent(ImageCacheExplorer).exists()).toBe(false);
+
+ await wrapper.find('[aria-label="Browse cached images"]').trigger('click');
+ const explorer = wrapper.findComponent(ImageCacheExplorer);
+ const stack = wrapper.findAllComponents(Stack).find((stack) => stack.props('title') === 'Cached images');
+
+ expect(explorer.props()).toMatchObject({
+ url: '/image-containers',
+ clearUrl: '/clear-image-container',
+ assetsUrl: '/image-assets',
+ clearAssetUrl: '/clear-image-asset',
+ variantsUrl: '/image-variants',
+ clearVariantUrl: '/clear-image-variant',
+ });
+ expect(stack.props('open')).toBe(true);
+
+ explorer.vm.$emit('processing', true);
+ await nextTick();
+ expect(stack.props('beforeClose')()).toBe(false);
+
+ explorer.vm.$emit('cleared', { count: 2, size: 10 });
+ expect(router.reload).toHaveBeenCalledExactlyOnceWith({ only: ['images'], preserveScroll: true });
+
+ explorer.vm.$emit('processing', false);
+ await nextTick();
+ expect(stack.props('beforeClose')()).toBe(true);
+});
+
+test('shows image variations and asset totals and browses cached or uncached static pages', async () => {
+ const wrapper = mountCache();
+ const imagePanel = wrapper.findAllComponents(Panel).find((panel) => panel.text().includes('Image Cache'));
+ const imageMetrics = imagePanel.find('dl');
+ expect(imageMetrics.findAll('dt').map((label) => label.text())).toEqual(['Variations', 'Assets', 'Size']);
+ expect(imageMetrics.findAll('dd').map((value) => value.text())).toEqual(['4', '2', '1 KB']);
+
+ expect(wrapper.find('[text="Browse uncached"]').exists()).toBe(false);
+ await wrapper.find('[text="Uncached pages"]').trigger('click');
+ const explorer = wrapper.findComponent(StaticCacheExplorer);
+ expect(explorer.props('status')).toBe('uncached');
+ expect(explorer.props('cacheUrl')).toBe('/cache-page');
+ expect(wrapper.findAllComponents(Stack).some((stack) => stack.props('title') === 'Static Page Cache')).toBe(true);
+
+ explorer.vm.$emit('invalidated');
+ expect(router.reload).toHaveBeenCalledExactlyOnceWith({ only: ['static', 'staticCoverage'], preserveScroll: true });
+ router.reload.mockClear();
+ explorer.vm.$emit('cached');
+ expect(router.reload).toHaveBeenCalledExactlyOnceWith({ only: ['static', 'staticCoverage'], preserveScroll: true });
+
+ await wrapper.find('[text="Cached pages"]').trigger('click');
+ expect(explorer.props('status')).toBe('cached');
+});
+
+test('debounces pattern previews and includes the selected site', async () => {
+ axios.post.mockResolvedValue({ data: { total: 2, data: [{ url: 'https://example.com/fr/blog/one' }] } });
+ const wrapper = mountCache();
+ wrapper.vm.openInvalidateStaticUrls();
+ wrapper.vm.staticUrls = '/about';
+ wrapper.vm.invalidationSite = 'fr';
+ await nextTick();
+ wrapper.vm.staticUrls = '/blog/*\n\n';
+ await nextTick();
+
+ await vi.advanceTimersByTimeAsync(299);
+ expect(axios.post).not.toHaveBeenCalled();
+ expect(wrapper.vm.previewLoading).toBe(true);
+
+ await vi.advanceTimersByTimeAsync(1);
+ expect(axios.post).toHaveBeenCalledExactlyOnceWith('/preview', { urls: ['/blog/*'], site: 'fr' }, {
+ signal: expect.any(AbortSignal),
+ });
+ expect(wrapper.vm.preview.total).toBe(2);
+ expect(wrapper.vm.previewLoading).toBe(false);
+});
+
+test('cancels obsolete previews and ignores responses for old patterns', async () => {
+ let resolveOld;
+ axios.post.mockReturnValueOnce(new Promise((resolve) => resolveOld = resolve));
+ const wrapper = mountCache();
+ wrapper.vm.openInvalidateStaticUrls();
+ wrapper.vm.staticUrls = '/blog/*';
+ await nextTick();
+ await vi.advanceTimersByTimeAsync(300);
+ const oldSignal = axios.post.mock.calls[0][2].signal;
+
+ axios.post.mockResolvedValueOnce({ data: { total: 0, data: [] } });
+ wrapper.vm.staticUrls = '/missing';
+ await nextTick();
+ expect(oldSignal.aborted).toBe(true);
+ await vi.advanceTimersByTimeAsync(300);
+ resolveOld({ data: { total: 99, data: [] } });
+ await Promise.resolve();
+
+ expect(wrapper.vm.preview.total).toBe(0);
+ expect(wrapper.vm.previewLoading).toBe(false);
+ wrapper.vm.invalidateStaticUrls();
+ expect(router.post).not.toHaveBeenCalled();
+});
+
+test('shows validation errors and recovers when the input is corrected', async () => {
+ axios.post.mockRejectedValueOnce({ response: { data: { errors: { 'urls.0': ['Use a trailing wildcard.'] } } } });
+ const wrapper = mountCache();
+ wrapper.vm.openInvalidateStaticUrls();
+ wrapper.vm.staticUrls = '/bl*g/*';
+ await nextTick();
+ await vi.advanceTimersByTimeAsync(300);
+
+ expect(wrapper.vm.previewError).toBe('Use a trailing wildcard.');
+ wrapper.vm.invalidateStaticUrls();
+ expect(router.post).not.toHaveBeenCalled();
+
+ axios.post.mockResolvedValueOnce({ data: { total: 1, data: [{ url: 'https://example.com/blog/one' }] } });
+ wrapper.vm.staticUrls = '/blog/*';
+ await nextTick();
+ expect(wrapper.vm.previewError).toBeNull();
+ await vi.advanceTimersByTimeAsync(300);
+ wrapper.vm.invalidateStaticUrls();
+
+ expect(router.post).toHaveBeenCalledExactlyOnceWith('/invalidate', { urls: ['/blog/*'], site: null }, expect.any(Object));
+ const options = router.post.mock.calls[0][2];
+ options.onSuccess();
+ options.onFinish();
+ await nextTick();
+
+ expect(wrapper.vm.invalidateStaticUrlsModal).toBe(false);
+ expect(wrapper.text()).not.toContain('Cleared 1 cached page in 2ms.');
+ expect(wrapper.vm.activeAction).toBeNull();
+});
+
+test('exposes static warming with loading and repeat-click protection', async () => {
+ const wrapper = mountCache();
+ wrapper.vm.warmStatic();
+ wrapper.vm.warmStatic();
+ await nextTick();
+
+ expect(router.post).toHaveBeenCalledExactlyOnceWith('/warm', {}, expect.any(Object));
+ expect(wrapper.vm.activeAction).toBe('warmStatic');
+
+ const options = router.post.mock.calls[0][2];
+ options.onFinish();
+ await nextTick();
+
+ expect(wrapper.vm.activeAction).toBeNull();
+ expect(wrapper.find('[variant="success"]').exists()).toBe(false);
+});
diff --git a/src/CP/Utilities/CoreUtilities.php b/src/CP/Utilities/CoreUtilities.php
index 4558be328cb..cc89f3bb84c 100644
--- a/src/CP/Utilities/CoreUtilities.php
+++ b/src/CP/Utilities/CoreUtilities.php
@@ -31,9 +31,20 @@ public static function boot()
$router->post('clear-stache', [CacheController::class, 'clearStacheCache'])->name('clear-stache');
$router->post('warm-stache', [CacheController::class, 'warmStacheCache'])->name('warm-stache');
$router->post('clear-static', [CacheController::class, 'clearStaticCache'])->name('clear-static');
+ $router->post('warm-static', [CacheController::class, 'warmStaticCache'])->name('warm-static');
+ $router->get('static-pages', [CacheController::class, 'staticPages'])->name('static-pages');
+ $router->post('cache-static-page', [CacheController::class, 'cacheStaticPage'])->name('cache-static-page');
+ $router->post('preview-static-pages', [CacheController::class, 'previewStaticPages'])->name('preview-static-pages');
$router->post('invalidate-static-pages', [CacheController::class, 'invalidateStaticUrls'])->name('invalidate-static-pages');
$router->post('clear-application-cache', [CacheController::class, 'clearApplicationCache'])->name('clear-application-cache');
$router->post('clear-image-cache', [CacheController::class, 'clearImageCache'])->name('clear-image-cache');
+ $router->get('image-containers', [CacheController::class, 'imageContainers'])->name('image-containers');
+ $router->post('clear-image-container', [CacheController::class, 'clearImageContainer'])->name('clear-image-container');
+ $router->get('image-assets', [CacheController::class, 'imageAssets'])->name('image-assets');
+ $router->post('clear-image-asset', [CacheController::class, 'clearImageAsset'])->name('clear-image-asset');
+ $router->get('image-variants', [CacheController::class, 'imageVariants'])->name('image-variants');
+ $router->post('clear-image-variant', [CacheController::class, 'clearImageVariant'])->name('clear-image-variant');
+ $router->get('image-preview', [CacheController::class, 'imagePreview'])->name('image-preview');
});
Utility::register('phpinfo')
diff --git a/src/Console/Commands/StaticWarm.php b/src/Console/Commands/StaticWarm.php
index 684b39ef3e8..24e4b5206ba 100644
--- a/src/Console/Commands/StaticWarm.php
+++ b/src/Console/Commands/StaticWarm.php
@@ -10,23 +10,19 @@
use GuzzleHttp\Psr7\Response;
use Illuminate\Console\Command;
use Illuminate\Http\Request as HttpRequest;
-use Illuminate\Routing\Route;
use Illuminate\Support\Collection;
use Statamic\Console\Commands\Concerns\NormalizesPaginationHeader;
use Statamic\Console\EnhancesCommands;
use Statamic\Console\RunsInPlease;
use Statamic\Entries\Collection as EntriesCollection;
-use Statamic\Entries\Entry;
use Statamic\Facades;
use Statamic\Facades\Site;
use Statamic\Facades\URL;
-use Statamic\Http\Controllers\FrontendController;
use Statamic\StaticCaching\Cacher as StaticCacher;
use Statamic\StaticCaching\RecacheToken;
+use Statamic\StaticCaching\WarmableUrls;
use Statamic\Support\Str;
use Statamic\Support\Traits\Hookable;
-use Statamic\Taxonomies\LocalizedTerm;
-use Statamic\Taxonomies\Taxonomy;
class StaticWarm extends Command
{
@@ -320,20 +316,7 @@ protected function entryUris(): Collection
{
$this->line('[ ] Entries...');
- // "Warm" the structure trees
- Facades\Collection::whereStructured()->each(fn ($collection) => $collection->structure()->trees()->each->tree());
-
- $entries = Facades\Entry::all()->map(function (Entry $entry) {
- if (! $entry->published() || $entry->private()) {
- return null;
- }
-
- if ($entry->isRedirect()) {
- return null;
- }
-
- return $entry->absoluteUrl();
- })->filter();
+ $entries = app(WarmableUrls::class)->entries();
$this->line("\x1B[1A\x1B[2K[✔] Entries");
@@ -344,19 +327,7 @@ protected function taxonomyUris(): Collection
{
$this->line('[ ] Taxonomies...');
- $taxonomyUris = Facades\Taxonomy::all()
- ->filter(function ($taxonomy) {
- return view()->exists($taxonomy->template());
- })
- ->flatMap(function (Taxonomy $taxonomy) {
- return $taxonomy->sites()->map(function ($site) use ($taxonomy) {
- // Needed because Taxonomy uses the current site. If the Taxonomy
- // class ever gets its own localization logic we can remove this.
- Site::setCurrent($site);
-
- return $taxonomy->absoluteUrl();
- });
- });
+ $taxonomyUris = app(WarmableUrls::class)->taxonomies();
$this->line("\x1B[1A\x1B[2K[✔] Taxonomies");
@@ -367,16 +338,7 @@ protected function termUris(): Collection
{
$this->line('[ ] Taxonomy terms...');
- $terms = Facades\Term::all()
- ->merge($this->scopedTerms())
- ->filter(function ($term) {
- return view()->exists($term->template());
- })
- ->flatMap(function (LocalizedTerm $term) {
- return $term->taxonomy()->sites()->map(function ($site) use ($term) {
- return $term->in($site)->absoluteUrl();
- });
- });
+ $terms = app(WarmableUrls::class)->terms($this->scopedTerms());
$this->line("\x1B[1A\x1B[2K[✔] Taxonomy terms");
@@ -393,26 +355,14 @@ protected function scopedTerms(): Collection
protected function getCollectionTerms($collection)
{
- return $collection->taxonomies()
- ->flatMap(function (Taxonomy $taxonomy) {
- return $taxonomy->queryTerms()->get();
- })
- ->map->collection($collection);
+ return app(WarmableUrls::class)->collectionTerms($collection);
}
protected function customRouteUris(): Collection
{
$this->line('[ ] Custom routes...');
- $action = FrontendController::class.'@route';
-
- $routes = collect(app('router')->getRoutes()->getRoutes())
- ->filter(function (Route $route) use ($action) {
- return $route->getActionName() === $action && ! Str::contains($route->uri(), '{');
- })
- ->map(function (Route $route) {
- return URL::tidy(Str::start($route->uri(), config('app.url').'/'));
- });
+ $routes = app(WarmableUrls::class)->customRoutes();
$this->line("\x1B[1A\x1B[2K[✔] Custom routes");
@@ -423,11 +373,16 @@ protected function additionalUris(): Collection
{
$this->line('[ ] Additional...');
- $uris = $this->runHooks('additional', collect());
+ $uris = $this->additionalUrls();
$this->line("\x1B[1A\x1B[2K[✔] Additional");
- return $uris->map(fn ($uri) => URL::makeAbsolute($uri));
+ return $uris;
+ }
+
+ public function additionalUrls(): Collection
+ {
+ return $this->runHooks('additional', collect())->map(fn ($uri) => URL::makeAbsolute($uri));
}
private function parseHeaders($headerOptions): array
diff --git a/src/Facades/Glide.php b/src/Facades/Glide.php
index 3e5c92df1e1..b55d561f19b 100644
--- a/src/Facades/Glide.php
+++ b/src/Facades/Glide.php
@@ -15,7 +15,7 @@
* @method static string route()
* @method static string url()
* @method static \Illuminate\Contracts\Cache\Repository cacheStore()
- * @method static void clearAsset($asset)
+ * @method static void clearAsset($asset, bool $preserveHybridMappings = false)
* @method static array normalizeParameters($params)
* @method static void generateHashUsing(\Closure $callback)
*
diff --git a/src/Http/Controllers/CP/Utilities/CacheController.php b/src/Http/Controllers/CP/Utilities/CacheController.php
index 91fe82e1eec..96210c6747e 100644
--- a/src/Http/Controllers/CP/Utilities/CacheController.php
+++ b/src/Http/Controllers/CP/Utilities/CacheController.php
@@ -2,20 +2,32 @@
namespace Statamic\Http\Controllers\CP\Utilities;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Http\Request;
+use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Artisan;
+use Illuminate\Validation\Rule;
use Inertia\Inertia;
use League\Glide\Server;
+use Statamic\Contracts\Assets\Asset as AssetContract;
+use Statamic\Contracts\Assets\AssetContainer as AssetContainerContract;
+use Statamic\CP\Column;
+use Statamic\CP\Columns;
+use Statamic\Facades\AssetContainer;
use Statamic\Facades\Site;
use Statamic\Facades\Stache;
use Statamic\Facades\StaticCache;
-use Statamic\Facades\URL;
use Statamic\Http\Controllers\CP\CpController;
-use Statamic\StaticCaching\Cacher;
+use Statamic\Imaging\CachedImages;
+use Statamic\StaticCaching\CachedPages;
use Statamic\StaticCaching\Cachers\AbstractCacher;
+use Statamic\StaticCaching\RecacheToken;
+use Statamic\StaticCaching\WarmableUrls;
use Statamic\Support\Str;
use function Statamic\trans as __;
+use function Statamic\trans_choice;
class CacheController extends CpController
{
@@ -25,11 +37,25 @@ public function index()
'stache' => $this->getStacheStats(),
'cache' => $this->getApplicationCacheStats(),
'static' => $this->getStaticCacheStats(),
+ 'staticCoverage' => Inertia::defer(fn () => config('statamic.static_caching.strategy')
+ ? app(CachedPages::class)->coverage()
+ : null),
'images' => Inertia::defer(fn () => $this->getImageCacheStats()),
+ 'cacheSites' => Site::all()->map(fn ($site) => ['value' => $site->handle(), 'label' => $site->name()])->values(),
+ 'staticPagesUrl' => cp_route('utilities.cache.static-pages'),
+ 'cacheStaticPageUrl' => cp_route('utilities.cache.cache-static-page'),
+ 'previewStaticPagesUrl' => cp_route('utilities.cache.preview-static-pages'),
+ 'imageContainersUrl' => cp_route('utilities.cache.image-containers'),
+ 'clearImageContainerUrl' => cp_route('utilities.cache.clear-image-container'),
+ 'imageAssetsUrl' => cp_route('utilities.cache.image-assets'),
+ 'clearImageAssetUrl' => cp_route('utilities.cache.clear-image-asset'),
+ 'imageVariantsUrl' => cp_route('utilities.cache.image-variants'),
+ 'clearImageVariantUrl' => cp_route('utilities.cache.clear-image-variant'),
'clearAllUrl' => cp_route('utilities.cache.clear-all'),
'clearStacheUrl' => cp_route('utilities.cache.clear-stache'),
'warmStacheUrl' => cp_route('utilities.cache.warm-stache'),
'clearStaticUrl' => cp_route('utilities.cache.clear-static'),
+ 'warmStaticUrl' => cp_route('utilities.cache.warm-static'),
'invalidatePagesUrl' => cp_route('utilities.cache.invalidate-static-pages'),
'clearApplicationUrl' => cp_route('utilities.cache.clear-application-cache'),
'clearImageUrl' => cp_route('utilities.cache.clear-image-cache'),
@@ -45,8 +71,8 @@ protected function getStacheStats()
return [
'records' => Stache::fileCount(),
'size' => $size ? Str::fileSizeForHumans($size) : null,
- 'time' => $time ? Str::timeForHumans($time) : __('Refresh'),
- 'rebuilt' => $built ? $built->diffForHumans() : __('Refresh'),
+ 'time' => $time === null ? null : Str::timeForHumans($time),
+ 'rebuilt' => $built?->diffForHumans(),
];
}
@@ -60,16 +86,12 @@ protected function getApplicationCacheStats()
protected function getImageCacheStats()
{
- $files = collect(app(Server::class)->getCache()->listContents('', true))
- ->filter(function ($file) {
- return $file['type'] === 'file';
- });
+ $stats = app(CachedImages::class)->summary();
return [
- 'count' => $files->count(),
- 'size' => Str::fileSizeForHumans($files->reduce(function ($size, $file) {
- return $size + $file->fileSize();
- }, 0)),
+ 'count' => $stats['count'],
+ 'size' => Str::fileSizeForHumans($stats['size']),
+ 'assets' => $stats['assets'],
];
}
@@ -91,74 +113,495 @@ protected function getStaticCacheStats()
public function clearAll(Request $request)
{
+ $started = microtime(true);
+
$this->clearStacheCache();
$this->clearStaticCache();
$this->clearApplicationCache();
$this->clearImageCache();
- return back()->withSuccess(__('All caches cleared.'));
+ return back()->withSuccess(__('All caches cleared in :time.', ['time' => $this->operationTime($started)]));
}
public function clearStacheCache()
{
- Stache::refresh();
+ $started = microtime(true);
+
+ Stache::clear();
- return back()->withSuccess(__('Stache cleared.'));
+ return back()->withSuccess(__('Stache cleared in :time.', ['time' => $this->operationTime($started)]));
}
public function clearStaticCache()
{
+ $started = microtime(true);
+
StaticCache::flush();
- return back()->withSuccess(__('Static page cache cleared.'));
+ return back()->withSuccess(__('Static page cache cleared in :time.', ['time' => $this->operationTime($started)]));
+ }
+
+ public function warmStaticCache()
+ {
+ if (! config('statamic.static_caching.strategy')) {
+ return back()->withError(__('Static caching is not enabled.'));
+ }
+
+ $started = microtime(true);
+ $connection = config('statamic.static_caching.warm_queue_connection') ?? config('queue.default');
+ $queued = ! in_array(config("queue.connections.{$connection}.driver", $connection), ['sync', 'null'], true);
+
+ if (Artisan::call('statamic:static:warm', ['--queue' => $queued]) !== 0) {
+ return back()->withError(__('Unable to warm the static cache.'));
+ }
+
+ CachedPages::clearCache();
+
+ return back()->withSuccess($queued
+ ? __('Static cache warming queued.')
+ : __('Static cache warmed in :time.', ['time' => $this->operationTime($started)]));
+ }
+
+ public function staticPages(Request $request, CachedPages $cachedPages)
+ {
+ $filters = $request->validate([
+ 'status' => ['sometimes', Rule::in(['cached', 'uncached'])],
+ 'site' => ['nullable', Rule::in(Site::all()->keys()->all())],
+ 'domain' => ['nullable', 'string', 'max:2048'],
+ 'search' => ['nullable', 'string', 'max:255'],
+ 'page' => ['sometimes', 'integer', 'min:1'],
+ 'perPage' => ['sometimes', 'integer', 'min:1', 'max:100'],
+ 'sort' => ['sometimes', Rule::in($request->input('status') === 'uncached'
+ ? ['url', 'path', 'site_name', 'domain']
+ : ['url', 'path', 'generated_at', 'site_name', 'domain'])],
+ 'order' => ['sometimes', Rule::in(['asc', 'desc'])],
+ 'columns' => ['nullable', 'string', 'max:255'],
+ ]);
+
+ $uncached = ($filters['status'] ?? 'cached') === 'uncached';
+ $all = $uncached ? $cachedPages->uncached() : $cachedPages->all();
+ $pages = $cachedPages->filter($all, $filters);
+ $sort = $filters['sort'] ?? ($uncached ? 'url' : 'generated_at');
+ $pages = $cachedPages->sort($pages, $sort, $filters['order'] ?? ($uncached ? 'asc' : 'desc'));
+ $perPage = $filters['perPage'] ?? 15;
+ $page = $filters['page'] ?? 1;
+ $items = $pages->forPage($page, $perPage)->values();
+
+ if (! $uncached && $sort !== 'generated_at') {
+ $items = $cachedPages->withGeneratedAt($items);
+ }
+
+ $paginator = new LengthAwarePaginator($items, $pages->count(), $perPage, $page);
+
+ return [
+ 'data' => $paginator->items(),
+ 'meta' => [
+ 'total' => $paginator->total(),
+ 'current_page' => $paginator->currentPage(),
+ 'last_page' => $paginator->lastPage(),
+ 'per_page' => $paginator->perPage(),
+ 'from' => $paginator->firstItem(),
+ 'to' => $paginator->lastItem(),
+ 'domains' => $all->pluck('domain')->unique()->sort()->values(),
+ 'columns' => $this->staticPageColumns($request),
+ ],
+ ];
}
- public function invalidateStaticUrls(Request $request)
+ public function cacheStaticPage(Request $request, CachedPages $cachedPages, WarmableUrls $warmableUrls, Client $client)
{
- $request->validate([
- 'urls' => ['required', 'array'],
+ abort_unless(config('statamic.static_caching.strategy'), 422, __('Static caching is not enabled.'));
+
+ $input = $request->validate([
+ 'url' => [
+ 'bail', 'required', 'string', 'max:2048',
+ function ($attribute, $value, $fail) use ($warmableUrls) {
+ if (! $warmableUrls->contains($value)) {
+ $fail(__('validation.in', ['attribute' => $attribute]));
+ }
+ },
+ ],
]);
+ $url = $input['url'];
+
+ if ($cachedPages->isCached($url)) {
+ CachedPages::clearCache();
- $urls = $request->collect('urls');
+ return ['message' => __('Page is already cached.'), 'url' => $url];
+ }
- $absoluteUrls = $urls->filter(fn (string $rule) => URL::isAbsolute($rule))->all();
+ $started = microtime(true);
+ $target = config('statamic.static_caching.background_recache', false)
+ ? RecacheToken::addToUrl($url)
+ : $url;
- $prefixedRelativeUrls = $urls
- ->reject(fn (string $rule) => URL::isAbsolute($rule))
- ->map(fn (string $rule) => URL::tidy(Site::selected()->url().'/'.$rule, withTrailingSlash: false))
- ->all();
+ try {
+ $response = $client->get($target, [
+ 'verify' => ! config('statamic.static_caching.warm_insecure') && ! app()->isLocal(),
+ 'allow_redirects' => false,
+ 'timeout' => 30,
+ ]);
+ } catch (GuzzleException) {
+ return response()->json(['message' => __('Unable to cache this page.')], 502);
+ }
- $urls = [
- ...$absoluteUrls,
- ...$prefixedRelativeUrls,
+ if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300 || ! $cachedPages->isCached($url)) {
+ return response()->json(['message' => __('The page could not be cached.')], 422);
+ }
+
+ CachedPages::clearCache();
+
+ return [
+ 'message' => __('Page cached in :time.', ['time' => $this->operationTime($started)]),
+ 'url' => $url,
];
+ }
+
+ protected function staticPageColumns(Request $request): Columns
+ {
+ $uncached = $request->input('status') === 'uncached';
+
+ return (new Columns([
+ Column::make('url')->label(__('URL')),
+ Column::make('path')->label(__('Path'))->visible(false)->defaultVisibility(false),
+ Column::make('generated_at')->label(__('Generated'))->fieldtype('date'),
+ Column::make('site_name')->label(__('Site')),
+ Column::make('domain')->label(__('Domain'))->visible(false)->defaultVisibility(false),
+ Column::make('actions')->label(__('Actions'))->sortable(false),
+ ]))
+ ->reject(fn ($column) => $uncached && $column->field() === 'generated_at')
+ ->values()
+ ->map(fn ($column, $index) => $column->defaultOrder($index))
+ ->setPreferred($request->filled('columns')
+ ? explode(',', $request->input('columns'))
+ : ($uncached ? 'cache.uncached_pages.columns' : 'cache.static_pages.columns'));
+ }
+
+ public function previewStaticPages(Request $request, CachedPages $cachedPages)
+ {
+ $input = $this->validateStaticUrls($request);
+ $matches = $cachedPages->matching($cachedPages->all(), $input['urls'], $input['site'] ?? null);
+ $matches = $cachedPages->filter($matches, $input);
- app(Cacher::class)->invalidateUrls($urls);
+ return ['data' => $matches->take(10)->values(), 'total' => $matches->count()];
+ }
+
+ public function invalidateStaticUrls(Request $request, CachedPages $cachedPages)
+ {
+ $started = microtime(true);
+ $input = $this->validateStaticUrls($request);
+ $matches = $cachedPages->matching($cachedPages->all(), $input['urls'], $input['site'] ?? null);
+ $matches = $cachedPages->filter($matches, $input);
+ $count = $cachedPages->invalidate($matches);
+
+ $message = trans_choice('statamic::messages.cache_utility_invalidated', $count, [
+ 'time' => $this->operationTime($started),
+ ]);
+
+ return $request->expectsJson()
+ ? ['message' => $message, 'count' => $count]
+ : back()->withSuccess($message);
+ }
- return back()->withSuccess(__('Invalidated URLs in the Static Cache.'));
+ protected function validateStaticUrls(Request $request): array
+ {
+ if (is_array($urls = $request->input('urls'))) {
+ $request->merge([
+ 'urls' => collect($urls)
+ ->map(fn ($url) => is_string($url) ? trim($url) : $url)
+ ->reject(fn ($url) => $url === '' || $url === null)
+ ->values()->all(),
+ ]);
+ }
+
+ return $request->validate([
+ 'urls' => ['required', 'array', 'min:1', 'max:1000'],
+ 'urls.*' => ['required', 'string', 'max:2048', 'regex:/^[^*]*\\*?$/'],
+ 'site' => ['nullable', Rule::in(Site::all()->keys()->all())],
+ 'domain' => ['nullable', 'string', 'max:2048'],
+ ], [
+ 'urls.*.regex' => __('statamic::messages.cache_utility_invalid_wildcard'),
+ ]);
}
public function clearApplicationCache()
{
+ $started = microtime(true);
+
Artisan::call('cache:clear');
// TODO: Stache doesn't appear to be clearing?
// Maybe related to https://github.com/statamic/three-cms/issues/149
- return back()->withSuccess(__('Application cache cleared.'));
+ return back()->withSuccess(__('Application cache cleared in :time.', ['time' => $this->operationTime($started)]));
}
public function clearImageCache()
{
+ $started = microtime(true);
+
Artisan::call('statamic:glide:clear');
- return back()->withSuccess(__('Image cache cleared.'));
+ return back()->withSuccess(__('Image cache cleared in :time.', ['time' => $this->operationTime($started)]));
+ }
+
+ public function imageContainers(Request $request, CachedImages $cachedImages)
+ {
+ return $this->imageListing($request, $cachedImages);
+ }
+
+ public function imageAssets(Request $request, CachedImages $cachedImages)
+ {
+ return $this->imageListing($request, $cachedImages, $this->imageCacheContainer($request));
+ }
+
+ public function imageVariants(Request $request, CachedImages $cachedImages)
+ {
+ $asset = $this->imageCacheAsset($request, $cachedImages);
+ $input = $request->validate([
+ 'search' => ['nullable', 'string', 'max:255'],
+ 'page' => ['sometimes', 'integer', 'min:1'],
+ 'perPage' => ['sometimes', 'integer', 'min:1', 'max:100'],
+ 'sort' => ['sometimes', Rule::in(['title', 'format', 'size', 'generated_at', 'hash'])],
+ 'order' => ['sometimes', Rule::in(['asc', 'desc'])],
+ 'columns' => ['nullable', 'string', 'max:255'],
+ ]);
+
+ $sort = $input['sort'] ?? 'size';
+ $sortOptions = $sort === 'size' ? SORT_REGULAR : SORT_NATURAL | SORT_FLAG_CASE;
+ $descending = ($input['order'] ?? 'desc') === 'desc';
+ $variants = $cachedImages->variants($asset)
+ ->when($input['search'] ?? null, function ($variants, $search) {
+ return $variants->filter(fn ($variant) => Str::contains(
+ Str::lower($variant['title'].' '.$variant['format'].' '.$variant['hash']),
+ Str::lower($search)
+ ));
+ })
+ ->sortBy($sort, $sortOptions, $descending)
+ ->values();
+ $perPage = $input['perPage'] ?? 15;
+ $page = $input['page'] ?? 1;
+ $paginator = new LengthAwarePaginator(
+ $variants->forPage($page, $perPage)->values(),
+ $variants->count(),
+ $perPage,
+ $page
+ );
+
+ return [
+ 'data' => $paginator->items(),
+ 'meta' => [
+ 'total' => $paginator->total(),
+ 'current_page' => $paginator->currentPage(),
+ 'last_page' => $paginator->lastPage(),
+ 'per_page' => $paginator->perPage(),
+ 'from' => $paginator->firstItem(),
+ 'to' => $paginator->lastItem(),
+ 'columns' => (new Columns([
+ Column::make('preview')->label(__('Preview'))->sortable(false),
+ Column::make('dimensions')->label(__('Dimensions'))->sortable(false),
+ Column::make('format')->label(__('Format')),
+ Column::make('size')->label(__('Size'))->numeric(true),
+ Column::make('generated_at')->label(__('Generated'))->fieldtype('date'),
+ Column::make('actions')->label(__('Actions'))->sortable(false),
+ Column::make('title')->label(__('Filename'))->visible(false)->defaultVisibility(false),
+ Column::make('hash')->label(__('Hash'))->visible(false)->defaultVisibility(false),
+ ]))
+ ->map(fn ($column, $index) => $column->defaultOrder($index)),
+ ],
+ ];
+ }
+
+ public function imagePreview(Request $request, Server $server)
+ {
+ $container = $this->imageCacheContainer($request);
+ $this->authorize('view', $container);
+
+ $input = $request->validate(['path' => ['required', 'string', 'max:8192']]);
+ $path = $input['path'];
+ $segments = explode('/', $path);
+
+ abort_unless(str_starts_with($path, 'containers/'.$container->id().'/')
+ && ! str_contains($path, '\\')
+ && ! str_contains($path, "\0")
+ && ! in_array('..', $segments)
+ && ! in_array('.', $segments), 404);
+
+ $cache = $server->getCache();
+ abort_unless($cache->fileExists($path), 404);
+ abort_unless(in_array($cache->mimeType($path), [
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/bmp', 'image/tiff',
+ ]), 415);
+
+ $response = $server->getResponseFactory()->create($cache, $path);
+ $response->headers->set('Cache-Control', 'private, no-store');
+ $response->headers->remove('Expires');
+ $response->headers->set('X-Content-Type-Options', 'nosniff');
+
+ return $response;
+ }
+
+ protected function imageListing(Request $request, CachedImages $cachedImages, ?AssetContainerContract $container = null)
+ {
+ $pathColumn = $container ? 'path' : 'handle';
+ $input = $request->validate([
+ 'search' => ['nullable', 'string', 'max:255'],
+ 'page' => ['sometimes', 'integer', 'min:1'],
+ 'perPage' => ['sometimes', 'integer', 'min:1', 'max:100'],
+ 'sort' => ['sometimes', Rule::in(['title', $pathColumn, 'count', 'size'])],
+ 'order' => ['sometimes', Rule::in(['asc', 'desc'])],
+ 'columns' => ['nullable', 'string', 'max:255'],
+ ]);
+
+ $sort = $input['sort'] ?? 'size';
+ $sortOptions = in_array($sort, ['count', 'size']) ? SORT_REGULAR : SORT_NATURAL | SORT_FLAG_CASE;
+ $descending = ($input['order'] ?? 'desc') === 'desc';
+ $all = $container ? $cachedImages->assets($container) : $cachedImages->containers();
+ $items = $all
+ ->when($input['search'] ?? null, function ($items, $search) use ($pathColumn) {
+ return $items->filter(fn ($item) => Str::contains(
+ Str::lower($item['title'].' '.$item[$pathColumn]),
+ Str::lower($search)
+ ));
+ })
+ ->sortBy($sort, $sortOptions, $descending)
+ ->values();
+
+ $perPage = $input['perPage'] ?? 15;
+ $page = $input['page'] ?? 1;
+ $paginator = new LengthAwarePaginator(
+ $items->forPage($page, $perPage)->values(),
+ $items->count(),
+ $perPage,
+ $page
+ );
+
+ return [
+ 'data' => $paginator->items(),
+ 'meta' => [
+ 'total' => $paginator->total(),
+ 'current_page' => $paginator->currentPage(),
+ 'last_page' => $paginator->lastPage(),
+ 'per_page' => $paginator->perPage(),
+ 'from' => $paginator->firstItem(),
+ 'to' => $paginator->lastItem(),
+ 'container_count' => $container ? null : $all->count(),
+ 'single_container' => ! $container && $all->count() === 1 ? $all->first() : null,
+ 'columns' => (new Columns([
+ Column::make($container ? 'path' : 'title')->label($container ? __('Asset') : __('Container')),
+ Column::make($container ? 'title' : 'handle')
+ ->label($container ? __('Filename') : __('Handle'))
+ ->visible(false)->defaultVisibility(false),
+ Column::make('count')->label(__('Cached images'))->numeric(true),
+ Column::make('size')->label(__('Size'))->numeric(true),
+ Column::make('actions')->label(__('Actions'))->sortable(false),
+ ]))
+ ->map(fn ($column, $index) => $column->defaultOrder($index))
+ ->setPreferred($request->filled('columns')
+ ? explode(',', $request->input('columns'))
+ : ($container ? 'cache.image_assets.columns' : 'cache.image_containers.columns')),
+ ],
+ ];
+ }
+
+ public function clearImageContainer(Request $request, CachedImages $cachedImages)
+ {
+ $started = microtime(true);
+ $handles = $this->imageCacheSelections($request, 'container', 'containers', AssetContainer::all()->map->handle()->all());
+ $containers = collect($handles)->map(fn ($handle) => AssetContainer::find($handle));
+ $results = $containers->map(fn ($container) => $cachedImages->clear($container));
+ $result = ['count' => $results->sum('count'), 'size' => $results->sum('size')];
+ $source = $containers->count() === 1
+ ? $containers->first()->title()
+ : __(':count containers', ['count' => $containers->count()]);
+
+ return $result + [
+ 'message' => trans_choice('statamic::messages.cache_utility_images_cleared', $result['count'], [
+ 'container' => $source,
+ 'size' => Str::fileSizeForHumans($result['size']),
+ 'time' => $this->operationTime($started),
+ ]),
+ ];
+ }
+
+ public function clearImageAsset(Request $request, CachedImages $cachedImages)
+ {
+ $started = microtime(true);
+ $container = $this->imageCacheContainer($request);
+ $ids = $this->imageCacheSelections($request, 'asset', 'assets', $cachedImages->assets($container)->pluck('id')->all());
+ $assets = collect($ids)->map(fn ($id) => $container->makeAsset(Str::after($id, $container->id().'::')));
+ $results = $assets->map(fn ($asset) => $cachedImages->clearAsset($asset));
+ $result = ['count' => $results->sum('count'), 'size' => $results->sum('size')];
+ $source = $assets->count() === 1
+ ? $assets->first()->path()
+ : __(':count assets', ['count' => $assets->count()]);
+
+ return $result + [
+ 'message' => trans_choice('statamic::messages.cache_utility_asset_images_cleared', $result['count'], [
+ 'asset' => $source,
+ 'size' => Str::fileSizeForHumans($result['size']),
+ 'time' => $this->operationTime($started),
+ ]),
+ ];
+ }
+
+ public function clearImageVariant(Request $request, CachedImages $cachedImages)
+ {
+ $started = microtime(true);
+ $asset = $this->imageCacheAsset($request, $cachedImages);
+ $paths = $this->imageCacheSelections($request, 'variant', 'variants', $cachedImages->variants($asset)->pluck('id')->all());
+ $result = $cachedImages->clearVariants($asset, $paths);
+
+ return $result + [
+ 'message' => trans_choice('statamic::messages.cache_utility_asset_images_cleared', $result['count'], [
+ 'asset' => $asset->path(),
+ 'size' => Str::fileSizeForHumans($result['size']),
+ 'time' => $this->operationTime($started),
+ ]),
+ ];
+ }
+
+ protected function imageCacheSelections(Request $request, string $single, string $multiple, array $allowed): array
+ {
+ $input = $request->validate([
+ $single => ['required_without:'.$multiple, 'prohibits:'.$multiple, 'string', Rule::in($allowed)],
+ $multiple => ['required_without:'.$single, 'prohibits:'.$single, 'array', 'min:1', 'max:1000'],
+ $multiple.'.*' => ['required', 'string', 'distinct', Rule::in($allowed)],
+ ]);
+
+ return $input[$multiple] ?? [$input[$single]];
+ }
+
+ protected function imageCacheAsset(Request $request, CachedImages $cachedImages): AssetContract
+ {
+ $container = $this->imageCacheContainer($request);
+ $input = $request->validate([
+ 'asset' => ['required', 'string', 'max:4096', Rule::in($cachedImages->assets($container)->pluck('id')->all())],
+ ]);
+
+ return $container->makeAsset(Str::after($input['asset'], $container->id().'::'));
+ }
+
+ protected function imageCacheContainer(Request $request): AssetContainerContract
+ {
+ $input = $request->validate([
+ 'container' => ['required', 'string', Rule::in(AssetContainer::all()->map->handle()->all())],
+ ]);
+
+ return AssetContainer::find($input['container']);
}
public function warmStacheCache()
{
+ $started = microtime(true);
+
Stache::warm();
- return back()->withSuccess(__('Stache warmed.'));
+ return back()->withSuccess(__('Stache warmed in :time.', ['time' => $this->operationTime($started)]));
+ }
+
+ protected function operationTime(float $started): string
+ {
+ return Str::timeForHumans((int) round((microtime(true) - $started) * 1000));
}
}
diff --git a/src/Imaging/CachedImages.php b/src/Imaging/CachedImages.php
new file mode 100644
index 00000000000..e2e96d0633e
--- /dev/null
+++ b/src/Imaging/CachedImages.php
@@ -0,0 +1,227 @@
+server->getCache()->listContents('', true) as $file) {
+ if (! $file->isFile()) {
+ continue;
+ }
+
+ $count++;
+ $size += $file->fileSize() ?? $this->server->getCache()->fileSize($file->path());
+
+ if (str_starts_with($file->path(), 'containers/') && substr_count($file->path(), '/') >= 4) {
+ $assets[dirname($file->path(), 2)] = true;
+ }
+ }
+
+ return ['count' => $count, 'size' => $size, 'assets' => count($assets)];
+ }
+
+ public function containers(): Collection
+ {
+ return AssetContainer::all()->map(function ($container) {
+ $stats = $this->stats($container);
+
+ return [
+ 'id' => $container->id(),
+ 'handle' => $container->handle(),
+ 'title' => $container->title(),
+ 'count' => $stats['count'],
+ 'size' => $stats['size'],
+ 'size_formatted' => Str::fileSizeForHumans($stats['size']),
+ ];
+ })->sortBy('title', SORT_NATURAL | SORT_FLAG_CASE)->values();
+ }
+
+ public function clear(AssetContainerContract $container): array
+ {
+ $before = $this->stats($container);
+
+ $container->assets()->each(fn ($asset) => Glide::clearAsset($asset, preserveHybridMappings: true));
+
+ $this->server->getCache()->deleteDirectory('containers/'.$container->id());
+
+ $after = $this->stats($container);
+
+ return [
+ 'count' => max(0, $before['count'] - $after['count']),
+ 'size' => max(0, $before['size'] - $after['size']),
+ ];
+ }
+
+ public function assets(AssetContainerContract $container): Collection
+ {
+ $prefix = 'containers/'.$container->id().'/';
+ $assets = [];
+
+ foreach ($this->server->getCache()->listContents($prefix, true) as $file) {
+ if (! $file->isFile()) {
+ continue;
+ }
+
+ // Variations are stored under {asset path}/{hash}/{filename}.
+ $path = dirname(Str::after($file->path(), $prefix), 2);
+
+ if ($path === '.') {
+ continue;
+ }
+
+ $assets[$path] ??= [
+ 'id' => $container->id().'::'.$path,
+ 'title' => basename($path),
+ 'path' => $path,
+ 'extension' => pathinfo($path, PATHINFO_EXTENSION),
+ 'thumbnail' => null,
+ 'thumbnail_size' => null,
+ 'count' => 0,
+ 'size' => 0,
+ ];
+
+ $size = $file->fileSize() ?? $this->server->getCache()->fileSize($file->path());
+ $assets[$path]['count']++;
+ $assets[$path]['size'] += $size;
+
+ if ($assets[$path]['thumbnail_size'] === null || $size < $assets[$path]['thumbnail_size']) {
+ $assets[$path]['thumbnail'] = $this->previewUrl($container, $file->path());
+ $assets[$path]['thumbnail_size'] = $size;
+ }
+ }
+
+ return collect($assets)->map(function ($asset) {
+ $asset['size_formatted'] = Str::fileSizeForHumans($asset['size']);
+ unset($asset['thumbnail_size']);
+
+ return $asset;
+ })->sortBy('path', SORT_NATURAL | SORT_FLAG_CASE)->values();
+ }
+
+ public function variants(Asset $asset): Collection
+ {
+ $variants = [];
+ $path = 'containers/'.$asset->containerId().'/'.$asset->path();
+
+ foreach ($this->server->getCache()->listContents($path, true) as $file) {
+ if (! $file->isFile()) {
+ continue;
+ }
+
+ $size = $file->fileSize() ?? $this->server->getCache()->fileSize($file->path());
+ $generated = $file->lastModified() ?? $this->server->getCache()->lastModified($file->path());
+
+ $variants[] = [
+ 'id' => $file->path(),
+ 'title' => basename($file->path()),
+ 'hash' => basename(dirname($file->path())),
+ 'format' => strtoupper(pathinfo($file->path(), PATHINFO_EXTENSION)),
+ 'size' => $size,
+ 'size_formatted' => Str::fileSizeForHumans($size),
+ 'generated_at' => Carbon::createFromTimestampUTC($generated)->toIso8601String(),
+ 'preview_url' => $this->previewUrl($asset->container(), $file->path()),
+ ];
+ }
+
+ return collect($variants)->sortBy('id', SORT_NATURAL)->values();
+ }
+
+ public function clearAsset(Asset $asset): array
+ {
+ $before = $this->stats($asset->container(), $asset->path());
+
+ Glide::clearAsset($asset, preserveHybridMappings: true);
+
+ $after = $this->stats($asset->container(), $asset->path());
+
+ return [
+ 'count' => max(0, $before['count'] - $after['count']),
+ 'size' => max(0, $before['size'] - $after['size']),
+ ];
+ }
+
+ public function clearVariants(Asset $asset, array $paths): array
+ {
+ $cache = $this->server->getCache();
+ $size = 0;
+
+ foreach ($paths as $path) {
+ $size += $cache->fileSize($path);
+ $cache->delete($path);
+ }
+
+ $store = Glide::cacheStore();
+ $manifestKey = ImageGenerator::assetCacheManifestKey($asset);
+ $clearedPaths = array_fill_keys($paths, true);
+
+ // Keep hybrid source mappings so existing URLs can regenerate the cleared variation.
+ $keys = collect($store->get($manifestKey, []))->reject(function ($key) use ($store, $clearedPaths) {
+ $path = $store->get($key);
+
+ if (! is_string($path) || ! isset($clearedPaths[$path])) {
+ return false;
+ }
+
+ $store->forget($key);
+
+ return true;
+ })->values()->all();
+
+ if ($keys) {
+ $store->forever($manifestKey, $keys);
+ } else {
+ $store->forget($manifestKey);
+ }
+
+ $remaining = $this->stats($asset->container(), $asset->path());
+
+ return [
+ 'count' => count($paths),
+ 'size' => $size,
+ 'remaining' => $remaining['count'],
+ 'remaining_size' => $remaining['size'],
+ ];
+ }
+
+ private function stats(AssetContainerContract $container, ?string $assetPath = null): array
+ {
+ $count = 0;
+ $size = 0;
+ $path = 'containers/'.$container->id().($assetPath === null ? '' : '/'.$assetPath);
+
+ foreach ($this->server->getCache()->listContents($path, true) as $file) {
+ if (! $file->isFile()) {
+ continue;
+ }
+
+ $count++;
+ $size += $file->fileSize() ?? $this->server->getCache()->fileSize($file->path());
+ }
+
+ return compact('count', 'size');
+ }
+
+ private function previewUrl(AssetContainerContract $container, string $path): string
+ {
+ return cp_route('utilities.cache.image-preview', ['container' => $container->id(), 'path' => $path]);
+ }
+}
diff --git a/src/Imaging/GlideManager.php b/src/Imaging/GlideManager.php
index 755bd4d77b1..9e56ffd08ca 100644
--- a/src/Imaging/GlideManager.php
+++ b/src/Imaging/GlideManager.php
@@ -142,7 +142,7 @@ public function cacheStore()
return Cache::store('glide');
}
- public function clearAsset($asset)
+ public function clearAsset($asset, bool $preserveHybridMappings = false)
{
$pathPrefix = ImageGenerator::assetCachePathPrefix($asset);
$manifestKey = ImageGenerator::assetCacheManifestKey($asset);
@@ -156,13 +156,22 @@ public function clearAsset($asset)
$server->setCachePathCallable($customCallable);
});
- // Use manifest to clear each manipulation key from cache store.
- collect($this->cacheStore()->get($manifestKey, []))->each(function ($manipulationKey) {
- $this->cacheStore()->forget($manipulationKey);
- });
+ $remainingKeys = collect($this->cacheStore()->get($manifestKey, []))
+ ->filter(function ($key) use ($preserveHybridMappings) {
+ if ($preserveHybridMappings && Str::startsWith($key, 'hybrid::')) {
+ return true;
+ }
+
+ $this->cacheStore()->forget($key);
- // Clear manifest itself from cache store.
- $this->cacheStore()->forget($manifestKey);
+ return false;
+ })->values()->all();
+
+ if ($remainingKeys) {
+ $this->cacheStore()->forever($manifestKey, $remainingKeys);
+ } else {
+ $this->cacheStore()->forget($manifestKey);
+ }
GlideAssetCacheCleared::dispatch($asset);
}
diff --git a/src/StaticCaching/CachedPages.php b/src/StaticCaching/CachedPages.php
new file mode 100644
index 00000000000..f1595fb4a31
--- /dev/null
+++ b/src/StaticCaching/CachedPages.php
@@ -0,0 +1,202 @@
+cacher instanceof AbstractCacher
+ ? $this->cacher->getDomains()
+ : collect([rtrim(Site::selected()->absoluteUrl(), '/')]);
+
+ return $domains->flatMap(function ($domain) {
+ return $this->cacher->getUrls($domain)->map(function ($path) use ($domain) {
+ $url = URL::isAbsolute($path) ? $path : rtrim($domain, '/').'/'.ltrim($path, '/');
+
+ return $this->page($url, $domain);
+ })->values();
+ })->unique('url')->sortBy('url', SORT_NATURAL)->values();
+ }
+
+ public function coverage(): array
+ {
+ $statuses = $this->statuses();
+ $uncached = count($statuses['uncached']);
+
+ return [
+ 'cached' => $statuses['total'] - $uncached,
+ 'total' => $statuses['total'],
+ 'uncached' => $uncached,
+ ];
+ }
+
+ public function uncached(): Collection
+ {
+ return collect($this->statuses()['uncached'])
+ ->map(fn ($url) => $this->page($url, Request::create($url)->getSchemeAndHttpHost()))
+ ->values();
+ }
+
+ public static function clearCache(): void
+ {
+ Cache::forget(self::CACHE_KEY);
+ }
+
+ private function statuses(): array
+ {
+ return Cache::remember(self::CACHE_KEY, 60, function () {
+ $urls = app(WarmableUrls::class)->all();
+
+ return [
+ 'total' => $urls->count(),
+ 'uncached' => $urls->reject(fn ($url) => $this->isCached($url))->values()->all(),
+ ];
+ });
+ }
+
+ public function isCached(string $url): bool
+ {
+ $request = Request::create($url);
+
+ if ($this->cacher instanceof FileCacher) {
+ $normalized = $this->cacher->getUrl($request);
+
+ return File::exists($this->cacher->getFilePath($normalized, Site::findByUrl($url)?->handle()));
+ }
+
+ if ($this->cacher instanceof ApplicationCacher && $this->cacher->getGeneratedAt($url) !== null) {
+ return true;
+ }
+
+ return $this->cacher->hasCachedPage($request);
+ }
+
+ private function page(string $url, string $domain): array
+ {
+ $site = Site::findByUrl($url);
+ $parts = parse_url($url);
+
+ return [
+ 'id' => $url,
+ 'title' => $url,
+ 'url' => $url,
+ 'path' => ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : ''),
+ 'domain' => $domain,
+ 'site' => $site?->handle(),
+ 'site_name' => $site?->name(),
+ ];
+ }
+
+ public function filter(Collection $pages, array $filters): Collection
+ {
+ return $pages
+ ->when($filters['site'] ?? null, fn ($pages, $site) => $pages->where('site', $site))
+ ->when($filters['domain'] ?? null, fn ($pages, $domain) => $pages->where('domain', $domain))
+ ->when($filters['search'] ?? null, function ($pages, $search) {
+ return $pages->filter(fn ($page) => Str::contains(Str::lower($page['url']), Str::lower($search)));
+ })
+ ->values();
+ }
+
+ public function withGeneratedAt(Collection $pages): Collection
+ {
+ return $pages->map(function ($page) {
+ $timestamp = $this->cacher instanceof AbstractCacher
+ ? $this->cacher->getGeneratedAt($page['url'])
+ : null;
+
+ $page['generated_at'] = $timestamp === null
+ ? null
+ : Carbon::createFromTimestampUTC($timestamp)->toIso8601String();
+
+ return $page;
+ });
+ }
+
+ public function sort(Collection $pages, string $column, string $direction): Collection
+ {
+ if ($column === 'generated_at') {
+ $pages = $this->withGeneratedAt($pages);
+ }
+
+ return $pages->sort(function ($a, $b) use ($column, $direction) {
+ $aValue = $a[$column];
+ $bValue = $b[$column];
+
+ if ($aValue === null || $bValue === null) {
+ $comparison = ($aValue === null) <=> ($bValue === null);
+ } else {
+ $comparison = strnatcasecmp($aValue, $bValue) * ($direction === 'desc' ? -1 : 1);
+ }
+
+ return $comparison ?: strnatcasecmp($a['url'], $b['url']);
+ })->values();
+ }
+
+ public function matching(Collection $pages, array $rules, ?string $site = null): Collection
+ {
+ $sites = $site ? collect([Site::get($site)]) : Site::all();
+
+ $rules = collect($rules)->flatMap(function ($rule) use ($sites) {
+ if (URL::isAbsolute($rule)) {
+ return [$rule];
+ }
+
+ return $sites->map(fn ($site) => rtrim($site->absoluteUrl(), '/').'/'.ltrim($rule, '/'))->values()->all();
+ })->unique()->values();
+
+ if ($this->cacher instanceof FileCacher) {
+ // Static files can outlive the URL index when the application cache is cleared.
+ $unindexed = $rules
+ ->reject(fn ($rule) => str_ends_with($rule, '*') || $pages->contains('url', $rule))
+ ->filter(fn ($url) => Site::findByUrl($url) && $this->isCached($url))
+ ->map(fn ($url) => $this->page($url, Request::create($url)->getSchemeAndHttpHost()));
+
+ $pages = $pages->merge($unindexed)->unique('url');
+ }
+
+ return $pages->filter(function ($page) use ($rules) {
+ return $rules->contains(function ($rule) use ($page) {
+ if (str_ends_with($rule, '*')) {
+ return str_starts_with($page['url'], substr($rule, 0, -1));
+ }
+
+ return $page['url'] === $rule || str_starts_with($page['url'], $rule.'?');
+ });
+ })->values();
+ }
+
+ public function invalidate(Collection $pages): int
+ {
+ if ($pages->isEmpty()) {
+ return 0;
+ }
+
+ $this->cacher->invalidateUrls($pages->pluck('url')->all());
+ self::clearCache();
+
+ $remaining = $this->all()->pluck('url');
+
+ return $pages->reject(fn ($page) => $remaining->contains($page['url'])
+ || ($this->cacher instanceof FileCacher && $this->isCached($page['url'])))->count();
+ }
+}
diff --git a/src/StaticCaching/Cachers/AbstractCacher.php b/src/StaticCaching/Cachers/AbstractCacher.php
index 40c3c509fb5..34df6bd7409 100644
--- a/src/StaticCaching/Cachers/AbstractCacher.php
+++ b/src/StaticCaching/Cachers/AbstractCacher.php
@@ -149,6 +149,11 @@ public function getUrls($domain = null)
return collect($this->cache->get($key, []));
}
+ public function getGeneratedAt(string $url): ?int
+ {
+ return null;
+ }
+
/**
* Flush all the cached URLs.
*
diff --git a/src/StaticCaching/Cachers/ApplicationCacher.php b/src/StaticCaching/Cachers/ApplicationCacher.php
index 4a374c809e5..8846b595e12 100644
--- a/src/StaticCaching/Cachers/ApplicationCacher.php
+++ b/src/StaticCaching/Cachers/ApplicationCacher.php
@@ -42,6 +42,7 @@ public function cachePage(Request $request, $content)
// Keep track of the URL and key the response content is about to be stored within.
$this->cacheUrl($key, ...$this->getPathAndDomain($url));
+ $generatedAtKey = $this->normalizeKey('generated-at:'.$key);
$key = $this->normalizeKey('responses:'.$key);
$value = $this->normalizeContent($content);
@@ -51,7 +52,7 @@ public function cachePage(Request $request, $content)
// re-store this entry using later requests' statuses and headers.
$handled = false;
- Event::listen(ResponsePrepared::class, function (ResponsePrepared $event) use ($key, $value, &$handled) {
+ Event::listen(ResponsePrepared::class, function (ResponsePrepared $event) use ($key, $generatedAtKey, $value, &$handled) {
if ($handled) {
return;
}
@@ -68,9 +69,10 @@ public function cachePage(Request $request, $content)
'status' => $event->response->getStatusCode(),
];
- $this->getDefaultExpiration()
- ? $this->cache->put($key, $cacheValue, now()->addMinutes($this->getDefaultExpiration()))
- : $this->cache->forever($key, $cacheValue);
+ $this->cache->putMany([
+ $key => $cacheValue,
+ $generatedAtKey => now()->getTimestamp(),
+ ], $this->getDefaultExpiration() ? now()->addMinutes($this->getDefaultExpiration()) : null);
});
}
@@ -105,6 +107,13 @@ private function getFromCache(Request $request)
return $this->cache->get($this->normalizeKey('responses:'.$key));
}
+ public function getGeneratedAt(string $url): ?int
+ {
+ $key = $this->makeHash($this->getUrl(Request::create($url)));
+
+ return $this->cache->get($this->normalizeKey('generated-at:'.$key));
+ }
+
/**
* Check if the cached response for a URL key is an error response.
*
@@ -132,6 +141,7 @@ public function flush()
$this->getDomains()->each(function ($domain) {
$this->getUrls($domain)->keys()->each(function ($key) {
$this->cache->forget($this->normalizeKey('responses:'.$key));
+ $this->cache->forget($this->normalizeKey('generated-at:'.$key));
});
});
@@ -158,6 +168,7 @@ public function invalidateUrl($url, $domain = null)
->filter(fn ($value) => $value === $url || str_starts_with($value, $url.'?'))
->each(function ($value, $key) use ($domain) {
$this->cache->forget($this->normalizeKey('responses:'.$key));
+ $this->cache->forget($this->normalizeKey('generated-at:'.$key));
$this->forgetUrl($key, $domain);
});
@@ -223,6 +234,7 @@ public function invalidateUrls($urls)
foreach ($keys as $key) {
$this->cache->forget($this->normalizeKey('responses:'.$key));
+ $this->cache->forget($this->normalizeKey('generated-at:'.$key));
}
$this->forgetUrls($keys, $domain);
diff --git a/src/StaticCaching/Cachers/FileCacher.php b/src/StaticCaching/Cachers/FileCacher.php
index c99a3ae3447..9e1506da520 100644
--- a/src/StaticCaching/Cachers/FileCacher.php
+++ b/src/StaticCaching/Cachers/FileCacher.php
@@ -113,6 +113,14 @@ public function hasCachedPage(Request $request)
return File::exists($this->getFilePath($url));
}
+ public function getGeneratedAt(string $url): ?int
+ {
+ $site = Site::findByUrl($url)?->handle();
+ $timestamp = @File::lastModified($this->getFilePath($url, $site));
+
+ return $timestamp === false ? null : $timestamp;
+ }
+
/**
* Flush out the entire static cache.
*
@@ -145,8 +153,10 @@ public function invalidateUrl($url, $domain = null)
$this->forgetUrl($key, $domain);
});
+ $filePath = $this->getFilePath($domain.$url, $site);
+
$this->getFiles($site)
- ->filter(fn ($file) => str_starts_with($file, $url.'_'))
+ ->filter(fn ($file, $path) => $path === $filePath || str_starts_with($file, $url.'_'))
->each(function ($file, $path) {
$this->writer->delete($path);
});
diff --git a/src/StaticCaching/ServiceProvider.php b/src/StaticCaching/ServiceProvider.php
index c4544c4decf..6019cfd7d30 100644
--- a/src/StaticCaching/ServiceProvider.php
+++ b/src/StaticCaching/ServiceProvider.php
@@ -7,6 +7,24 @@
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider as LaravelServiceProvider;
use Illuminate\Support\Str;
+use Statamic\Events\CollectionDeleted;
+use Statamic\Events\CollectionSaved;
+use Statamic\Events\CollectionTreeDeleted;
+use Statamic\Events\CollectionTreeSaved;
+use Statamic\Events\EntryDeleted;
+use Statamic\Events\EntrySaved;
+use Statamic\Events\EntryScheduleReached;
+use Statamic\Events\LocalizedTermDeleted;
+use Statamic\Events\LocalizedTermSaved;
+use Statamic\Events\SiteDeleted;
+use Statamic\Events\SiteSaved;
+use Statamic\Events\StacheCleared;
+use Statamic\Events\StaticCacheCleared;
+use Statamic\Events\TaxonomyDeleted;
+use Statamic\Events\TaxonomySaved;
+use Statamic\Events\TermDeleted;
+use Statamic\Events\TermSaved;
+use Statamic\Events\UrlInvalidated;
use Statamic\Facades\Cascade;
use Statamic\Facades\Site;
use Statamic\StaticCaching\NoCache\DatabaseSession;
@@ -79,6 +97,27 @@ public function register()
public function boot()
{
+ Event::listen([
+ EntrySaved::class,
+ EntryDeleted::class,
+ EntryScheduleReached::class,
+ CollectionSaved::class,
+ CollectionDeleted::class,
+ CollectionTreeSaved::class,
+ CollectionTreeDeleted::class,
+ TaxonomySaved::class,
+ TaxonomyDeleted::class,
+ TermSaved::class,
+ TermDeleted::class,
+ LocalizedTermSaved::class,
+ LocalizedTermDeleted::class,
+ SiteSaved::class,
+ SiteDeleted::class,
+ StacheCleared::class,
+ ], [WarmableUrls::class, 'clearCache']);
+
+ Event::listen([UrlInvalidated::class, StaticCacheCleared::class], [CachedPages::class, 'clearCache']);
+
if (config('statamic.static_caching.strategy')) {
Event::subscribe(Invalidate::class);
}
diff --git a/src/StaticCaching/WarmableUrls.php b/src/StaticCaching/WarmableUrls.php
new file mode 100644
index 00000000000..e0fe0e3de7d
--- /dev/null
+++ b/src/StaticCaching/WarmableUrls.php
@@ -0,0 +1,133 @@
+cachedUrls()));
+ }
+
+ public function contains(string $url): bool
+ {
+ return isset($this->cachedUrls()[$url]);
+ }
+
+ public static function clearCache(): void
+ {
+ Cache::forget(self::CACHE_KEY);
+ CachedPages::clearCache();
+ }
+
+ private function cachedUrls(): array
+ {
+ return Cache::remember(self::CACHE_KEY, 60, fn () => array_fill_keys($this->discover()->all(), true));
+ }
+
+ protected function discover(): Collection
+ {
+ $site = Site::current();
+ $cacher = app(Cacher::class);
+
+ try {
+ return collect()
+ ->merge($this->entries())
+ ->merge($this->taxonomies())
+ ->merge($this->terms($this->scopedTerms()))
+ ->merge($this->customRoutes())
+ ->merge(app(StaticWarm::class)->additionalUrls())
+ ->filter()
+ ->unique()
+ ->reject(function ($url) use ($cacher, $site) {
+ Site::setCurrent((Site::findByUrl($url) ?? $site)->handle());
+
+ return $cacher->isExcluded($url);
+ })
+ ->sort()
+ ->values();
+ } finally {
+ Site::setCurrent($site->handle());
+ }
+ }
+
+ public function entries(): Collection
+ {
+ Facades\Collection::whereStructured()->each(fn ($collection) => $collection->structure()->trees()->each->tree());
+
+ return Facades\Entry::all()->map(function (Entry $entry) {
+ if (! $entry->published() || $entry->private() || $entry->isRedirect()) {
+ return null;
+ }
+
+ return $entry->absoluteUrl();
+ })->filter();
+ }
+
+ public function taxonomies(): Collection
+ {
+ $site = Site::current();
+
+ try {
+ return Facades\Taxonomy::all()
+ ->filter(fn ($taxonomy) => view()->exists($taxonomy->template()))
+ ->flatMap(function (Taxonomy $taxonomy) {
+ return $taxonomy->sites()->map(function ($site) use ($taxonomy) {
+ Site::setCurrent($site);
+
+ return $taxonomy->absoluteUrl();
+ });
+ });
+ } finally {
+ Site::setCurrent($site->handle());
+ }
+ }
+
+ public function terms(Collection $scopedTerms): Collection
+ {
+ return Facades\Term::all()
+ ->merge($scopedTerms)
+ ->filter(fn ($term) => view()->exists($term->template()))
+ ->flatMap(function (LocalizedTerm $term) {
+ return $term->taxonomy()->sites()->map(fn ($site) => $term->in($site)->absoluteUrl());
+ });
+ }
+
+ public function scopedTerms(): Collection
+ {
+ return Facades\Collection::all()
+ ->flatMap(fn (EntriesCollection $collection) => $this->collectionTerms($collection));
+ }
+
+ public function collectionTerms($collection): Collection
+ {
+ return $collection->taxonomies()
+ ->flatMap(fn (Taxonomy $taxonomy) => $taxonomy->queryTerms()->get())
+ ->map->collection($collection);
+ }
+
+ public function customRoutes(): Collection
+ {
+ $action = FrontendController::class.'@route';
+
+ return collect(app('router')->getRoutes()->getRoutes())
+ ->filter(fn (Route $route) => $route->getActionName() === $action && ! Str::contains($route->uri(), '{'))
+ ->map(fn (Route $route) => URL::tidy(Str::start($route->uri(), config('app.url').'/')));
+ }
+}
diff --git a/tests/Feature/Utilities/CacheManagerTest.php b/tests/Feature/Utilities/CacheManagerTest.php
index 294387f9108..b242198ab02 100644
--- a/tests/Feature/Utilities/CacheManagerTest.php
+++ b/tests/Feature/Utilities/CacheManagerTest.php
@@ -2,10 +2,36 @@
namespace Tests\Feature\Utilities;
+use Facades\Tests\Factories\EntryFactory;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Psr7\Request as GuzzleRequest;
+use GuzzleHttp\Psr7\Response as GuzzleResponse;
+use Illuminate\Cache\Events\CacheHit;
+use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Events\ResponsePrepared;
+use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\Facades\File;
+use Illuminate\Support\Facades\Queue;
+use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
+use Mockery;
use PHPUnit\Framework\Attributes\Test;
+use Statamic\Console\Commands\StaticWarm;
+use Statamic\Console\Commands\StaticWarmJob;
+use Statamic\Events\StacheCleared;
+use Statamic\Events\StacheWarmed;
+use Statamic\Facades\Collection;
+use Statamic\Facades\Site;
+use Statamic\Facades\StaticCache;
+use Statamic\Facades\Taxonomy;
+use Statamic\Facades\Term;
use Statamic\Facades\User;
+use Statamic\Http\Controllers\FrontendController;
+use Statamic\StaticCaching\WarmableUrls;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;
@@ -13,6 +39,32 @@ class CacheManagerTest extends TestCase
{
use PreventSavingStacheItemsToDisk;
+ private function cachePages(array $urls, array $strategy = ['driver' => 'application'])
+ {
+ $this->setSites([
+ 'en' => ['name' => 'English', 'url' => 'https://example.com', 'locale' => 'en_US'],
+ 'fr' => ['name' => 'French', 'url' => 'https://example.com/fr', 'locale' => 'fr_FR'],
+ 'de' => ['name' => 'German', 'url' => 'https://example.de', 'locale' => 'de_DE'],
+ ]);
+
+ config([
+ 'statamic.static_caching.strategy' => 'explorer',
+ 'statamic.static_caching.strategies.explorer' => $strategy,
+ ]);
+
+ $cacher = StaticCache::driver();
+
+ foreach ($urls as $url) {
+ $parts = parse_url($url);
+ $path = ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : '');
+ $cacher->cacheUrl(md5($url), $path, $parts['scheme'].'://'.$parts['host']);
+ }
+
+ $this->actingAs(tap(User::make()->makeSuper())->save());
+
+ return $cacher;
+ }
+
/**
* @see https://github.com/statamic/cms/issues/15623
*/
@@ -22,8 +74,8 @@ public function it_defers_the_image_cache_stats()
config(['statamic.assets.image_manipulation.cache' => 'glide']);
Storage::fake('glide');
- Storage::disk('glide')->put('containers/main/foo.jpg/abc.jpg', 'foo');
- Storage::disk('glide')->put('containers/main/bar.jpg/def.jpg', 'barbaz');
+ Storage::disk('glide')->put('containers/main/foo.jpg/abc/foo.jpg', 'foo');
+ Storage::disk('glide')->put('containers/main/bar.jpg/def/bar.jpg', 'barbaz');
$this
->actingAs(tap(User::make()->makeSuper())->save())
@@ -34,8 +86,1022 @@ public function it_defers_the_image_cache_stats()
->missing('images')
->loadDeferredProps(fn (AssertableInertia $reload) => $reload
->where('images.count', 2)
+ ->where('images.assets', 2)
->where('images.size', '9 B')
)
);
}
+
+ #[Test]
+ public function it_counts_distinct_cached_assets_without_counting_paths_or_remote_images_as_assets()
+ {
+ config(['statamic.assets.image_manipulation.cache' => 'glide']);
+ Storage::fake('glide');
+
+ foreach ([
+ 'containers/main/nested/photo.jpg/one/photo.jpg',
+ 'containers/main/nested/photo.jpg/two/photo.webp',
+ 'containers/other/nested/photo.jpg/one/photo.jpg',
+ 'paths/photo.jpg/one/photo.jpg',
+ 'http/example.com/photo.jpg/one/photo.jpg',
+ ] as $path) {
+ Storage::disk('glide')->put($path, '123');
+ }
+
+ $this->actingAs(tap(User::make()->makeSuper())->save())
+ ->get(cp_route('utilities.cache'))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->loadDeferredProps(fn (AssertableInertia $reload) => $reload
+ ->where('images.count', 5)
+ ->where('images.assets', 2)
+ ->where('images.size', '15 B')
+ )
+ );
+ }
+
+ #[Test]
+ public function it_reports_coverage_and_lists_only_uncached_warmable_urls()
+ {
+ $cacher = $this->cachePages(['https://example.com/expired']);
+ config(['statamic.static_caching.exclude' => ['/excluded']]);
+
+ Collection::make('pages')->routes('/{slug}')->sites(['en', 'fr', 'de'])->save();
+
+ foreach (['about', 'missing', 'expired', 'excluded'] as $slug) {
+ EntryFactory::collection('pages')->slug($slug)->create();
+ }
+
+ EntryFactory::collection('pages')->slug('draft')->published(false)->create();
+ EntryFactory::collection('pages')->slug('redirect')->data(['redirect' => '/about'])->create();
+ EntryFactory::collection('pages')->slug('bonjour')->locale('fr')->create();
+ EntryFactory::collection('pages')->slug('hallo')->locale('de')->create();
+
+ Route::get('/custom', FrontendController::class.'@route');
+ Route::get('/dynamic/{slug}', FrontendController::class.'@route');
+ StaticWarm::hook('additional', fn () => collect(['/extra', '/extra']));
+
+ $request = Request::create('https://example.com/about');
+ $response = response('Cached');
+ $cacher->cachePage($request, $response);
+ event(new ResponsePrepared($request, $response));
+ Site::setCurrent('en');
+
+ $this->get(cp_route('utilities.cache'))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->missing('staticCoverage')
+ ->loadDeferredProps(fn (AssertableInertia $reload) => $reload
+ ->where('staticCoverage', ['cached' => 1, 'total' => 7, 'uncached' => 6])
+ )
+ );
+ $this->assertSame('en', Site::current()->handle());
+
+ $result = $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached', 'perPage' => 2]))
+ ->assertOk()
+ ->assertJsonCount(2, 'data')
+ ->assertJsonPath('meta.total', 6)
+ ->assertJsonPath('meta.last_page', 3);
+ $columns = collect($result->json('meta.columns'))->pluck('field');
+ $this->assertNotContains('generated_at', $columns);
+ $this->assertContains('actions', $columns);
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached', 'site' => 'fr', 'search' => 'bon']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 1)
+ ->assertJsonPath('data.0.url', 'https://example.com/fr/bonjour');
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached', 'domain' => 'https://example.de']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 1)
+ ->assertJsonPath('data.0.url', 'https://example.de/hallo');
+ }
+
+ #[Test]
+ public function it_reports_zero_coverage_when_there_are_no_warmable_urls_and_skips_it_when_disabled()
+ {
+ $this->cachePages([]);
+
+ $this->get(cp_route('utilities.cache'))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->loadDeferredProps(fn (AssertableInertia $reload) => $reload
+ ->where('staticCoverage', ['cached' => 0, 'total' => 0, 'uncached' => 0])
+ )
+ );
+
+ config(['statamic.static_caching.strategy' => null]);
+
+ $this->get(cp_route('utilities.cache'))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->loadDeferredProps(fn (AssertableInertia $reload) => $reload
+ ->where('staticCoverage', null)
+ )
+ );
+ }
+
+ #[Test]
+ public function it_reuses_url_discovery_across_coverage_listing_and_bulk_cache_requests()
+ {
+ $cacher = $this->cachePages([]);
+ $urls = ['https://example.com/one', 'https://example.com/two', 'https://example.com/three'];
+ $discoveries = 0;
+
+ app()->bind(WarmableUrls::class, function () use ($urls, &$discoveries) {
+ $warmableUrls = Mockery::mock(WarmableUrls::class)->makePartial()->shouldAllowMockingProtectedMethods();
+ $warmableUrls->shouldReceive('discover')->andReturnUsing(function () use ($urls, &$discoveries) {
+ $discoveries++;
+
+ return collect($urls);
+ });
+
+ return $warmableUrls;
+ });
+
+ foreach (range(1, 2) as $visit) {
+ $this->get(cp_route('utilities.cache'))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->loadDeferredProps(fn (AssertableInertia $reload) => $reload
+ ->where('staticCoverage', ['cached' => 0, 'total' => 3, 'uncached' => 3])
+ )
+ );
+ }
+
+ foreach (range(1, 3) as $page) {
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached', 'perPage' => 1, 'page' => $page]))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 3);
+ }
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached', 'search' => 'two', 'sort' => 'url', 'order' => 'desc']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 1);
+
+ $this->mock(Client::class)->shouldReceive('get')->times(3)
+ ->andReturnUsing(function ($url) use ($cacher) {
+ $request = Request::create($url);
+ $response = response('Cached content');
+ $cacher->cachePage($request, $response);
+ event(new ResponsePrepared($request, $response));
+
+ return new GuzzleResponse(200);
+ });
+
+ foreach ($urls as $index => $url) {
+ $this->postJson(cp_route('utilities.cache.cache-static-page'), ['url' => $url])->assertOk();
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 2 - $index);
+ }
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), ['urls' => [$urls[0]]])
+ ->assertOk()->assertJsonPath('count', 1);
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached']))
+ ->assertOk()->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.url', $urls[0]);
+
+ $this->assertSame(1, $discoveries);
+ }
+
+ #[Test]
+ public function it_refreshes_url_discovery_and_coverage_after_entry_and_collection_changes()
+ {
+ $this->cachePages([]);
+ $collection = tap(Collection::make('pages')->routes('/{slug}'))->save();
+ $entry = EntryFactory::collection('pages')->slug('about')->create();
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached']))
+ ->assertOk()->assertJsonPath('data.0.url', 'https://example.com/about');
+
+ $entry->published(false)->save();
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached']))
+ ->assertOk()->assertJsonPath('meta.total', 0);
+ $this->postJson(cp_route('utilities.cache.cache-static-page'), ['url' => 'https://example.com/about'])
+ ->assertUnprocessable()->assertJsonValidationErrors('url');
+
+ $entry->published(true)->save();
+ $collection->routes('/pages/{slug}')->save();
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached']))
+ ->assertOk()->assertJsonPath('data.0.url', 'https://example.com/pages/about');
+
+ $entry->delete();
+
+ $this->get(cp_route('utilities.cache'))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->loadDeferredProps(fn (AssertableInertia $reload) => $reload
+ ->where('staticCoverage', ['cached' => 0, 'total' => 0, 'uncached' => 0])
+ )
+ );
+ }
+
+ #[Test]
+ public function it_refreshes_url_discovery_after_term_changes()
+ {
+ $this->cachePages([]);
+ Taxonomy::make('tags')->template('example')->termTemplate('example')->save();
+ $term = tap(Term::make()->taxonomy('tags')->inDefaultLocale()->slug('one')->data(['title' => 'One']))->save();
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached', 'search' => '/tags/one']))
+ ->assertOk()->assertJsonPath('meta.total', 1);
+
+ $term->slug('two')->save();
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached', 'search' => '/tags/two']))
+ ->assertOk()->assertJsonPath('meta.total', 1);
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached', 'search' => '/tags/one']))
+ ->assertOk()->assertJsonPath('meta.total', 0);
+
+ $term->delete();
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached', 'search' => '/tags/two']))
+ ->assertOk()->assertJsonPath('meta.total', 0);
+ }
+
+ #[Test]
+ public function it_caches_one_warmable_url_and_removes_it_from_the_uncached_list()
+ {
+ $cacher = $this->cachePages([]);
+ Collection::make('pages')->routes('/{slug}')->save();
+ EntryFactory::collection('pages')->slug('about')->create();
+ EntryFactory::collection('pages')->slug('contact')->create();
+ $url = 'https://example.com/about';
+
+ $this->mock(Client::class)->shouldReceive('get')->once()
+ ->with($url, ['verify' => true, 'allow_redirects' => false, 'timeout' => 30])
+ ->andReturnUsing(function () use ($cacher, $url) {
+ $request = Request::create($url);
+ $response = response('Cached page');
+ $cacher->cachePage($request, $response);
+ event(new ResponsePrepared($request, $response));
+
+ return new GuzzleResponse(200);
+ });
+
+ $this->postJson(cp_route('utilities.cache.cache-static-page'), ['url' => $url])
+ ->assertOk()
+ ->assertJsonPath('url', $url)
+ ->assertJsonPath('message', fn ($message) => str_starts_with($message, 'Page cached in '));
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 1)
+ ->assertJsonPath('data.0.url', 'https://example.com/contact');
+
+ $this->getJson(cp_route('utilities.cache.static-pages'))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 1)
+ ->assertJsonPath('data.0.url', $url);
+ }
+
+ #[Test]
+ public function it_only_accepts_warmable_urls_for_individual_caching()
+ {
+ $this->cachePages([]);
+ config(['statamic.static_caching.exclude' => ['/excluded']]);
+ Collection::make('pages')->routes('/{slug}')->save();
+ EntryFactory::collection('pages')->slug('draft')->published(false)->create();
+ EntryFactory::collection('pages')->slug('excluded')->create();
+ $this->mock(Client::class)->shouldNotReceive('get');
+
+ foreach ([
+ 'https://external.example/about',
+ 'https://example.com/missing',
+ 'https://example.com/draft',
+ 'https://example.com/excluded',
+ 'https://example.com/*',
+ ] as $url) {
+ $this->postJson(cp_route('utilities.cache.cache-static-page'), ['url' => $url])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors('url');
+ }
+ }
+
+ #[Test]
+ public function it_does_not_report_a_page_as_cached_when_the_request_fails_or_does_not_cache_it()
+ {
+ $this->cachePages([]);
+ Collection::make('pages')->routes('/{slug}')->save();
+ EntryFactory::collection('pages')->slug('about')->create();
+ $url = 'https://example.com/about';
+ $client = $this->mock(Client::class);
+ $client->shouldReceive('get')->once()->andThrow(new ConnectException('Connection failed.', new GuzzleRequest('GET', $url)));
+ $client->shouldReceive('get')->once()->andReturn(new GuzzleResponse(200));
+
+ $this->postJson(cp_route('utilities.cache.cache-static-page'), ['url' => $url])
+ ->assertStatus(502)
+ ->assertJsonPath('message', 'Unable to cache this page.');
+
+ $this->postJson(cp_route('utilities.cache.cache-static-page'), ['url' => $url])
+ ->assertUnprocessable()
+ ->assertJsonPath('message', 'The page could not be cached.');
+ }
+
+ #[Test]
+ public function it_recognizes_an_already_cached_page_in_its_own_site_directory()
+ {
+ $disk = Storage::fake('cache-single-page');
+ $cacher = $this->cachePages([], [
+ 'driver' => 'file',
+ 'path' => [
+ 'en' => $disk->path('en'),
+ 'fr' => $disk->path('fr'),
+ 'de' => $disk->path('de'),
+ ],
+ ]);
+ Collection::make('pages')->routes('/{slug}')->sites(['en', 'fr'])->save();
+ EntryFactory::collection('pages')->locale('fr')->slug('bonjour')->create();
+ $url = 'https://example.com/fr/bonjour';
+ $path = $cacher->getFilePath($url, 'fr');
+ File::ensureDirectoryExists(dirname($path));
+ File::put($path, 'Already cached');
+ $this->mock(Client::class)->shouldNotReceive('get');
+
+ $this->postJson(cp_route('utilities.cache.cache-static-page'), ['url' => $url])
+ ->assertOk()
+ ->assertJsonPath('message', 'Page is already cached.');
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 0);
+ }
+
+ #[Test]
+ public function it_lists_and_caches_a_homepage_without_a_trailing_slash_using_the_file_driver()
+ {
+ $disk = Storage::fake('cache-homepage');
+ $cacher = $this->cachePages([], ['driver' => 'file', 'path' => $disk->path('pages')]);
+ $url = 'https://example.com';
+ StaticWarm::hook('additional', fn () => collect([$url]));
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 1)
+ ->assertJsonPath('data.0.url', $url);
+
+ $this->mock(Client::class)->shouldReceive('get')->once()->with($url, Mockery::type('array'))
+ ->andReturnUsing(function () use ($cacher, $url) {
+ $cacher->cachePage(Request::create($url), 'Cached homepage');
+
+ return new GuzzleResponse(200);
+ });
+
+ $this->postJson(cp_route('utilities.cache.cache-static-page'), ['url' => $url])
+ ->assertOk()
+ ->assertJsonPath('url', $url);
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['status' => 'uncached']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 0);
+ }
+
+ #[Test]
+ public function it_does_not_cache_individual_pages_when_static_caching_is_disabled()
+ {
+ $this->actingAs(tap(User::make()->makeSuper())->save());
+ config(['statamic.static_caching.strategy' => null]);
+ $this->mock(Client::class)->shouldNotReceive('get');
+
+ $this->postJson(cp_route('utilities.cache.cache-static-page'), ['url' => 'https://example.com/about'])
+ ->assertUnprocessable()
+ ->assertJsonPath('message', 'Static caching is not enabled.');
+ }
+
+ #[Test]
+ public function it_sends_operation_feedback_as_a_toast()
+ {
+ config(['statamic.static_caching.strategy' => null]);
+
+ $url = cp_route('utilities.cache');
+
+ $this
+ ->actingAs(tap(User::make()->makeSuper())->save())
+ ->from($url)
+ ->post(cp_route('utilities.cache.clear-static'))
+ ->assertRedirect($url)
+ ->assertSessionHas('success');
+
+ $message = session('success');
+
+ $this->assertMatchesRegularExpression('/^Static page cache cleared in [\\d.]+(?:ms|s)\\.$/', $message);
+
+ $this->get($url)->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('_toasts.0.message', $message)
+ ->missing('operationResult')
+ ->missing('images')
+ );
+ }
+
+ #[Test]
+ public function it_clears_the_stache_without_warming_it()
+ {
+ $this->actingAs(tap(User::make()->makeSuper())->save());
+ Event::fake([StacheCleared::class, StacheWarmed::class]);
+ $url = cp_route('utilities.cache');
+
+ $this->from($url)->post(cp_route('utilities.cache.clear-stache'))
+ ->assertRedirect($url)
+ ->assertSessionHas('success', fn ($message) => str_starts_with($message, 'Stache cleared in '));
+
+ Event::assertDispatched(StacheCleared::class);
+ Event::assertNotDispatched(StacheWarmed::class);
+ }
+
+ #[Test]
+ public function it_warms_the_stache_without_clearing_it()
+ {
+ $this->actingAs(tap(User::make()->makeSuper())->save());
+ Event::fake([StacheCleared::class, StacheWarmed::class]);
+ $url = cp_route('utilities.cache');
+
+ $this->from($url)->post(cp_route('utilities.cache.warm-stache'))
+ ->assertRedirect($url)
+ ->assertSessionHas('success', fn ($message) => str_starts_with($message, 'Stache warmed in '));
+
+ Event::assertDispatched(StacheWarmed::class);
+ Event::assertNotDispatched(StacheCleared::class);
+ }
+
+ #[Test]
+ public function it_lists_and_paginates_cached_urls_across_domains()
+ {
+ $this->cachePages([
+ 'https://example.com/blog/one',
+ 'https://example.com/fr/blog/two',
+ 'https://example.de/blog/three',
+ ]);
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['perPage' => 2]))
+ ->assertOk()
+ ->assertJsonCount(2, 'data')
+ ->assertJsonPath('meta.total', 3)
+ ->assertJsonPath('meta.last_page', 2)
+ ->assertJsonPath('meta.domains', ['https://example.com', 'https://example.de'])
+ ->assertJsonPath('data.1.site', 'fr');
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['perPage' => 2, 'page' => 2]))
+ ->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.url', 'https://example.de/blog/three')
+ ->assertJsonPath('meta.from', 3)
+ ->assertJsonPath('meta.to', 3);
+ }
+
+ #[Test]
+ public function it_searches_cached_urls_and_combines_site_and_domain_filters()
+ {
+ $this->cachePages([
+ 'https://example.com/about',
+ 'https://example.com/blog/one',
+ 'https://example.com/fr/blog/two',
+ 'https://example.de/blog/three',
+ ]);
+
+ $this->getJson(cp_route('utilities.cache.static-pages', [
+ 'search' => 'BLOG',
+ 'site' => 'fr',
+ 'domain' => 'https://example.com',
+ ]))
+ ->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.url', 'https://example.com/fr/blog/two');
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['domain' => 'https://example.de']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 1)
+ ->assertJsonPath('data.0.site', 'de');
+ }
+
+ #[Test]
+ public function it_sorts_by_generation_date_before_pagination_with_unknown_dates_last()
+ {
+ $timestamps = [
+ 'https://example.com/blog/a' => now()->subHour()->getTimestamp(),
+ 'https://example.com/blog/b' => now()->subHours(2)->getTimestamp(),
+ 'https://example.com/blog/z' => now()->getTimestamp(),
+ 'https://example.de/unknown' => null,
+ ];
+
+ $this->cachePages(array_keys($timestamps));
+
+ foreach ($timestamps as $url => $timestamp) {
+ StaticCache::cacheStore()->forever('static-cache:responses:'.md5($url), [
+ 'content' => 'Cached content',
+ 'headers' => [],
+ 'status' => 200,
+ ]);
+
+ if ($timestamp !== null) {
+ StaticCache::cacheStore()->forever('static-cache:generated-at:'.md5($url), $timestamp);
+ }
+ }
+
+ Event::listen(CacheHit::class, function ($event) {
+ $this->assertStringNotContainsString('static-cache:responses:', $event->key);
+ });
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['perPage' => 2]))
+ ->assertOk()
+ ->assertJsonPath('data.0.url', 'https://example.com/blog/z')
+ ->assertJsonPath('data.1.url', 'https://example.com/blog/a');
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['perPage' => 2, 'page' => 2]))
+ ->assertOk()
+ ->assertJsonPath('data.0.url', 'https://example.com/blog/b')
+ ->assertJsonPath('data.1.url', 'https://example.de/unknown');
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['sort' => 'generated_at', 'order' => 'asc']))
+ ->assertOk()
+ ->assertJsonPath('data.0.url', 'https://example.com/blog/b')
+ ->assertJsonPath('data.1.url', 'https://example.com/blog/a')
+ ->assertJsonPath('data.2.url', 'https://example.com/blog/z')
+ ->assertJsonPath('data.3.url', 'https://example.de/unknown');
+ }
+
+ #[Test]
+ public function it_sorts_paths_naturally_and_preserves_query_strings()
+ {
+ $this->cachePages([
+ 'https://example.com/blog/10',
+ 'https://example.com/blog/2?view=full',
+ 'https://example.de/about',
+ ]);
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['sort' => 'path', 'order' => 'asc']))
+ ->assertOk()
+ ->assertJsonPath('data.0.path', '/about')
+ ->assertJsonPath('data.1.path', '/blog/2?view=full')
+ ->assertJsonPath('data.2.path', '/blog/10');
+
+ $this->getJson(cp_route('utilities.cache.static-pages', ['sort' => 'path', 'order' => 'desc']))
+ ->assertOk()
+ ->assertJsonPath('data.0.path', '/blog/10')
+ ->assertJsonPath('data.2.path', '/about');
+ }
+
+ #[Test]
+ public function it_exposes_available_columns_and_their_defaults()
+ {
+ $this->cachePages([]);
+
+ $columns = collect($this->getJson(cp_route('utilities.cache.static-pages'))
+ ->assertOk()->json('meta.columns'));
+
+ $this->assertSame(['url', 'path', 'generated_at', 'site_name', 'domain', 'actions'], $columns->pluck('field')->all());
+ $this->assertSame(['url', 'generated_at', 'site_name', 'actions'], $columns->where('visible', true)->pluck('field')->values()->all());
+ $this->assertSame(range(0, 5), $columns->pluck('defaultOrder')->all());
+ $this->assertSame($columns->pluck('visible')->all(), $columns->pluck('defaultVisibility')->all());
+ $this->assertSame('date', $columns->keyBy('field')['generated_at']['fieldtype']);
+ $this->assertTrue($columns->keyBy('field')['generated_at']['sortable']);
+ $this->assertFalse($columns->keyBy('field')['actions']['sortable']);
+ }
+
+ #[Test]
+ public function it_applies_saved_columns_and_honors_unsaved_column_changes()
+ {
+ $this->cachePages(['https://example.com/about']);
+ User::current()->setPreference('cache.static_pages.columns', ['path', 'generated_at'])->save();
+
+ $columns = collect($this->getJson(cp_route('utilities.cache.static-pages'))
+ ->assertOk()->json('meta.columns'));
+
+ $this->assertSame(['path', 'generated_at'], $columns->where('visible', true)->pluck('field')->values()->all());
+
+ $columns = collect($this->getJson(cp_route('utilities.cache.static-pages', ['columns' => 'domain,path,actions']))
+ ->assertOk()->json('meta.columns'));
+
+ $this->assertSame(['domain', 'path', 'actions'], $columns->where('visible', true)->pluck('field')->values()->all());
+ $this->assertFalse($columns->keyBy('field')['domain']['defaultVisibility']);
+ $this->assertSame(0, $columns->keyBy('field')['url']['defaultOrder']);
+ }
+
+ #[Test]
+ public function it_previews_wildcards_without_invalidating_any_pages()
+ {
+ $cacher = $this->cachePages([
+ 'https://example.com/blog/one',
+ 'https://example.com/blog/one?sort=title',
+ 'https://example.com/blog/two',
+ 'https://example.com/about',
+ ]);
+
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), ['urls' => ['/blog/*', '/blog/one']])
+ ->assertOk()
+ ->assertJsonPath('total', 3)
+ ->assertJsonCount(3, 'data');
+
+ $this->assertCount(4, $cacher->getUrls('https://example.com'));
+ }
+
+ #[Test]
+ public function it_limits_preview_rows_but_reports_the_full_match_count()
+ {
+ $this->cachePages(collect(range(1, 20))->map(fn ($page) => 'https://example.com/blog/'.$page)->all());
+
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), ['urls' => ['/blog/*']])
+ ->assertOk()
+ ->assertJsonPath('total', 20)
+ ->assertJsonCount(10, 'data');
+ }
+
+ #[Test]
+ public function it_invalidates_an_individual_url_and_its_query_string_variants()
+ {
+ $cacher = $this->cachePages([
+ 'https://example.com/blog/one',
+ 'https://example.com/blog/one?sort=title',
+ 'https://example.com/blog/one-more',
+ 'https://example.de/blog/one',
+ ]);
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), ['urls' => ['https://example.com/blog/one']])
+ ->assertOk()
+ ->assertJsonPath('count', 2)
+ ->assertJsonPath('message', fn ($message) => str_starts_with($message, 'Cleared 2 cached pages in '));
+
+ $this->assertSame(['/blog/one-more'], $cacher->getUrls('https://example.com')->values()->all());
+ $this->assertSame(['/blog/one'], $cacher->getUrls('https://example.de')->values()->all());
+ }
+
+ #[Test]
+ public function it_invalidates_selected_urls_on_multiple_domains()
+ {
+ $cacher = $this->cachePages([
+ 'https://example.com/blog/one',
+ 'https://example.com/blog/two',
+ 'https://example.de/blog/one',
+ ]);
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), [
+ 'urls' => ['https://example.com/blog/one', 'https://example.de/blog/one'],
+ ])->assertOk()->assertJsonPath('count', 2);
+
+ $this->assertSame(['/blog/two'], $cacher->getUrls('https://example.com')->values()->all());
+ $this->assertCount(0, $cacher->getUrls('https://example.de'));
+ }
+
+ #[Test]
+ public function it_resolves_relative_patterns_against_the_chosen_site()
+ {
+ $cacher = $this->cachePages([
+ 'https://example.com/blog/one',
+ 'https://example.com/fr/blog/one',
+ 'https://example.com/fr/about',
+ 'https://example.de/blog/one',
+ ]);
+
+ $input = ['urls' => ['/blog/*'], 'site' => 'fr'];
+
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), $input)
+ ->assertOk()
+ ->assertJsonPath('total', 1)
+ ->assertJsonPath('data.0.url', 'https://example.com/fr/blog/one');
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), $input)
+ ->assertOk()->assertJsonPath('count', 1);
+
+ $this->assertSame(['/blog/one', '/fr/about'], $cacher->getUrls('https://example.com')->values()->all());
+ $this->assertCount(1, $cacher->getUrls('https://example.de'));
+ }
+
+ #[Test]
+ public function it_previews_and_invalidates_relative_urls_across_all_sites()
+ {
+ $urls = [
+ 'https://example.com/blog/one',
+ 'https://example.com/fr/blog/one',
+ 'https://example.de/blog/one',
+ ];
+ $cacher = $this->cachePages([...$urls, 'https://example.com/about']);
+ Site::setSelected('fr');
+ $input = ['urls' => ['/blog/*', '/blog/one'], 'site' => null];
+
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), $input)
+ ->assertOk()
+ ->assertJsonPath('total', 3)
+ ->assertJsonCount(3, 'data');
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), $input)
+ ->assertOk()
+ ->assertJsonPath('count', 3);
+
+ $this->assertSame(['/about'], $cacher->getUrls('https://example.com')->values()->all());
+ $this->assertCount(0, $cacher->getUrls('https://example.de'));
+ }
+
+ #[Test]
+ public function it_ignores_blank_lines_and_deduplicates_overlapping_matches()
+ {
+ $this->cachePages(['https://example.com/blog/one', 'https://example.com/blog/two']);
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), [
+ 'urls' => [' /blog/* ', '', ' ', '/blog/one', '/blog/*'],
+ ])->assertOk()->assertJsonPath('count', 2);
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), ['urls' => ['/blog/*']])
+ ->assertOk()->assertJsonPath('count', 0);
+ }
+
+ #[Test]
+ public function it_rejects_invalid_patterns_and_empty_input_without_clearing_the_homepage()
+ {
+ $cacher = $this->cachePages(['https://example.com/', 'https://example.com/blog/one']);
+
+ foreach ([[''], [' '], [], [null], [42], ['/bl*g/*']] as $urls) {
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), ['urls' => $urls])->assertUnprocessable();
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), ['urls' => $urls])->assertUnprocessable();
+ }
+
+ $this->assertCount(2, $cacher->getUrls('https://example.com'));
+ }
+
+ #[Test]
+ public function it_requires_cache_utility_permission_for_explorer_endpoints()
+ {
+ $this->cachePages(['https://example.com/blog/one']);
+ $this->actingAs(tap(User::make()->set('permissions', ['access cp']))->save());
+
+ $this->getJson(cp_route('utilities.cache.static-pages'))->assertForbidden();
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), ['urls' => ['/blog/*']])->assertForbidden();
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), ['urls' => ['/blog/*']])->assertForbidden();
+ $this->postJson(cp_route('utilities.cache.warm-static'))->assertForbidden();
+ $this->postJson(cp_route('utilities.cache.cache-static-page'), ['url' => 'https://example.com/blog/one'])->assertForbidden();
+ }
+
+ #[Test]
+ public function it_passes_explorer_endpoints_and_sites_to_the_cache_page()
+ {
+ $this->cachePages(['https://example.com/blog/one']);
+
+ $this->get(cp_route('utilities.cache'))->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('staticPagesUrl', cp_route('utilities.cache.static-pages'))
+ ->where('cacheStaticPageUrl', cp_route('utilities.cache.cache-static-page'))
+ ->where('previewStaticPagesUrl', cp_route('utilities.cache.preview-static-pages'))
+ ->where('warmStaticUrl', cp_route('utilities.cache.warm-static'))
+ ->where('cacheSites.1', ['value' => 'fr', 'label' => 'French'])
+ ->where('static.count', 1)
+ );
+ }
+
+ #[Test]
+ public function it_reports_invalidation_counts_through_inertia_toasts()
+ {
+ $this->cachePages(['https://example.com/blog/one']);
+ $url = cp_route('utilities.cache');
+
+ $this->from($url)->post(cp_route('utilities.cache.invalidate-static-pages'), ['urls' => ['/blog/*']])
+ ->assertRedirect($url)
+ ->assertSessionHas('success', fn ($message) => str_starts_with($message, 'Cleared 1 cached page in '));
+ }
+
+ #[Test]
+ public function it_previews_and_invalidates_files_using_the_file_cache_driver()
+ {
+ $disk = Storage::fake('static-pages');
+ $urls = ['https://example.com/blog/one', 'https://example.com/blog/two', 'https://example.com/about'];
+ $cacher = $this->cachePages($urls, ['driver' => 'file', 'path' => $disk->path('pages')]);
+
+ foreach ($urls as $url) {
+ $cacher->cachePage(Request::create($url), 'Cached page ');
+ $this->assertFileExists($cacher->getFilePath($url));
+ }
+
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), ['urls' => ['/blog/*']])
+ ->assertOk()->assertJsonPath('total', 2);
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), ['urls' => ['/blog/*']])
+ ->assertOk()->assertJsonPath('count', 2);
+
+ $this->assertFileDoesNotExist($cacher->getFilePath($urls[0]));
+ $this->assertFileDoesNotExist($cacher->getFilePath($urls[1]));
+ $this->assertFileExists($cacher->getFilePath($urls[2]));
+ }
+
+ #[Test]
+ public function it_previews_and_invalidates_exact_file_urls_after_the_application_cache_is_cleared()
+ {
+ $disk = Storage::fake('unindexed-static-pages');
+ $cacher = $this->cachePages([], ['driver' => 'file', 'path' => [
+ 'en' => $disk->path('en'),
+ 'fr' => $disk->path('fr'),
+ 'de' => $disk->path('de'),
+ ]]);
+ $urls = [
+ 'https://example.com/about',
+ 'https://example.com/fr/about',
+ 'https://example.de/about?foo=bar',
+ ];
+
+ foreach ($urls as $url) {
+ $site = Site::findByUrl($url)->handle();
+ $path = $cacher->getFilePath($url, $site);
+ File::ensureDirectoryExists(dirname($path));
+ File::put($path, 'Cached page');
+ $parts = parse_url($url);
+ $cacher->cacheUrl(md5($url), $url, $parts['scheme'].'://'.$parts['host']);
+ }
+
+ $this->post(cp_route('utilities.cache.clear-application-cache'))->assertRedirect();
+ $this->assertCount(0, $cacher->getDomains());
+
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), ['urls' => [...$urls, $urls[0]]])
+ ->assertOk()
+ ->assertJsonPath('total', 3)
+ ->assertJsonCount(3, 'data');
+
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), ['urls' => ['/about'], 'site' => 'fr'])
+ ->assertOk()
+ ->assertJsonPath('total', 1)
+ ->assertJsonPath('data.0.url', $urls[1]);
+
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), ['urls' => [$urls[0]], 'site' => 'fr'])
+ ->assertOk()
+ ->assertJsonPath('total', 0);
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), ['urls' => [$urls[0]], 'site' => 'fr'])
+ ->assertOk()
+ ->assertJsonPath('count', 0);
+
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), ['urls' => ['https://unknown.example/about']])
+ ->assertOk()
+ ->assertJsonPath('total', 0);
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), [
+ 'urls' => $urls,
+ 'domain' => 'https://example.de',
+ ])->assertOk()->assertJsonPath('count', 1);
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), ['urls' => ['/about']])
+ ->assertOk()
+ ->assertJsonPath('count', 2);
+
+ foreach ($urls as $url) {
+ $this->assertFileDoesNotExist($cacher->getFilePath($url, Site::findByUrl($url)->handle()));
+ }
+
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), ['urls' => $urls])
+ ->assertOk()
+ ->assertJsonPath('total', 0);
+
+ $this->postJson(cp_route('utilities.cache.invalidate-static-pages'), ['urls' => $urls])
+ ->assertOk()
+ ->assertJsonPath('count', 0);
+ }
+
+ #[Test]
+ public function it_validates_filter_pagination_and_sort_parameters()
+ {
+ $this->cachePages(['https://example.com/blog/one']);
+
+ foreach ([
+ ['site' => 'missing'],
+ ['page' => 0],
+ ['perPage' => 101],
+ ['search' => ['invalid']],
+ ['sort' => 'missing'],
+ ['sort' => 'actions'],
+ ['order' => 'invalid'],
+ ['columns' => ['url']],
+ ] as $input) {
+ $this->getJson(cp_route('utilities.cache.static-pages', $input))->assertUnprocessable();
+ }
+
+ $this->postJson(cp_route('utilities.cache.preview-static-pages'), ['urls' => ['/blog/*'], 'site' => 'missing'])
+ ->assertUnprocessable()->assertJsonValidationErrors('site');
+ }
+
+ #[Test]
+ public function it_queues_static_warming_using_the_existing_command_and_configured_queue()
+ {
+ $this->cachePages([]);
+ Collection::make('pages')->routes('{slug}')->save();
+ EntryFactory::collection('pages')->locale('en')->slug('about')->create();
+
+ config([
+ 'queue.default' => 'sync',
+ 'statamic.static_caching.warm_queue_connection' => 'redis',
+ 'statamic.static_caching.warm_queue' => 'cache-warming',
+ ]);
+
+ Queue::fake();
+
+ $this->from(cp_route('utilities.cache'))->post(cp_route('utilities.cache.warm-static'))
+ ->assertRedirect(cp_route('utilities.cache'))
+ ->assertSessionHas('success', 'Static cache warming queued.');
+
+ Queue::assertPushed(StaticWarmJob::class, fn ($job) => $job->request->getUri()->getPath() === '/about'
+ && $job->connection === 'redis'
+ && $job->queue === 'cache-warming'
+ );
+ }
+
+ #[Test]
+ public function it_warms_synchronously_when_the_configured_connection_uses_the_sync_driver()
+ {
+ $this->cachePages([]);
+
+ config([
+ 'statamic.static_caching.warm_queue_connection' => 'inline',
+ 'queue.connections.inline.driver' => 'sync',
+ ]);
+
+ Artisan::swap($kernel = Mockery::mock(ConsoleKernel::class));
+ $kernel->shouldReceive('call')->once()->with('statamic:static:warm', ['--queue' => false])->andReturn(0);
+
+ $this->from(cp_route('utilities.cache'))->post(cp_route('utilities.cache.warm-static'))
+ ->assertRedirect(cp_route('utilities.cache'))
+ ->assertSessionHas('success', fn ($message) => str_starts_with($message, 'Static cache warmed in '));
+ }
+
+ #[Test]
+ public function it_reports_an_error_when_the_warming_command_fails()
+ {
+ $this->cachePages([]);
+
+ Artisan::swap($kernel = Mockery::mock(ConsoleKernel::class));
+ $kernel->shouldReceive('call')->once()->with('statamic:static:warm', ['--queue' => false])->andReturn(1);
+
+ $this->post(cp_route('utilities.cache.warm-static'))
+ ->assertSessionHas('error', 'Unable to warm the static cache.')
+ ->assertSessionMissing('success');
+ }
+
+ #[Test]
+ public function it_does_not_warm_when_static_caching_is_disabled()
+ {
+ $this->actingAs(tap(User::make()->makeSuper())->save());
+ config(['statamic.static_caching.strategy' => null]);
+
+ $this->post(cp_route('utilities.cache.warm-static'))
+ ->assertSessionHas('error', 'Static caching is not enabled.')
+ ->assertSessionMissing('success');
+ }
+
+ #[Test]
+ public function it_records_when_application_cached_pages_are_generated_and_regenerated()
+ {
+ $url = 'https://example.com/blog/one';
+ $cacher = $this->cachePages([$url]);
+ $this->freezeTime();
+ $generated = now()->utc()->toIso8601String();
+ $request = Request::create($url);
+ $response = response('Cached content');
+
+ $cacher->cachePage($request, $response);
+ event(new ResponsePrepared($request, $response));
+
+ $this->getJson(cp_route('utilities.cache.static-pages'))
+ ->assertOk()
+ ->assertJsonPath('data.0.generated_at', $generated);
+
+ $this->travel(2)->minutes();
+ $regenerated = now()->utc()->toIso8601String();
+ $cacher->cachePage($request, $response);
+ event(new ResponsePrepared($request, $response));
+
+ $this->getJson(cp_route('utilities.cache.static-pages'))
+ ->assertOk()
+ ->assertJsonPath('data.0.generated_at', $regenerated);
+ }
+
+ #[Test]
+ public function it_reports_unknown_generation_dates_for_existing_application_cache_entries()
+ {
+ $url = 'https://example.com/blog/one';
+ $this->cachePages([$url]);
+
+ StaticCache::cacheStore()->forever('static-cache:responses:'.md5($url), [
+ 'content' => 'Legacy cached content',
+ 'headers' => [],
+ 'status' => 200,
+ ]);
+
+ $this->getJson(cp_route('utilities.cache.static-pages'))
+ ->assertOk()
+ ->assertJsonPath('data.0.generated_at', null);
+ }
+
+ #[Test]
+ public function it_reads_file_generation_dates_from_the_correct_site_path()
+ {
+ $disk = Storage::fake('static-dates');
+ $url = 'https://example.com/fr/blog/one';
+ $cacher = $this->cachePages([$url, 'https://example.com/missing'], [
+ 'driver' => 'file',
+ 'path' => [
+ 'en' => $disk->path('en'),
+ 'fr' => $disk->path('fr'),
+ 'de' => $disk->path('de'),
+ ],
+ ]);
+
+ $path = $cacher->getFilePath($url, 'fr');
+ File::ensureDirectoryExists(dirname($path));
+ File::put($path, 'Cached content');
+ $generated = now()->subHours(2)->startOfSecond();
+ touch($path, $generated->getTimestamp());
+ clearstatcache(true, $path);
+
+ $this->getJson(cp_route('utilities.cache.static-pages'))
+ ->assertOk()
+ ->assertJsonPath('data.0.generated_at', $generated->utc()->toIso8601String())
+ ->assertJsonPath('data.1.generated_at', null);
+ }
}
diff --git a/tests/Feature/Utilities/ImageCacheExplorerTest.php b/tests/Feature/Utilities/ImageCacheExplorerTest.php
new file mode 100644
index 00000000000..9b7691f72a5
--- /dev/null
+++ b/tests/Feature/Utilities/ImageCacheExplorerTest.php
@@ -0,0 +1,758 @@
+ 'image-cache-explorer',
+ 'cache.stores.glide' => ['driver' => 'array'],
+ ]);
+ Storage::fake('image-cache-explorer');
+ Storage::fake('image-originals');
+
+ foreach (['blog' => 'Editorial', 'blogger' => 'Profile photos', 'empty' => 'Empty'] as $handle => $title) {
+ AssetContainer::make($handle)->title($title)->disk('image-originals')->save();
+ }
+
+ $this->actingAs(tap(User::make()->makeSuper())->save());
+ }
+
+ #[Test]
+ public function it_lists_counts_and_sizes_per_container_including_empty_containers()
+ {
+ $disk = Storage::disk('image-cache-explorer');
+ $disk->put('containers/blog/photo.jpg/one.jpg', '123');
+ $disk->put('containers/blog/nested/photo.jpg/two.webp', '1234567');
+ $disk->put('containers/blogger/profile.jpg/one.jpg', '12345678901');
+ $disk->put('paths/photo.jpg/one.jpg', 'unrelated');
+ $disk->put('http/example.com/photo.jpg/one.jpg', 'unrelated');
+
+ $this->getJson(cp_route('utilities.cache.image-containers'))
+ ->assertOk()
+ ->assertJsonCount(3, 'data')
+ ->assertJsonPath('meta.total', 3)
+ ->assertJsonPath('data.0.handle', 'blogger')
+ ->assertJsonPath('data.0.count', 1)
+ ->assertJsonPath('data.0.size', 11)
+ ->assertJsonPath('data.1.title', 'Editorial')
+ ->assertJsonPath('data.1.count', 2)
+ ->assertJsonPath('data.1.size', 10)
+ ->assertJsonPath('data.1.size_formatted', '10 B')
+ ->assertJsonPath('data.2.handle', 'empty')
+ ->assertJsonPath('data.2.count', 0)
+ ->assertJsonPath('data.2.size', 0);
+ }
+
+ #[Test]
+ public function it_searches_titles_and_handles_and_sorts_before_paginating()
+ {
+ $disk = Storage::disk('image-cache-explorer');
+ $disk->put('containers/blog/photo.jpg/one.jpg', str_repeat('a', 20));
+ $disk->put('containers/blogger/photo.jpg/one.jpg', 'ab');
+
+ $this->getJson(cp_route('utilities.cache.image-containers', ['perPage' => 1]))
+ ->assertOk()
+ ->assertJsonPath('data.0.handle', 'blog')
+ ->assertJsonPath('meta.last_page', 3);
+
+ $this->getJson(cp_route('utilities.cache.image-containers', ['perPage' => 1, 'page' => 2]))
+ ->assertOk()
+ ->assertJsonPath('data.0.handle', 'blogger')
+ ->assertJsonPath('meta.from', 2);
+
+ $this->getJson(cp_route('utilities.cache.image-containers', ['sort' => 'size', 'order' => 'asc']))
+ ->assertOk()
+ ->assertJsonPath('data.0.handle', 'empty')
+ ->assertJsonPath('data.1.handle', 'blogger')
+ ->assertJsonPath('data.2.handle', 'blog');
+
+ $this->getJson(cp_route('utilities.cache.image-containers', ['search' => 'EDITORIAL']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 1)
+ ->assertJsonPath('meta.container_count', 3)
+ ->assertJsonPath('meta.single_container', null)
+ ->assertJsonPath('data.0.handle', 'blog');
+
+ $this->getJson(cp_route('utilities.cache.image-containers', ['search' => 'BLOG']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 2);
+ }
+
+ #[Test]
+ public function it_honors_saved_and_requested_columns()
+ {
+ User::current()->setPreference('cache.image_containers.columns', ['handle', 'size'])->save();
+
+ $columns = collect($this->getJson(cp_route('utilities.cache.image-containers'))
+ ->assertOk()->json('meta.columns'));
+
+ $this->assertSame(['handle', 'size'], $columns->where('visible', true)->pluck('field')->values()->all());
+
+ $columns = collect($this->getJson(cp_route('utilities.cache.image-containers', ['columns' => 'count,title,actions']))
+ ->assertOk()->json('meta.columns'));
+
+ $this->assertSame(['count', 'title', 'actions'], $columns->where('visible', true)->pluck('field')->values()->all());
+ $this->assertSame(0, $columns->keyBy('field')['title']['defaultOrder']);
+ $this->assertFalse($columns->keyBy('field')['handle']['defaultVisibility']);
+
+ User::current()->setPreference('cache.image_assets.columns', ['path', 'size'])->save();
+
+ $columns = collect($this->getJson(cp_route('utilities.cache.image-assets', ['container' => 'blog']))
+ ->assertOk()->json('meta.columns'));
+
+ $this->assertSame(['path', 'size'], $columns->where('visible', true)->pluck('field')->values()->all());
+ }
+
+ #[Test]
+ public function it_identifies_a_single_container_even_when_search_hides_it()
+ {
+ AssetContainer::find('blogger')->delete();
+ AssetContainer::find('empty')->delete();
+
+ $this->getJson(cp_route('utilities.cache.image-containers', ['search' => 'no matches']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 0)
+ ->assertJsonPath('meta.container_count', 1)
+ ->assertJsonPath('meta.single_container.id', 'blog');
+ }
+
+ #[Test]
+ public function it_groups_cached_variations_by_asset_including_nested_paths_and_missing_originals()
+ {
+ $asset = $this->createAsset('blog', 'nested/my photo.jpg');
+ $generator = app(ImageGenerator::class);
+ $generator->generateByAsset($asset, ['w' => 10]);
+ $generator->generateByAsset($asset, ['w' => 20, 'fm' => 'webp']);
+
+ $disk = Storage::disk('image-cache-explorer');
+ $size = collect($disk->allFiles('containers/blog'))->sum(fn ($path) => $disk->size($path));
+ $disk->put('containers/blog/deleted.jpg/hash/deleted.jpg', 'orphan');
+ $disk->put('containers/blogger/other.jpg/hash/other.jpg', 'other');
+
+ $response = $this->getJson(cp_route('utilities.cache.image-assets', ['container' => 'blog']))
+ ->assertOk()
+ ->assertJsonCount(2, 'data')
+ ->assertJsonPath('meta.total', 2);
+
+ $assets = collect($response->json('data'))->keyBy('path');
+ $this->assertSame('blog::nested/my photo.jpg', $assets['nested/my photo.jpg']['id']);
+ $this->assertSame(2, $assets['nested/my photo.jpg']['count']);
+ $this->assertSame($size, $assets['nested/my photo.jpg']['size']);
+ $this->assertSame(1, $assets['deleted.jpg']['count']);
+ $this->assertSame(6, $assets['deleted.jpg']['size']);
+ }
+
+ #[Test]
+ public function it_searches_and_sorts_cached_assets_before_paginating()
+ {
+ $disk = Storage::disk('image-cache-explorer');
+ $disk->put('containers/blog/nested/large.jpg/hash/large.jpg', str_repeat('a', 20));
+ $disk->put('containers/blog/small.jpg/hash/small.jpg', 'ab');
+
+ $this->getJson(cp_route('utilities.cache.image-assets', ['container' => 'blog', 'perPage' => 1]))
+ ->assertOk()
+ ->assertJsonPath('data.0.path', 'nested/large.jpg')
+ ->assertJsonPath('meta.last_page', 2);
+
+ $this->getJson(cp_route('utilities.cache.image-assets', ['container' => 'blog', 'perPage' => 1, 'page' => 2]))
+ ->assertOk()
+ ->assertJsonPath('data.0.path', 'small.jpg');
+
+ $this->getJson(cp_route('utilities.cache.image-assets', ['container' => 'blog', 'sort' => 'size', 'order' => 'asc']))
+ ->assertOk()
+ ->assertJsonPath('data.0.path', 'small.jpg');
+
+ $this->getJson(cp_route('utilities.cache.image-assets', ['container' => 'blog', 'search' => 'NESTED']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 1)
+ ->assertJsonPath('data.0.path', 'nested/large.jpg');
+ }
+
+ #[Test]
+ public function it_previews_existing_variants_without_generating_more_cached_images()
+ {
+ $asset = $this->createAsset('blog', 'nested/my photo.jpg');
+ $generator = app(ImageGenerator::class);
+ $generator->generateByAsset($asset, ['w' => 10]);
+ $generator->generateByAsset($asset, ['w' => 20, 'fm' => 'webp']);
+
+ $disk = Storage::disk('image-cache-explorer');
+ $files = $disk->allFiles();
+ $manifest = Glide::cacheStore()->get(ImageGenerator::assetCacheManifestKey($asset));
+
+ $variants = collect($this->getJson(cp_route('utilities.cache.image-variants', [
+ 'container' => 'blog',
+ 'asset' => $asset->id(),
+ ]))->assertOk()->assertJsonCount(2, 'data')->json('data'));
+
+ $this->assertEqualsCanonicalizing(['JPG', 'WEBP'], $variants->pluck('format')->all());
+
+ foreach ($variants as $variant) {
+ $this->assertSame($disk->size($variant['id']), $variant['size']);
+ $this->assertNotNull($variant['generated_at']);
+ }
+
+ $assetData = $this->getJson(cp_route('utilities.cache.image-assets', ['container' => 'blog']))
+ ->assertOk()->json('data.0');
+
+ $smallest = $variants->sortBy('size')->first();
+ $this->assertSame($smallest['preview_url'], $assetData['thumbnail']);
+ $this->assertArrayNotHasKey('thumbnail_size', $assetData);
+
+ $response = $this->get($assetData['thumbnail'])->assertOk();
+ $this->assertSame($disk->get($smallest['id']), $response->streamedContent());
+ $this->assertStringContainsString('private', $response->headers->get('Cache-Control'));
+ $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
+ $response->assertHeader('X-Content-Type-Options', 'nosniff');
+
+ $this->assertSame($files, $disk->allFiles());
+ $this->assertSame($manifest, Glide::cacheStore()->get(ImageGenerator::assetCacheManifestKey($asset)));
+ }
+
+ #[Test]
+ public function it_sorts_and_paginates_variants_by_size_and_generation_date()
+ {
+ $disk = Storage::disk('image-cache-explorer');
+ $large = 'containers/blog/photo.jpg/large/photo.jpg';
+ $small = 'containers/blog/photo.jpg/small/photo.webp';
+ $disk->put($large, '1234567890');
+ $disk->put($small, 'ab');
+ touch($disk->path($large), now()->subHour()->getTimestamp());
+ touch($disk->path($small), now()->getTimestamp());
+ clearstatcache();
+
+ $input = ['container' => 'blog', 'asset' => 'blog::photo.jpg', 'perPage' => 1];
+ $url = 'utilities.cache.image-variants';
+
+ $this->getJson(cp_route($url, $input))
+ ->assertOk()
+ ->assertJsonPath('data.0.id', $large)
+ ->assertJsonPath('meta.last_page', 2);
+
+ $this->getJson(cp_route($url, $input + ['page' => 2]))
+ ->assertOk()
+ ->assertJsonPath('data.0.id', $small);
+
+ $this->getJson(cp_route($url, $input + ['sort' => 'generated_at', 'order' => 'desc']))
+ ->assertOk()
+ ->assertJsonPath('data.0.id', $small);
+
+ $this->getJson(cp_route($url, $input + ['sort' => 'size', 'order' => 'asc']))
+ ->assertOk()
+ ->assertJsonPath('data.0.id', $small);
+
+ $this->getJson(cp_route($url, $input + ['search' => 'WEBP']))
+ ->assertOk()
+ ->assertJsonPath('meta.total', 1)
+ ->assertJsonPath('data.0.id', $small);
+ }
+
+ #[Test]
+ public function it_clears_one_variation_and_its_path_cache_while_preserving_other_variations_and_hybrid_recipes()
+ {
+ $asset = $this->createAsset('blog', 'nested/photo.jpg');
+ $otherAsset = $this->createAsset('blog', 'other.jpg');
+ $generator = app(ImageGenerator::class);
+ $path = $generator->generateByAsset($asset, ['w' => 10]);
+ $otherPath = $generator->generateByAsset($asset, ['w' => 20]);
+ $otherAssetPath = $generator->generateByAsset($otherAsset, ['w' => 10]);
+
+ $builder = new HybridUrlBuilder(app(GlideCachePathResolver::class), ['route' => '/img']);
+ $builder->build($asset, ['w' => 10]);
+ $builder->build($asset, ['w' => 20]);
+
+ $store = Glide::cacheStore();
+ $manifestKey = ImageGenerator::assetCacheManifestKey($asset);
+ $aliasKey = 'asset::'.$asset->id().'::alias';
+ $store->forever($aliasKey, $path);
+ $store->forever($manifestKey, [...$store->get($manifestKey), $aliasKey]);
+ $manifest = $store->get($manifestKey);
+ $hybridRecipe = $store->get('hybrid::'.$path);
+
+ $disk = Storage::disk('image-cache-explorer');
+ $size = $disk->size($path);
+
+ $this->postJson(cp_route('utilities.cache.clear-image-variant'), [
+ 'container' => 'blog',
+ 'asset' => $asset->id(),
+ 'variant' => $path,
+ ])
+ ->assertOk()
+ ->assertJsonPath('count', 1)
+ ->assertJsonPath('size', $size)
+ ->assertJsonPath('remaining', 1)
+ ->assertJsonPath('remaining_size', $disk->size($otherPath));
+
+ $disk->assertMissing($path);
+ $disk->assertExists([$otherPath, $otherAssetPath]);
+ Storage::disk('image-originals')->assertExists(['nested/photo.jpg', 'other.jpg']);
+
+ $clearedKey = ImageGenerator::manipulationCacheKey('asset', $asset, ['w' => 10]);
+ $otherKey = ImageGenerator::manipulationCacheKey('asset', $asset, ['w' => 20]);
+ $this->assertNull($store->get($clearedKey));
+ $this->assertNull($store->get($aliasKey));
+ $this->assertSame($otherPath, $store->get($otherKey));
+ $this->assertSame($hybridRecipe, $store->get('hybrid::'.$path));
+ $this->assertSame(array_values(array_diff($manifest, [$clearedKey, $aliasKey])), $store->get($manifestKey));
+
+ $this->getJson(cp_route('utilities.cache.image-variants', ['container' => 'blog', 'asset' => $asset->id()]))
+ ->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.id', $otherPath);
+
+ $generator->generateByAsset($asset, ['w' => 10]);
+ $disk->assertExists($path);
+ }
+
+ #[Test]
+ public function it_clears_the_last_variation_and_rejects_variations_outside_the_selected_asset()
+ {
+ $asset = $this->createAsset('blog', 'photo.jpg');
+ $otherAsset = $this->createAsset('blogger', 'other.jpg');
+ $generator = app(ImageGenerator::class);
+ $path = $generator->generateByAsset($asset, ['w' => 10]);
+ $otherPath = $generator->generateByAsset($otherAsset, ['w' => 10]);
+
+ foreach ([$otherPath, 'containers/blog/photo.jpg/../../other.jpg', 'missing', null] as $variant) {
+ $this->postJson(cp_route('utilities.cache.clear-image-variant'), [
+ 'container' => 'blog',
+ 'asset' => $asset->id(),
+ 'variant' => $variant,
+ ])->assertUnprocessable()->assertJsonValidationErrors('variant');
+ }
+
+ $this->postJson(cp_route('utilities.cache.clear-image-variant'), [
+ 'container' => 'blog',
+ 'asset' => $asset->id(),
+ 'variant' => $path,
+ ])->assertOk()->assertJsonPath('remaining', 0)->assertJsonPath('remaining_size', 0);
+
+ Storage::disk('image-cache-explorer')->assertExists($otherPath);
+ $this->assertNull(Glide::cacheStore()->get(ImageGenerator::assetCacheManifestKey($asset)));
+ $this->getJson(cp_route('utilities.cache.image-assets', ['container' => 'blog']))
+ ->assertOk()->assertJsonCount(0, 'data');
+ }
+
+ #[Test]
+ public function it_bulk_clears_only_selected_containers_and_assets()
+ {
+ $disk = Storage::disk('image-cache-explorer');
+ $disk->put('containers/blog/first.jpg/hash/first.jpg', 'first');
+ $disk->put('containers/blog/second.jpg/hash/second.jpg', 'second');
+ $disk->put('containers/blog/keep.jpg/hash/keep.jpg', 'keep');
+ $disk->put('containers/blogger/keep.jpg/hash/keep.jpg', 'other');
+
+ $this->postJson(cp_route('utilities.cache.clear-image-asset'), [
+ 'container' => 'blog',
+ 'assets' => ['blog::first.jpg', 'blog::second.jpg'],
+ ])
+ ->assertOk()
+ ->assertJsonPath('count', 2)
+ ->assertJsonPath('size', 11);
+
+ $disk->assertExists(['containers/blog/keep.jpg/hash/keep.jpg', 'containers/blogger/keep.jpg/hash/keep.jpg']);
+ $disk->assertMissing(['containers/blog/first.jpg/hash/first.jpg', 'containers/blog/second.jpg/hash/second.jpg']);
+
+ $this->postJson(cp_route('utilities.cache.clear-image-container'), ['containers' => ['blog', 'empty']])
+ ->assertOk()
+ ->assertJsonPath('count', 1)
+ ->assertJsonPath('size', 4);
+
+ $disk->assertExists('containers/blogger/keep.jpg/hash/keep.jpg');
+ $this->assertSame([], $disk->allFiles('containers/blog'));
+ }
+
+ #[Test]
+ public function it_bulk_clears_selected_variations_and_updates_their_shared_manifest()
+ {
+ $asset = $this->createAsset('blog', 'photo.jpg');
+ $generator = app(ImageGenerator::class);
+ $first = $generator->generateByAsset($asset, ['w' => 10]);
+ $second = $generator->generateByAsset($asset, ['w' => 20]);
+ $keep = $generator->generateByAsset($asset, ['w' => 30]);
+ $disk = Storage::disk('image-cache-explorer');
+ $size = $disk->size($first) + $disk->size($second);
+
+ $this->postJson(cp_route('utilities.cache.clear-image-variant'), [
+ 'container' => 'blog',
+ 'asset' => $asset->id(),
+ 'variants' => [$first, $second],
+ ])
+ ->assertOk()
+ ->assertJsonPath('count', 2)
+ ->assertJsonPath('size', $size)
+ ->assertJsonPath('remaining', 1);
+
+ $disk->assertMissing([$first, $second]);
+ $disk->assertExists($keep);
+ $this->assertCount(1, Glide::cacheStore()->get(ImageGenerator::assetCacheManifestKey($asset)));
+ $generator->generateByAsset($asset, ['w' => 10]);
+ $disk->assertExists($first);
+ }
+
+ #[Test]
+ public function it_validates_every_bulk_selection_before_clearing_any_files()
+ {
+ $disk = Storage::disk('image-cache-explorer');
+ $path = 'containers/blog/photo.jpg/hash/photo.jpg';
+ $disk->put($path, 'cached');
+
+ foreach ([
+ ['containers' => ['blog', 'missing']],
+ ['containers' => []],
+ ['containers' => ['blog', 'blog']],
+ ['container' => 'blog', 'containers' => ['blog']],
+ ] as $input) {
+ $this->postJson(cp_route('utilities.cache.clear-image-container'), $input)->assertUnprocessable();
+ $disk->assertExists($path);
+ }
+
+ $this->postJson(cp_route('utilities.cache.clear-image-asset'), [
+ 'container' => 'blog',
+ 'assets' => ['blog::photo.jpg', 'blogger::other.jpg'],
+ ])->assertUnprocessable();
+
+ $this->postJson(cp_route('utilities.cache.clear-image-variant'), [
+ 'container' => 'blog',
+ 'asset' => 'blog::photo.jpg',
+ 'variants' => [$path, 'containers/blogger/other.jpg/hash/other.jpg'],
+ ])->assertUnprocessable();
+
+ $disk->assertExists($path);
+ }
+
+ #[Test]
+ public function it_always_shows_variation_actions_with_the_column_customizer_disabled()
+ {
+ Storage::disk('image-cache-explorer')->put('containers/blog/photo.jpg/hash/photo.jpg', 'cached');
+ User::current()->setPreference('cache.image_variants.columns', ['preview', 'size'])->save();
+
+ $columns = collect($this->getJson(cp_route('utilities.cache.image-variants', [
+ 'container' => 'blog',
+ 'asset' => 'blog::photo.jpg',
+ 'columns' => 'preview,size',
+ ]))->assertOk()->json('meta.columns'))->keyBy('field');
+
+ $this->assertTrue($columns['actions']['visible']);
+ $this->assertFalse($columns['actions']['sortable']);
+ }
+
+ #[Test]
+ public function it_restricts_previews_to_images_in_the_selected_container()
+ {
+ $disk = Storage::disk('image-cache-explorer');
+ $disk->put('containers/blogger/photo.jpg/hash/photo.jpg', UploadedFile::fake()->image('photo.jpg')->get());
+ $disk->put('containers/blog/not-an-image.txt', 'not an image');
+ $disk->put('containers/blog/script.svg', ' ');
+
+ foreach ([
+ 'containers/blogger/photo.jpg/hash/photo.jpg',
+ 'containers/blog/../blogger/photo.jpg/hash/photo.jpg',
+ 'containers/blog/./photo.jpg',
+ 'containers/blog/missing.jpg',
+ 'containers/blog/..\\blogger/photo.jpg',
+ 'paths/photo.jpg',
+ ] as $path) {
+ $this->get(cp_route('utilities.cache.image-preview', ['container' => 'blog', 'path' => $path]))
+ ->assertNotFound();
+ }
+
+ foreach (['containers/blog/not-an-image.txt', 'containers/blog/script.svg'] as $path) {
+ $this->get(cp_route('utilities.cache.image-preview', ['container' => 'blog', 'path' => $path]))
+ ->assertStatus(415);
+ }
+ }
+
+ #[Test]
+ public function it_requires_asset_view_permission_to_preview_cached_images()
+ {
+ $path = 'containers/blog/photo.jpg/hash/photo.jpg';
+ Storage::disk('image-cache-explorer')->put($path, UploadedFile::fake()->image('photo.jpg')->get());
+
+ $this->setTestRoles([
+ 'cache_manager' => ['access cp', 'access cache utility'],
+ 'asset_viewer' => ['access cp', 'access cache utility', 'view blog assets'],
+ ]);
+ $user = User::make()->assignRole('cache_manager')->save();
+ $this->actingAs($user);
+ $url = cp_route('utilities.cache.image-preview', ['container' => 'blog', 'path' => $path]);
+
+ $this->getJson($url)->assertForbidden();
+
+ $this->actingAs(User::make()->assignRole('asset_viewer')->save());
+ $this->getJson($url)->assertOk();
+ }
+
+ #[Test]
+ public function it_clears_an_individual_asset_cache_and_updates_totals()
+ {
+ $asset = $this->createAsset('blog', 'nested/photo.jpg');
+ $otherAsset = $this->createAsset('blog', 'nested/photo.jpg-extra.jpg');
+ $generator = app(ImageGenerator::class);
+ $generator->generateByAsset($asset, ['w' => 10]);
+ $generator->generateByAsset($asset, ['w' => 20]);
+ $generator->generateByAsset($otherAsset, ['w' => 10]);
+
+ (new HybridUrlBuilder(app(GlideCachePathResolver::class), ['route' => '/img']))->build($asset, ['w' => 10]);
+
+ $manifestKey = ImageGenerator::assetCacheManifestKey($asset);
+ $keys = Glide::cacheStore()->get($manifestKey);
+ $hybridKeys = collect($keys)->filter(fn ($key) => str_starts_with($key, 'hybrid::'))->values()->all();
+ $disk = Storage::disk('image-cache-explorer');
+ $size = collect($disk->allFiles('containers/blog/'.$asset->path()))->sum(fn ($path) => $disk->size($path));
+ $otherFiles = $disk->allFiles('containers/blog/'.$otherAsset->path());
+
+ $this->postJson(cp_route('utilities.cache.clear-image-asset'), ['container' => 'blog', 'asset' => $asset->id()])
+ ->assertOk()
+ ->assertJsonPath('count', 2)
+ ->assertJsonPath('size', $size)
+ ->assertJsonPath('message', fn ($message) => str_contains($message, 'for nested/photo.jpg in '));
+
+ $this->assertSame([], $disk->allFiles('containers/blog/'.$asset->path()));
+ $this->assertSame($otherFiles, $disk->allFiles('containers/blog/'.$otherAsset->path()));
+ Storage::disk('image-originals')->assertExists($asset->path());
+ $this->assertSame($hybridKeys, Glide::cacheStore()->get($manifestKey));
+
+ foreach (array_diff($keys, $hybridKeys) as $key) {
+ $this->assertNull(Glide::cacheStore()->get($key));
+ }
+
+ $this->assertNotEmpty(Glide::cacheStore()->get(ImageGenerator::assetCacheManifestKey($otherAsset)));
+
+ $this->getJson(cp_route('utilities.cache.image-assets', ['container' => 'blog']))
+ ->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.path', $otherAsset->path());
+
+ $this->getJson(cp_route('utilities.cache.image-containers', ['search' => 'Editorial']))
+ ->assertOk()
+ ->assertJsonPath('data.0.count', 1);
+
+ $generator->generateByAsset($asset, ['w' => 10]);
+ $this->assertCount(1, $disk->allFiles('containers/blog/'.$asset->path()));
+ }
+
+ #[Test]
+ #[DataProvider('hybridCacheClearActions')]
+ public function it_regenerates_existing_hybrid_urls_after_clearing_an_asset_or_container(string $action)
+ {
+ $disk = Storage::disk('image-cache-explorer');
+ config([
+ 'statamic.assets.image_manipulation.cache' => 'hybrid',
+ 'statamic.assets.image_manipulation.cache_path' => $disk->path(''),
+ ]);
+
+ $asset = $this->createAsset('blog', 'nested/photo.jpg');
+ $builder = new HybridUrlBuilder(app(GlideCachePathResolver::class), ['route' => '/img']);
+ $url = $builder->build($asset, ['w' => 10]);
+ $uncachedUrl = $builder->build($asset, ['w' => 20]);
+ $path = app(ImageGenerator::class)->generateByAsset($asset, ['w' => 10]);
+ $manifestKey = ImageGenerator::assetCacheManifestKey($asset);
+ $recipes = collect(Glide::cacheStore()->get($manifestKey))
+ ->filter(fn ($key) => str_starts_with($key, 'hybrid::'))
+ ->mapWithKeys(fn ($key) => [$key => Glide::cacheStore()->get($key)]);
+
+ $this->get($url)->assertOk();
+
+ $input = ['container' => 'blog'];
+
+ if ($action === 'clear-image-asset') {
+ $input['asset'] = $asset->id();
+ }
+
+ $this->postJson(cp_route('utilities.cache.'.$action), $input)
+ ->assertOk()
+ ->assertJsonPath('count', 1);
+
+ $disk->assertMissing($path);
+ $this->assertSame($recipes->keys()->all(), Glide::cacheStore()->get($manifestKey));
+ $this->assertNull(Glide::cacheStore()->get(ImageGenerator::manipulationCacheKey('asset', $asset, ['w' => 10])));
+
+ foreach ($recipes as $key => $recipe) {
+ $this->assertSame($recipe, Glide::cacheStore()->get($key));
+ }
+
+ $this->get($url)->assertOk();
+ $this->get($uncachedUrl)->assertOk();
+ $disk->assertExists($path);
+ $this->assertCount(2, $disk->allFiles('containers/blog/'.$asset->path()));
+
+ Glide::clearAsset($asset);
+
+ $this->assertSame([], $disk->allFiles('containers/blog/'.$asset->path()));
+ $this->assertNull(Glide::cacheStore()->get($manifestKey));
+
+ foreach ($recipes->keys() as $key) {
+ $this->assertNull(Glide::cacheStore()->get($key));
+ }
+ }
+
+ public static function hybridCacheClearActions(): array
+ {
+ return [
+ 'asset' => ['clear-image-asset'],
+ 'container' => ['clear-image-container'],
+ ];
+ }
+
+ #[Test]
+ public function it_can_clear_cached_files_for_a_missing_original_asset()
+ {
+ $disk = Storage::disk('image-cache-explorer');
+ $path = 'containers/blog/deleted.jpg/hash/deleted.jpg';
+ $disk->put($path, 'orphan');
+
+ $this->postJson(cp_route('utilities.cache.clear-image-asset'), ['container' => 'blog', 'asset' => 'blog::deleted.jpg'])
+ ->assertOk()
+ ->assertJsonPath('count', 1)
+ ->assertJsonPath('size', 6);
+
+ $disk->assertMissing($path);
+ }
+
+ #[Test]
+ public function it_clears_only_the_selected_container_and_its_cache_metadata()
+ {
+ $asset = $this->createAsset('blog', 'nested/photo.jpg');
+ $otherAsset = $this->createAsset('blogger', 'profile.jpg');
+ $generator = app(ImageGenerator::class);
+ $params = ['w' => 10];
+ $generator->generateByAsset($asset, $params);
+ $generator->generateByAsset($otherAsset, $params);
+
+ (new HybridUrlBuilder(app(GlideCachePathResolver::class), ['route' => '/img']))->build($asset, $params);
+
+ $manifestKey = ImageGenerator::assetCacheManifestKey($asset);
+ $keys = Glide::cacheStore()->get($manifestKey);
+ $this->assertCount(2, $keys);
+ $hybridKeys = collect($keys)->filter(fn ($key) => str_starts_with($key, 'hybrid::'))->values()->all();
+
+ $disk = Storage::disk('image-cache-explorer');
+ $disk->put('containers/blog/deleted.jpg/old.jpg', 'orphan');
+ $disk->put('paths/photo.jpg/one.jpg', 'unrelated');
+ $size = collect($disk->allFiles('containers/blog'))->sum(fn ($path) => $disk->size($path));
+ $otherFiles = $disk->allFiles('containers/blogger');
+
+ Glide::cacheStore()->forever('unrelated', 'keep');
+
+ $this->postJson(cp_route('utilities.cache.clear-image-container'), ['container' => 'blog'])
+ ->assertOk()
+ ->assertJsonPath('count', 2)
+ ->assertJsonPath('size', $size)
+ ->assertJsonPath('message', fn ($message) => str_starts_with($message, 'Cleared 2 cached images (')
+ && str_contains($message, 'from Editorial in '));
+
+ $this->assertSame([], $disk->allFiles('containers/blog'));
+ $this->assertSame($otherFiles, $disk->allFiles('containers/blogger'));
+ $disk->assertExists('paths/photo.jpg/one.jpg');
+ Storage::disk('image-originals')->assertExists(['nested/photo.jpg', 'profile.jpg']);
+ $this->assertSame($hybridKeys, Glide::cacheStore()->get($manifestKey));
+
+ foreach (array_diff($keys, $hybridKeys) as $key) {
+ $this->assertNull(Glide::cacheStore()->get($key));
+ }
+
+ $this->assertNotEmpty(Glide::cacheStore()->get(ImageGenerator::assetCacheManifestKey($otherAsset)));
+ $this->assertSame('keep', Glide::cacheStore()->get('unrelated'));
+
+ $this->getJson(cp_route('utilities.cache.image-containers', ['search' => 'Editorial']))
+ ->assertOk()
+ ->assertJsonPath('data.0.count', 0)
+ ->assertJsonPath('data.0.size', 0);
+
+ $generator->generateByAsset($asset, $params);
+ $this->assertCount(1, $disk->allFiles('containers/blog'));
+ }
+
+ #[Test]
+ public function it_handles_clearing_an_empty_container()
+ {
+ $this->postJson(cp_route('utilities.cache.clear-image-container'), ['container' => 'empty'])
+ ->assertOk()
+ ->assertJsonPath('count', 0)
+ ->assertJsonPath('size', 0);
+ }
+
+ #[Test]
+ public function it_requires_cache_utility_permission()
+ {
+ $this->setTestRoles(['cp_user' => ['access cp']]);
+ $this->actingAs(User::make()->assignRole('cp_user')->save());
+
+ $this->getJson(cp_route('utilities.cache.image-containers'))->assertForbidden();
+ $this->postJson(cp_route('utilities.cache.clear-image-container'), ['container' => 'blog'])->assertForbidden();
+ $this->getJson(cp_route('utilities.cache.image-assets', ['container' => 'blog']))->assertForbidden();
+ $this->postJson(cp_route('utilities.cache.clear-image-asset'), ['container' => 'blog', 'asset' => 'blog::photo.jpg'])->assertForbidden();
+ $this->getJson(cp_route('utilities.cache.image-variants', ['container' => 'blog', 'asset' => 'blog::photo.jpg']))->assertForbidden();
+ $this->getJson(cp_route('utilities.cache.image-preview', ['container' => 'blog', 'path' => 'containers/blog/photo.jpg/hash/photo.jpg']))->assertForbidden();
+ $this->postJson(cp_route('utilities.cache.clear-image-variant'), [
+ 'container' => 'blog',
+ 'asset' => 'blog::photo.jpg',
+ 'variant' => 'containers/blog/photo.jpg/hash/photo.jpg',
+ ])->assertForbidden();
+ }
+
+ #[Test]
+ public function it_validates_listing_parameters_and_container_handles()
+ {
+ foreach ([['page' => 0], ['perPage' => 101], ['sort' => 'actions'], ['order' => 'invalid'], ['search' => ['invalid']]] as $input) {
+ $this->getJson(cp_route('utilities.cache.image-containers', $input))->assertUnprocessable();
+ }
+
+ foreach ([[], ['container' => 'missing'], ['container' => '../blog'], ['container' => ['blog']]] as $input) {
+ $this->postJson(cp_route('utilities.cache.clear-image-container'), $input)
+ ->assertUnprocessable()->assertJsonValidationErrors('container');
+ }
+
+ foreach (['blogger::photo.jpg', 'blog::../blogger/photo.jpg', 'blog::missing.jpg', null, ['blog::photo.jpg']] as $asset) {
+ $this->postJson(cp_route('utilities.cache.clear-image-asset'), ['container' => 'blog', 'asset' => $asset])
+ ->assertUnprocessable()->assertJsonValidationErrors('asset');
+ }
+
+ $this->getJson(cp_route('utilities.cache.image-assets', ['container' => 'missing']))
+ ->assertUnprocessable()->assertJsonValidationErrors('container');
+ }
+
+ #[Test]
+ public function it_passes_the_explorer_endpoints_to_the_cache_page()
+ {
+ $this->get(cp_route('utilities.cache'))->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('imageContainersUrl', cp_route('utilities.cache.image-containers'))
+ ->where('clearImageContainerUrl', cp_route('utilities.cache.clear-image-container'))
+ ->where('imageAssetsUrl', cp_route('utilities.cache.image-assets'))
+ ->where('clearImageAssetUrl', cp_route('utilities.cache.clear-image-asset'))
+ ->where('imageVariantsUrl', cp_route('utilities.cache.image-variants'))
+ ->where('clearImageVariantUrl', cp_route('utilities.cache.clear-image-variant'))
+ ->missing('images')
+ );
+ }
+
+ private function createAsset(string $container, string $path)
+ {
+ Storage::disk('image-originals')->put($path, UploadedFile::fake()->image(basename($path), 30, 60)->get());
+
+ return tap(AssetContainer::find($container)->makeAsset($path))->save();
+ }
+}
diff --git a/tests/StaticCaching/ApplicationCacherTest.php b/tests/StaticCaching/ApplicationCacherTest.php
index a03c19182af..12694feae75 100644
--- a/tests/StaticCaching/ApplicationCacherTest.php
+++ b/tests/StaticCaching/ApplicationCacherTest.php
@@ -91,6 +91,78 @@ public function caching_a_page_tracks_the_url($status, $content)
$this->assertEquals($content, $cacher->getCachedPage($request)->content);
}
+ #[Test]
+ public function reading_a_generation_date_does_not_load_the_cached_response()
+ {
+ $url = 'http://example.com/test?foo=bar';
+ $timestamp = now()->getTimestamp();
+ $cache = $this->mock(Repository::class);
+ $cache->shouldReceive('get')->with('static-cache:generated-at:'.md5($url))->once()->andReturn($timestamp);
+ $cache->shouldNotReceive('get')->with('static-cache:responses:'.md5($url));
+
+ $cacher = new ApplicationCacher($cache, []);
+
+ $this->assertSame($timestamp, $cacher->getGeneratedAt($url));
+ }
+
+ #[Test]
+ public function generation_dates_expire_with_their_responses()
+ {
+ $this->freezeTime();
+ $cacher = new ApplicationCacher(app(Repository::class), ['base_url' => 'http://example.com', 'expiry' => 1]);
+ $request = Request::create('http://example.com/one');
+ $response = response('Cached content');
+ $cacher->cachePage($request, $response);
+ event(new ResponsePrepared($request, $response));
+
+ $this->assertSame(now()->getTimestamp(), $cacher->getGeneratedAt($request->getUri()));
+ $this->assertTrue($cacher->hasCachedPage($request));
+
+ $this->travel(61)->seconds();
+
+ $this->assertNull($cacher->getGeneratedAt($request->getUri()));
+ $this->assertFalse($cacher->hasCachedPage($request));
+ }
+
+ #[Test]
+ #[DataProvider('generationDateInvalidationMethods')]
+ public function generation_dates_are_removed_with_their_responses(string $method)
+ {
+ $cacher = new ApplicationCacher(app(Repository::class), ['base_url' => 'http://example.com']);
+
+ foreach (['one', 'one?foo=bar', 'other'] as $path) {
+ $request = Request::create('http://example.com/'.$path);
+ $response = response('Cached content');
+ $cacher->cachePage($request, $response);
+ event(new ResponsePrepared($request, $response));
+ $this->assertNotNull($cacher->getGeneratedAt($request->getUri()));
+ }
+
+ match ($method) {
+ 'invalidateUrl' => $cacher->invalidateUrl('/one'),
+ 'invalidateUrls' => $cacher->invalidateUrls(['/one']),
+ 'flush' => $cacher->flush(),
+ };
+
+ $this->assertNull($cacher->getGeneratedAt('http://example.com/one'));
+ $this->assertNull($cacher->getGeneratedAt('http://example.com/one?foo=bar'));
+
+ if ($method === 'flush') {
+ $this->assertNull($cacher->getGeneratedAt('http://example.com/other'));
+ } else {
+ $this->assertNotNull($cacher->getGeneratedAt('http://example.com/other'));
+ }
+ }
+
+ public static function generationDateInvalidationMethods(): array
+ {
+ return [
+ 'single URL' => ['invalidateUrl'],
+ 'multiple URLs' => ['invalidateUrls'],
+ 'flush' => ['flush'],
+ ];
+ }
+
public static function cachedResponseProvider()
{
return [
diff --git a/tests/StaticCaching/CachedPagesTest.php b/tests/StaticCaching/CachedPagesTest.php
new file mode 100644
index 00000000000..42ded7dbfd1
--- /dev/null
+++ b/tests/StaticCaching/CachedPagesTest.php
@@ -0,0 +1,57 @@
+freezeTime();
+ $one = 'http://localhost/one';
+ $two = 'http://localhost/two';
+ $this->mock(WarmableUrls::class)->shouldReceive('all')->twice()->andReturn(collect([$one, $two]));
+ $cacher = $this->mock(Cacher::class);
+ $cacher->shouldReceive('hasCachedPage')->withArgs(fn ($request) => $request->getUri() === $one)
+ ->twice()->andReturn(true, false);
+ $cacher->shouldReceive('hasCachedPage')->withArgs(fn ($request) => $request->getUri() === $two)
+ ->twice()->andReturn(false, true);
+
+ $this->assertSame(['cached' => 1, 'total' => 2, 'uncached' => 1], (new CachedPages($cacher))->coverage());
+ $this->assertSame([$two], (new CachedPages($cacher))->uncached()->pluck('url')->all());
+ $this->assertSame([$two], (new CachedPages($cacher))->uncached()->pluck('url')->all());
+
+ $this->travel(61)->seconds();
+
+ $this->assertSame([$one], (new CachedPages($cacher))->uncached()->pluck('url')->all());
+ $this->assertSame(['cached' => 1, 'total' => 2, 'uncached' => 1], (new CachedPages($cacher))->coverage());
+ }
+
+ #[Test]
+ public function flushing_or_invalidating_static_pages_discards_cached_coverage()
+ {
+ $url = 'http://localhost/one';
+ $this->mock(WarmableUrls::class)->shouldReceive('all')->times(3)->andReturn(collect([$url]));
+ $cacher = $this->mock(Cacher::class);
+ $cacher->shouldReceive('hasCachedPage')->times(3)->andReturn(true, false, false);
+ $pages = new CachedPages($cacher);
+
+ $this->assertSame(['cached' => 1, 'total' => 1, 'uncached' => 0], $pages->coverage());
+
+ StaticCacheCleared::dispatch();
+
+ $this->assertSame(['cached' => 0, 'total' => 1, 'uncached' => 1], $pages->coverage());
+
+ UrlInvalidated::dispatch('/one', 'http://localhost');
+
+ $this->assertSame([$url], $pages->uncached()->pluck('url')->all());
+ }
+}
diff --git a/tests/StaticCaching/ManagerTest.php b/tests/StaticCaching/ManagerTest.php
index 563c2dbacb6..f42d38f0ad4 100644
--- a/tests/StaticCaching/ManagerTest.php
+++ b/tests/StaticCaching/ManagerTest.php
@@ -67,6 +67,7 @@ public function it_flushes()
Cache::shouldReceive('store')->andReturnSelf();
Cache::shouldReceive('flush')->once();
+ Cache::shouldReceive('forget')->with('statamic:cache-utility:page-statuses')->once();
StaticCache::flush();
}
@@ -93,6 +94,7 @@ public function it_flushes_without_static_cache_store()
Cache::shouldReceive('forget')->with('nocache::session.'.md5('/one'))->once();
Cache::shouldReceive('forget')->with('nocache::session.'.md5('/two'))->once();
Cache::shouldReceive('forget')->with('nocache::urls')->once();
+ Cache::shouldReceive('forget')->with('statamic:cache-utility:page-statuses')->once();
StaticCache::flush();
}
diff --git a/tests/StaticCaching/WarmableUrlsTest.php b/tests/StaticCaching/WarmableUrlsTest.php
new file mode 100644
index 00000000000..bb477a47aba
--- /dev/null
+++ b/tests/StaticCaching/WarmableUrlsTest.php
@@ -0,0 +1,48 @@
+freezeTime();
+ $first = Mockery::mock(WarmableUrls::class)->makePartial()->shouldAllowMockingProtectedMethods();
+ $first->shouldReceive('discover')->once()->andReturn(collect(['http://localhost/one']));
+
+ $this->assertSame(['http://localhost/one'], $first->all()->all());
+ $this->assertTrue((new WarmableUrls)->contains('http://localhost/one'));
+ $this->assertFalse((new WarmableUrls)->contains('http://localhost/two'));
+ $this->assertSame(['http://localhost/one'], (new WarmableUrls)->all()->all());
+
+ $this->travel(61)->seconds();
+ $second = Mockery::mock(WarmableUrls::class)->makePartial()->shouldAllowMockingProtectedMethods();
+ $second->shouldReceive('discover')->once()->andReturn(collect(['http://localhost/two']));
+
+ $this->assertSame(['http://localhost/two'], $second->all()->all());
+ $this->assertFalse((new WarmableUrls)->contains('http://localhost/one'));
+ }
+
+ #[Test]
+ public function clearing_the_stache_also_discards_discovered_urls()
+ {
+ $warmableUrls = Mockery::mock(WarmableUrls::class)->makePartial()->shouldAllowMockingProtectedMethods();
+ $warmableUrls->shouldReceive('discover')->twice()->andReturn(
+ collect(['http://localhost/one']),
+ collect(['http://localhost/two'])
+ );
+
+ $this->assertSame(['http://localhost/one'], $warmableUrls->all()->all());
+
+ StacheCleared::dispatch();
+
+ $this->assertSame(['http://localhost/two'], $warmableUrls->all()->all());
+ }
+}