blob: 7c1355774c84d6cd939c29c9f0e7ee433ce53e02 [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chai';
import {beforeEach, it} from 'mocha';
import sinon from 'sinon';
import * as Common from '../../core/common/common.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import {
getCleanTextContentFromElements,
renderElementIntoDOM,
} from '../../testing/DOMHelpers.js';
import {createTarget, describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
import {MockCDPConnection} from '../../testing/MockCDPConnection.js';
import {createViewFunctionStub} from '../../testing/ViewFunctionHelpers.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Application from './application.js';
const {urlString} = Platform.DevToolsPath;
describeWithEnvironment('ServiceWorkerCacheView', function() {
let target: SDK.Target.Target;
let cacheEntries: Protocol.CacheStorage.DataEntry[];
let cacheStorageModel: SDK.ServiceWorkerCacheModel.ServiceWorkerCacheModel;
let cache: SDK.ServiceWorkerCacheModel.Cache;
// Use a third-party storage key (origin + top-level site) to ensure high-signal metadata rendering.
const testStorageKey = 'https://example.com/^0https://example.org';
const testStorageBucket: Protocol.Storage.StorageBucket = {
storageKey: testStorageKey,
};
function createTestEntry(overrides: Partial<Protocol.CacheStorage.DataEntry> = {}): Protocol.CacheStorage.DataEntry {
return {
requestURL: 'https://example.com/script.js',
requestMethod: 'GET',
requestHeaders: [],
responseTime: 1700000000,
responseStatus: 200,
responseStatusText: 'OK',
responseType: 'basic' as Protocol.CacheStorage.CachedResponseType,
responseHeaders: [
{name: 'content-type', value: 'application/javascript'},
{name: 'content-length', value: '1234'},
{name: 'vary', value: 'Accept-Encoding'},
],
...overrides,
};
}
function waitForUpdate(view: Application.ServiceWorkerCacheViews.ServiceWorkerCacheView): Promise<void> {
return new Promise<void>(resolve => {
const stub = sinon.stub(view as unknown as {updatedForTest: () => void}, 'updatedForTest').callsFake(() => {
stub.restore();
resolve();
});
});
}
beforeEach(() => {
cacheEntries = [];
const connection = new MockCDPConnection();
connection.setSuccessHandler('CacheStorage.requestEntries', params => {
const pathFilter = params.pathFilter ?? '';
const filtered = cacheEntries.filter(entry => entry.requestURL.includes(pathFilter));
return {cacheDataEntries: filtered, returnCount: filtered.length};
});
connection.setSuccessHandler('CacheStorage.requestCachedResponse', () => ({response: {body: ''}}));
connection.setSuccessHandler('CacheStorage.deleteEntry', () => ({}));
target = createTarget({connection});
cacheStorageModel = new SDK.ServiceWorkerCacheModel.ServiceWorkerCacheModel(target);
cache = new SDK.ServiceWorkerCacheModel.Cache(cacheStorageModel, testStorageBucket, 'test-cache',
'id' as Protocol.CacheStorage.CacheId);
});
it('creates the expected view structure with toolbar, metadata, grid, and details pane', () => {
const view = new Application.ServiceWorkerCacheViews.ServiceWorkerCacheView(cacheStorageModel, cache);
const toolbar = view.element.querySelector('devtools-toolbar');
assert.isNotNull(toolbar, 'Expected a toolbar');
const metadataView = view.element.querySelector('.storage-metadata-view');
assert.isNotNull(metadataView, 'Expected a metadata view');
const dataGrid = view.element.querySelector('devtools-data-grid');
assert.isNotNull(dataGrid, 'Expected a cache entries data grid');
const detailsPane = view.element.querySelector('[slot="main"]');
assert.isNotNull(detailsPane, 'Expected a details pane');
});
it('renders metadata with storage key when no bucket info is found', async () => {
const view = new Application.ServiceWorkerCacheViews.ServiceWorkerCacheView(cacheStorageModel, cache);
renderElementIntoDOM(view);
const metadataView = view.element.querySelector('.storage-metadata-view');
assert.isNotNull(metadataView);
await UI.Widget.Widget.allUpdatesComplete;
assert.isNotNull(metadataView.shadowRoot);
const keys = getCleanTextContentFromElements(metadataView.shadowRoot, 'devtools-report-key');
assert.deepEqual(keys, ['Frame origin', 'Top-level site', 'Is third-party']);
const values = getCleanTextContentFromElements(metadataView.shadowRoot, 'devtools-report-value');
assert.deepEqual(values, [
'https://example.com',
'https://example.org',
'Yes, because the origin is outside of the top-level site',
]);
view.detach();
});
it('renders metadata with storage bucket info when found', async () => {
const storageBucketsModel = target.model(SDK.StorageBucketsModel.StorageBucketsModel);
assert.isNotNull(storageBucketsModel);
sinon.stub(storageBucketsModel, 'getBucketByName').returns({
bucket: {storageKey: testStorageKey, name: 'test-bucket'},
id: 'id',
expiration: 42,
quota: 1024,
persistent: true,
durability: 'strict' as Protocol.Storage.StorageBucketsDurability,
});
const view = new Application.ServiceWorkerCacheViews.ServiceWorkerCacheView(cacheStorageModel, cache);
renderElementIntoDOM(view);
const metadataView = view.element.querySelector('.storage-metadata-view');
assert.isNotNull(metadataView);
await UI.Widget.Widget.allUpdatesComplete;
assert.isNotNull(metadataView.shadowRoot);
const keys = getCleanTextContentFromElements(metadataView.shadowRoot, 'devtools-report-key');
assert.deepEqual(keys, [
'Frame origin',
'Top-level site',
'Is third-party',
'Bucket name',
]);
const values = getCleanTextContentFromElements(metadataView.shadowRoot, 'devtools-report-value');
assert.deepEqual(values, [
'https://example.com',
'https://example.org',
'Yes, because the origin is outside of the top-level site',
'test-bucket',
]);
view.detach();
});
it('updates when the cache is changed', async () => {
let resolveUpdate: () => void;
let updatedForTestPromise = new Promise<void>(resolve => {
resolveUpdate = resolve;
});
const loadStub =
sinon.stub(cacheStorageModel, 'loadAllCacheData').callsFake((_cache, _skipCount, callback) => callback([], 0));
const updateStub = sinon
.stub(Application.ServiceWorkerCacheViews.ServiceWorkerCacheView.prototype as unknown as
{updatedForTest: () => void},
'updatedForTest')
.callsFake(() => {
resolveUpdate();
});
const view = new Application.ServiceWorkerCacheViews.ServiceWorkerCacheView(cacheStorageModel, cache);
view.markAsRoot();
renderElementIntoDOM(view);
try {
await updatedForTestPromise;
sinon.assert.calledOnce(loadStub);
updatedForTestPromise = new Promise<void>(resolve => {
resolveUpdate = resolve;
});
cacheStorageModel.dispatchEventToListeners(SDK.ServiceWorkerCacheModel.Events.CACHE_STORAGE_CONTENT_UPDATED,
{cacheName: cache.cacheName, storageBucket: cache.storageBucket});
await updatedForTestPromise;
sinon.assert.calledTwice(loadStub);
} finally {
view.detach();
updateStub.restore();
}
});
it('renders populated cache entries in the data grid and summary bar', async () => {
cacheEntries = [
createTestEntry(),
createTestEntry({
requestURL: 'https://example.com/image.png',
responseType: 'opaqueResponse' as Protocol.CacheStorage.CachedResponseType,
responseHeaders: [{name: 'content-type', value: 'image/png'}],
}),
];
const view = new Application.ServiceWorkerCacheViews.ServiceWorkerCacheView(cacheStorageModel, cache);
const updatedPromise = waitForUpdate(view);
view.markAsRoot();
renderElementIntoDOM(view);
try {
await updatedPromise;
await UI.Widget.Widget.allUpdatesComplete;
const nameCells = getCleanTextContentFromElements(view.element, 'devtools-data-grid td.name-column');
assert.deepEqual(nameCells, ['/script.js', '/image.png']);
const responseTypeCells =
getCleanTextContentFromElements(view.element, 'devtools-data-grid td.response-type-column');
assert.deepEqual(responseTypeCells, ['basic', 'opaque']);
const contentTypeCells =
getCleanTextContentFromElements(view.element, 'devtools-data-grid td.content-type-column');
assert.deepEqual(contentTypeCells, ['application/javascript', 'image/png']);
const varyHeaderCells = getCleanTextContentFromElements(view.element, 'devtools-data-grid td.vary-header-column');
assert.deepEqual(varyHeaderCells, ['Accept-Encoding', '']);
const summaryBar = view.element.querySelector('.cache-storage-summary-bar');
assert.isNotNull(summaryBar);
assert.strictEqual(summaryBar.textContent?.trim(), 'Total entries: 2');
} finally {
view.detach();
}
});
it('filters entries by path and updates summary bar to show matching entries count', async () => {
cacheEntries = [
createTestEntry({requestURL: 'https://example.com/script.js'}),
createTestEntry({requestURL: 'https://example.com/style.css'}),
];
const view = new Application.ServiceWorkerCacheViews.ServiceWorkerCacheView(cacheStorageModel, cache);
const initialUpdate = waitForUpdate(view);
view.markAsRoot();
renderElementIntoDOM(view);
try {
await initialUpdate;
const filterUpdate = waitForUpdate(view);
const filterPrompt = view.element.querySelector<HTMLElement>('.toolbar-input-prompt');
assert.isNotNull(filterPrompt);
filterPrompt.textContent = 'style';
filterPrompt.dispatchEvent(new Event('input', {bubbles: true}));
await filterUpdate;
await UI.Widget.Widget.allUpdatesComplete;
const nameCells = getCleanTextContentFromElements(view.element, 'devtools-data-grid td.name-column');
assert.deepEqual(nameCells, ['/style.css']);
const summaryBar = view.element.querySelector('.cache-storage-summary-bar');
assert.isNotNull(summaryBar);
assert.strictEqual(summaryBar.textContent?.trim(), 'Matching entries: 1');
} finally {
view.detach();
}
});
it('previews the selected entry and deletes it when the delete button is clicked', async () => {
cacheEntries = [createTestEntry({requestURL: 'https://example.com/script.js'})];
const deleteSpy = sinon.spy(cacheStorageModel, 'deleteCacheEntry');
const view = new Application.ServiceWorkerCacheViews.ServiceWorkerCacheView(cacheStorageModel, cache);
const updatedPromise = waitForUpdate(view);
view.markAsRoot();
renderElementIntoDOM(view);
try {
await updatedPromise;
await UI.Widget.Widget.allUpdatesComplete;
const row = view.element.querySelector<HTMLElement>('devtools-data-grid table tr:nth-of-type(2)');
assert.isNotNull(row);
row.dispatchEvent(new Event('select'));
await UI.Widget.Widget.allUpdatesComplete;
const tabbedPane = view.element.querySelector('.tabbed-pane');
assert.isNotNull(tabbedPane, 'Expected RequestView tabbed pane to be shown when an entry is selected');
const deleteButton = view.element.querySelector<HTMLElement>('devtools-button[aria-label="Delete selected"]');
assert.isNotNull(deleteButton);
deleteButton.click();
sinon.assert.calledOnceWithExactly(deleteSpy, cache, 'https://example.com/script.js');
} finally {
view.detach();
}
});
it('persists the selected RequestView tab in settings only on user gesture', async () => {
const request = SDK.NetworkRequest.NetworkRequest.createWithoutBackendRequest(
'cache-storage-test', urlString`https://example.com/script.js`, Platform.DevToolsPath.EmptyUrlString, null);
const viewStub = createViewFunctionStub(Application.ServiceWorkerCacheViews.RequestView);
const requestView = new Application.ServiceWorkerCacheViews.RequestView(request, viewStub);
await viewStub.nextInput;
assert.strictEqual(viewStub.input.request, request);
assert.strictEqual(viewStub.input.selectedTabId, 'preview');
viewStub.input.onTabSelected('headers', /* isUserGesture= */ false);
assert.strictEqual(Common.Settings.Settings.instance().createSetting('cache-storage-view-tab', 'preview').get(),
'preview');
viewStub.input.onTabSelected('headers', /* isUserGesture= */ true);
assert.strictEqual(Common.Settings.Settings.instance().createSetting('cache-storage-view-tab', 'preview').get(),
'headers');
requestView.requestUpdate();
await viewStub.nextInput;
assert.strictEqual(viewStub.input.selectedTabId, 'headers');
});
});