UNPKG

chrome-devtools-frontend

Version:
1,000 lines (855 loc) • 84.1 kB
// 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']); } {