chrome-devtools-frontend
Version:
Chrome DevTools UI
1,000 lines (855 loc) • 84.1 kB
text/typescript
// Copyright 2022 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import type {SinonStub} from 'sinon';
import * as Common from '../../core/common/common.js';
import * as SDK from '../../core/sdk/sdk.js';
import type * as Protocol from '../../generated/protocol.js';
import * as Bindings from '../../models/bindings/bindings.js';
import * as Workspace from '../../models/workspace/workspace.js';
import {renderElementIntoDOM} from '../../testing/DOMHelpers.js';
import {createTarget, stubNoopSettings} from '../../testing/EnvironmentHelpers.js';
import {expectCall} from '../../testing/ExpectStubCall.js';
import {describeWithMockConnection, setMockConnectionResponseHandler} from '../../testing/MockConnection.js';
import {
getMatchedStylesWithBlankRule,
} from '../../testing/StyleHelpers.js';
import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
import * as InlineEditor from '../../ui/legacy/components/inline_editor/inline_editor.js';
import * as LegacyUI from '../../ui/legacy/legacy.js';
import * as ElementsComponents from './components/components.js';
import * as Elements from './elements.js';
describeWithMockConnection('StylePropertyTreeElement', () => {
let stylesSidebarPane: Elements.StylesSidebarPane.StylesSidebarPane;
let mockVariableMap: Record<string, string>;
let matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles;
let fakeComputeCSSVariable: SinonStub<
[style: SDK.CSSStyleDeclaration.CSSStyleDeclaration, variableName: string],
SDK.CSSMatchedStyles.CSSVariableValue|null>;
async function setUpCSSModel() {
stubNoopSettings();
setMockConnectionResponseHandler('CSS.enable', () => ({}));
const cssModel = new SDK.CSSModel.CSSModel(createTarget());
await cssModel.resumeModel();
const domModel = cssModel.domModel();
const node = new SDK.DOMModel.DOMNode(domModel);
node.id = 0 as Protocol.DOM.NodeId;
LegacyUI.Context.Context.instance().setFlavor(SDK.DOMModel.DOMNode, node);
return {cssModel};
}
beforeEach(async () => {
const computedStyleModel = new Elements.ComputedStyleModel.ComputedStyleModel();
stylesSidebarPane = new Elements.StylesSidebarPane.StylesSidebarPane(computedStyleModel);
mockVariableMap = {
'--a': 'red',
'--b': 'blue',
'--blue': 'blue',
'--space': 'shorter hue',
'--garbage-space': 'this-is-garbage-text',
'--prop': 'customproperty',
'--zero': '0',
'--empty': '',
};
matchedStyles = await getMatchedStylesWithBlankRule(
new SDK.CSSModel.CSSModel(createTarget()), undefined, {startLine: 0, startColumn: 0, endLine: 0, endColumn: 1});
sinon.stub(matchedStyles, 'availableCSSVariables').returns(Object.keys(mockVariableMap));
fakeComputeCSSVariable = sinon.stub(matchedStyles, 'computeCSSVariable').callsFake((style, name) => {
return {
value: mockVariableMap[name],
declaration: new SDK.CSSMatchedStyles.CSSValueSource(sinon.createStubInstance(SDK.CSSProperty.CSSProperty)),
};
});
const workspace = Workspace.Workspace.WorkspaceImpl.instance({forceNew: true});
const resourceMapping =
new Bindings.ResourceMapping.ResourceMapping(SDK.TargetManager.TargetManager.instance(), workspace);
Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance(
{forceNew: true, resourceMapping, targetManager: SDK.TargetManager.TargetManager.instance()});
});
function addProperty(name: string, value: string, longhandProperties: Protocol.CSS.CSSProperty[] = []) {
return new SDK.CSSProperty.CSSProperty(
matchedStyles.nodeStyles()[0], matchedStyles.nodeStyles()[0].pastLastSourcePropertyIndex(), name, value, true,
false, true, false, '', undefined, longhandProperties);
}
function getTreeElement(name: string, value: string, longhandProperties: Protocol.CSS.CSSProperty[] = []) {
const property = addProperty(name, value, longhandProperties);
return new Elements.StylePropertyTreeElement.StylePropertyTreeElement({
stylesPane: stylesSidebarPane,
section: sinon.createStubInstance(Elements.StylePropertiesSection.StylePropertiesSection),
matchedStyles,
property,
isShorthand: longhandProperties.length > 0,
inherited: false,
overloaded: false,
newProperty: true,
});
}
describe('updateTitle', () => {
it('timing swatch, shadow swatch and length swatch are not shown for longhands expanded inside shorthands',
async () => {
const stylePropertyTreeElement = getTreeElement('', '', [
{name: 'animation-timing-function', value: 'linear'},
{name: 'text-shadow', value: '2px 2px #ff0000'},
{name: 'box-shadow', value: '2px 2px #ff0000'},
{name: 'margin-top', value: '10px'},
]);
await stylePropertyTreeElement.onpopulate();
stylePropertyTreeElement.updateTitle();
stylePropertyTreeElement.expand();
const assertNullSwatchOnChildAt = (n: number, swatchSelector: string) => {
const childValueElement =
(stylePropertyTreeElement.childAt(n) as Elements.StylePropertyTreeElement.StylePropertyTreeElement)
.valueElement;
assert.exists(childValueElement);
assert.notExists(childValueElement.querySelector(swatchSelector));
};
assertNullSwatchOnChildAt(0, 'devtools-bezier-swatch');
assertNullSwatchOnChildAt(1, '[is="css-shadow-swatch"]');
assertNullSwatchOnChildAt(2, '[is="css-shadow-swatch"]');
assertNullSwatchOnChildAt(3, 'devtools-css-length');
});
it('is able to expand longhands with vars', async () => {
await setUpCSSModel();
setMockConnectionResponseHandler(
'CSS.getLonghandProperties', (request: Protocol.CSS.GetLonghandPropertiesRequest) => {
if (request.shorthandName !== 'shorthand') {
return {getError: () => 'Invalid shorthand'};
}
const longhands = request.value.split(' ');
if (longhands.length !== 3) {
return {getError: () => 'Invalid value'};
}
return {
longhandProperties: [
{name: 'first', value: longhands[0]},
{name: 'second', value: longhands[1]},
{name: 'third', value: longhands[2]},
]
};
});
const stylePropertyTreeElement = getTreeElement(
'shorthand', 'var(--a) var(--space)',
[{name: 'first', value: ''}, {name: 'second', value: ''}, {name: 'third', value: ''}]);
await stylePropertyTreeElement.onpopulate();
stylePropertyTreeElement.updateTitle();
stylePropertyTreeElement.expand();
const children = stylePropertyTreeElement.children().map(
child => (child as Elements.StylePropertyTreeElement.StylePropertyTreeElement).valueElement?.textContent);
assert.deepEqual(children, ['red', 'shorter', 'hue']);
});
describe('color-mix swatch', () => {
it('should show color mix swatch when color-mix is used with a color', () => {
const stylePropertyTreeElement = getTreeElement('color', 'color-mix(in srgb, red, blue)');
stylePropertyTreeElement.updateTitle();
const colorMixSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch');
const colorSwatches =
Array.from(stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-color-swatch') || []);
assert.exists(colorMixSwatch);
assert.exists(colorSwatches.find(colorSwatch => colorSwatch.textContent === 'red'));
assert.exists(colorSwatches.find(colorSwatch => colorSwatch.textContent === 'blue'));
});
it('should show color mix swatch when color-mix is used with a known variable as color', () => {
const stylePropertyTreeElement = getTreeElement('color', 'color-mix(in srgb, var(--a), var(--b))');
stylePropertyTreeElement.updateTitle();
const colorMixSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch');
const cssVarSwatches =
Array.from(stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-css-var-swatch') || []);
assert.exists(colorMixSwatch);
assert.exists(cssVarSwatches.find(cssVarSwatch => cssVarSwatch.textContent === 'var(--a)'));
assert.exists(cssVarSwatches.find(cssVarSwatch => cssVarSwatch.textContent === 'var(--b)'));
});
it('should not show color mix swatch when color-mix is used with an unknown variable as color', () => {
const stylePropertyTreeElement = getTreeElement('color', 'color-mix(in srgb, var(--unknown-a), var(--b))');
stylePropertyTreeElement.updateTitle();
const colorMixSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch');
assert.isNull(colorMixSwatch);
});
it('should show color mix swatch when color-mix is used with a known variable in interpolation method', () => {
const stylePropertyTreeElement = getTreeElement('color', 'color-mix(in lch var(--space), var(--a), var(--b))');
stylePropertyTreeElement.updateTitle();
const colorMixSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch');
assert.exists(colorMixSwatch);
assert.isTrue(colorMixSwatch.textContent?.includes('var(--space)'));
});
it('should show color mix swatch when color-mix is used with an known variable in interpolation method even if it is not a valid method',
() => {
const stylePropertyTreeElement =
getTreeElement('color', 'color-mix(in lch var(--garbage-space), var(--a), var(--b))');
stylePropertyTreeElement.updateTitle();
const colorMixSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch');
assert.exists(colorMixSwatch);
assert.isTrue(colorMixSwatch.textContent?.includes('var(--garbage-space)'));
});
it('should not show color mix swatch when color-mix is used with an unknown variable in interpolation method',
() => {
const stylePropertyTreeElement =
getTreeElement('color', 'color-mix(in lch var(--not-existing-space), var(--a), var(--b))');
stylePropertyTreeElement.updateTitle();
const colorMixSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch');
assert.isNull(colorMixSwatch);
});
it('shows a popover with it\'s computed color as RGB if possible', () => {
const stylePropertyTreeElement = getTreeElement('color', 'color-mix(in srgb, red 50%, yellow)');
const addPopoverSpy = sinon.spy(stylesSidebarPane, 'addPopover');
stylePropertyTreeElement.updateTitle();
const colorMixSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch');
assert.exists(colorMixSwatch);
renderElementIntoDOM(colorMixSwatch);
assert.isTrue(addPopoverSpy.calledOnce);
assert.strictEqual(addPopoverSpy.args[0][0], colorMixSwatch.icon);
assert.strictEqual(addPopoverSpy.args[0][1].contents()?.textContent, '#ff8000');
});
it('shows a popover with it\'s computed color as wide gamut if necessary', () => {
const stylePropertyTreeElement = getTreeElement('color', 'color-mix(in srgb, oklch(.5 .5 .5) 50%, yellow)');
const addPopoverSpy = sinon.spy(stylesSidebarPane, 'addPopover');
stylePropertyTreeElement.updateTitle();
const colorMixSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch');
assert.exists(colorMixSwatch);
renderElementIntoDOM(colorMixSwatch);
assert.isTrue(addPopoverSpy.calledOnce);
assert.strictEqual(addPopoverSpy.args[0][0], colorMixSwatch.icon);
assert.strictEqual(addPopoverSpy.args[0][1].contents()?.textContent, 'color(srgb 1 0.24 0.17)');
});
it('propagates updates to outer color-mixes', () => {
const stylePropertyTreeElement =
getTreeElement('color', 'color-mix(in srgb, color-mix(in oklch, red, green), blue)');
stylePropertyTreeElement.updateTitle();
const outerColorMix = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch');
assert.exists(outerColorMix);
const handler = sinon.fake();
outerColorMix.addEventListener(InlineEditor.ColorMixSwatch.Events.COLOR_CHANGED, handler);
const innerColorMix = outerColorMix.querySelector('devtools-color-mix-swatch');
assert.exists(innerColorMix);
assert.strictEqual(outerColorMix.getText(), 'color-mix(in srgb, color-mix(in oklch, red, green), blue)');
assert.strictEqual(innerColorMix.getText(), 'color-mix(in oklch, red, green)');
innerColorMix.setFirstColor('blue');
assert.deepEqual(handler.args[0][0].data, {text: 'color-mix(in srgb, color-mix(in oklch, blue, green), blue)'});
assert.strictEqual(outerColorMix.getText(), 'color-mix(in srgb, color-mix(in oklch, blue, green), blue)');
// setFirstColor does not actually update the rendered color swatches or the textContent, which is why the first
// color is still red here.
const colorSwatch = innerColorMix.querySelector('devtools-color-swatch');
assert.isOk(colorSwatch);
const newColor = colorSwatch.getColor()?.as(Common.Color.Format.HEX);
assert.isOk(newColor);
colorSwatch.setColor(newColor);
assert.strictEqual(outerColorMix.getText(), 'color-mix(in srgb, color-mix(in oklch, #ff0000, green), blue)');
assert.deepEqual(
handler.args[1][0].data, {text: 'color-mix(in srgb, color-mix(in oklch, #ff0000, green), blue)'});
});
});
describe('animation-name', () => {
it('should link-swatch be rendered for animation-name declaration', () => {
const stylePropertyTreeElement = getTreeElement('animation-name', 'first-keyframe');
stylePropertyTreeElement.updateTitle();
const animationNameSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-link-swatch');
assert.isNotNull(animationNameSwatch);
});
it('should two link-swatches be rendered for animation-name declaration that contains two keyframe references',
() => {
const stylePropertyTreeElement = getTreeElement('animation-name', 'first-keyframe, second-keyframe');
stylePropertyTreeElement.updateTitle();
const animationNameSwatches =
stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-link-swatch');
assert.strictEqual(animationNameSwatches?.length, 2);
});
describe('jumping to animations panel', () => {
let domModel: SDK.DOMModel.DOMModel;
beforeEach(() => {
const target = createTarget();
const domModelBeforeAssertion = target.model(SDK.DOMModel.DOMModel);
assert.exists(domModelBeforeAssertion);
domModel = domModelBeforeAssertion;
});
afterEach(() => {
sinon.reset();
});
it('should render a jump-to icon when the animation with the given name exists for the node', async () => {
const stubAnimationGroup = sinon.createStubInstance(SDK.AnimationModel.AnimationGroup);
const getAnimationGroupForAnimationStub =
sinon.stub(SDK.AnimationModel.AnimationModel.prototype, 'getAnimationGroupForAnimation')
.resolves(stubAnimationGroup);
const domNode = SDK.DOMModel.DOMNode.create(domModel, null, false, {
nodeId: 1 as Protocol.DOM.NodeId,
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: 'div',
localName: 'div',
nodeValue: '',
});
const stylePropertyTreeElement = getTreeElement('animation-name', 'first-keyframe, second-keyframe');
sinon.stub(stylePropertyTreeElement, 'node').returns(domNode);
stylePropertyTreeElement.updateTitle();
await Promise.all(getAnimationGroupForAnimationStub.returnValues);
const jumpToIcon =
stylePropertyTreeElement.valueElement?.querySelector('devtools-icon.open-in-animations-panel');
assert.exists(jumpToIcon);
});
it('should clicking on the jump-to icon reveal the resolved animation group', async () => {
const stubAnimationGroup = sinon.createStubInstance(SDK.AnimationModel.AnimationGroup);
const revealerSpy = sinon.stub(Common.Revealer.RevealerRegistry.instance(), 'reveal');
const getAnimationGroupForAnimationStub =
sinon.stub(SDK.AnimationModel.AnimationModel.prototype, 'getAnimationGroupForAnimation')
.resolves(stubAnimationGroup);
const domNode = SDK.DOMModel.DOMNode.create(domModel, null, false, {
nodeId: 1 as Protocol.DOM.NodeId,
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: 'div',
localName: 'div',
nodeValue: '',
});
const stylePropertyTreeElement = getTreeElement('animation-name', 'first-keyframe, second-keyframe');
sinon.stub(stylePropertyTreeElement, 'node').returns(domNode);
stylePropertyTreeElement.updateTitle();
await Promise.all(getAnimationGroupForAnimationStub.returnValues);
const jumpToIcon =
stylePropertyTreeElement.valueElement?.querySelector('devtools-icon.open-in-animations-panel');
jumpToIcon?.dispatchEvent(new Event('mouseup'));
assert.isTrue(
revealerSpy.calledWith(stubAnimationGroup),
'Common.Revealer.reveal is not called for the animation group');
});
it('should not render a jump-to icon when the animation with the given name does not exist for the node',
async () => {
const getAnimationGroupForAnimationStub =
sinon.stub(SDK.AnimationModel.AnimationModel.prototype, 'getAnimationGroupForAnimation')
.resolves(null);
const domNode = SDK.DOMModel.DOMNode.create(domModel, null, false, {
nodeId: 1 as Protocol.DOM.NodeId,
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
nodeType: Node.ELEMENT_NODE,
nodeName: 'div',
localName: 'div',
nodeValue: '',
});
const stylePropertyTreeElement = getTreeElement('animation-name', 'first-keyframe, second-keyframe');
sinon.stub(stylePropertyTreeElement, 'node').returns(domNode);
stylePropertyTreeElement.updateTitle();
await Promise.all(getAnimationGroupForAnimationStub.returnValues);
const jumpToIcon =
stylePropertyTreeElement.valueElement?.querySelector('devtools-icon.open-in-animations-panel');
assert.notExists(jumpToIcon);
});
});
});
});
it('applies the new style when the color format is changed', async () => {
const stylePropertyTreeElement = getTreeElement('color', 'color(srgb .5 .5 1)');
const applyStyleTextStub = sinon.stub(stylePropertyTreeElement, 'applyStyleText');
// Make sure we don't leave a dangling promise behind:
const returnValue = (async () => {})();
await returnValue;
applyStyleTextStub.returns(returnValue);
stylePropertyTreeElement.updateTitle();
const {valueElement} = stylePropertyTreeElement;
assert.exists(valueElement);
const swatch = valueElement.querySelector<InlineEditor.ColorSwatch.ColorSwatch>('devtools-color-swatch');
assert.exists(swatch);
const expectedColorString = swatch.getColor()?.asString(Common.Color.Format.LAB);
assert.exists(expectedColorString);
assert.match(expectedColorString, /lab\([-.0-9]* [-.0-9]* [-.0-9]*\)/);
const newColor = swatch.getColor()?.as(Common.Color.Format.LAB);
assert.isOk(newColor);
swatch.setColorText(newColor);
assert.deepEqual(stylePropertyTreeElement.renderedPropertyText(), `color: ${expectedColorString}`);
assert.isTrue(applyStyleTextStub.alwaysCalledWith(`color: ${expectedColorString}`, false));
});
describe('Context menu', () => {
const expectedHeaderSectionItemsLabels =
['Copy declaration', 'Copy property', 'Copy value', 'Copy rule', 'Copy declaration as JS'];
const expectedClipboardSectionItemsLabels = ['Copy all declarations', 'Copy all declarations as JS'];
const expectedDefaultSectionItemsLabels = ['Copy all CSS changes'];
const expectedFooterSectionItemsLabels = ['View computed value'];
it('should create a context menu', () => {
const verifySection = (expectedSectionItemLabels: string[], sectionItems: LegacyUI.ContextMenu.Item[]) => {
const sectionItemLabels = sectionItems.map(item => item.buildDescriptor().label);
assert.deepEqual(sectionItemLabels, expectedSectionItemLabels);
};
const stylePropertyTreeElement = getTreeElement('', '');
const event = new CustomEvent('contextmenu');
const contextMenu = stylePropertyTreeElement.createCopyContextMenu(event);
const headerSection = contextMenu.headerSection();
const clipboardSection = contextMenu.clipboardSection();
const defaultSection = contextMenu.defaultSection();
const footerSection = contextMenu.footerSection();
verifySection(expectedHeaderSectionItemsLabels, headerSection.items);
verifySection(expectedClipboardSectionItemsLabels, clipboardSection.items);
verifySection(expectedDefaultSectionItemsLabels, defaultSection.items);
verifySection(expectedFooterSectionItemsLabels, footerSection.items);
});
});
describe('CSS hints', () => {
it('should create a hint for inline elements', () => {
sinon.stub(stylesSidebarPane, 'node').returns({
localName() {
return 'span';
},
isSVGNode() {
return false;
},
} as SDK.DOMModel.DOMNode);
const stylePropertyTreeElement = getTreeElement('width', '100px');
stylePropertyTreeElement.setComputedStyles(new Map([
['width', '100px'],
['display', 'inline'],
]));
stylePropertyTreeElement.updateAuthoringHint();
assert(
stylePropertyTreeElement.listItemElement.classList.contains('inactive-property'),
'CSS hint was not rendered.');
});
it('should not create a hint for SVG elements', () => {
sinon.stub(stylesSidebarPane, 'node').returns({
localName() {
return 'rect';
},
isSVGNode() {
return true;
},
} as SDK.DOMModel.DOMNode);
const stylePropertyTreeElement = getTreeElement('width', '100px');
stylePropertyTreeElement.setComputedStyles(new Map([
['width', '100px'],
['display', 'inline'],
]));
stylePropertyTreeElement.updateAuthoringHint();
assert(
!stylePropertyTreeElement.listItemElement.classList.contains('inactive-property'),
'CSS hint was rendered unexpectedly.');
});
});
describe('custom-properties', () => {
it('linkifies var functions to declarations', async () => {
const cssCustomPropertyDef = addProperty('--prop', 'value');
fakeComputeCSSVariable.callsFake(
(_, name) => name === '--prop' ? {
value: 'computedvalue',
declaration: new SDK.CSSMatchedStyles.CSSValueSource(cssCustomPropertyDef),
fromFallback: false,
} :
null);
const renderValueSpy = sinon.spy(Elements.PropertyRenderer.Renderer, 'renderValueElement');
const stylePropertyTreeElement = getTreeElement('prop', 'var(--prop)');
stylePropertyTreeElement.updateTitle();
const varSwatch =
renderValueSpy.returnValues.find(value => value.firstChild instanceof InlineEditor.LinkSwatch.CSSVarSwatch)
?.firstChild as InlineEditor.LinkSwatch.CSSVarSwatch |
undefined;
assert.exists(varSwatch);
const revealPropertySpy = sinon.spy(stylesSidebarPane, 'revealProperty');
varSwatch.link?.linkElement?.click();
assert.isTrue(revealPropertySpy.calledWith(cssCustomPropertyDef));
});
it('linkifies property definition to registrations', async () => {
const addElementPopoverHook = sinon.stub(stylesSidebarPane, 'addPopover');
const stylePropertyTreeElement = getTreeElement('--prop', 'value');
stylePropertyTreeElement.updateTitle();
assert.isTrue(addElementPopoverHook.calledOnce);
const registration = sinon.createStubInstance(SDK.CSSMatchedStyles.CSSRegisteredProperty);
sinon.stub(matchedStyles, 'getRegisteredProperty')
.callsFake(name => name === '--prop' ? registration : undefined);
fakeComputeCSSVariable.returns({
value: 'computedvalue',
declaration: new SDK.CSSMatchedStyles.CSSValueSource(sinon.createStubInstance(SDK.CSSProperty.CSSProperty)),
});
const popoverContents = addElementPopoverHook.args[0][1].contents();
assert.instanceOf(popoverContents, ElementsComponents.CSSVariableValueView.CSSVariableValueView);
const {details} = popoverContents as ElementsComponents.CSSVariableValueView.CSSVariableValueView;
assert.exists(details);
const jumpToSectionSpy = sinon.spy(stylesSidebarPane, 'jumpToSection');
details.goToDefinition();
assert.isTrue(jumpToSectionSpy.calledOnceWithExactly(
'--prop', Elements.StylesSidebarPane.REGISTERED_PROPERTY_SECTION_NAME));
});
it('linkifies var functions to initial-value registrations', async () => {
fakeComputeCSSVariable.returns({
value: 'computedvalue',
declaration: new SDK.CSSMatchedStyles.CSSValueSource(
sinon.createStubInstance(SDK.CSSMatchedStyles.CSSRegisteredProperty, {propertyName: '--prop'})),
});
const renderValueSpy = sinon.spy(Elements.PropertyRenderer.Renderer, 'renderValueElement');
const stylePropertyTreeElement = getTreeElement('prop', 'var(--prop)');
stylePropertyTreeElement.updateTitle();
const varSwatch =
renderValueSpy.returnValues.find(value => value.firstChild instanceof InlineEditor.LinkSwatch.CSSVarSwatch)
?.firstChild as InlineEditor.LinkSwatch.CSSVarSwatch |
undefined;
assert.exists(varSwatch);
const jumpToPropertySpy = sinon.spy(stylesSidebarPane, 'jumpToProperty');
varSwatch.link?.linkElement?.click();
assert.isTrue(jumpToPropertySpy.calledWith(
'initial-value', '--prop', Elements.StylesSidebarPane.REGISTERED_PROPERTY_SECTION_NAME));
});
});
describe('CSSVarSwatch', () => {
it('should render a CSSVarSwatch for variable usage without fallback', () => {
const stylePropertyTreeElement = getTreeElement('color', 'var(--a)');
stylePropertyTreeElement.updateTitle();
assert.exists(stylePropertyTreeElement.valueElement);
const cssVarSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-css-var-swatch');
assert.exists(cssVarSwatch);
const linkSwatch = cssVarSwatch.shadowRoot?.querySelector('devtools-base-link-swatch');
assert.exists(linkSwatch);
assert.strictEqual(cssVarSwatch.textContent, 'var(--a)');
assert.strictEqual(linkSwatch.shadowRoot?.textContent, '--a');
assert.strictEqual(stylePropertyTreeElement.valueElement.textContent, 'var(--a)');
});
it('should render a CSSVarSwatch for variable usage with fallback', () => {
const stylePropertyTreeElement = getTreeElement('color', 'var(--not-existing, red)');
stylePropertyTreeElement.updateTitle();
assert.exists(stylePropertyTreeElement.valueElement);
const cssVarSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-css-var-swatch');
assert.exists(cssVarSwatch);
const linkSwatch = cssVarSwatch.shadowRoot?.querySelector('devtools-base-link-swatch');
assert.exists(linkSwatch);
assert.strictEqual(linkSwatch.shadowRoot?.textContent, '--not-existing');
assert.strictEqual(cssVarSwatch.deepTextContent(), 'var(--not-existing, red)');
assert.strictEqual(stylePropertyTreeElement.valueElement.textContent, 'var(--not-existing, red)');
});
it('should render a CSSVarSwatch inside CSSVarSwatch for variable usage with another variable fallback', () => {
const stylePropertyTreeElement = getTreeElement('color', 'var(--not-existing, var(--a))');
stylePropertyTreeElement.updateTitle();
assert.exists(stylePropertyTreeElement.valueElement);
const cssVarSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-css-var-swatch');
assert.exists(cssVarSwatch);
const firstLinkSwatch = cssVarSwatch.shadowRoot?.querySelector('devtools-base-link-swatch');
const insideCssVarSwatch = cssVarSwatch.querySelector('devtools-css-var-swatch');
const secondLinkSwatch = insideCssVarSwatch?.shadowRoot?.querySelector('devtools-base-link-swatch');
assert.strictEqual(stylePropertyTreeElement.valueElement.textContent, 'var(--not-existing, var(--a))');
assert.strictEqual(firstLinkSwatch?.shadowRoot?.textContent, '--not-existing');
assert.strictEqual(cssVarSwatch.textContent, 'var(--not-existing, var(--a))');
assert.strictEqual(secondLinkSwatch?.shadowRoot?.textContent, '--a');
assert.strictEqual(insideCssVarSwatch?.textContent, 'var(--a)');
});
it('should render a CSSVarSwatch inside CSSVarSwatch for variable usage with calc expression as fallback', () => {
const stylePropertyTreeElement = getTreeElement('color', 'var(--not-existing, calc(15px + 20px))');
stylePropertyTreeElement.updateTitle();
assert.exists(stylePropertyTreeElement.valueElement);
const cssVarSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-css-var-swatch');
assert.exists(cssVarSwatch);
const firstLinkSwatch = cssVarSwatch.shadowRoot?.querySelector('devtools-base-link-swatch');
assert.strictEqual(stylePropertyTreeElement.valueElement.textContent, 'var(--not-existing, calc(15px + 20px))');
assert.strictEqual(firstLinkSwatch?.shadowRoot?.textContent, '--not-existing');
assert.strictEqual(cssVarSwatch.textContent, 'var(--not-existing, calc(15px + 20px))');
});
it('should render a CSSVarSwatch inside CSSVarSwatch for variable usage with color and also a color swatch', () => {
for (const varName of ['--a', '--not-existing']) {
const stylePropertyTreeElement = getTreeElement('color', `var(${varName}, var(--blue))`);
stylePropertyTreeElement.updateTitle();
assert.exists(stylePropertyTreeElement.valueElement);
const cssVarSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-css-var-swatch');
assert.exists(cssVarSwatch);
const colorSwatch = cssVarSwatch.querySelector('devtools-color-swatch');
assert.exists(colorSwatch);
assert.isTrue(InlineEditor.ColorSwatch.ColorSwatch.isColorSwatch(colorSwatch));
const firstLinkSwatch = cssVarSwatch.shadowRoot?.querySelector('devtools-base-link-swatch');
assert.strictEqual(stylePropertyTreeElement.valueElement.textContent, `var(${varName}, var(--blue))`);
assert.strictEqual(firstLinkSwatch?.shadowRoot?.textContent, varName);
assert.strictEqual(cssVarSwatch.textContent, `var(${varName}, var(--blue))`);
}
});
it('should render CSSVarSwatches for multiple var() usages in the same property declaration', () => {
const stylePropertyTreeElement = getTreeElement('--shadow', 'var(--a) var(--b)');
stylePropertyTreeElement.updateTitle();
const cssVarSwatches = stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-css-var-swatch');
assert.strictEqual(cssVarSwatches?.length, 2);
});
it('should render a CSSVarSwatch for var() with spaces', () => {
const stylePropertyTreeElement = getTreeElement('color', 'var( --test )');
stylePropertyTreeElement.updateTitle();
assert.exists(stylePropertyTreeElement.valueElement);
const cssVarSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-css-var-swatch');
assert.exists(cssVarSwatch);
const linkSwatch = cssVarSwatch.shadowRoot?.querySelector('devtools-base-link-swatch');
assert.strictEqual(linkSwatch?.shadowRoot?.textContent, '--test');
assert.strictEqual(cssVarSwatch.textContent, 'var( --test )');
assert.strictEqual(stylePropertyTreeElement.valueElement.textContent, 'var( --test )');
});
it('connects nested color swatches', () => {
const stylePropertyTreeElement = getTreeElement('color', 'var(--void, red)');
stylePropertyTreeElement.updateTitle();
assert.exists(stylePropertyTreeElement.valueElement);
const cssVarSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-css-var-swatch');
assert.exists(cssVarSwatch);
const outerColorSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-swatch');
assert.exists(outerColorSwatch);
const innerColorSwatch = cssVarSwatch.querySelector('devtools-color-swatch');
assert.exists(innerColorSwatch);
assert.notStrictEqual(outerColorSwatch, innerColorSwatch);
const color = new Common.Color.Lab(1, 0, 0, null, undefined);
innerColorSwatch.setColor(color);
assert.strictEqual(outerColorSwatch.getColor(), color);
});
it('only connects nested color swatches if the fallback is actually taken', () => {
const stylePropertyTreeElement = getTreeElement('color', 'var(--blue, red)');
stylePropertyTreeElement.updateTitle();
assert.exists(stylePropertyTreeElement.valueElement);
const cssVarSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-css-var-swatch');
assert.exists(cssVarSwatch);
const outerColorSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-swatch');
assert.exists(outerColorSwatch);
const innerColorSwatch = cssVarSwatch.querySelector('devtools-color-swatch');
assert.exists(innerColorSwatch);
assert.notStrictEqual(outerColorSwatch, innerColorSwatch);
const color = new Common.Color.Lab(1, 0, 0, null, undefined);
innerColorSwatch.setColor(color);
assert.strictEqual(outerColorSwatch.getColor()?.asString(), 'blue');
});
});
describe('VariableRenderer', () => {
it('computes the text for var()s correctly', async () => {
async function matchProperty(value: string, name = 'color') {
addProperty('--blue', 'blue');
const stylePropertyTreeElement = getTreeElement(name, value);
const ast =
SDK.CSSPropertyParser.tokenizeDeclaration(stylePropertyTreeElement.name, stylePropertyTreeElement.value);
assert.exists(ast);
const matching = SDK.CSSPropertyParser.BottomUpTreeMatching.walk(
ast, [new Elements.StylePropertyTreeElement
.VariableRenderer(stylePropertyTreeElement, stylePropertyTreeElement.property.ownerStyle)
.matcher()]);
const res = {
hasUnresolvedVars: matching.hasUnresolvedVars(ast.tree),
computedText: matching.getComputedText(ast.tree),
};
return res;
}
assert.deepEqual(
await matchProperty('var( --blue )'), {hasUnresolvedVars: false, computedText: 'color: blue'});
assert.deepEqual(
await matchProperty('var(--no, var(--blue))'), {hasUnresolvedVars: false, computedText: 'color: blue'});
assert.deepEqual(
await matchProperty('pre var(--no) post'),
{hasUnresolvedVars: true, computedText: 'color: pre var(--no) post'});
assert.deepEqual(
await matchProperty('var(--no, var(--no2))'),
{hasUnresolvedVars: true, computedText: 'color: var(--no, var(--no2))'});
assert.deepEqual(await matchProperty(''), {hasUnresolvedVars: false, computedText: 'color:'});
});
it('layers correctly with the font renderer', () => {
const stylePropertyTreeElement = getTreeElement('font-size', 'calc(1 + var(--no))');
stylePropertyTreeElement.updateTitle();
assert.exists(stylePropertyTreeElement.valueElement?.querySelector('devtools-css-var-swatch'));
});
});
describe('ColorRenderer', () => {
it('correctly renders children of the color swatch', () => {
const value = 'rgb(255, var(--zero), var(--zero))';
const stylePropertyTreeElement = getTreeElement('color', value);
stylePropertyTreeElement.updateTitle();
assert.strictEqual(stylePropertyTreeElement.valueElement?.textContent, value);
const colorSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-swatch');
assert.exists(colorSwatch);
assert.strictEqual(colorSwatch.getColor()?.asString(Common.Color.Format.HEX), '#ff0000');
const varSwatches = stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-css-var-swatch');
assert.exists(varSwatches);
assert.lengthOf(varSwatches, 2);
});
it('connects correctly with an inner angle swatch', () => {
const stylePropertyTreeElement = getTreeElement('color', 'hsl(120deg, 50%, 25%)');
stylePropertyTreeElement.updateTitle();
const colorSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-swatch');
assert.exists(colorSwatch);
assert.strictEqual(colorSwatch.getColor()?.asString(Common.Color.Format.HSL), 'hsl(120deg 50% 25%)');
const eventHandler = sinon.stub<[InlineEditor.ColorSwatch.ColorChangedEvent]>();
colorSwatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, eventHandler);
const angleSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-css-angle');
assert.exists(angleSwatch);
angleSwatch.updateAngle({value: 130, unit: InlineEditor.CSSAngleUtils.AngleUnit.DEG});
assert.strictEqual(colorSwatch.getColor()?.asString(Common.Color.Format.HSL), 'hsl(130deg 50% 25%)');
assert.isTrue(eventHandler.calledOnce);
assert.strictEqual(eventHandler.args[0][0].data.color, colorSwatch.getColor());
});
it('renders relative colors', () => {
const stylePropertyTreeElement = getTreeElement('color', 'hsl( from var(--blue) h calc(s/2) l / alpha)');
stylePropertyTreeElement.updateTitle();
const colorSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-swatch');
assert.isOk(colorSwatch);
assert.isOk(colorSwatch.getColor());
assert.strictEqual(colorSwatch?.getColor()?.asString(Common.Color.Format.HSL), 'hsl(240deg 50% 50%)');
});
it('does not render relative colors if property text is invalid', () => {
const invalidColor = 'hsl( from var(--zero) h calc(s/2) l / alpha)';
const stylePropertyTreeElement = getTreeElement('color', invalidColor);
stylePropertyTreeElement.updateTitle();
const colorSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-swatch');
assert.isNull(colorSwatch);
});
it('correctly renders currentcolor', () => {
const stylePropertyTreeElement = getTreeElement('background-color', 'currentcolor');
stylePropertyTreeElement.setComputedStyles(new Map([['color', 'red']]));
stylePropertyTreeElement.updateTitle();
const colorSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-swatch');
assert.isOk(colorSwatch);
assert.isOk(colorSwatch.getColor());
assert.strictEqual(colorSwatch?.getColor()?.asString(), 'red');
});
it('renders relative colors using currentcolor', () => {
const stylePropertyTreeElement = getTreeElement('color', 'hsl(from currentcolor h calc(s/2) l / alpha)');
stylePropertyTreeElement.setComputedStyles(new Map([['color', 'blue']]));
stylePropertyTreeElement.updateTitle();
const colorSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-swatch');
assert.isOk(colorSwatch);
assert.isOk(colorSwatch.getColor());
assert.strictEqual(colorSwatch?.getColor()?.asString(Common.Color.Format.HSL), 'hsl(240deg 50% 50%)');
});
});
describe('BezierRenderer', () => {
it('renders the easing function swatch', () => {
const stylePropertyTreeElement = getTreeElement('animation-timing-function', 'ease-out');
stylePropertyTreeElement.updateTitle();
assert.instanceOf(stylePropertyTreeElement.valueElement?.firstChild, InlineEditor.Swatches.BezierSwatch);
});
});
describe('UrlRenderer', () => {
it('linkifies and unescapes urls', () => {
const stylePropertyTreeElement = getTreeElement('--url', 'url(devtools:\\/\\/abc)');
stylePropertyTreeElement.updateTitle();
assert.strictEqual(stylePropertyTreeElement.valueElement?.textContent, 'url(devtools://abc)');
});
});
describe('StringRenderer', () => {
it('unescapes strings', () => {
const stylePropertyTreeElement = getTreeElement('content', '"\\2716"');
stylePropertyTreeElement.updateTitle();
assert.strictEqual(
(stylePropertyTreeElement.valueElement?.firstElementChild as HTMLElement | null | undefined)?.title,
'"\u2716"');
});
});
describe('ShadowRenderer', () => {
it('parses shadows correctly', () => {
const parseShadow = (property: string, value: string, success: boolean) => {
const stylePropertyTreeElement = getTreeElement(property, value);
stylePropertyTreeElement.updateTitle();
assert.strictEqual(
stylePropertyTreeElement.valueElement?.firstElementChild instanceof InlineEditor.Swatches.CSSShadowSwatch,
success);
assert.strictEqual(stylePropertyTreeElement.valueElement?.textContent, value);
};
const parseTextShadowSuccess = (value: string) => parseShadow('text-shadow', value, true);
const parseTextShadowFailure = (value: string) => parseShadow('text-shadow', value, false);
const parseBoxShadowSuccess = (value: string) => parseShadow('box-shadow', value, true);
const parseBoxShadowFailure = (value: string) => parseShadow('box-shadow', value, false);
parseTextShadowSuccess('0 0');
parseTextShadowSuccess('1px 2px');
parseTextShadowSuccess('1px 2px black');
parseTextShadowSuccess('1px 2px 2px');
parseTextShadowSuccess('rgb(0, 0, 0) 1px 2px 2px');
parseTextShadowSuccess('1px 2px 2px rgb(0, 0, 0)');
parseTextShadowSuccess('1px 2px black, 0 0 #ffffff');
parseTextShadowSuccess('1px -2px black, 0 0 rgb(0, 0, 0), 3px 3.5px 3px');
parseTextShadowSuccess('1px -2px black, 0 0 rgb(0, 0, 0), 3px 3.5px 3px !important');
parseTextShadowSuccess('1px 2px black, , 0 0 #ffffff');
parseTextShadowFailure('');
parseTextShadowFailure('0');
parseTextShadowFailure('1 2 black !important');
parseTextShadowFailure('1px black 2px');
parseTextShadowFailure('1px 2px 2px 3px');
parseTextShadowFailure('inset 1px 2px 2px');
parseTextShadowFailure('red 1px 2px 2px red');
parseTextShadowFailure('1px 2px rgb(0, 0, 0) 2px');
parseTextShadowFailure('hello 1px 2px');
parseTextShadowFailure('1px 2px black 0 0 #ffffff');
// TODO(crbug.com/40945390) Add coverage after rolling codemirror: parseTextShadowFailure('1px2px');
parseTextShadowFailure('1px 2pxrgb(0, 0, 0)');
parseBoxShadowSuccess('0 0');
parseBoxShadowSuccess('1px 2px');
parseBoxShadowSuccess('1px 2px black');
parseBoxShadowSuccess('1px 2px 2px');
parseBoxShadowSuccess('1px 2px 2px 3px');
parseBoxShadowSuccess('inset 1px 2px');
parseBoxShadowSuccess('1px 2px inset');
parseBoxShadowSuccess('INSET 1px 2px 2px 3px');
parseBoxShadowSuccess('rgb(0, 0, 0) 1px 2px 2px');
parseBoxShadowSuccess('inset rgb(0, 0, 0) 1px 2px 2px');
parseBoxShadowSuccess('inset 1px 2px 2px 3px rgb(0, 0, 0)');
parseBoxShadowSuccess('1px 2px 2px 3px rgb(0, 0, 0) inset');
parseBoxShadowSuccess('1px 2px black, inset 0 0 #ffffff');
parseBoxShadowSuccess('1px -2px black, inset 0 0 rgb(0, 0, 0), 3px 3.5px 3px 4px');
parseBoxShadowSuccess('1px 2px black, , 0 0 #ffffff');
parseBoxShadowFailure('');
parseBoxShadowFailure('0');
parseBoxShadowFailure('1 2 black');
parseBoxShadowFailure('1px black 2px');
parseBoxShadowFailure('1px 2px 2px 3px 4px');
parseBoxShadowFailure('1px 2px 2px inset 3px');
parseBoxShadowFailure('inset 1px 2px 2px inset');
parseBoxShadowFailure('1px 2px rgb(0, 0, 0) 2px');
parseBoxShadowFailure('hello 1px 2px');
parseBoxShadowFailure('1px 2px black 0 0 #ffffff');
// TODO(crbug.com/40945390) Add coverage after rolling codemirror: parseBoxShadowFailure('1px2px');
parseBoxShadowFailure('1px 2pxrgb(0, 0, 0)');
});
it('renders the shadow swatch and color swatch', () => {
const stylePropertyTreeElement = getTreeElement('box-shadow', 'inset 10px 10px blue');
stylePropertyTreeElement.updateTitle();
assert.instanceOf(
stylePropertyTreeElement.valueElement?.firstElementChild, InlineEditor.Swatches.CSSShadowSwatch);
const colorSwatch =
stylePropertyTreeElement.valueElement?.firstElementChild?.querySelector('devtools-color-swatch');
assert.exists(colorSwatch);
assert.strictEqual(colorSwatch.getColor()?.asString(), 'blue');
});
it('renders multiple icons for multiple shadows', () => {
const stylePropertyTreeElement = getTreeElement('box-shadow', 'inset 10px 11px blue, notashadow, 6px 5px red');
stylePropertyTreeElement.updateTitle();
const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('css-shadow-swatch');
assert.exists(swatches);
assert.lengthOf(swatches, 2);
assert.strictEqual(swatches[0].textContent, 'inset 10px 11px blue');
assert.strictEqual(swatches[1].textContent, '6px 5px red');
});
it('correctly parses text-shadow', () => {
const stylePropertyTreeElement =
getTreeElement('text-shadow', 'inset 10px 11px blue, 6px 5px red, 5px 5px 0 0 yellow');
stylePropertyTreeElement.updateTitle();
const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('css-shadow-swatch');
assert.exists(swatches);
assert.lengthOf(swatches, 1);
assert.strictEqual(swatches[0].textContent, '6px 5px red');
});
it('renders a color-mix child', () => {
const stylePropertyTreeElement = getTreeElement('box-shadow', '10px 11px color-mix(in srgb, red, blue)');
stylePropertyTreeElement.updateTitle();
assert.instanceOf(
stylePropertyTreeElement.valueElement?.firstElementChild, InlineEditor.Swatches.CSSShadowSwatch);
const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-color-mix-swatch');
assert.exists(swatches);
});
it('renders shadow icon in the presence of a var()', () => {
mockVariableMap['--offset'] = '10px 10px';
mockVariableMap['--shadow'] = '10px 10px blue';
const stylePropertyTreeElement = getTreeElement('box-shadow', 'var(--offset) red, var(--shadow)');
stylePropertyTreeElement.updateTitle();
const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('css-shadow-swatch');
assert.exists(swatches);
assert.lengthOf(swatches, 2);
assert.strictEqual(swatches[0].textContent, 'var(--offset) red');
assert.strictEqual(swatches[1].textContent, 'var(--shadow)');
});
it('opens a shadow editor with the correct values', () => {
mockVariableMap['--offset'] = '10px 10px';
const stylePropertyTreeElement =
getTreeElement('box-shadow', 'var(--offset) red, inset 8px 9px 10px 11px yellow');
stylePropertyTreeElement.updateTitle();
const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('css-shadow-swatch');
assert.exists(swatches);
assert.lengthOf(swatches, 2);
const showPopoverStub = sinon.stub(stylePropertyTreeElement.parentPane().swatchPopoverHelper(), 'show');
const editorProperties = (editor: InlineEditor.CSSShadowEditor.CSSShadowEditor): string[] =>
Array.from(editor.contentElement.querySelectorAll('.shadow-editor-field'))
.map(
field => field.querySelector('input')?.value ??
Array.from(field.querySelectorAll('button'))
.map(button => button.classList.contains('enabled') ? button.textContent : undefined)
.filter((b): b is string => Boolean(b)))
.flat();
{
swatches[0].iconElement().click();
assert.isTrue(showPopoverStub.calledOnce);
assert.instanceOf(showPopoverStub.args[0][0], InlineEditor.CSSShadowEditor.CSSShadowEditor);
const editor = showPopoverStub.args[0][0] as InlineEditor.CSSShadowEditor.CSSShadowEditor;
const text = editorProperties(editor);
assert.deepEqual(text, ['Outset', '10px', '10px', '0', '0']);
}
{