| // Copyright 2022 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import type {SinonStub, SinonStubbedInstance} from 'sinon'; |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import * 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} from '../../testing/EnvironmentHelpers.js'; |
| import {expectCalled, spyCall} from '../../testing/ExpectStubCall.js'; |
| import {describeWithMockConnection, setMockConnectionResponseHandler} from '../../testing/MockConnection.js'; |
| import { |
| getMatchedStyles, |
| getMatchedStylesWithBlankRule, |
| } from '../../testing/StyleHelpers.js'; |
| import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js'; |
| import * as Tooltips from '../../ui/components/tooltips/tooltips.js'; |
| import {Icon} from '../../ui/kit/kit.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|SDK.CSSProperty.CSSProperty>; |
| let matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles; |
| let fakeComputeCSSVariable: SinonStub< |
| [style: SDK.CSSStyleDeclaration.CSSStyleDeclaration, variableName: string], |
| SDK.CSSMatchedStyles.CSSVariableValue|null>; |
| let cssModel: SDK.CSSModel.CSSModel; |
| |
| const environmentVariables = {a: 'A'}; |
| |
| 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({ |
| cssModel: new SDK.CSSModel.CSSModel(createTarget()), |
| range: { |
| startLine: 0, |
| startColumn: 0, |
| endLine: 0, |
| endColumn: 1, |
| }, |
| getEnvironmentVariablesCallback: () => ({environmentVariables}) |
| }); |
| sinon.stub(matchedStyles, 'availableCSSVariables').returns(Object.keys(mockVariableMap)); |
| fakeComputeCSSVariable = sinon.stub(matchedStyles, 'computeCSSVariable').callsFake((_style, name) => { |
| const value = mockVariableMap[name]; |
| if (!value) { |
| return null; |
| } |
| if (typeof value === 'string') { |
| return { |
| value, |
| declaration: new SDK.CSSMatchedStyles.CSSValueSource(sinon.createStubInstance(SDK.CSSProperty.CSSProperty)), |
| }; |
| } |
| return {value: value.value, declaration: new SDK.CSSMatchedStyles.CSSValueSource(value)}; |
| }); |
| |
| 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()}); |
| |
| setMockConnectionResponseHandler('CSS.enable', () => ({})); |
| 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); |
| }); |
| |
| function addProperty(name: string, value: string, longhandProperties: Protocol.CSS.CSSProperty[] = []) { |
| const property = new SDK.CSSProperty.CSSProperty( |
| matchedStyles.nodeStyles()[0], matchedStyles.nodeStyles()[0].pastLastSourcePropertyIndex(), name, value, true, |
| false, true, false, '', undefined, longhandProperties); |
| matchedStyles.nodeStyles()[0].allProperties().push(property); |
| return property; |
| } |
| |
| async function getTreeElementForFunctionRule(functionName: string, result: string, propertyName = 'result') { |
| const matchedStyles = await getMatchedStyles({ |
| functionRules: |
| [{name: {text: functionName}, origin: Protocol.CSS.StyleSheetOrigin.Regular, parameters: [], children: []}] |
| }); |
| |
| const property = new SDK.CSSProperty.CSSProperty( |
| matchedStyles.functionRules()[0].style, matchedStyles.functionRules()[0].style.pastLastSourcePropertyIndex(), |
| propertyName, result, true, false, true, false, '', undefined, []); |
| matchedStyles.functionRules()[0].style.allProperties().push(property); |
| return new Elements.StylePropertyTreeElement.StylePropertyTreeElement({ |
| stylesPane: stylesSidebarPane, |
| section: sinon.createStubInstance(Elements.StylePropertiesSection.StylePropertiesSection), |
| matchedStyles, |
| property, |
| isShorthand: false, |
| inherited: false, |
| overloaded: false, |
| newProperty: true, |
| }); |
| } |
| |
| function getTreeElement(name: string, value: string, longhandProperties: Protocol.CSS.CSSProperty[] = []) { |
| const property = addProperty(name, value, longhandProperties); |
| const section = new Elements.StylePropertiesSection.StylePropertiesSection( |
| stylesSidebarPane, matchedStyles, property.ownerStyle, 0, null, null); |
| return new Elements.StylePropertyTreeElement.StylePropertyTreeElement({ |
| stylesPane: stylesSidebarPane, |
| section, |
| 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 () => { |
| 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?.innerText); |
| 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.nextElementSibling?.textContent === 'red')); |
| assert.exists(colorSwatches.find(colorSwatch => colorSwatch.nextElementSibling?.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(); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement!); |
| |
| const colorMixSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch'); |
| const cssVarSwatches = |
| Array.from(stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-link-swatch') || []); |
| assert.exists(colorMixSwatch); |
| assert.exists(cssVarSwatches.find(cssVarSwatch => cssVarSwatch.innerText === '--a')); |
| assert.exists(cssVarSwatches.find(cssVarSwatch => cssVarSwatch.innerText === '--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); |
| }); |
| |
| 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); |
| }); |
| |
| 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 its computed color as RGB if possible', async () => { |
| const stylePropertyTreeElement = getTreeElement('color', 'color-mix(in srgb, red 50%, yellow)'); |
| stylePropertyTreeElement.treeOutline = new LegacyUI.TreeOutline.TreeOutline(); |
| |
| stylePropertyTreeElement.updateTitle(); |
| const colorMixSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch'); |
| assert.exists(colorMixSwatch); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement as HTMLElement); |
| |
| const tooltip = |
| stylePropertyTreeElement.valueElement?.querySelector<Tooltips.Tooltip.Tooltip>(':scope > devtools-tooltip'); |
| assert.exists(tooltip); |
| tooltip.showPopover(); |
| assert.strictEqual(tooltip.textContent, '#ff8000'); |
| }); |
| |
| it('shows a popover with its computed color as wide gamut if necessary', () => { |
| const stylePropertyTreeElement = getTreeElement('color', 'color-mix(in srgb, oklch(.5 .5 .5) 50%, yellow)'); |
| |
| stylePropertyTreeElement.updateTitle(); |
| const colorMixSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-mix-swatch'); |
| assert.exists(colorMixSwatch); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement as HTMLElement); |
| |
| const tooltip = |
| stylePropertyTreeElement.valueElement?.querySelector<Tooltips.Tooltip.Tooltip>(':scope > devtools-tooltip'); |
| tooltip?.showPopover(); |
| assert.strictEqual(tooltip?.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(); |
| |
| assert.exists(stylePropertyTreeElement.valueElement); |
| |
| const [outerColorMix, innerColorMix] = |
| Array.from(stylePropertyTreeElement.valueElement.querySelectorAll('devtools-color-mix-swatch')); |
| assert.exists(outerColorMix); |
| assert.exists(innerColorMix); |
| const handler = sinon.fake(); |
| outerColorMix.addEventListener(InlineEditor.ColorMixSwatch.ColorMixChangedEvent.eventName, handler); |
| 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 = stylePropertyTreeElement.valueElement.querySelector('devtools-color-swatch'); |
| assert.isOk(colorSwatch); |
| const newColor = colorSwatch.color?.as(Common.Color.Format.HEX); |
| assert.isOk(newColor); |
| colorSwatch.color = 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)'}); |
| }); |
| |
| it('supports evaluation during tracing', async () => { |
| const property = addProperty('color', 'color-mix(in srgb, black, white)'); |
| setMockConnectionResponseHandler( |
| 'CSS.resolveValues', |
| (request: Protocol.CSS.ResolveValuesRequest) => |
| ({results: request.values.map(v => v === property.value ? 'grey' : v)})); |
| const matchedResult = property.parseValue(matchedStyles, new Map()); |
| |
| const context = |
| new Elements.PropertyRenderer.TracingContext(new Elements.PropertyRenderer.Highlighting(), false); |
| assert.isTrue(context.nextEvaluation()); |
| const {valueElement} = Elements.PropertyRenderer.Renderer.renderValueElement( |
| property, matchedResult, |
| Elements.StylePropertyTreeElement.getPropertyRenderers( |
| property.name, matchedStyles.nodeStyles()[0], stylesSidebarPane, matchedStyles, null, new Map()), |
| context); |
| |
| const colorSwatch = valueElement.querySelector('devtools-color-swatch'); |
| assert.exists(colorSwatch); |
| |
| const setColorText = sinon.spy(colorSwatch, 'color', ['set']).set; |
| const textColorChanged = new Promise( |
| resolve => colorSwatch.addEventListener(InlineEditor.ColorSwatch.ColorChangedEvent.eventName, resolve)); |
| |
| assert.isTrue(await context.runAsyncEvaluations()); |
| await textColorChanged; |
| assert.strictEqual(setColorText.lastCall.args[0]!.asString(), '#808080'); |
| assert.strictEqual(valueElement.innerText, '#808080'); |
| }); |
| |
| it('shows a value tracing tooltip on the var function', async () => { |
| const stylePropertyTreeElement = getTreeElement('color', 'color-mix(in srgb, yellow, green)'); |
| stylePropertyTreeElement.updateTitle(); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| const tooltip = stylePropertyTreeElement.valueElement.querySelector('devtools-tooltip'); |
| assert.exists(tooltip); |
| const widget = tooltip.firstElementChild && LegacyUI.Widget.Widget.get(tooltip.firstElementChild); |
| assert.instanceOf(widget, Elements.CSSValueTraceView.CSSValueTraceView); |
| }); |
| }); |
| |
| 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(async () => { |
| const target = createTarget(); |
| const domModelBeforeAssertion = target.model(SDK.DOMModel.DOMModel); |
| assert.exists(domModelBeforeAssertion); |
| domModel = domModelBeforeAssertion; |
| }); |
| |
| 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('devtools-color-swatch'); |
| assert.exists(swatch); |
| |
| const expectedColorString = swatch.color?.asString(Common.Color.Format.LAB); |
| assert.exists(expectedColorString); |
| assert.match(expectedColorString, /lab\([-.0-9]* [-.0-9]* [-.0-9]*\)/); |
| |
| const newColor = swatch.color?.as(Common.Color.Format.LAB); |
| assert.isOk(newColor); |
| swatch.dispatchEvent(new InlineEditor.ColorSwatch.ColorFormatChangedEvent(newColor)); |
| assert.deepEqual(stylePropertyTreeElement.renderedPropertyText(), `color: ${expectedColorString}`); |
| |
| sinon.assert.alwaysCalledWith(applyStyleTextStub, `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 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 footerSection = contextMenu.footerSection(); |
| verifySection(expectedHeaderSectionItemsLabels, headerSection.items); |
| verifySection(expectedClipboardSectionItemsLabels, clipboardSection.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.isNotOk( |
| 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 |
| .map(fragment => Array.from(fragment.valueElement.querySelectorAll('devtools-link-swatch'))) |
| .flat()[0]; |
| assert.exists(varSwatch); |
| const revealPropertySpy = sinon.spy(stylesSidebarPane, 'revealProperty'); |
| varSwatch.linkElement?.click(); |
| sinon.assert.calledWith(revealPropertySpy, cssCustomPropertyDef); |
| }); |
| |
| it('linkifies property definition to registrations', async () => { |
| 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 stylePropertyTreeElement = getTreeElement('--prop', 'value'); |
| stylePropertyTreeElement.treeOutline = new LegacyUI.TreeOutline.TreeOutline(); |
| stylePropertyTreeElement.updateTitle(); |
| |
| const popoverContents = |
| stylePropertyTreeElement.listItemElement.querySelector('devtools-tooltip > devtools-css-variable-value-view'); |
| assert.instanceOf(popoverContents, ElementsComponents.CSSVariableValueView.CSSVariableValueView); |
| const {details} = popoverContents; |
| assert.exists(details); |
| |
| const jumpToSectionSpy = sinon.spy(stylesSidebarPane, 'jumpToSection'); |
| details.goToDefinition(); |
| sinon.assert.calledOnceWithExactly( |
| jumpToSectionSpy, '--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 |
| .map(fragment => Array.from(fragment.valueElement.querySelectorAll('devtools-link-swatch'))) |
| .flat()[0]; |
| assert.exists(varSwatch); |
| const jumpToPropertySpy = sinon.spy(stylesSidebarPane, 'jumpToProperty'); |
| varSwatch.linkElement?.click(); |
| sinon.assert.calledWith( |
| jumpToPropertySpy, '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 linkSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-link-swatch'); |
| assert.exists(linkSwatch); |
| |
| const cssVarSwatch = linkSwatch.parentElement; |
| assert.exists(cssVarSwatch); |
| |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| assert.strictEqual(cssVarSwatch.innerText, 'var(--a)'); |
| assert.strictEqual(linkSwatch.innerText, '--a'); |
| assert.strictEqual(stylePropertyTreeElement.valueElement.innerText, '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 linkSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-link-swatch'); |
| assert.exists(linkSwatch); |
| |
| const cssVarSwatch = linkSwatch.parentElement; |
| assert.exists(cssVarSwatch); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| |
| assert.strictEqual(linkSwatch.innerText, '--not-existing'); |
| assert.strictEqual(cssVarSwatch.innerText, 'var(--not-existing, red)'); |
| assert.strictEqual(stylePropertyTreeElement.valueElement.innerText, '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 [firstLinkSwatch, secondLinkSwatch] = |
| stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-link-swatch'); |
| assert.exists(firstLinkSwatch); |
| assert.exists(secondLinkSwatch); |
| const cssVarSwatch = firstLinkSwatch.parentElement; |
| assert.exists(cssVarSwatch); |
| const insideCssVarSwatch = secondLinkSwatch.parentElement; |
| assert.exists(insideCssVarSwatch); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| |
| assert.strictEqual(stylePropertyTreeElement.valueElement.innerText, 'var(--not-existing, var(--a))'); |
| assert.strictEqual(firstLinkSwatch?.innerText, '--not-existing'); |
| assert.strictEqual(cssVarSwatch.innerText, 'var(--not-existing, var(--a))'); |
| assert.strictEqual(secondLinkSwatch?.innerText, '--a'); |
| assert.strictEqual(insideCssVarSwatch?.innerText, '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 linkSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-link-swatch'); |
| assert.exists(linkSwatch); |
| const cssVarSwatch = linkSwatch.parentElement; |
| assert.exists(cssVarSwatch); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| |
| assert.strictEqual(stylePropertyTreeElement.valueElement.innerText, 'var(--not-existing, calc(15px + 20px))'); |
| assert.strictEqual(linkSwatch?.innerText, '--not-existing'); |
| assert.strictEqual(cssVarSwatch.innerText, '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 colorSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-swatch'); |
| assert.exists(colorSwatch); |
| assert.isTrue(InlineEditor.ColorSwatch.ColorSwatch.isColorSwatch(colorSwatch)); |
| |
| const linkSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-link-swatch'); |
| assert.exists(linkSwatch); |
| const cssVarSwatch = linkSwatch.parentElement; |
| assert.exists(cssVarSwatch); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| |
| assert.strictEqual(stylePropertyTreeElement.valueElement.innerText, `var(${varName}, var(--blue))`); |
| assert.strictEqual(linkSwatch?.innerText, varName); |
| assert.strictEqual(cssVarSwatch.innerText, `var(${varName}, var(--blue))`); |
| stylePropertyTreeElement.valueElement.remove(); |
| } |
| }); |
| |
| 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-link-swatch'); |
| assert.strictEqual(cssVarSwatches?.length, 2); |
| }); |
| |
| it('connects nested color swatches', () => { |
| const stylePropertyTreeElement = getTreeElement('color', 'var(--void, red)'); |
| stylePropertyTreeElement.updateTitle(); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| |
| const linkSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-link-swatch'); |
| assert.exists(linkSwatch); |
| const cssVarSwatch = linkSwatch.parentElement; |
| assert.exists(cssVarSwatch); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| |
| 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.color = color; |
| assert.strictEqual(outerColorSwatch.color, 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 linkSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-link-swatch'); |
| assert.exists(linkSwatch); |
| const cssVarSwatch = linkSwatch.parentElement; |
| 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.color = color; |
| assert.strictEqual(outerColorSwatch.color?.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 SDK.CSSPropertyParserMatchers.VariableMatcher( |
| stylePropertyTreeElement.matchedStyles(), stylePropertyTreeElement.property.ownerStyle)]); |
| |
| const res = { |
| hasUnresolvedVars: matching.hasUnresolvedSubstitutions(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-link-swatch')); |
| }); |
| |
| it('shows a value tracing tooltip on the var function', async () => { |
| const stylePropertyTreeElement = getTreeElement('color', 'var(--blue)'); |
| stylePropertyTreeElement.updateTitle(); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| const tooltip = stylePropertyTreeElement.valueElement.querySelector('devtools-tooltip'); |
| assert.exists(tooltip); |
| const widget = tooltip.firstElementChild && LegacyUI.Widget.Widget.get(tooltip.firstElementChild); |
| assert.instanceOf(widget, Elements.CSSValueTraceView.CSSValueTraceView); |
| }); |
| |
| it('does not render inside function rules', async () => { |
| const stylePropertyTreeElement = await getTreeElementForFunctionRule('--func', 'var(--b)'); |
| stylePropertyTreeElement.updateTitle(); |
| assert.notExists(stylePropertyTreeElement.valueElement?.querySelector('devtools-link-swatch')); |
| }); |
| |
| it('retains empty fallbacks', async () => { |
| const stylePropertyTreeElement = getTreeElement('color', 'var(--blue,)'); |
| stylePropertyTreeElement.updateTitle(); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| assert.strictEqual(stylePropertyTreeElement.renderedPropertyText(), 'color: var(--blue, )'); |
| }); |
| }); |
| |
| 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.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| |
| assert.strictEqual(stylePropertyTreeElement.valueElement?.innerText, value); |
| const colorSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-swatch'); |
| assert.exists(colorSwatch); |
| assert.strictEqual(colorSwatch.color?.asString(Common.Color.Format.HEX), '#ff0000'); |
| |
| const varSwatches = stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-link-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(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| |
| assert.exists(colorSwatch); |
| assert.strictEqual(colorSwatch.color?.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.color?.asString(Common.Color.Format.HSL), 'hsl(130deg 50% 25%)'); |
| sinon.assert.calledOnce(eventHandler); |
| assert.strictEqual(eventHandler.args[0][0].data.color, colorSwatch.color); |
| }); |
| |
| 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.color); |
| assert.strictEqual(colorSwatch?.color?.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.color); |
| assert.strictEqual(colorSwatch?.color?.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.color); |
| assert.strictEqual(colorSwatch?.color?.asString(Common.Color.Format.HSL), 'hsl(240deg 50% 50%)'); |
| }); |
| |
| it('renders fallbacks correctly when the color fails to parse', () => { |
| const stylePropertyTreeElement = getTreeElement('color', 'lch(50 min(1, 8) 8deg)'); |
| stylePropertyTreeElement.updateTitle(); |
| const angle = stylePropertyTreeElement.valueElement?.querySelector('devtools-css-angle'); |
| assert.exists(angle); |
| }); |
| |
| it('shows a value tracing tooltip on color functions', async () => { |
| for (const property of ['rgb(255 0 0)', 'color(srgb 0.5 0.5 0.5)', 'oklch(from purple calc(l * 2) c h)']) { |
| const stylePropertyTreeElement = getTreeElement('color', property); |
| stylePropertyTreeElement.updateTitle(); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| const tooltip = stylePropertyTreeElement.valueElement.querySelector('devtools-tooltip'); |
| assert.exists(tooltip); |
| const widget = tooltip.firstElementChild && LegacyUI.Widget.Widget.get(tooltip.firstElementChild); |
| assert.instanceOf(widget, Elements.CSSValueTraceView.CSSValueTraceView); |
| stylePropertyTreeElement.valueElement.remove(); |
| } |
| }); |
| }); |
| |
| describe('RelativeColorChannelRenderer', () => { |
| it('provides a tooltip for relative color channels', () => { |
| const stylePropertyTreeElement = getTreeElement('color', 'rgb(from #ff0c0c calc(r / 2) g b)'); |
| stylePropertyTreeElement.updateTitle(); |
| |
| const tooltips = stylePropertyTreeElement.valueElement?.querySelectorAll( |
| 'devtools-tooltip:not([jslogcontext="elements.css-value-trace"])'); |
| assert.exists(tooltips); |
| assert.lengthOf(tooltips, 3); |
| assert.deepEqual(Array.from(tooltips).map(tooltip => tooltip.textContent), ['1.000', '0.047', '0.047']); |
| }); |
| |
| it('evaluates relative color channels during tracing', async () => { |
| setMockConnectionResponseHandler( |
| 'CSS.resolveValues', |
| (request: Protocol.CSS.ResolveValuesRequest) => |
| ({results: request.values.map(v => v === 'calc(1.000 / 2)' ? '0.5' : '')})); |
| const property = addProperty('color', 'rgb(from #ff0c0c calc(r / 2) g b)'); |
| |
| const {promise, resolve} = Promise.withResolvers<void>(); |
| const view = sinon.stub<Parameters<Elements.CSSValueTraceView.View>>().callsFake(() => resolve()); |
| void new Elements.CSSValueTraceView.CSSValueTraceView(undefined, view) |
| .showTrace( |
| property, null, matchedStyles, new Map(), |
| Elements.StylePropertyTreeElement.getPropertyRenderers( |
| property.name, property.ownerStyle, stylesSidebarPane, matchedStyles, null, new Map()), |
| false, 0, false); |
| |
| await promise; |
| |
| const {evaluations} = view.args[0][0]; |
| |
| assert.deepEqual(evaluations.flat().map(args => args?.textContent).flat(), [ |
| '', 'rgb(from #ff0c0c calc(1.000 / 2) 0.047 0.047)', '', 'rgb(from #ff0c0c 0.5 0.047 0.047)', '', '#800c0c' |
| ]); |
| }); |
| }); |
| |
| describe('BezierRenderer', () => { |
| it('renders the easing function swatch', () => { |
| const stylePropertyTreeElement = getTreeElement('animation-timing-function', 'ease-out'); |
| stylePropertyTreeElement.updateTitle(); |
| assert.instanceOf(stylePropertyTreeElement.valueElement?.firstChild?.firstChild, Icon); |
| }); |
| }); |
| |
| describe('UrlRenderer', () => { |
| it('linkifies and unescapes urls', () => { |
| const stylePropertyTreeElement = getTreeElement('--url', 'url(devtools:\\/\\/abc)'); |
| stylePropertyTreeElement.updateTitle(); |
| |
| assert.strictEqual(stylePropertyTreeElement.valueElement?.innerText, '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.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement, {allowMultipleChildren: true}); |
| |
| assert.strictEqual( |
| stylePropertyTreeElement.valueElement?.firstElementChild instanceof InlineEditor.Swatches.CSSShadowSwatch, |
| success); |
| assert.strictEqual(stylePropertyTreeElement.valueElement?.innerText, 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?.querySelector('devtools-color-swatch'); |
| assert.exists(colorSwatch); |
| assert.strictEqual(colorSwatch.color?.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].nextElementSibling as HTMLElement).innerText, 'inset 10px 11px blue'); |
| assert.strictEqual((swatches[1].nextElementSibling as HTMLElement).innerText, '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].nextElementSibling as HTMLElement).innerText, '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(); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('css-shadow-swatch'); |
| assert.exists(swatches); |
| assert.lengthOf(swatches, 2); |
| assert.strictEqual((swatches[0].nextElementSibling as HTMLElement).innerText, 'var(--offset) red'); |
| assert.strictEqual((swatches[1].nextElementSibling as HTMLElement).innerText, '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.innerText : undefined) |
| .filter((b): b is string => Boolean(b))) |
| .flat(); |
| |
| { |
| swatches[0].iconElement().click(); |
| sinon.assert.calledOnce(showPopoverStub); |
| assert.instanceOf(showPopoverStub.args[0][0], InlineEditor.CSSShadowEditor.CSSShadowEditor); |
| const editor = showPopoverStub.args[0][0]; |
| const text = editorProperties(editor); |
| assert.deepEqual(text, ['Outset', '10px', '10px', '0', '0']); |
| } |
| |
| { |
| swatches[1].iconElement().click(); |
| sinon.assert.calledTwice(showPopoverStub); |
| assert.instanceOf(showPopoverStub.args[1][0], InlineEditor.CSSShadowEditor.CSSShadowEditor); |
| const editor = showPopoverStub.args[1][0]; |
| const text = editorProperties(editor); |
| assert.deepEqual(text, ['Inset', '8px', '9px', '10px', '11px']); |
| } |
| }); |
| |
| it('updates the style for shadow editor changes', () => { |
| const stylePropertyTreeElement = getTreeElement('box-shadow', '10px 11px red'); |
| stylePropertyTreeElement.updateTitle(); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('css-shadow-swatch'); |
| assert.exists(swatches); |
| assert.lengthOf(swatches, 1); |
| const showPopoverStub = sinon.stub(stylePropertyTreeElement.parentPane().swatchPopoverHelper(), 'show'); |
| swatches[0].iconElement().click(); |
| sinon.assert.calledOnce(showPopoverStub); |
| |
| const applyStyleTextStub = sinon.stub(stylePropertyTreeElement, 'applyStyleText'); |
| const button = |
| showPopoverStub.args[0][0].contentElement.querySelector('.shadow-editor-button-right') as HTMLElement | null; |
| button?.click(); |
| |
| sinon.assert.calledOnceWithExactly(applyStyleTextStub, 'box-shadow: inset 10px 11px red', false); |
| }); |
| |
| it('updates the style for shadow editor changes and respects ordering', () => { |
| mockVariableMap['--y-color'] = '11px red'; |
| const stylePropertyTreeElement = getTreeElement('box-shadow', '10px var(--y-color)'); |
| stylePropertyTreeElement.updateTitle(); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('css-shadow-swatch'); |
| assert.exists(swatches); |
| assert.lengthOf(swatches, 1); |
| const showPopoverStub = sinon.stub(stylePropertyTreeElement.parentPane().swatchPopoverHelper(), 'show'); |
| swatches[0].iconElement().click(); |
| sinon.assert.calledOnce(showPopoverStub); |
| |
| const applyStyleTextStub = sinon.stub(stylePropertyTreeElement, 'applyStyleText'); |
| const inputs = Array.from(showPopoverStub.args[0][0].contentElement.querySelectorAll('.shadow-editor-field')) |
| .map(field => field.querySelector('input')); |
| assert.exists(inputs[3]); |
| inputs[3].value = '13px'; |
| inputs[3].dispatchEvent(new InputEvent('input', {data: '13px'})); |
| |
| sinon.assert.calledOnceWithExactly(applyStyleTextStub, 'box-shadow: 10px 11px 13px red', false); |
| }); |
| |
| it('correctly builds and updates the shadow model', () => { |
| mockVariableMap['--props'] = '12px 13px red'; |
| const stylePropertyTreeElement = getTreeElement('box-shadow', '10px 11px red, var(--props)'); |
| stylePropertyTreeElement.updateTitle(); |
| const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('css-shadow-swatch'); |
| assert.exists(swatches); |
| assert.lengthOf(swatches, 2); |
| |
| assert.isTrue(swatches[0].model().isBoxShadow()); |
| assert.isFalse(swatches[0].model().inset()); |
| assert.strictEqual(swatches[0].model().offsetX().asCSSText(), '10px'); |
| assert.strictEqual(swatches[0].model().offsetY().asCSSText(), '11px'); |
| assert.strictEqual(swatches[0].model().blurRadius().asCSSText(), '0'); |
| assert.strictEqual(swatches[0].model().spreadRadius().asCSSText(), '0'); |
| |
| swatches[0].model().setSpreadRadius(new InlineEditor.CSSShadowEditor.CSSLength(8, 'px')); |
| swatches[0].model().setBlurRadius(new InlineEditor.CSSShadowEditor.CSSLength(5, 'px')); |
| assert.strictEqual(swatches[0].model().blurRadius().asCSSText(), '5px'); |
| assert.strictEqual(swatches[0].model().spreadRadius().asCSSText(), '8px'); |
| |
| assert.isTrue(swatches[1].model().isBoxShadow()); |
| assert.isFalse(swatches[1].model().inset()); |
| assert.strictEqual(swatches[1].model().offsetX().asCSSText(), '12px'); |
| assert.strictEqual(swatches[1].model().offsetY().asCSSText(), '13px'); |
| assert.strictEqual(swatches[1].model().blurRadius().asCSSText(), '0'); |
| assert.strictEqual(swatches[1].model().spreadRadius().asCSSText(), '0'); |
| |
| swatches[1].model().setBlurRadius(new InlineEditor.CSSShadowEditor.CSSLength(5, 'px')); |
| swatches[1].model().setSpreadRadius(new InlineEditor.CSSShadowEditor.CSSLength(8, 'px')); |
| assert.strictEqual(swatches[1].model().blurRadius().asCSSText(), '5px'); |
| assert.strictEqual(swatches[1].model().spreadRadius().asCSSText(), '8px'); |
| }); |
| |
| class StubSyntaxNode implements CodeMirror.SyntaxNode { |
| parent: CodeMirror.SyntaxNode|null = null; |
| firstChild: CodeMirror.SyntaxNode|null = null; |
| lastChild: CodeMirror.SyntaxNode|null = null; |
| childAfter(): CodeMirror.SyntaxNode|null { |
| return null; |
| } |
| childBefore(): CodeMirror.SyntaxNode|null { |
| return null; |
| } |
| enter(): CodeMirror.SyntaxNode|null { |
| return null; |
| } |
| nextSibling: CodeMirror.SyntaxNode|null = null; |
| prevSibling: CodeMirror.SyntaxNode|null = null; |
| cursor(): CodeMirror.TreeCursor { |
| throw new Error('Method not implemented.'); |
| } |
| resolve(): CodeMirror.SyntaxNode { |
| return this; |
| } |
| resolveInner(): CodeMirror.SyntaxNode { |
| return this; |
| } |
| enterUnfinishedNodesBefore(): CodeMirror.SyntaxNode { |
| return this; |
| } |
| toTree(): CodeMirror.Tree { |
| throw new Error('Method not implemented.'); |
| } |
| getChild(): CodeMirror.SyntaxNode|null { |
| throw new Error('Method not implemented.'); |
| } |
| getChildren(): CodeMirror.SyntaxNode[] { |
| throw new Error('Method not implemented.'); |
| } |
| from = 0; |
| to = 0; |
| type = new CodeMirror.NodeType(); |
| name = ''; |
| tree: CodeMirror.Tree|null = null; |
| node: CodeMirror.SyntaxNode = this; |
| matchContext(): boolean { |
| return false; |
| } |
| } |
| |
| it('shadow model renders text properties, authored properties, and computed text properties correctly', () => { |
| const renderingContext = sinon.createStubInstance(Elements.PropertyRenderer.RenderingContext); |
| const expansionContext = sinon.createStubInstance(Elements.PropertyRenderer.RenderingContext); |
| const y = new StubSyntaxNode(); |
| const spread = new StubSyntaxNode(); |
| const blur = new StubSyntaxNode(); |
| const variable = new StubSyntaxNode(); |
| const properties = [ |
| { |
| value: '10px', |
| source: null, |
| expansionContext: null, |
| propertyType: Elements.StylePropertyTreeElement.ShadowPropertyType.X, |
| }, |
| { |
| value: y, |
| source: null, |
| expansionContext: null, |
| propertyType: Elements.StylePropertyTreeElement.ShadowPropertyType.Y, |
| }, |
| { |
| value: blur, |
| source: variable, |
| expansionContext, |
| propertyType: Elements.StylePropertyTreeElement.ShadowPropertyType.BLUR, |
| }, |
| { |
| value: spread, |
| source: variable, |
| expansionContext, |
| propertyType: Elements.StylePropertyTreeElement.ShadowPropertyType.SPREAD, |
| }, |
| ]; |
| |
| sinon.stub(Elements.PropertyRenderer.Renderer, 'render').callsFake((nodeOrNodes, context) => { |
| if (!Array.isArray(nodeOrNodes)) { |
| nodeOrNodes = [nodeOrNodes]; |
| } |
| const nodes = nodeOrNodes |
| .map(node => { |
| switch (node) { |
| case y: |
| return context === renderingContext && document.createTextNode('y'); |
| case blur: |
| return context === expansionContext && document.createTextNode('blur'); |
| case spread: |
| return context === expansionContext && document.createTextNode('spread'); |
| case variable: |
| return context === renderingContext && document.createTextNode('var()'); |
| default: |
| return undefined; |
| } |
| }) |
| .filter(b => !!b); |
| return { |
| nodes, |
| nodeGroups: [nodes], |
| cssControls: new Map(), |
| }; |
| }); |
| const model = new Elements.StylePropertyTreeElement.ShadowModel( |
| SDK.CSSPropertyParserMatchers.ShadowType.BOX_SHADOW, properties, renderingContext); |
| |
| const container = document.createElement('div'); |
| model.renderContents(container); |
| assert.strictEqual(container.innerText, '10px y var()'); |
| |
| model.setBlurRadius(new InlineEditor.CSSShadowEditor.CSSLength(12, 'px')); |
| model.renderContents(container); |
| assert.strictEqual(container.innerText, '10px y 12px spread'); |
| assert.deepEqual(properties.map(p => p.source), [null, null, null, null]); |
| }); |
| }); |
| |
| describe('AnchorFunctionRenderer', () => { |
| let revealStub: sinon.SinonStub; |
| let hideDOMNodeHighlightStub: sinon.SinonStub; |
| let highlightMock: sinon.SinonExpectation; |
| let fakeParentNode: SDK.DOMModel.DOMNode; |
| let fakeDOMNode: SDK.DOMModel.DOMNode; |
| |
| beforeEach(() => { |
| fakeParentNode = { |
| localName() { |
| return 'span'; |
| }, |
| isSVGNode() { |
| return false; |
| }, |
| getAnchorBySpecifier() { |
| return Promise.resolve(fakeDOMNode); |
| }, |
| } as SDK.DOMModel.DOMNode; |
| |
| fakeDOMNode = { |
| localName() { |
| return 'span'; |
| }, |
| isSVGNode() { |
| return false; |
| }, |
| highlight() { |
| highlightMock(); |
| }, |
| } as SDK.DOMModel.DOMNode; |
| highlightMock = sinon.mock(); |
| revealStub = sinon.stub(Common.Revealer.RevealerRegistry.prototype, 'reveal'); |
| hideDOMNodeHighlightStub = sinon.stub(SDK.OverlayModel.OverlayModel, 'hideDOMNodeHighlight'); |
| setMockConnectionResponseHandler( |
| 'DOM.getAnchorElement', () => ({result: undefined} as unknown as Protocol.DOM.GetAnchorElementResponse)); |
| }); |
| |
| it('renders anchor() function correctly', async () => { |
| const stylePropertyTreeElement = getTreeElement('left', 'anchor(top)'); |
| |
| stylePropertyTreeElement.updateTitle(); |
| |
| assert.strictEqual(stylePropertyTreeElement.valueElement!.innerText, 'anchor(top)'); |
| }); |
| |
| it('renders `AnchorFunctionLinkSwatch` after decorating the element', async () => { |
| const waitForDecorationPromise = |
| spyCall(Elements.StylePropertyTreeElement.AnchorFunctionRenderer, 'decorateAnchorForAnchorLink'); |
| const stylePropertyTreeElement = getTreeElement('left', 'anchor(--identifier top)'); |
| sinon.stub(stylePropertyTreeElement, 'node').returns(fakeParentNode); |
| |
| stylePropertyTreeElement.updateTitle(); |
| await (await waitForDecorationPromise).result; |
| const anchorFunctionLinkSwatch = stylePropertyTreeElement.valueElement!.querySelector('devtools-link-swatch')!; |
| |
| renderElementIntoDOM(anchorFunctionLinkSwatch); |
| assert.strictEqual(anchorFunctionLinkSwatch.linkElement?.textContent, '--identifier'); |
| }); |
| |
| it('should highlight node when `onMouseEnter` triggered from `AnchorFunctionLinkSwatch`', async () => { |
| const waitForDecorationPromise = |
| spyCall(Elements.StylePropertyTreeElement.AnchorFunctionRenderer, 'decorateAnchorForAnchorLink'); |
| const stylePropertyTreeElement = getTreeElement('left', 'anchor(--identifier top)'); |
| sinon.stub(stylesSidebarPane, 'node').returns(fakeParentNode); |
| |
| stylePropertyTreeElement.updateTitle(); |
| await (await waitForDecorationPromise).result; |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| const anchorFunctionLinkSwatch = stylePropertyTreeElement.valueElement.querySelector('devtools-link-swatch')!; |
| anchorFunctionLinkSwatch.dispatchEvent(new Event('mouseenter')); |
| |
| sinon.assert.calledOnce(highlightMock); |
| }); |
| |
| it('should clear DOM highlight when `onMouseLeave` triggered from `AnchorFunctionLinkSwatch`', async () => { |
| const waitForDecorationPromise = |
| spyCall(Elements.StylePropertyTreeElement.AnchorFunctionRenderer, 'decorateAnchorForAnchorLink'); |
| const stylePropertyTreeElement = getTreeElement('left', 'anchor(--identifier top)'); |
| sinon.stub(stylesSidebarPane, 'node').returns(fakeParentNode); |
| |
| stylePropertyTreeElement.updateTitle(); |
| await (await waitForDecorationPromise).result; |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| const anchorFunctionLinkSwatch = stylePropertyTreeElement.valueElement!.querySelector('devtools-link-swatch')!; |
| anchorFunctionLinkSwatch.dispatchEvent(new Event('mouseleave')); |
| |
| sinon.assert.calledOnce(hideDOMNodeHighlightStub); |
| }); |
| |
| it('should reveal anchor node when `onLinkActivate` triggered from `AnchorFunctionLinkSwatch`', async () => { |
| const waitForDecorationPromise = |
| spyCall(Elements.StylePropertyTreeElement.AnchorFunctionRenderer, 'decorateAnchorForAnchorLink'); |
| const stylePropertyTreeElement = getTreeElement('left', 'anchor(--identifier top)'); |
| sinon.stub(stylesSidebarPane, 'node').returns(fakeParentNode); |
| |
| stylePropertyTreeElement.updateTitle(); |
| await (await waitForDecorationPromise).result; |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| const anchorFunctionLinkSwatch = stylePropertyTreeElement.valueElement!.querySelector('devtools-link-swatch')!; |
| anchorFunctionLinkSwatch.linkElement?.click(); |
| |
| sinon.assert.calledOnce(revealStub); |
| sinon.assert.calledWith(revealStub, fakeDOMNode); |
| }); |
| |
| async function createAnchorFunctionLinkSwatch( |
| data: {identifier: string|undefined, anchorNode: SinonStubbedInstance<SDK.DOMModel.DOMNode>|undefined}): |
| Promise<HTMLDivElement> { |
| sinon.stub(stylesSidebarPane.node()!, 'getAnchorBySpecifier').resolves(data.anchorNode); |
| const content = document.createElement('div'); |
| await Elements.StylePropertyTreeElement.AnchorFunctionRenderer.decorateAnchorForAnchorLink( |
| stylesSidebarPane, content, data); |
| return content; |
| } |
| |
| describe('when identifier exists', () => { |
| let linkSwatchDataStub: {set: sinon.SinonSpy}; |
| |
| beforeEach(() => { |
| linkSwatchDataStub = sinon.spy(InlineEditor.LinkSwatch.LinkSwatch.prototype, 'data', ['set']); |
| }); |
| |
| it('should render a defined link when `anchorNode` is resolved correctly', async () => { |
| const data = { |
| identifier: '--identifier', |
| anchorNode: sinon.createStubInstance(SDK.DOMModel.DOMNode), |
| }; |
| await createAnchorFunctionLinkSwatch(data); |
| |
| sinon.assert.calledWith(linkSwatchDataStub.set, { |
| text: data.identifier, |
| isDefined: true, |
| tooltip: undefined, |
| jslogContext: 'anchor-link', |
| onLinkActivate: sinon.match.func, |
| }); |
| }); |
| |
| it('should render an undefined link when `anchorNode` is not resolved correctly', async () => { |
| const data = { |
| identifier: '--identifier', |
| anchorNode: undefined, |
| }; |
| await createAnchorFunctionLinkSwatch(data); |
| |
| sinon.assert.calledWith(linkSwatchDataStub.set, { |
| text: data.identifier, |
| isDefined: false, |
| tooltip: {title: '--identifier is not defined'}, |
| jslogContext: 'anchor-link', |
| onLinkActivate: sinon.match.func, |
| }); |
| }); |
| |
| it('should call `onMouseEnter` when mouse enters linkSwatch', async () => { |
| const data = { |
| identifier: '--identifier', |
| anchorNode: sinon.createStubInstance(SDK.DOMModel.DOMNode), |
| }; |
| |
| const linkSwatch = await createAnchorFunctionLinkSwatch(data); |
| linkSwatch.querySelector('devtools-link-swatch')?.dispatchEvent(new Event('mouseenter')); |
| |
| sinon.assert.calledOnce(data.anchorNode.highlight); |
| }); |
| |
| it('should call `onMouseLeave` when mouse leaves linkSwatch', async () => { |
| const data = { |
| identifier: '--identifier', |
| anchorNode: sinon.createStubInstance(SDK.DOMModel.DOMNode), |
| }; |
| const linkSwatch = await createAnchorFunctionLinkSwatch(data); |
| linkSwatch.querySelector('devtools-link-swatch')?.dispatchEvent(new Event('mouseleave')); |
| |
| sinon.assert.calledOnce(hideDOMNodeHighlightStub); |
| }); |
| }); |
| |
| describe('when identifier does not exist', () => { |
| it('should not render anything when `anchorNode` is not resolved correctly', async () => { |
| const data = { |
| identifier: undefined, |
| anchorNode: undefined, |
| }; |
| const component = await createAnchorFunctionLinkSwatch(data); |
| |
| assert.isEmpty(component.innerHTML); |
| }); |
| |
| it('should render icon link when `anchorNode` is resolved correctly', async () => { |
| const data = { |
| identifier: undefined, |
| anchorNode: sinon.createStubInstance(SDK.DOMModel.DOMNode), |
| }; |
| const component = await createAnchorFunctionLinkSwatch(data); |
| |
| const icon = component?.querySelector('devtools-icon'); |
| |
| assert.exists(icon); |
| }); |
| |
| it('should call `onMouseEnter` when mouse enters the icon', async () => { |
| const data = { |
| identifier: undefined, |
| anchorNode: sinon.createStubInstance(SDK.DOMModel.DOMNode), |
| }; |
| const component = await createAnchorFunctionLinkSwatch(data); |
| |
| const icon = component?.querySelector('devtools-icon')!; |
| icon?.dispatchEvent(new Event('mouseenter')); |
| |
| sinon.assert.calledOnce(data.anchorNode.highlight); |
| }); |
| |
| it('should call `onMouseLeave` when mouse leaves the icon', async () => { |
| const data = { |
| identifier: undefined, |
| anchorNode: sinon.createStubInstance(SDK.DOMModel.DOMNode), |
| }; |
| const component = await createAnchorFunctionLinkSwatch(data); |
| |
| const icon = component?.querySelector('devtools-icon')!; |
| icon?.dispatchEvent(new Event('mouseleave')); |
| |
| sinon.assert.calledOnce(hideDOMNodeHighlightStub); |
| }); |
| |
| it('should call `onLinkActivate` when clicking on the icon', async () => { |
| const data = { |
| identifier: undefined, |
| anchorNode: sinon.createStubInstance(SDK.DOMModel.DOMNode), |
| }; |
| const component = await createAnchorFunctionLinkSwatch(data); |
| |
| const icon = component?.querySelector('devtools-icon')!; |
| icon?.dispatchEvent(new Event('click')); |
| |
| sinon.assert.calledOnce(revealStub); |
| assert.strictEqual(revealStub.args[0][0], data.anchorNode); |
| }); |
| }); |
| }); |
| |
| describe('AnchorFunctionRenderer', () => { |
| let highlightMock: sinon.SinonExpectation; |
| let fakeDOMNode: SDK.DOMModel.DOMNode; |
| |
| beforeEach(() => { |
| fakeDOMNode = { |
| localName() { |
| return 'span'; |
| }, |
| isSVGNode() { |
| return false; |
| }, |
| highlight() { |
| highlightMock(); |
| }, |
| } as SDK.DOMModel.DOMNode; |
| highlightMock = sinon.mock(); |
| sinon.stub(SDK.DOMModel.DOMNode.prototype, 'getAnchorBySpecifier').resolves(fakeDOMNode); |
| }); |
| |
| it('renders `position-anchor` property correctly before anchor is decorated', async () => { |
| const stylePropertyTreeElement = getTreeElement('position-anchor', '--anchor'); |
| |
| stylePropertyTreeElement.updateTitle(); |
| |
| assert.strictEqual(stylePropertyTreeElement.valueElement!.innerText, '--anchor'); |
| }); |
| |
| it('renders `position-anchor` property correctly after anchor is decorated', async () => { |
| const waitForDecorationPromise = |
| spyCall(Elements.StylePropertyTreeElement.AnchorFunctionRenderer, 'decorateAnchorForAnchorLink'); |
| const stylePropertyTreeElement = getTreeElement('position-anchor', '--anchor'); |
| |
| stylePropertyTreeElement.updateTitle(); |
| await (await waitForDecorationPromise).result; |
| |
| const anchorFunctionLinkSwatch = stylePropertyTreeElement.valueElement!.querySelector('devtools-link-swatch'); |
| assert.exists(anchorFunctionLinkSwatch); |
| }); |
| }); |
| |
| describe('LightDarkColorRenderer', () => { |
| it('renders light-dark correctly', async () => { |
| const colorSchemeSpy = |
| sinon.spy(Elements.StylePropertyTreeElement.LightDarkColorRenderer.prototype, 'applyColorScheme'); |
| const resolvePropertySpy = sinon.spy(SDK.CSSMatchedStyles.CSSMatchedStyles.prototype, 'resolveProperty'); |
| const colorSchemeProperty = addProperty('color-scheme', 'light dark'); |
| |
| async function check(colorScheme: SDK.CSSModel.ColorScheme, lightText: string, darkText: string) { |
| const variableName = (text: string) => text.substring('var('.length, text.length - 1); |
| const lightDark = `light-dark(${lightText}, ${darkText})`; |
| colorSchemeProperty.setLocalValue(colorScheme); |
| resolvePropertySpy.resetHistory(); |
| const stylePropertyTreeElement = getTreeElement('color', lightDark); |
| stylePropertyTreeElement.updateTitle(); |
| await Promise.all(colorSchemeSpy.returnValues); |
| |
| sinon.assert.calledOnceWithExactly( |
| resolvePropertySpy, 'color-scheme', stylePropertyTreeElement.property.ownerStyle); |
| |
| assert.exists(stylePropertyTreeElement.valueElement); |
| const swatches = stylePropertyTreeElement.valueElement.querySelectorAll('devtools-color-swatch'); |
| assert.exists(swatches); |
| assert.lengthOf(swatches, 3); |
| const [swatch, light, dark] = swatches; |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| assert.strictEqual((swatch?.nextElementSibling as HTMLElement | null)?.innerText, lightDark); |
| const activeColor = colorScheme === SDK.CSSModel.ColorScheme.LIGHT ? lightText : darkText; |
| assert.strictEqual(swatch.color?.getAuthoredText(), mockVariableMap[variableName(activeColor)] ?? activeColor); |
| const active = colorScheme === SDK.CSSModel.ColorScheme.LIGHT ? light : dark; |
| const inactive = colorScheme === SDK.CSSModel.ColorScheme.LIGHT ? dark : light; |
| assert.isTrue(inactive.parentElement?.classList.contains('inactive-value')); |
| assert.isFalse(active.parentElement?.classList.contains('inactive-value')); |
| stylePropertyTreeElement.valueElement.remove(); |
| } |
| |
| await check(SDK.CSSModel.ColorScheme.LIGHT, 'red', 'blue'); |
| await check(SDK.CSSModel.ColorScheme.DARK, 'red', 'blue'); |
| await check(SDK.CSSModel.ColorScheme.LIGHT, 'red', 'var(--blue)'); |
| await check(SDK.CSSModel.ColorScheme.DARK, 'red', 'var(--blue)'); |
| await check(SDK.CSSModel.ColorScheme.LIGHT, 'var(--blue)', 'red'); |
| await check(SDK.CSSModel.ColorScheme.DARK, 'var(--blue)', 'red'); |
| }); |
| |
| it('renders light-dark correctly if the color scheme cannot be resolved', async () => { |
| const lightDark = 'light-dark(red, blue)'; |
| const cssModel = sinon.createStubInstance(SDK.CSSModel.CSSModel); |
| sinon.stub(stylesSidebarPane, 'cssModel').returns(cssModel); |
| cssModel.colorScheme.resolves(undefined); |
| addProperty('color-scheme', 'light dark'); |
| const stylePropertyTreeElement = getTreeElement('color', lightDark); |
| const colorSchemeSpy = |
| sinon.spy(Elements.StylePropertyTreeElement.LightDarkColorRenderer.prototype, 'applyColorScheme'); |
| stylePropertyTreeElement.updateTitle(); |
| await Promise.all(colorSchemeSpy.returnValues); |
| |
| const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-color-swatch'); |
| assert.exists(swatches); |
| assert.lengthOf(swatches, 3); |
| assert.isNull(swatches[0].color); |
| assert.strictEqual(swatches[0].nextElementSibling?.textContent, 'light-dark(red, blue)'); |
| assert.strictEqual(swatches[1].nextElementSibling?.textContent, 'red'); |
| assert.strictEqual(swatches[2].nextElementSibling?.textContent, 'blue'); |
| }); |
| |
| it('renders light-dark without color-scheme correctly', async () => { |
| const lightDark = 'light-dark(red, blue)'; |
| const stylePropertyTreeElement = getTreeElement('color', lightDark); |
| // leave color-scheme unset |
| const colorSchemeSpy = |
| sinon.spy(Elements.StylePropertyTreeElement.LightDarkColorRenderer.prototype, 'applyColorScheme'); |
| stylePropertyTreeElement.updateTitle(); |
| await Promise.all(colorSchemeSpy.returnValues); |
| |
| const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-color-swatch'); |
| assert.exists(swatches); |
| assert.lengthOf(swatches, 3); |
| assert.strictEqual(swatches[0].getText(), 'red'); |
| assert.strictEqual(swatches[0].nextElementSibling?.textContent, 'light-dark(red, blue)'); |
| assert.strictEqual(swatches[1].nextElementSibling?.textContent, 'red'); |
| assert.strictEqual(swatches[2].nextElementSibling?.textContent, 'blue'); |
| }); |
| |
| it('renders light-dark with undefined vars correctly', async () => { |
| const lightDark = 'light-dark(red, var(--undefined))'; |
| addProperty('color-scheme', 'light dark'); |
| const stylePropertyTreeElement = getTreeElement('color', lightDark); |
| const colorSchemeSpy = |
| sinon.spy(Elements.StylePropertyTreeElement.LightDarkColorRenderer.prototype, 'applyColorScheme'); |
| stylePropertyTreeElement.updateTitle(); |
| await Promise.all(colorSchemeSpy.returnValues); |
| |
| const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-color-swatch'); |
| assert.exists(swatches); |
| assert.lengthOf(swatches, 1); |
| assert.strictEqual(swatches[0].nextElementSibling?.textContent, 'red'); |
| assert.strictEqual(swatches[0].parentElement?.style.textDecoration, ''); |
| }); |
| |
| it('connects inner and outer swatches', async () => { |
| const colorSchemeSpy = |
| sinon.spy(Elements.StylePropertyTreeElement.LightDarkColorRenderer.prototype, 'applyColorScheme'); |
| const colorSchemeProperty = addProperty('color-scheme', 'light dark'); |
| for (const colorScheme of [SDK.CSSModel.ColorScheme.LIGHT, SDK.CSSModel.ColorScheme.DARK]) { |
| const lightDark = 'light-dark(red, blue)'; |
| colorSchemeProperty.setLocalValue(colorScheme); |
| const stylePropertyTreeElement = getTreeElement('color', lightDark); |
| stylePropertyTreeElement.updateTitle(); |
| await Promise.all(colorSchemeSpy.returnValues); |
| |
| const swatches = stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-color-swatch'); |
| assert.exists(swatches); |
| assert.lengthOf(swatches, 3); |
| const [outerSwatch, lightSwatch, darkSwatch] = swatches; |
| const newLightColor = Common.Color.parse('white') as Common.Color.Color; |
| const newDarkColor = Common.Color.parse('black') as Common.Color.Color; |
| lightSwatch.color = newLightColor; |
| darkSwatch.color = newDarkColor; |
| |
| if (colorScheme === SDK.CSSModel.ColorScheme.DARK) { |
| assert.strictEqual(outerSwatch.color, newDarkColor); |
| } else { |
| assert.strictEqual(outerSwatch.color, newLightColor); |
| } |
| } |
| }); |
| }); |
| |
| describe('LinearGradientRenderer', () => { |
| it('correctly connects to an angle match', () => { |
| const stylePropertyTreeElement = getTreeElement('background', 'linear-gradient(45deg, red, var(--blue))'); |
| stylePropertyTreeElement.updateTitle(); |
| const swatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-css-angle'); |
| assert.exists(swatch); |
| swatch.data = { |
| angleText: swatch.innerText ?? '', |
| containingPane: document.createElement('div'), |
| }; |
| |
| sinon.stub(swatch, 'dispatchEvent'); |
| swatch.popOver(); |
| const popover = swatch.querySelector('devtools-css-angle-editor'); |
| assert.exists(popover); |
| const clock = popover.shadowRoot?.querySelector<HTMLElement>('.clock'); |
| assert.exists(clock); |
| assert.strictEqual(clock.style.background, 'linear-gradient(45deg, red, blue)'); |
| }); |
| }); |
| |
| describe('CSSWideKeywordRenderer', () => { |
| function mockResolvedKeyword(propertyName: string, _keyword: SDK.CSSMetadata.CSSWideKeyword, propertyValue = ''): |
| sinon.SinonStubbedInstance<SDK.CSSProperty.CSSProperty> { |
| const originalDeclaration = sinon.createStubInstance(SDK.CSSProperty.CSSProperty); |
| sinon.stub(matchedStyles, 'resolveGlobalKeyword') |
| .callsFake( |
| (property, keyword) => property.name === propertyName && property.value === keyword ? |
| new SDK.CSSMatchedStyles.CSSValueSource(originalDeclaration) : |
| null); |
| originalDeclaration.name = propertyName; |
| originalDeclaration.value = propertyValue; |
| originalDeclaration.ownerStyle = sinon.createStubInstance(SDK.CSSStyleDeclaration.CSSStyleDeclaration); |
| return originalDeclaration; |
| } |
| |
| it('linkifies keywords to the referenced declarations', () => { |
| const keyword = SDK.CSSMetadata.CSSWideKeywords[0]; |
| const originalDeclaration = mockResolvedKeyword('width', keyword); |
| const stylePropertyTreeElement = getTreeElement(originalDeclaration.name, keyword); |
| stylePropertyTreeElement.updateTitle(); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| |
| const linkSwatch = stylePropertyTreeElement.valueElement.querySelector('devtools-link-swatch'); |
| assert.isOk(linkSwatch); |
| assert.strictEqual(linkSwatch.innerText, keyword); |
| const spy = sinon.spy(stylePropertyTreeElement.parentPane(), 'revealProperty'); |
| (linkSwatch.querySelector('button') as HTMLElement | undefined)?.click(); |
| sinon.assert.calledOnceWithExactly(spy, originalDeclaration); |
| }); |
| |
| it('shows non-existant referenced declarations as unlinked', () => { |
| const stylePropertyTreeElement = getTreeElement('width', SDK.CSSMetadata.CSSWideKeywords[0]); |
| stylePropertyTreeElement.updateTitle(); |
| const linkSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-link-swatch'); |
| assert.isNotOk(linkSwatch); |
| }); |
| |
| it('renders colors', () => { |
| const keyword = SDK.CSSMetadata.CSSWideKeywords[0]; |
| const originalDeclaration = mockResolvedKeyword('color', keyword, 'red'); |
| const stylePropertyTreeElement = getTreeElement(originalDeclaration.name, keyword); |
| stylePropertyTreeElement.updateTitle(); |
| const colorSwatch = stylePropertyTreeElement.valueElement?.querySelector('devtools-color-swatch'); |
| assert.isOk(colorSwatch); |
| assert.strictEqual(colorSwatch.color?.asString(), 'red'); |
| }); |
| |
| it('does not render inside function rules', async () => { |
| const stylePropertyTreeElement = await getTreeElementForFunctionRule('--func', 'initial'); |
| stylePropertyTreeElement.updateTitle(); |
| assert.notExists(stylePropertyTreeElement.valueElement?.querySelector('devtools-link-swatch')); |
| }); |
| }); |
| |
| describe('PositionTryRenderer', () => { |
| it('renders the position-try fallback values with correct styles', () => { |
| sinon.stub(matchedStyles, 'activePositionFallbackIndex').returns(1); |
| sinon.stub(matchedStyles, 'positionTryRules').returns([]); |
| const stylePropertyTreeElement = getTreeElement('position-try-fallbacks', '--top, --left, --bottom'); |
| stylePropertyTreeElement.updateTitle(); |
| const values = stylePropertyTreeElement.valueElement?.querySelectorAll(':scope > span'); |
| assert.exists(values); |
| assert.strictEqual(values?.length, 3); |
| assert.isTrue(values[0].classList.contains('inactive-value')); |
| assert.isFalse(values[1].classList.contains('inactive-value')); |
| assert.isTrue(values[2].classList.contains('inactive-value')); |
| }); |
| |
| it('renders the position-try correctly with keyword', () => { |
| sinon.stub(matchedStyles, 'activePositionFallbackIndex').returns(1); |
| sinon.stub(matchedStyles, 'positionTryRules').returns([]); |
| const stylePropertyTreeElement = |
| getTreeElement('position-try', '/* comment */ most-height --top, --left, --bottom'); |
| stylePropertyTreeElement.updateTitle(); |
| const values = |
| stylePropertyTreeElement.valueElement?.querySelectorAll(':scope > span:has(> devtools-link-swatch)'); |
| assert.exists(values); |
| assert.strictEqual(values?.length, 3); |
| assert.isTrue(values[0].classList.contains('inactive-value')); |
| assert.isFalse(values[1].classList.contains('inactive-value')); |
| assert.isTrue(values[2].classList.contains('inactive-value')); |
| }); |
| }); |
| |
| describe('LengthRenderer', () => { |
| it('shows a popover with pixel values for relative units', async () => { |
| setMockConnectionResponseHandler( |
| 'CSS.resolveValues', |
| (request: Protocol.CSS.ResolveValuesRequest) => |
| ({results: request.values.map(v => v === '2em' ? '15px' : v)})); |
| const cssModel = new SDK.CSSModel.CSSModel(createTarget()); |
| 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); |
| const stylePropertyTreeElement = getTreeElement('property', '5px 2em'); |
| setMockConnectionResponseHandler( |
| 'CSS.getComputedStyleForNode', () => ({computedStyle: {}} as Protocol.CSS.GetComputedStyleForNodeResponse)); |
| |
| await stylePropertyTreeElement.onpopulate(); |
| stylePropertyTreeElement.updateTitle(); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement as HTMLElement); |
| const popover = stylePropertyTreeElement.valueElement?.querySelector('devtools-tooltip'); |
| assert.exists(popover); |
| const popoverOpenSpy = spyCall(Elements.StylePropertyTreeElement.LengthRenderer.prototype, 'getTooltipValue'); |
| popover.showPopover(); |
| await (await popoverOpenSpy).result; |
| assert.strictEqual(popover.deepInnerText(), '15px'); |
| }); |
| |
| it('passes the property name to evaluations', async () => { |
| const cssModel = stylesSidebarPane.cssModel(); |
| assert.exists(cssModel); |
| const resolveValuesStub = sinon.stub(cssModel, 'resolveValues').resolves([]); |
| const stylePropertyTreeElement = getTreeElement('left', '2%'); |
| stylePropertyTreeElement.updateTitle(); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement as HTMLElement); |
| stylePropertyTreeElement.valueElement?.querySelector('devtools-tooltip')?.showPopover(); |
| |
| sinon.assert.calledOnce(resolveValuesStub); |
| assert.strictEqual(resolveValuesStub.args[0][0], 'left'); |
| }); |
| |
| it('uses the right longhand name in length shorthands', async () => { |
| const cssModel = stylesSidebarPane.cssModel(); |
| assert.exists(cssModel); |
| const resolveValuesStub = sinon.stub(cssModel, 'resolveValues').resolves([]); |
| |
| for (const shorthand of Elements.StylePropertyTreeElement.SHORTHANDS_FOR_PERCENTAGES) { |
| const longhands = SDK.CSSMetadata.cssMetadata().getLonghands(shorthand); |
| assert.exists(longhands); |
| const stylePropertyTreeElement = getTreeElement(shorthand, longhands.map((_, i) => `${i * 2}%`).join(' ')); |
| stylePropertyTreeElement.updateTitle(); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement as HTMLElement, {allowMultipleChildren: true}); |
| |
| const resolvedValues: Array<string|undefined> = []; |
| const expectedCalls = expectCalled(resolveValuesStub, { |
| callCount: longhands.length, |
| fakeFn: (name, nodeIds, ...values) => { |
| resolvedValues.push(name); |
| return Promise.resolve(values.slice(0)); |
| } |
| }); |
| const tooltips = stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-tooltip'); |
| assert.exists(tooltips); |
| assert.lengthOf(tooltips, longhands.length); |
| tooltips.forEach(t => t.showPopover()); |
| |
| await expectedCalls; |
| |
| assert.deepEqual(resolvedValues, longhands); |
| resolveValuesStub.resetHistory(); |
| } |
| }); |
| |
| it('uses the right longhand name in length shorthands inside of substitutions during tracing', async () => { |
| const cssModel = stylesSidebarPane.cssModel(); |
| assert.exists(cssModel); |
| const resolveValuesStub = sinon.stub(cssModel, 'resolveValues').callsFake((name, nodeId, ...values) => { |
| return Promise.resolve(values.map(v => v === '20%' ? '100px' : v)); |
| }); |
| |
| const customPropertyDef = addProperty('--left', '20%'); |
| mockVariableMap['--left'] = customPropertyDef; |
| const stylePropertyTreeElement = getTreeElement('padding', '10px var(--left)'); |
| stylePropertyTreeElement.updateTitle(); |
| |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| |
| const tooltip = stylePropertyTreeElement.valueElement.querySelector('devtools-tooltip'); |
| const performUpdateStub = sinon.stub(Elements.CSSValueTraceView.CSSValueTraceView.prototype, 'performUpdate'); |
| const performUpdatePromise = new Promise<void>(resolve => performUpdateStub.callsFake(() => { |
| performUpdateStub.callThrough(); |
| resolve(); |
| })); |
| tooltip?.showPopover(); |
| await performUpdatePromise; |
| const args = resolveValuesStub.args.map(args => args[0]); |
| assert.deepEqual(args, ['padding-right']); |
| resolveValuesStub.resetHistory(); |
| }); |
| }); |
| |
| describe('MathFunctionRenderer', () => { |
| it('strikes out non-selected values', async () => { |
| setMockConnectionResponseHandler( |
| 'CSS.resolveValues', |
| (request: Protocol.CSS.ResolveValuesRequest) => ({ |
| results: request.values.map( |
| value => value.startsWith('min') ? '4px' : value.trim().replaceAll(/(em|pt)$/g, 'px')) |
| })); |
| const strikeOutSpy = |
| sinon.spy(Elements.StylePropertyTreeElement.MathFunctionRenderer.prototype, 'applyMathFunction'); |
| const stylePropertyTreeElement = getTreeElement('width', 'min(5em, 4px, 8pt)'); |
| stylePropertyTreeElement.updateTitle(); |
| |
| sinon.assert.calledOnce(strikeOutSpy); |
| await strikeOutSpy.returnValues[0]; |
| const args = stylePropertyTreeElement.valueElement?.querySelectorAll( |
| ':scope > span > span:not(.tracing-anchor)') as NodeListOf<HTMLSpanElement>; |
| assert.lengthOf(args, 3); |
| assert.deepEqual( |
| Array.from(args.values()).map(arg => arg.classList.contains('inactive-value')), [true, false, true]); |
| }); |
| |
| it('shows a value tracing tooltip on the calc function', async () => { |
| for (const property of ['calc(1px + 2px)', 'min(1px, 2px)', 'max(3px, 1px)']) { |
| const stylePropertyTreeElement = getTreeElement('width', property); |
| stylePropertyTreeElement.updateTitle(); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| renderElementIntoDOM(stylePropertyTreeElement.valueElement); |
| const tooltip = stylePropertyTreeElement.valueElement.querySelector('devtools-tooltip'); |
| assert.exists(tooltip); |
| const widget = tooltip.firstElementChild && LegacyUI.Widget.Widget.get(tooltip.firstElementChild); |
| assert.instanceOf(widget, Elements.CSSValueTraceView.CSSValueTraceView); |
| stylePropertyTreeElement.valueElement.remove(); |
| } |
| }); |
| |
| it('shows the original text during tracing when evaluation fails', async () => { |
| setMockConnectionResponseHandler( |
| 'CSS.resolveValues', |
| (request: Protocol.CSS.ResolveValuesRequest) => ({results: request.values.map(() => '')})); |
| const evaluationSpy = |
| sinon.spy(Elements.StylePropertyTreeElement.MathFunctionRenderer.prototype, 'applyEvaluation'); |
| const property = addProperty('width', 'calc(1 + 1)'); |
| |
| const view = new Elements.CSSValueTraceView.CSSValueTraceView(undefined, () => {}); |
| await view.showTrace( |
| property, null, matchedStyles, new Map(), |
| Elements.StylePropertyTreeElement.getPropertyRenderers( |
| property.name, property.ownerStyle, stylesSidebarPane, matchedStyles, null, new Map()), |
| false, 0, false); |
| |
| sinon.assert.calledOnce(evaluationSpy); |
| const originalText = evaluationSpy.args[0][0].textContent; |
| await evaluationSpy.returnValues[0]; |
| assert.strictEqual(originalText, evaluationSpy.args[0][0].textContent); |
| }); |
| |
| it('should try to resolve the values for the correct property name', async () => { |
| const cssModel = stylesSidebarPane.cssModel(); |
| assert.exists(cssModel); |
| const resolveValuesStub = sinon.stub(cssModel, 'resolveValues').resolves([]); |
| const property = addProperty('width', 'calc(1 + 1)'); |
| |
| const view = new Elements.CSSValueTraceView.CSSValueTraceView(undefined, () => {}); |
| await view.showTrace( |
| property, null, matchedStyles, new Map(), |
| Elements.StylePropertyTreeElement.getPropertyRenderers( |
| property.name, property.ownerStyle, stylesSidebarPane, matchedStyles, null, new Map()), |
| false, 0, false); |
| |
| sinon.assert.calledOnce(resolveValuesStub); |
| assert.strictEqual(resolveValuesStub.args[0][0], 'width'); |
| }); |
| }); |
| |
| describe('AutoBaseRenderer', () => { |
| it('strikes out non-selected values', async () => { |
| const stylePropertyTreeElement = getTreeElement('display', '-internal-auto-base(inline, block)'); |
| |
| stylePropertyTreeElement.updateTitle(); |
| |
| let args = stylePropertyTreeElement.valueElement?.querySelectorAll('span') as NodeListOf<HTMLSpanElement>; |
| assert.lengthOf(args, 5); |
| assert.deepEqual( |
| Array.from(args.values()).map(arg => arg.classList.contains('inactive-value')), |
| [false, false, false, true, false]); |
| |
| stylePropertyTreeElement.setComputedStyles(new Map([['appearance', 'base-select']])); |
| stylePropertyTreeElement.updateTitle(); |
| |
| args = stylePropertyTreeElement.valueElement?.querySelectorAll('span') as NodeListOf<HTMLSpanElement>; |
| assert.lengthOf(args, 5); |
| assert.deepEqual( |
| Array.from(args.values()).map(arg => arg.classList.contains('inactive-value')), |
| [false, true, false, false, false]); |
| }); |
| }); |
| |
| describe('EnvFunctionRenderer', () => { |
| it('strikes out non-selected values', async () => { |
| const stylePropertyTreeElement = getTreeElement('--env', 'env(a, b) env(c, b)'); |
| stylePropertyTreeElement.updateTitle(); |
| |
| const args = stylePropertyTreeElement.valueElement?.querySelectorAll('span') |
| .values() |
| .filter(span => ['a', 'b', 'c'].includes(span.textContent ?? '')) |
| .toArray(); |
| assert.exists(args); |
| assert.lengthOf(args, 4); |
| assert.deepEqual( |
| Array.from(args.values()).map(arg => arg.classList.contains('inactive-value')), [false, true, true, false]); |
| }); |
| |
| it('shows a value tracing tooltip', async () => { |
| const stylePropertyTreeElement = getTreeElement('--env', 'env(a, b) env(c, b)'); |
| stylePropertyTreeElement.updateTitle(); |
| assert.exists(stylePropertyTreeElement.valueElement); |
| const tooltips = stylePropertyTreeElement.valueElement.querySelectorAll('devtools-tooltip'); |
| assert.lengthOf(tooltips, 2); |
| const anchors = stylePropertyTreeElement.valueElement.querySelectorAll('.tracing-anchor'); |
| assert.lengthOf(anchors, 2); |
| assert.deepEqual(anchors.values().map(anchor => anchor.textContent).toArray(), ['env', 'env']); |
| }); |
| }); |
| |
| describe('Autocompletion', function(this: Mocha.Suite) { |
| let promptStub: sinon.SinonStub<Parameters<Elements.StylesSidebarPane.CSSPropertyPrompt['initialize']>>; |
| beforeEach(async () => { |
| promptStub = sinon.stub(Elements.StylesSidebarPane.CSSPropertyPrompt.prototype, 'initialize').resolves([]); |
| |
| const gridNode = LegacyUI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode); |
| const currentNode = new SDK.DOMModel.DOMNode(cssModel.domModel()); |
| currentNode.id = 1 as Protocol.DOM.NodeId; |
| currentNode.parentNode = gridNode; |
| LegacyUI.Context.Context.instance().setFlavor(SDK.DOMModel.DOMNode, currentNode); |
| }); |
| |
| function suggestions() { |
| assert.lengthOf(promptStub.args, 1); |
| return promptStub.args[0][0].call(null, '', '', false); |
| } |
| |
| function setParentComputedStyle(style: Record<string, string>) { |
| const computedStyle = Object.keys(style).map(name => ({name, value: style[name]})); |
| setMockConnectionResponseHandler('CSS.getComputedStyleForNode', ({nodeId}) => { |
| if (nodeId === 0) { |
| return {computedStyle} as Protocol.CSS.GetComputedStyleForNodeResponse; |
| } |
| return {} as Protocol.CSS.GetComputedStyleForNodeResponse; |
| }); |
| } |
| |
| it('includes grid row names', async () => { |
| setParentComputedStyle({display: 'grid', 'grid-template-rows': '[row-name] 1fr [row-name-2]'}); |
| const stylePropertyTreeElement = getTreeElement('grid-row', 'somename'); |
| await stylePropertyTreeElement.onpopulate(); |
| stylePropertyTreeElement.updateTitle(); |
| stylePropertyTreeElement.startEditingValue(); |
| const autocompletions = await suggestions(); |
| assert.deepEqual( |
| autocompletions.map(({text}) => text), |
| ['row-name', 'row-name-2', 'auto', 'none', 'inherit', 'initial', 'revert', 'revert-layer', 'unset']); |
| }); |
| |
| it('includes grid column names', async () => { |
| setParentComputedStyle({display: 'grid', 'grid-template-columns': '[col-name] 1fr [col-name-2]'}); |
| const stylePropertyTreeElement = getTreeElement('grid-column', 'somename'); |
| await stylePropertyTreeElement.onpopulate(); |
| stylePropertyTreeElement.updateTitle(); |
| stylePropertyTreeElement.startEditingValue(); |
| const autocompletions = await suggestions(); |
| assert.deepEqual( |
| autocompletions.map(({text}) => text), |
| ['col-name', 'col-name-2', 'auto', 'none', 'inherit', 'initial', 'revert', 'revert-layer', 'unset']); |
| }); |
| |
| it('includes grid area names', async () => { |
| setParentComputedStyle({display: 'grid', 'grid-template-areas': '"area-name-a area-name-b" "area-name-c ."'}); |
| const stylePropertyTreeElement = getTreeElement('grid-area', 'somename'); |
| await stylePropertyTreeElement.onpopulate(); |
| stylePropertyTreeElement.updateTitle(); |
| stylePropertyTreeElement.startEditingValue(); |
| const autocompletions = await suggestions(); |
| assert.deepEqual(autocompletions.map(({text}) => text), [ |
| 'area-name-a', |
| 'area-name-b', |
| 'area-name-c', |
| 'auto', |
| 'none', |
| 'inherit', |
| 'initial', |
| 'revert', |
| 'revert-layer', |
| 'unset', |
| ]); |
| }); |
| }); |
| |
| it('reopens open tooltips on updates', async () => { |
| const openTooltipStub = sinon.stub(Tooltips.Tooltip.Tooltip.prototype, 'showPopover'); |
| const openTooltipPromise1 = new Promise<void>(r => openTooltipStub.callsFake(r)); |
| const stylePropertyTreeElement = getTreeElement('color', 'color-mix(in srgb, red, blue)'); |
| stylePropertyTreeElement.updateTitle(); |
| const tooltip = stylePropertyTreeElement.valueElement?.querySelector( |
| 'devtools-tooltip:not([jslogcontext="elements.css-value-trace"])') as Tooltips.Tooltip.Tooltip | |
| null | undefined; |
| assert.exists(tooltip); |
| renderElementIntoDOM(tooltip); |
| tooltip.showTooltip(); |
| await openTooltipPromise1; |
| tooltip.remove(); |
| |
| const openTooltipPromise2 = new Promise<void>(r => openTooltipStub.callsFake(r)); |
| stylePropertyTreeElement.updateTitle(); |
| const tooltip2 = |
| stylePropertyTreeElement.valueElement?.querySelector( |
| 'devtools-tooltip:not([jslogcontext="elements.css-value-trace"])') as Tooltips.Tooltip.Tooltip | |
| null | undefined; |
| assert.exists(tooltip2); |
| renderElementIntoDOM(tooltip2); |
| await openTooltipPromise2; |
| assert.notStrictEqual(tooltip, tooltip2); |
| }); |
| |
| it('shows a property docs tooltip', async () => { |
| const webCustomDataStub = sinon.createStubInstance(Elements.WebCustomData.WebCustomData); |
| webCustomDataStub.findCssProperty.returns({name: 'color', description: 'test color'}); |
| sinon.stub(stylesSidebarPane, 'webCustomData').get(() => webCustomDataStub); |
| Common.Settings.Settings.instance().moduleSetting('show-css-property-documentation-on-hover').set(false); |
| const treeElementWithoutTooltip = getTreeElement('color', 'blue'); |
| treeElementWithoutTooltip.treeOutline = new LegacyUI.TreeOutline.TreeOutline(); |
| treeElementWithoutTooltip.updateTitle(); |
| assert.notExists(treeElementWithoutTooltip.listItemElement.querySelector( |
| 'devtools-tooltip[jslogcontext="elements.css-property-doc"]')); |
| |
| Common.Settings.Settings.instance().moduleSetting('show-css-property-documentation-on-hover').set(true); |
| const treeElementWithTooltip = getTreeElement('color', 'blue'); |
| treeElementWithTooltip.treeOutline = new LegacyUI.TreeOutline.TreeOutline(); |
| treeElementWithTooltip.updateTitle(); |
| renderElementIntoDOM(treeElementWithTooltip.listItemElement); |
| const tooltip = treeElementWithTooltip.listItemElement.querySelector( |
| 'devtools-tooltip[jslogcontext="elements.css-property-doc"]'); |
| assert.instanceOf(tooltip, Tooltips.Tooltip.Tooltip); |
| |
| tooltip.showPopover(); |
| assert.isTrue(tooltip.open); |
| tooltip.hidePopover(); |
| assert.isFalse(tooltip.open); |
| |
| Common.Settings.Settings.instance().moduleSetting('show-css-property-documentation-on-hover').set(false); |
| tooltip.showPopover(); |
| assert.isFalse(tooltip.open); |
| }); |
| |
| it('does not show a variable tooltip on custom property names in function rules', async () => { |
| const stylePropertyTreeElement = await getTreeElementForFunctionRule('--func', 'red', '--foo'); |
| stylePropertyTreeElement.treeOutline = new LegacyUI.TreeOutline.TreeOutline(); |
| stylePropertyTreeElement.updateTitle(); |
| assert.exists(stylePropertyTreeElement.nameElement); |
| assert.notExists(stylePropertyTreeElement.nameElement.getAttribute('aria-details')); |
| assert.exists(stylePropertyTreeElement.nameElement.parentElement); |
| assert.notExists(stylePropertyTreeElement.nameElement.parentElement.querySelector('devtools-tooltip')); |
| }); |
| |
| it('correctly identifies when a semicolon terminates editing a property', () => { |
| const inputText = '" " ( ) [ ] { } { ( ) } { [ ( " ) " ) ] } { [ } ] } ( " ) " )'; |
| const positions = '+--++--++--++--++------++----------------++--------++--------'; |
| // + identifies a position in which a semicolon should terminate editing |
| for (let i = 0; i < inputText.length; i++) { |
| const shouldCommit = |
| Elements.StylePropertyTreeElement.StylePropertyTreeElement.shouldCommitValueSemicolon(inputText, i); |
| assert.strictEqual(shouldCommit, positions[i] === '+', `\n${inputText}\n${' '.repeat(i)}^`); |
| } |
| }); |
| }); |