chrome-devtools-frontend
Version:
Chrome DevTools UI
341 lines (296 loc) • 17.2 kB
text/typescript
// 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']);
});
});