UNPKG

chrome-devtools-frontend

Version:
341 lines (296 loc) • 17.2 kB
// Copyright 2024 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as SDK from '../../core/sdk/sdk.js'; import {renderElementIntoDOM} from '../../testing/DOMHelpers.js'; import {describeWithEnvironment} from '../../testing/EnvironmentHelpers.js'; import {Printer} from '../../testing/PropertyParser.js'; import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js'; import * as Elements from './elements.js'; describeWithEnvironment('PropertyRenderer', () => { function renderValueElement(name: string, value: string) { return Elements.PropertyRenderer.Renderer.renderValueElement( {name, value}, SDK.CSSPropertyParser.matchDeclaration(name, value, []), []); } describe('Renderer', () => { function textFragments(nodes: Node[]): Array<string|null> { return nodes.map(n => n.textContent); } it('parses text', () => { // Prevent normaliztaion to get an accurate representation of the parser result. sinon.stub(Element.prototype, 'normalize'); assert.deepEqual( textFragments(Array.from(renderValueElement('--p', 'var(--v)').valueElement.childNodes)), ['var', '(', '--v', ')']); assert.deepEqual( textFragments( Array.from(renderValueElement('--p', '/* comments are text */ 1px solid 4').valueElement.childNodes)), ['/* comments are text */', ' ', '1px', ' ', 'solid', ' ', '4']); assert.deepEqual( textFragments(Array.from( renderValueElement('--p', '2px var(--double, var(--fallback, black)) #32a1ce rgb(124 125 21 0)') .valueElement.childNodes)), [ '2px', ' ', 'var', '(', '--double', ',', ' ', 'var', '(', '--fallback', ',', ' ', 'black', ')', ')', ' ', '#32a1ce', ' ', 'rgb', '(', '124', ' ', '125', ' ', '21', ' ', '0', ')', ]); }); const cssParser = CodeMirror.css.cssLanguage.parser; it('reproduces the input if nothing matched', () => { const property = '2px var(--double, var(--fallback, black)) #32a1ce rgb(124 125 21 0)'; const rule = `*{--property: ${property};}`; const tree = cssParser.parse(rule).topNode; const ast = new SDK.CSSPropertyParser.SyntaxTree(property, rule, tree); const matchedResult = SDK.CSSPropertyParser.BottomUpTreeMatching.walk(ast, []); const context = new Elements.PropertyRenderer.RenderingContext(ast, null, new Map(), matchedResult); assert.deepEqual( textFragments(Elements.PropertyRenderer.Renderer.render(tree, context).nodes).join(''), rule, Printer.walk(ast).get()); }); it('nicely formats binary expressions', () => { const property = 'calc((50 -(0* 4))* 1vmin)'; const rule = `*{--property: ${property};}`; const tree = cssParser.parse(rule).topNode; const ast = new SDK.CSSPropertyParser.SyntaxTree(property, rule, tree); const matchedResult = SDK.CSSPropertyParser.BottomUpTreeMatching.walk(ast, [new SDK.CSSPropertyParserMatchers.BinOpMatcher()]); const renderer = new Elements.PropertyRenderer.BinOpRenderer(); const context = new Elements.PropertyRenderer.RenderingContext( ast, null, new Map([[renderer.matchType, renderer]]), matchedResult); assert.deepEqual( textFragments(Elements.PropertyRenderer.Renderer.render(tree, context).nodes).join(''), '*{--property: calc((50 - (0 * 4)) * 1vmin);}', Printer.walk(ast).get()); }); it('correctly renders subtrees', () => { const property = '2px var(--double, var(--fallback, black)) #32a1ce rgb(124 125 21 0)'; const rule = `*{--property: ${property};}`; const tree = cssParser.parse(rule).topNode.firstChild?.firstChild?.nextSibling?.firstChild?.nextSibling; assert.exists(tree); const ast = new SDK.CSSPropertyParser.SyntaxTree(property, rule, tree); const matchedResult = SDK.CSSPropertyParser.BottomUpTreeMatching.walk(ast, []); const context = new Elements.PropertyRenderer.RenderingContext(ast, null, new Map(), matchedResult); assert.deepEqual( textFragments(Elements.PropertyRenderer.Renderer.render(tree, context).nodes).join(''), property, Printer.walk(ast).get()); }); it('renders trailing comments', () => { const property = '/* color: red */ blue /* color: red */'; assert.strictEqual( textFragments(Array.from(renderValueElement('--p', property).valueElement.childNodes)).join(''), property); }); it('renders malformed comments', () => { const property = 'red /* foo: bar'; assert.strictEqual( textFragments(Array.from(renderValueElement('--p', property).valueElement.childNodes)).join(''), property); }); }); }); describe('TracingContext', () => { it('assumes no substitutions by default', () => { const matchedResult = SDK.CSSPropertyParser.matchDeclaration('prop', 'value', []); assert.exists(matchedResult); const context = new Elements.PropertyRenderer.TracingContext( new Elements.PropertyRenderer.Highlighting(), false, 0, matchedResult); assert.isFalse(context.nextSubstitution()); sinon.stub(matchedResult, 'hasMatches').returns(true); const context2 = new Elements.PropertyRenderer.TracingContext( new Elements.PropertyRenderer.Highlighting(), false, 0, matchedResult); assert.isTrue(context2.nextSubstitution()); const context3 = new Elements.PropertyRenderer.TracingContext(new Elements.PropertyRenderer.Highlighting(), false); assert.isFalse(context3.nextSubstitution()); }); it('controls substitution by creating "nested" tracing contexts', () => { const matchedResult = SDK.CSSPropertyParser.matchDeclaration('prop', 'value', []); assert.exists(matchedResult); sinon.stub(matchedResult, 'hasMatches').returns(true); const context = new Elements.PropertyRenderer.TracingContext( new Elements.PropertyRenderer.Highlighting(), false, 0, matchedResult); assert.isTrue(context.nextSubstitution()); assert.exists(context.substitution()); assert.notExists(context.substitution()?.substitution()); assert.notExists(context.substitution()?.substitution()?.substitution()); assert.isTrue(context.nextSubstitution()); assert.exists(context.substitution()); assert.exists(context.substitution()?.substitution()); assert.notExists(context.substitution()?.substitution()?.substitution()); assert.isTrue(context.nextSubstitution()); assert.exists(context.substitution()); assert.exists(context.substitution()?.substitution()); assert.exists(context.substitution()?.substitution()?.substitution()); assert.isFalse(context.nextSubstitution()); assert.exists(context.substitution()); assert.exists(context.substitution()?.substitution()); assert.exists(context.substitution()?.substitution()?.substitution()); }); it('does not allow tracing evaluations until substitutions are exhausted', () => { const matchedResult = SDK.CSSPropertyParser.matchDeclaration('prop', 'value', []); assert.exists(matchedResult); sinon.stub(matchedResult, 'hasMatches').returns(true); const context = new Elements.PropertyRenderer.TracingContext( new Elements.PropertyRenderer.Highlighting(), false, 0, matchedResult); assert.throw(() => context.nextEvaluation()); context.nextSubstitution(); assert.doesNotThrow(() => context.nextEvaluation()); }); it('controls evaluations creating nested context', () => { const matchedResult = SDK.CSSPropertyParser.matchDeclaration('prop', 'value', []); assert.exists(matchedResult); const context = new Elements.PropertyRenderer.TracingContext( new Elements.PropertyRenderer.Highlighting(), false, 0, matchedResult); const evaluation = () => ({placeholder: []}); // Evaluations are applied bottom up assert.isTrue(context.nextEvaluation()); { // Top-down pass: first prepare evaluations for outermost scopes with nested expressions [1, 2] and [4, 5, 6] const childContexts = context.evaluation([1, 2]); assert.exists(childContexts); assert.lengthOf(childContexts, 2); const [firstChild, secondChild] = childContexts; const [firstGrandChild, secondGrandChild, thirdGrandChild] = secondChild.evaluation([4, 5, 6]) ?? []; assert.exists(firstGrandChild); assert.exists(secondGrandChild); assert.exists(thirdGrandChild); // Bottom-up pass: actually apply evaluations "from the inside out". // Grand children don't have any inner expressions to be evaluated, no need to request evaluation and no inner // tracing contexts to be passed here. assert.isOk(firstGrandChild.applyEvaluation([], evaluation)); assert.isOk(secondGrandChild.applyEvaluation([], evaluation)); assert.isOk(thirdGrandChild.applyEvaluation([], evaluation)); assert.isOk(secondChild.applyEvaluation([], evaluation)); // firstChild has inner expressions (the grand children), so pass the inner tracing contexts here. assert.isNotOk(firstChild.applyEvaluation([firstGrandChild, secondGrandChild, thirdGrandChild], evaluation)); assert.isNotOk(context.applyEvaluation([firstChild, secondChild], evaluation)); } assert.isTrue(context.nextEvaluation()); { const [firstChild, secondChild] = context.evaluation([1, 2]) ?? []; assert.exists(firstChild); assert.exists(secondChild); const [firstGrandChild, secondGrandChild, thirdGrandChild] = secondChild.evaluation([4, 5, 6]) ?? []; assert.exists(firstGrandChild); assert.exists(secondGrandChild); assert.exists(thirdGrandChild); assert.isOk(firstGrandChild.applyEvaluation([], evaluation)); assert.isOk(secondGrandChild.applyEvaluation([], evaluation)); assert.isOk(thirdGrandChild.applyEvaluation([], evaluation)); assert.isOk(secondChild.applyEvaluation([], evaluation)); assert.isOk(firstChild.applyEvaluation([firstGrandChild, secondGrandChild, thirdGrandChild], evaluation)); assert.isNotOk(context.applyEvaluation([firstChild, secondChild], evaluation)); } assert.isTrue(context.nextEvaluation()); { const [firstChild, secondChild] = context.evaluation([1, 2]) ?? []; assert.exists(firstChild); assert.exists(secondChild); const [firstGrandChild, secondGrandChild, thirdGrandChild] = secondChild.evaluation([4, 5, 6]) ?? []; assert.exists(firstGrandChild); assert.exists(secondGrandChild); assert.exists(thirdGrandChild); assert.isOk(firstGrandChild.applyEvaluation([], evaluation)); assert.isOk(secondGrandChild.applyEvaluation([], evaluation)); assert.isOk(thirdGrandChild.applyEvaluation([], evaluation)); assert.isOk(secondChild.applyEvaluation([], evaluation)); assert.isOk(firstChild.applyEvaluation([firstGrandChild, secondGrandChild, thirdGrandChild], evaluation)); assert.isOk(context.applyEvaluation([firstChild, secondChild], evaluation)); } assert.isFalse(context.nextEvaluation()); }); it('can inject itself into a RenderingContext', () => { const tracingContext = new Elements.PropertyRenderer.TracingContext(new Elements.PropertyRenderer.Highlighting(), false); const renderingContext = sinon.createStubInstance(Elements.PropertyRenderer.RenderingContext); assert.strictEqual(tracingContext.renderingContext(renderingContext).tracing, tracingContext); }); it('keeps track of longhand offsets', () => { // The property name isn't relevant, but we need something with sufficiently many longhands. `animation` has lots // of longhands. const matchedResult = SDK.CSSPropertyParser.matchDeclaration( 'animation', 'a b var(--c)', [new SDK.CSSPropertyParserMatchers.BaseVariableMatcher(match => match.name === '--c' ? 'ddd' : null)]); assert.exists(matchedResult); const tracingContext = new Elements.PropertyRenderer.TracingContext( new Elements.PropertyRenderer.Highlighting(), false, 0, matchedResult); // The initial offset is 0. assert.strictEqual(tracingContext.longhandOffset, 0); const varNode = matchedResult.ast.tree.lastChild; const match = varNode && matchedResult.getMatch(varNode); assert.exists(match); // Apply one level of substitutions (for the var(--c)). assert.isTrue(tracingContext.nextSubstitution()); const childTracingContext = tracingContext.substitution({ match, context: new Elements.PropertyRenderer.RenderingContext(matchedResult.ast, null, new Map(), matchedResult) }); assert.exists(childTracingContext); // The var(--c) appears in the longhand position #2. assert.strictEqual(childTracingContext?.longhandOffset, 2); // Now test that the offset of the var(--c) gets passed down to what the var gets substituted with. const innerMatchedResult = SDK.CSSPropertyParser.matchDeclaration('--c', 'c1 c2 c3', []); assert.exists(innerMatchedResult); const lastNode = innerMatchedResult.ast.tree.lastChild; // c3 assert.exists(lastNode); const renderingContext = new Elements.PropertyRenderer.RenderingContext( innerMatchedResult.ast, null, new Map(), innerMatchedResult, undefined, undefined, childTracingContext); const longhands = SDK.CSSMetadata.cssMetadata().getLonghands(matchedResult.ast.propertyName ?? ''); assert.exists(longhands); // In the fully substituted value, c3 appears in the fifth position. const expectedLonghand = longhands.at(4); assert.exists(expectedLonghand); assert.strictEqual(renderingContext.getComputedLonghandName(lastNode), expectedLonghand); }); }); describe('Highlighting', () => { const node = (id: string) => { const span = document.createElement('span'); span.textContent = id; span.id = `node-${id}`; renderElementIntoDOM(span, {allowMultipleChildren: true}); return span; }; beforeEach(() => { const highlighting = new Elements.PropertyRenderer.Highlighting(); const match1 = sinon.createStubInstance(SDK.CSSPropertyParserMatchers.TextMatch); const match2 = sinon.createStubInstance(SDK.CSSPropertyParserMatchers.TextMatch); highlighting.addMatch(match1, [node('1'), node('2'), node('3')]); highlighting.addMatch(match1, [node('4'), node('5'), node('6'), node('7')]); highlighting.addMatch(match1, [node('8')]); highlighting.addMatch(match2, [node('a'), node('b'), node('c')]); }); it('adds highlights on mouseenter', () => { const registry = CSS.highlights.get(Elements.PropertyRenderer.Highlighting.REGISTRY_NAME); assert.exists(registry); document.querySelector('#node-6')?.dispatchEvent(new MouseEvent('mouseenter')); assert.deepEqual( Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['123', '4567', '8']); }); it('removes highlights on mouseexit', () => { const registry = CSS.highlights.get(Elements.PropertyRenderer.Highlighting.REGISTRY_NAME); assert.exists(registry); document.querySelector('#node-6')?.dispatchEvent(new MouseEvent('mouseenter')); assert.deepEqual( Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['123', '4567', '8']); document.querySelector('#node-6')?.dispatchEvent(new MouseEvent('mouseleave')); assert.deepEqual(Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), []); }); it('replaces highlights on subsequent mouseenter', () => { const registry = CSS.highlights.get(Elements.PropertyRenderer.Highlighting.REGISTRY_NAME); assert.exists(registry); document.querySelector('#node-6')?.dispatchEvent(new MouseEvent('mouseenter')); assert.deepEqual( Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['123', '4567', '8']); document.querySelector('#node-a')?.dispatchEvent(new MouseEvent('mouseenter')); assert.deepEqual(Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['abc']); }); it('restores previous highlights on mouseexit', () => { const registry = CSS.highlights.get(Elements.PropertyRenderer.Highlighting.REGISTRY_NAME); assert.exists(registry); document.querySelector('#node-6')?.dispatchEvent(new MouseEvent('mouseenter')); assert.deepEqual( Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['123', '4567', '8']); document.querySelector('#node-a')?.dispatchEvent(new MouseEvent('mouseenter')); assert.deepEqual(Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['abc']); document.querySelector('#node-a')?.dispatchEvent(new MouseEvent('mouseleave')); assert.deepEqual( Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['123', '4567', '8']); }); });