blob: 8e51a79e7a516919e3e369f4e07eeff6ddabd513 [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 {afterEach, beforeEach, describe, it} from 'mocha';
import sinon from 'sinon';
import type * as Common from '../../core/common/common.js';
import * as Platform from '../../core/platform/platform.js';
import * as ProtocolClient from '../../core/protocol_client/protocol_client.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as TextUtils from '../../core/text_utils/text_utils.js';
import * as Protocol from '../../generated/protocol.js';
import {setupLocaleHooks} from '../../testing/LocaleHelpers.js';
import {MockCDPConnection} from '../../testing/MockCDPConnection.js';
import {getMainFrame} from '../../testing/ResourceTreeHelpers.js';
import {setupRuntimeHooks} from '../../testing/RuntimeHelpers.js';
import {setupSettingsHooks} from '../../testing/SettingsHelpers.js';
import {TestUniverse} from '../../testing/TestUniverse.js';
import {createFileSystemUISourceCode} from '../../testing/UISourceCodeHelpers.js';
import * as Persistence from '../persistence/persistence.js';
import * as Workspace from '../workspace/workspace.js';
import * as Bindings from './bindings.js';
const {urlString} = Platform.DevToolsPath;
describe('StylesSourceMapping', () => {
setupLocaleHooks();
setupSettingsHooks();
setupRuntimeHooks();
let universe: TestUniverse;
beforeEach(() => {
universe = new TestUniverse();
});
afterEach(() => {
universe.dispose();
});
it('does not overwrite CSS files when CSS model reports error on getStyleSheetText', async () => {
const connection = new MockCDPConnection();
// Stub DOM.enable and CSS.enable to succeed
connection.setSuccessHandler('DOM.enable', () => ({}));
connection.setSuccessHandler('CSS.enable', () => ({}));
// Stub CSS.setStyleSheetText to succeed
connection.setSuccessHandler('CSS.setStyleSheetText', () => ({sourceMapURL: ''}));
// Stub CSS.getStyleSheetText to fail
connection.setFailureHandler('CSS.getStyleSheetText',
() => ({
message: 'FAKE PROTOCOL ERROR',
code: ProtocolClient.CDPConnection.CDPErrorStatus.DEVTOOLS_STUB_ERROR,
}));
const target = universe.createTarget({connection});
const cssModel = target.model(SDK.CSSModel.CSSModel);
assert.exists(cssModel);
// Eagerly instantiate bindings and persistence
void universe.cssWorkspaceBinding;
const persistence = universe.persistence;
const styleSheetId = 'stylesheet' as Protocol.DOM.StyleSheetId;
const frameId = 'frame' as Protocol.Page.FrameId;
const sourceURL = urlString`http://example.com/simple.css`;
const headerPayload: Protocol.CSS.CSSStyleSheetHeader = {
styleSheetId,
frameId,
sourceURL,
origin: Protocol.CSS.StyleSheetOrigin.Regular,
title: 'simple.css',
disabled: false,
isInline: false,
isMutable: false,
isConstructed: false,
loadingFailed: false,
startLine: 0,
startColumn: 0,
length: 0,
endLine: 0,
endColumn: 0,
};
// Wait for the StylesSourceMapping to create the network UISourceCode
const networkUISourceCodePromise = new Promise<Workspace.UISourceCode.UISourceCode>(resolve => {
const listener = (event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>) => {
if (event.data.project().type() === Workspace.Workspace.projectTypes.Network &&
event.data.url() === sourceURL) {
universe.workspace.removeEventListener(Workspace.Workspace.Events.UISourceCodeAdded, listener);
resolve(event.data);
}
};
universe.workspace.addEventListener(Workspace.Workspace.Events.UISourceCodeAdded, listener);
});
cssModel.styleSheetAdded(headerPayload);
const networkUISourceCode = await networkUISourceCodePromise;
// Create filesystem UISourceCode
const fileSystemPath = urlString`file://path/to/filesystem`;
const fileSystemFileUrl = urlString`${fileSystemPath + '/simple.css'}`;
const origContent = 'body {\n color: red;\n}\n';
const {uiSourceCode: fileSystemUiSourceCode, project} = createFileSystemUISourceCode({
url: fileSystemFileUrl,
mimeType: 'text/css',
content: origContent,
fileSystemPath,
autoMapping: true,
type: Persistence.PlatformFileSystem.PlatformFileSystemType.WORKSPACE_PROJECT,
universe,
});
// Verify binding is created.
// Manually bind them.
const binding = new Persistence.Persistence.PersistenceBinding(networkUISourceCode, fileSystemUiSourceCode);
await persistence.addBindingForTest(binding);
// Verify initial content of filesystem
await fileSystemUiSourceCode.requestContentData();
assert.strictEqual(fileSystemUiSourceCode.workingCopy(), origContent);
const styleFileProto = Bindings.StylesSourceMapping.StyleFile.prototype as unknown as {
styleFileSyncedForTest: () => void,
};
const syncStub = sinon.stub(styleFileProto, 'styleFileSyncedForTest');
const syncPromise = new Promise<void>(resolve => {
syncStub.callsFake(() => {
resolve();
});
});
// Call setStyleSheetText on CSSModel
const setStyleSheetTextPromise = cssModel.setStyleSheetText(styleSheetId, 'body {color: blue}', true);
// Wait for sync (which should fail and return early)
await syncPromise;
await setStyleSheetTextPromise;
// Verify filesystem content is NOT changed
assert.strictEqual(fileSystemUiSourceCode.workingCopy(), origContent);
// Restore stubs
syncStub.restore();
project.dispose();
});
it('marks UISourceCode as having synthesized sourceURL if header hasSourceURL', async () => {
const connection = new MockCDPConnection();
const target = universe.createTarget({connection});
const cssModel = target.model(SDK.CSSModel.CSSModel);
assert.exists(cssModel);
void universe.cssWorkspaceBinding;
const styleSheetId = 'stylesheet-source-url' as Protocol.DOM.StyleSheetId;
const frameId = 'frame' as Protocol.Page.FrameId;
const sourceURL = urlString`http://example.com/custom-style.css`;
const headerPayload: Protocol.CSS.CSSStyleSheetHeader = {
styleSheetId,
frameId,
sourceURL,
hasSourceURL: true,
origin: Protocol.CSS.StyleSheetOrigin.Regular,
title: 'custom-style.css',
disabled: false,
isInline: false,
isMutable: false,
isConstructed: false,
loadingFailed: false,
startLine: 0,
startColumn: 0,
length: 0,
endLine: 0,
endColumn: 0,
};
const networkUISourceCodePromise = new Promise<Workspace.UISourceCode.UISourceCode>(resolve => {
const listener = (event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>) => {
if (event.data.project().type() === Workspace.Workspace.projectTypes.Network &&
event.data.url() === sourceURL) {
universe.workspace.removeEventListener(Workspace.Workspace.Events.UISourceCodeAdded, listener);
resolve(event.data);
}
};
universe.workspace.addEventListener(Workspace.Workspace.Events.UISourceCodeAdded, listener);
});
cssModel.styleSheetAdded(headerPayload);
const networkUISourceCode = await networkUISourceCodePromise;
assert.isTrue(Bindings.NetworkProject.NetworkProject.isSourceURLSynthesized(networkUISourceCode));
assert.isFalse(universe.networkPersistenceManager.isUISourceCodeOverridable(networkUISourceCode));
});
it('marks UISourceCode as having synthesized sourceURL when a header with hasSourceURL is added to an existing StyleFile',
async () => {
const connection = new MockCDPConnection();
const target = universe.createTarget({connection});
const cssModel = target.model(SDK.CSSModel.CSSModel);
assert.exists(cssModel);
void universe.cssWorkspaceBinding;
const styleSheetId1 = 'stylesheet1' as Protocol.DOM.StyleSheetId;
const styleSheetId2 = 'stylesheet2' as Protocol.DOM.StyleSheetId;
const frameId = 'frame' as Protocol.Page.FrameId;
const sourceURL = urlString`http://example.com/spoofed.css`;
const headerPayload1: Protocol.CSS.CSSStyleSheetHeader = {
styleSheetId: styleSheetId1,
frameId,
sourceURL,
hasSourceURL: false,
origin: Protocol.CSS.StyleSheetOrigin.Regular,
title: 'spoofed.css',
disabled: false,
isInline: false,
isMutable: false,
isConstructed: false,
loadingFailed: false,
startLine: 0,
startColumn: 0,
length: 0,
endLine: 0,
endColumn: 0,
};
const headerPayload2: Protocol.CSS.CSSStyleSheetHeader = {
styleSheetId: styleSheetId2,
frameId,
sourceURL,
hasSourceURL: true,
origin: Protocol.CSS.StyleSheetOrigin.Regular,
title: 'spoofed.css',
disabled: false,
isInline: false,
isMutable: false,
isConstructed: false,
loadingFailed: false,
startLine: 0,
startColumn: 0,
length: 0,
endLine: 0,
endColumn: 0,
};
const networkUISourceCodePromise = waitForNetworkUISourceCode(uiSourceCode => uiSourceCode.url() === sourceURL);
cssModel.styleSheetAdded(headerPayload1);
const networkUISourceCode = await networkUISourceCodePromise;
assert.isFalse(Bindings.NetworkProject.NetworkProject.isSourceURLSynthesized(networkUISourceCode));
assert.isTrue(universe.networkPersistenceManager.isUISourceCodeOverridable(networkUISourceCode));
cssModel.styleSheetAdded(headerPayload2);
cssModel.styleSheetRemoved(styleSheetId1);
assert.isTrue(Bindings.NetworkProject.NetworkProject.isSourceURLSynthesized(networkUISourceCode));
assert.isFalse(universe.networkPersistenceManager.isUISourceCodeOverridable(networkUISourceCode));
});
function waitForNetworkUISourceCode(predicate: (uiSourceCode: Workspace.UISourceCode.UISourceCode) =>
boolean): Promise<Workspace.UISourceCode.UISourceCode> {
return new Promise<Workspace.UISourceCode.UISourceCode>(resolve => {
const listener = (event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>) => {
if (event.data.project().type() === Workspace.Workspace.projectTypes.Network && predicate(event.data)) {
universe.workspace.removeEventListener(Workspace.Workspace.Events.UISourceCodeAdded, listener);
resolve(event.data);
}
};
universe.workspace.addEventListener(Workspace.Workspace.Events.UISourceCodeAdded, listener);
});
}
function stubStyleFileSynced(): {promise: Promise<void>, stub: sinon.SinonStub} {
const styleFileProto = Bindings.StylesSourceMapping.StyleFile.prototype as unknown as {
styleFileSyncedForTest: () => void,
};
const {promise, resolve} = Promise.withResolvers<void>();
const stub = sinon.stub(styleFileProto, 'styleFileSyncedForTest').callsFake(() => resolve());
return {promise, stub};
}
async function addInspectorStyleSheet(
target: SDK.Target.Target, cssModel: SDK.CSSModel.CSSModel,
styleSheetId: Protocol.DOM.StyleSheetId): Promise<Workspace.UISourceCode.UISourceCode> {
// The inspector stylesheet URL is derived from the URL of the frame it belongs to.
const frame = getMainFrame(target);
const uiSourceCodePromise =
waitForNetworkUISourceCode(uiSourceCode => uiSourceCode.url().startsWith('inspector://'));
cssModel.styleSheetAdded({
styleSheetId,
frameId: frame.id,
sourceURL: '',
origin: Protocol.CSS.StyleSheetOrigin.Inspector,
title: '',
disabled: false,
isInline: false,
isMutable: true,
isConstructed: false,
loadingFailed: false,
startLine: 0,
startColumn: 0,
length: 0,
endLine: 0,
endColumn: 0,
});
const uiSourceCode = await uiSourceCodePromise;
assert.strictEqual(uiSourceCode.displayName(), 'inspector-stylesheet');
await uiSourceCode.requestContentData();
assert.strictEqual(uiSourceCode.workingCopy(), '');
return uiSourceCode;
}
it('pushes committed working copy of the inspector stylesheet UISourceCode to the backend', async () => {
const connection = new MockCDPConnection();
connection.setSuccessHandler('DOM.enable', () => ({}));
connection.setSuccessHandler('CSS.enable', () => ({}));
connection.setSuccessHandler('DOM.markUndoableState', () => ({}));
let styleSheetText = '';
connection.setSuccessHandler('CSS.getStyleSheetText', () => ({text: styleSheetText}));
const setStyleSheetTextCalls: Array<{styleSheetId: string, text: string}> = [];
connection.setSuccessHandler('CSS.setStyleSheetText', ({styleSheetId, text}) => {
setStyleSheetTextCalls.push({styleSheetId, text});
styleSheetText = text;
return {sourceMapURL: ''};
});
const target = universe.createTarget({connection});
const cssModel = target.model(SDK.CSSModel.CSSModel);
assert.exists(cssModel);
void universe.cssWorkspaceBinding;
const styleSheetId = 'inspector-sheet' as Protocol.DOM.StyleSheetId;
const uiSourceCode = await addInspectorStyleSheet(target, cssModel, styleSheetId);
const setStyleSheetTextSpy = sinon.spy(cssModel, 'setStyleSheetText');
const {promise: syncPromise} = stubStyleFileSynced();
const newContent = '#inspected { background-color: green; }';
uiSourceCode.setWorkingCopy(newContent);
uiSourceCode.commitWorkingCopy();
await syncPromise;
// The commit is mirrored to the backend as a single major change.
sinon.assert.calledOnceWithExactly(setStyleSheetTextSpy, styleSheetId, newContent, true);
assert.deepEqual(setStyleSheetTextCalls, [{styleSheetId, text: newContent}]);
assert.isFalse(uiSourceCode.isDirty());
assert.strictEqual(uiSourceCode.workingCopy(), newContent);
});
it('mirrors CSS edits of an inline stylesheet with sourceURL into the UISourceCode working copy', async () => {
const sourceURL = urlString`http://localhost:8000/inspector/elements/styles/foo.css`;
const sourceURLComment = `/*# sourceURL=${sourceURL} */`;
const connection = new MockCDPConnection();
connection.setSuccessHandler('DOM.enable', () => ({}));
connection.setSuccessHandler('CSS.enable', () => ({}));
connection.setSuccessHandler('DOM.markUndoableState', () => ({}));
let styleSheetText = `#inspected {\n color: red;\n}\n${sourceURLComment}`;
connection.setSuccessHandler('CSS.getStyleSheetText', () => ({text: styleSheetText}));
connection.setSuccessHandler('CSS.setStyleTexts', ({edits}) => {
assert.lengthOf(edits, 1);
assert.strictEqual(edits[0].text, 'color: green;');
styleSheetText = `#inspected {\n color: green;\n}\n${sourceURLComment}`;
return {styles: [{cssProperties: [], shorthandEntries: []}]};
});
const setStyleSheetTextStub = sinon.stub().returns({sourceMapURL: ''});
connection.setSuccessHandler('CSS.setStyleSheetText', setStyleSheetTextStub);
const target = universe.createTarget({connection});
const cssModel = target.model(SDK.CSSModel.CSSModel);
assert.exists(cssModel);
void universe.cssWorkspaceBinding;
const styleSheetId = 'inline-sheet' as Protocol.DOM.StyleSheetId;
const uiSourceCodePromise = waitForNetworkUISourceCode(uiSourceCode => uiSourceCode.url() === sourceURL);
cssModel.styleSheetAdded({
styleSheetId,
frameId: 'frame' as Protocol.Page.FrameId,
sourceURL,
hasSourceURL: true,
origin: Protocol.CSS.StyleSheetOrigin.Regular,
title: '',
disabled: false,
isInline: true,
isMutable: false,
isConstructed: false,
loadingFailed: false,
startLine: 1,
startColumn: 13,
length: styleSheetText.length,
endLine: 5,
endColumn: 0,
});
const uiSourceCode = await uiSourceCodePromise;
assert.strictEqual(uiSourceCode.displayName(), 'foo.css');
await uiSourceCode.requestContentData();
// The sourceURL comment is stripped from the UISourceCode content.
assert.strictEqual(uiSourceCode.workingCopy(), '#inspected {\n color: red;\n}');
assert.isFalse(uiSourceCode.isDirty());
const {promise: syncPromise} = stubStyleFileSynced();
const success = await cssModel.setStyleText(styleSheetId, new TextUtils.TextRange.TextRange(1, 4, 1, 15),
'color: green;', /* majorChange */ true);
assert.isTrue(success);
await syncPromise;
const workingCopy = uiSourceCode.workingCopy();
assert.strictEqual(workingCopy, '#inspected {\n color: green;\n}');
assert.notInclude(workingCopy, 'sourceURL');
assert.isTrue(uiSourceCode.isDirty());
// The origin link is rendered as dirty ('*foo.css:1').
assert.strictEqual(uiSourceCode.uiLocation(0).linkText(), '*foo.css:1');
// The edit came from the only header, so it is not pushed back to the backend.
sinon.assert.notCalled(setStyleSheetTextStub);
});
it('updates the inspector stylesheet UISourceCode when rules are added to it', async () => {
const connection = new MockCDPConnection();
connection.setSuccessHandler('DOM.enable', () => ({}));
connection.setSuccessHandler('CSS.enable', () => ({}));
connection.setSuccessHandler('DOM.markUndoableState', () => ({}));
let styleSheetText = '';
connection.setSuccessHandler('CSS.getStyleSheetText', () => ({text: styleSheetText}));
const styleSheetId = 'inspector-sheet' as Protocol.DOM.StyleSheetId;
connection.setSuccessHandler('CSS.addRule', ({ruleText}) => {
styleSheetText += ruleText;
const selector = ruleText.substring(0, ruleText.indexOf('{')).trim();
return {
rule: {
styleSheetId,
selectorList: {selectors: [{text: selector}], text: selector},
origin: Protocol.CSS.StyleSheetOrigin.Inspector,
style: {cssProperties: [], shorthandEntries: []},
},
};
});
const setStyleSheetTextStub = sinon.stub().returns({sourceMapURL: ''});
connection.setSuccessHandler('CSS.setStyleSheetText', setStyleSheetTextStub);
const target = universe.createTarget({connection});
const cssModel = target.model(SDK.CSSModel.CSSModel);
assert.exists(cssModel);
void universe.cssWorkspaceBinding;
const uiSourceCode = await addInspectorStyleSheet(target, cssModel, styleSheetId);
const firstRuleText = 'foo, div#inspected, bar {}';
let {promise: syncPromise, stub: syncStub} = stubStyleFileSynced();
const firstRule =
await cssModel.addRule(styleSheetId, firstRuleText, TextUtils.TextRange.TextRange.createFromLocation(0, 0));
assert.exists(firstRule);
await syncPromise;
assert.strictEqual(uiSourceCode.workingCopy(), firstRuleText);
syncStub.restore();
const secondRuleText = '\n\ndiv#other {}';
({promise: syncPromise, stub: syncStub} = stubStyleFileSynced());
const secondRule =
await cssModel.addRule(styleSheetId, secondRuleText, TextUtils.TextRange.TextRange.createFromLocation(0, 26));
assert.exists(secondRule);
await syncPromise;
assert.strictEqual(uiSourceCode.workingCopy(), firstRuleText + secondRuleText);
assert.isTrue(uiSourceCode.isDirty());
// The change originates from the backend, so it is not echoed back via setStyleSheetText.
sinon.assert.notCalled(setStyleSheetTextStub);
});
});