blob: 86f2a38d8f5a8fb80a5dda7cd845cf304ef20149 [file] [edit]
// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chai';
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import * as ComputedStyle from '../../models/computed_style/computed_style.js';
import {raf, renderElementIntoDOM} from '../../testing/DOMHelpers.js';
import {
createTarget,
describeWithEnvironment,
updateHostConfig,
} from '../../testing/EnvironmentHelpers.js';
import {expectCall} from '../../testing/ExpectStubCall.js';
import {setupLocaleHooks} from '../../testing/LocaleHelpers.js';
import {describeWithMockConnection, setMockConnectionResponseHandler} from '../../testing/MockConnection.js';
import {createStubbedDomNodeWithModels, getMatchedStyles} from '../../testing/StyleHelpers.js';
import * as TextEditor from '../../ui/components/text_editor/text_editor.js';
import * as InlineEditor from '../../ui/legacy/components/inline_editor/inline_editor.js';
import * as UI from '../../ui/legacy/legacy.js';
import {html} from '../../ui/lit/lit.js';
import * as PanelsCommon from '../common/common.js';
import * as Elements from './elements.js';
describe('StylesSidebarPane', () => {
let node: SDK.DOMModel.DOMNode;
beforeEach(() => {
({node} = createStubbedDomNodeWithModels({nodeId: 1}));
UI.Context.Context.instance().setFlavor(SDK.DOMModel.DOMNode, node);
});
describeWithMockConnection('StylesSidebarPane', () => {
beforeEach(() => {
const target = createTarget();
const cssModel = target.model(SDK.CSSModel.CSSModel);
sinon.stub(ComputedStyle.ComputedStyleModel.ComputedStyleModel.prototype, 'cssModel').returns(cssModel);
sinon.stub(Host.AidaClient.HostConfigTracker, 'instance').returns({
addEventListener: () => {},
removeEventListener: () => {},
dispose: () => {},
} as unknown as Host.AidaClient.HostConfigTracker);
});
it('unescapes CSS strings', () => {
assert.strictEqual(
Elements.StylesSidebarPane.unescapeCssString(
String.raw`"I\F1 t\EB rn\E2 ti\F4 n\E0 liz\E6 ti\F8 n\2603 \1F308 can be \t\r\ic\k\y"`),
'"I\xF1t\xEBrn\xE2ti\xF4n\xE0liz\xE6ti\xF8n\u2603\u{1F308} can be tricky"');
assert.strictEqual(
Elements.StylesSidebarPane.unescapeCssString(String.raw`"_\DBFF_\\DBFF_\\\DBFF_\\\\DBFF_\\\\\DBFF_"`),
'"_\uFFFD_\\DBFF_\\\\DBFF_\\\\\\DBFF_\\\\\\\\DBFF_"');
assert.strictEqual(
Elements.StylesSidebarPane.unescapeCssString(String.raw`"\0_\DBFF_\DFFF_\110000"`),
'"\uFFFD_\uFFFD_\uFFFD_\uFFFD"', 'U+0000, lone surrogates, and values above U+10FFFF should become U+FFFD');
assert.strictEqual(
Elements.StylesSidebarPane.unescapeCssString(String.raw`"_\D83C\DF08_"`), '"_\uFFFD\uFFFD_"',
'surrogates should not be combined');
assert.strictEqual(
Elements.StylesSidebarPane.unescapeCssString('"_\\41\n_\\41\t_\\41\x20_"'), '"_A_A_A_"',
'certain trailing whitespace characters should be consumed as part of the escape sequence');
});
it('escapes URL as CSS comments', () => {
assert.strictEqual(Elements.StylesSidebarPane.escapeUrlAsCssComment('https://abc.com/'), 'https://abc.com/');
assert.strictEqual(Elements.StylesSidebarPane.escapeUrlAsCssComment('https://abc.com/*/'), 'https://abc.com/*/');
assert.strictEqual(
Elements.StylesSidebarPane.escapeUrlAsCssComment('https://abc.com/*/?q=*'), 'https://abc.com/*/?q=*');
assert.strictEqual(
Elements.StylesSidebarPane.escapeUrlAsCssComment('https://abc.com/*/?q=*/'), 'https://abc.com/*/?q=*%2F');
assert.strictEqual(
Elements.StylesSidebarPane.escapeUrlAsCssComment('https://abc.com/*/?q=*/#hash'),
'https://abc.com/*/?q=*%2F#hash');
});
describe('rebuildSectionsForMatchedStyleRulesForTest', () => {
it('should add @position-try section', async () => {
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node: sinon.createStubInstance(SDK.DOMModel.DOMNode),
positionTryRules: [{
name: {text: '--try-one'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'bottom', value: 'anchor(--anchor-name bottom)'}],
shorthandEntries: [],
},
active: false,
}],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks, 2);
assert.strictEqual(sectionBlocks[1].titleElement()?.textContent, '@position-try --try-one');
assert.lengthOf(sectionBlocks[1].sections, 1);
assert.instanceOf(sectionBlocks[1].sections[0], Elements.StylePropertiesSection.PositionTryRuleSection);
});
it('correctly hides and shows nested section blocks when filtering', async () => {
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.nodeName.returns('div');
node.id = 1 as Protocol.DOM.NodeId;
const parentNode = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.parentNode = parentNode;
parentNode.nodeName.returns('body');
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
matchedPayload: [{
rule: {
selectorList: {selectors: [{text: 'div'}], text: 'div'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'background', value: 'red'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
}],
inheritedPayload: [{
matchedCSSRules: [{
rule: {
selectorList: {selectors: [{text: 'body'}], text: 'body'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'blue'}],
shorthandEntries: [],
},
layers: [{text: 'mylayer'}],
},
matchingSelectors: [0],
}],
}],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks, 3);
const inheritedBlock = sectionBlocks[1];
assert.strictEqual(inheritedBlock.titleElement()?.textContent, 'Inherited from ');
assert.lengthOf(inheritedBlock.childBlocks, 1);
const layerBlock = inheritedBlock.childBlocks[0];
assert.strictEqual(layerBlock.titleElement()?.textContent, 'Layermylayer');
const elementStyleSections = sectionBlocks[0].sections;
const inheritedStyleSections = layerBlock.sections;
// Filter to something that only matches the inherited style.
stylesSidebarPane.setFilter(/color/i);
sectionBlocks.forEach(block => block.updateFilter());
assert.isTrue(elementStyleSections[0].element.classList.contains('hidden'));
assert.isFalse(inheritedStyleSections[0].element.classList.contains('hidden'));
assert.isFalse(inheritedBlock.titleElement()?.classList.contains('hidden'));
assert.isFalse(layerBlock.titleElement()?.classList.contains('hidden'));
// Filter to something that matches nothing.
stylesSidebarPane.setFilter(/display/i);
sectionBlocks.forEach(block => block.updateFilter());
assert.isTrue(elementStyleSections[0].element.classList.contains('hidden'));
assert.isTrue(inheritedStyleSections[0].element.classList.contains('hidden'));
assert.isTrue(inheritedBlock.titleElement()?.classList.contains('hidden'));
assert.isTrue(layerBlock.titleElement()?.classList.contains('hidden'));
// Clear filter.
stylesSidebarPane.setFilter(null);
sectionBlocks.forEach(block => block.updateFilter());
assert.isFalse(elementStyleSections[0].element.classList.contains('hidden'));
assert.isFalse(inheritedStyleSections[0].element.classList.contains('hidden'));
assert.isFalse(inheritedBlock.titleElement()?.classList.contains('hidden'));
assert.isFalse(layerBlock.titleElement()?.classList.contains('hidden'));
});
});
describe('collapsing non-contributing sections', () => {
const enableCollapse = () =>
Common.Settings.Settings.instance().moduleSetting('collapse-non-contributing-css-rules').set(true);
it('collapses a section where all properties are overloaded', async () => {
enableCollapse();
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.id = 1 as Protocol.DOM.NodeId;
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
matchedPayload: [
// Lower specificity rule: color will be overloaded by the higher specificity rule.
{
rule: {
selectorList: {selectors: [{text: 'div'}], text: 'div'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'blue'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
// Higher specificity rule: color is active here.
{
rule: {
selectorList: {selectors: [{text: '#id'}], text: '#id'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'red'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 2);
const activeSection = sectionBlocks[0].sections.find(section => section.headerText() === '#id');
const overloadedSection = sectionBlocks[0].sections.find(section => section.headerText() === 'div');
if (!activeSection || !overloadedSection) {
assert.fail('Expected #id and div sections to exist');
}
const overloadedProperty = overloadedSection.style().leadingProperties()[0];
if (!overloadedProperty) {
assert.fail('Expected overloaded section to have at least one property');
}
assert.strictEqual(
matchedStyles.propertyState(overloadedProperty), SDK.CSSMatchedStyles.PropertyState.OVERLOADED,
'Expected div{color:blue} to be overloaded by #id{color:red}');
// The section with all overloaded properties should be collapsed.
assert.isTrue(
overloadedSection.element.classList.contains('collapsed'),
'Section with all overloaded properties should have collapsed class');
// The section with active properties should NOT be collapsed.
assert.isFalse(
activeSection.element.classList.contains('collapsed'),
'Section with active properties should not be collapsed');
});
it('does not collapse non-contributing sections when the setting is disabled', async () => {
Common.Settings.Settings.instance().moduleSetting('collapse-non-contributing-css-rules').set(false);
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.id = 1 as Protocol.DOM.NodeId;
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
matchedPayload: [
{
rule: {
selectorList: {selectors: [{text: 'div'}], text: 'div'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'blue'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
{
rule: {
selectorList: {selectors: [{text: '#id'}], text: '#id'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'red'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
const overloadedSection = sectionBlocks[0].sections.find(section => section.headerText() === 'div');
if (!overloadedSection) {
assert.fail('Expected div section to exist');
}
assert.isFalse(
overloadedSection.element.classList.contains('collapsed'),
'Section with all overloaded properties should not collapse when the setting is disabled');
assert.isFalse(
overloadedSection.element.classList.contains('collapsible'),
'Section should not be marked collapsible when automatic collapsing is disabled');
});
it('collapses an empty section (no leading properties)', async () => {
enableCollapse();
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.id = 1 as Protocol.DOM.NodeId;
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
matchedPayload: [
// Empty rule (no properties -- common with CSS nesting).
{
rule: {
selectorList: {selectors: [{text: '.empty'}], text: '.empty'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
// Non-empty rule.
{
rule: {
selectorList: {selectors: [{text: '#id'}], text: '#id'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'red'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 2);
const activeSection = sectionBlocks[0].sections[0]; // #id - has properties
const emptySection = sectionBlocks[0].sections[1]; // .empty - no properties
assert.isTrue(emptySection.element.classList.contains('collapsed'), 'Empty section should be collapsed');
assert.isFalse(
activeSection.element.classList.contains('collapsed'), 'Non-empty section should not be collapsed');
});
it('does NOT collapse a section containing only disabled properties', async () => {
enableCollapse();
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.id = 1 as Protocol.DOM.NodeId;
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
matchedPayload: [
// Rule with a disabled (user-toggled-off) property.
{
rule: {
selectorList: {selectors: [{text: '.disabled-props'}], text: '.disabled-props'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'blue', disabled: true}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
// Another rule so the disabled one has something to compare against.
{
rule: {
selectorList: {selectors: [{text: '#id'}], text: '#id'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'background', value: 'white'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 2);
const disabledSection = sectionBlocks[0].sections[1]; // .disabled-props
// A section with disabled properties should NOT be collapsed.
// Disabled properties are user-intentional and should remain visible.
assert.isFalse(
disabledSection.element.classList.contains('collapsed'),
'Section with disabled properties should not be collapsed');
});
it('does NOT collapse a section with at least one active property', async () => {
enableCollapse();
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.id = 1 as Protocol.DOM.NodeId;
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
matchedPayload: [
// Rule with one overloaded and one active property.
{
rule: {
selectorList: {selectors: [{text: 'div'}], text: 'div'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [
{name: 'color', value: 'blue'}, // will be overloaded
{name: 'margin', value: '10px'}, // will be active (not set elsewhere)
],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
// Higher specificity rule that overrides color.
{
rule: {
selectorList: {selectors: [{text: '#id'}], text: '#id'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'red'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 2);
const mixedSection = sectionBlocks[0].sections[1]; // div - has one active, one overloaded
assert.isFalse(
mixedSection.element.classList.contains('collapsed'),
'Section with at least one active property should not be collapsed');
});
it('expands a collapsed section when jump-to targets it', async () => {
enableCollapse();
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.id = 1 as Protocol.DOM.NodeId;
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
matchedPayload: [
// Lower specificity -- all overloaded.
{
rule: {
selectorList: {selectors: [{text: 'div'}], text: 'div'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'blue'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
// Higher specificity.
{
rule: {
selectorList: {selectors: [{text: '#id'}], text: '#id'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'red'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
],
});
stylesSidebarPane.setMatchedStylesForTest(matchedStyles);
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
// In production, requestUpdate() stores these blocks internally.
// In this unit test we call rebuildSectionsForMatchedStyleRulesForTest()
// directly, so wire them into the pane explicitly for jump-to lookup.
(stylesSidebarPane as unknown as {sectionBlocks: Elements.StylesSidebarPane.SectionBlock[]}).sectionBlocks =
sectionBlocks;
const overloadedSection = sectionBlocks[0].sections.find(section => section.headerText() === 'div');
if (!overloadedSection) {
assert.fail('Expected div section to exist');
}
// Reveal the overloaded property via jump-to.
const overloadedProperty = overloadedSection.style().leadingProperties()[0];
if (!overloadedProperty) {
assert.fail('Expected overloaded section to have at least one property');
}
assert.strictEqual(
matchedStyles.propertyState(overloadedProperty), SDK.CSSMatchedStyles.PropertyState.OVERLOADED,
'Expected div{color:blue} to be overloaded by #id{color:red}');
// Verify section is initially collapsed.
assert.isTrue(
overloadedSection.element.classList.contains('collapsed'), 'Section should be initially collapsed');
stylesSidebarPane.revealProperty(overloadedProperty);
// After reveal, the section should be expanded (not collapsed).
assert.isFalse(
overloadedSection.element.classList.contains('collapsed'),
'Section should be expanded after revealProperty');
});
it('can be manually expanded and re-collapsed via toggle', async () => {
enableCollapse();
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.id = 1 as Protocol.DOM.NodeId;
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
matchedPayload: [
{
rule: {
selectorList: {selectors: [{text: 'div'}], text: 'div'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'blue'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
{
rule: {
selectorList: {selectors: [{text: '#id'}], text: '#id'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'red'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
const overloadedSection = sectionBlocks[0].sections.find(section => section.headerText() === 'div');
if (!overloadedSection) {
assert.fail('Expected div section to exist');
}
// Initially collapsed.
assert.isTrue(overloadedSection.isCollapsed(), 'Section should be initially collapsed');
assert.isTrue(
overloadedSection.element.classList.contains('collapsible'), 'Section should have collapsible class');
// Expand manually.
overloadedSection.expand();
assert.isFalse(overloadedSection.isCollapsed(), 'Section should be expanded after expand()');
assert.isFalse(
overloadedSection.element.classList.contains('collapsed'),
'Section should not have collapsed class after expand()');
// Still marked collapsible so the icon remains visible.
assert.isTrue(
overloadedSection.element.classList.contains('collapsible'),
'Section should retain collapsible class after manual expand');
});
it('reacts to toggling the collapse-non-contributing-css-rules setting at runtime', async () => {
enableCollapse();
const collapseSetting =
Common.Settings.Settings.instance().moduleSetting('collapse-non-contributing-css-rules');
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.id = 1 as Protocol.DOM.NodeId;
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
matchedPayload: [
{
rule: {
selectorList: {selectors: [{text: 'div'}], text: 'div'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'blue'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
{
rule: {
selectorList: {selectors: [{text: '#id'}], text: '#id'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'red'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
},
],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
// `allSections()` reads from `sectionBlocks`, which the test helper doesn't populate.
(stylesSidebarPane as unknown as {sectionBlocks: Elements.StylesSidebarPane.SectionBlock[]}).sectionBlocks =
sectionBlocks;
const overloadedSection = sectionBlocks[0].sections.find(section => section.headerText() === 'div');
if (!overloadedSection) {
assert.fail('Expected div section to exist');
}
assert.isTrue(
overloadedSection.element.classList.contains('collapsed'),
'Section should be collapsed while the setting is enabled');
collapseSetting.set(false);
assert.isFalse(
overloadedSection.element.classList.contains('collapsed'),
'Section should expand when the setting is turned off');
assert.isFalse(
overloadedSection.element.classList.contains('collapsible'),
'Section should drop the collapsible marker when the setting is turned off');
collapseSetting.set(true);
assert.isTrue(
overloadedSection.element.classList.contains('collapsed'),
'Section should collapse again when the setting is re-enabled');
assert.isTrue(
overloadedSection.element.classList.contains('collapsible'),
'Section should be marked collapsible again when the setting is re-enabled');
});
it('expands collapsed sections before adding a new blank property', async () => {
enableCollapse();
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.id = 1 as Protocol.DOM.NodeId;
const styleSheetId = '0' as Protocol.DOM.StyleSheetId;
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
matchedPayload: [
{
rule: {
selectorList: {selectors: [{text: 'div'}], text: 'div'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'blue'}],
shorthandEntries: [],
styleSheetId,
range: {startLine: 0, startColumn: 0, endLine: 0, endColumn: 15},
},
},
matchingSelectors: [0],
},
{
rule: {
selectorList: {selectors: [{text: '#id'}], text: '#id'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'red'}],
shorthandEntries: [],
styleSheetId,
range: {startLine: 1, startColumn: 0, endLine: 1, endColumn: 15},
},
},
matchingSelectors: [0],
},
],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
const overloadedSection = sectionBlocks[0].sections.find(section => section.headerText() === 'div');
if (!overloadedSection) {
assert.fail('Expected div section to exist');
}
assert.isTrue(overloadedSection.isCollapsed(), 'Section should be initially collapsed');
const treeElement = overloadedSection.addNewBlankProperty();
assert.isFalse(overloadedSection.isCollapsed(), 'Section should expand before adding a new property');
assert.exists(treeElement, 'Expected a new blank property tree element to be created');
});
it('collapses inherited sections with all overloaded properties', async () => {
enableCollapse();
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.nodeName.returns('div');
node.id = 1 as Protocol.DOM.NodeId;
const parentNode = sinon.createStubInstance(SDK.DOMModel.DOMNode);
parentNode.id = 2 as Protocol.DOM.NodeId;
node.parentNode = parentNode;
parentNode.nodeName.returns('body');
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
matchedPayload: [{
rule: {
selectorList: {selectors: [{text: 'div'}], text: 'div'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'color', value: 'red'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
}],
inheritedPayload: [{
matchedCSSRules: [{
rule: {
selectorList: {selectors: [{text: 'body'}], text: 'body'},
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
// color is inheritable but overloaded by the div rule above.
cssProperties: [{name: 'color', value: 'blue'}],
shorthandEntries: [],
},
},
matchingSelectors: [0],
}],
}],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
// Block 0: element styles (div rule), Block 1: inherited from body.
assert.isTrue(sectionBlocks.length >= 2);
const inheritedSection = sectionBlocks[1].sections.find(section => section.headerText() === 'body');
if (!inheritedSection) {
assert.fail('Expected inherited body section to exist');
}
const inheritedProperty = inheritedSection.style().leadingProperties()[0];
if (!inheritedProperty) {
assert.fail('Expected inherited section to have at least one property');
}
assert.strictEqual(
matchedStyles.propertyState(inheritedProperty), SDK.CSSMatchedStyles.PropertyState.OVERLOADED,
'Expected inherited color to be overloaded by element color');
// The inherited section's color property is overloaded by the div rule,
// so it should be collapsed.
assert.isTrue(
inheritedSection.element.classList.contains('collapsed'),
'Inherited section with all overloaded properties should be collapsed');
});
});
it('should add "Other @rules" section to the end', async () => {
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node: sinon.createStubInstance(SDK.DOMModel.DOMNode),
atRules: [
{
name: {text: '--palette'},
type: Protocol.CSS.CSSAtRuleType.FontPaletteValues,
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'font-family', value: 'Bixa'}, {name: 'override-colors', value: '0 red'}],
shorthandEntries: [],
},
},
{
type: Protocol.CSS.CSSAtRuleType.FontFace,
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'font-family', value: 'Bixa'}, {name: 'src', value: 'local(Bixa)'}],
shorthandEntries: [],
},
},
{
type: Protocol.CSS.CSSAtRuleType.FontFeatureValues,
name: {text: 'Bixa'},
subsection: Protocol.CSS.CSSAtRuleSubsection.Swash,
origin: Protocol.CSS.StyleSheetOrigin.Regular,
style: {
cssProperties: [{name: 'fancy', value: '1'}],
shorthandEntries: [],
},
},
],
});
const sectionBlocks =
await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks, 2);
assert.strictEqual(sectionBlocks[1].titleElement()?.textContent, 'Other @rules');
assert.lengthOf(sectionBlocks[1].sections, 3);
const contents = [
'@font-palette-values --palette { font-family: Bixa; override-colors: 0 red;}',
'@font-face { font-family: Bixa; src: local(Bixa);}',
'@font-feature-values Bixa { @swash { fancy: 1; }}',
];
for (let i = 0; i < 3; i++) {
const section = sectionBlocks[1].sections[i];
assert.instanceOf(section, Elements.StylePropertiesSection.AtRuleSection);
assert.strictEqual(section.element.deepTextContent(), contents[i]);
}
});
it('should add @function section to the end', async () => {
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node: sinon.createStubInstance(SDK.DOMModel.DOMNode),
functionRules: [{
name: {text: '--f'},
parameters: [{name: '--x', type: '*'}, {name: '--y', type: '*'}],
origin: Protocol.CSS.StyleSheetOrigin.Regular,
children: [
{
condition: {
media: {
text: '(width > 400px)',
source: Protocol.CSS.CSSMediaSource.MediaRule,
},
conditionText: '<unused>',
children: [
{
condition: {
containerQueries: {
text: '(width > 300px)',
conditionText: '--foo (width > 300px)',
},
conditionText: '<unused>',
children: [
{
condition: {
supports: {
text: '(color: red)',
active: true,
},
conditionText: '<unused>',
children: [
{
style: {
cssProperties: [{name: 'result', value: 'var(--y)'}],
shorthandEntries: [],
}
},
],
},
},
],
},
},
],
},
},
{
style: {
cssProperties: [{name: 'result', value: 'var(--x)'}],
shorthandEntries: [],
}
},
],
}],
});
const sectionBlocks =
await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks, 2);
assert.strictEqual(sectionBlocks[1].titleElement()?.textContent, '@function');
assert.lengthOf(sectionBlocks[1].sections, 1);
assert.instanceOf(sectionBlocks[1].sections[0], Elements.StylePropertiesSection.FunctionRuleSection);
assert.strictEqual(
sectionBlocks[1].sections[0].element.deepTextContent().replaceAll(/\s+/g, ' ').trim(),
'--f(--x, --y) { @media (width > 400px) { @container --foo (width > 300px) { @supports (color: red) { result: var(--y); } } } result: var(--x);}');
});
it('should add @functions with same name but different tree scope depths', async () => {
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const node = sinon.createStubInstance(SDK.DOMModel.DOMNode);
node.id = 1 as Protocol.DOM.NodeId;
node.backendNodeId.returns(1 as Protocol.DOM.BackendNodeId);
const parent = sinon.createStubInstance(SDK.DOMModel.DOMNode);
parent.id = 2 as Protocol.DOM.NodeId;
parent.backendNodeId.returns(2 as Protocol.DOM.BackendNodeId);
node.parentNode = parent;
const grandparent = sinon.createStubInstance(SDK.DOMModel.DOMNode);
grandparent.id = 3 as Protocol.DOM.NodeId;
grandparent.backendNodeId.returns(3 as Protocol.DOM.BackendNodeId);
parent.parentNode = grandparent;
node.getTreeRoot.returns(node);
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
functionRules: [
{
name: {text: '--f'},
parameters: [],
origin: Protocol.CSS.StyleSheetOrigin.Regular,
children: [{
style: {
cssProperties: [{name: 'result', value: 'red'}],
shorthandEntries: [],
}
}],
originTreeScopeNodeId: 2 as Protocol.DOM.BackendNodeId,
},
{
name: {text: '--f'},
parameters: [],
origin: Protocol.CSS.StyleSheetOrigin.Regular,
children: [{
style: {
cssProperties: [{name: 'result', value: 'blue'}],
shorthandEntries: [],
}
}],
originTreeScopeNodeId: 3 as Protocol.DOM.BackendNodeId,
}
],
});
const sectionBlocks =
await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks, 2);
assert.strictEqual(sectionBlocks[1].titleElement()?.textContent, '@function');
assert.lengthOf(sectionBlocks[1].sections, 2);
assert.instanceOf(sectionBlocks[1].sections[0], Elements.StylePropertiesSection.FunctionRuleSection);
assert.instanceOf(sectionBlocks[1].sections[1], Elements.StylePropertiesSection.FunctionRuleSection);
assert.strictEqual(
sectionBlocks[1].sections[0].element.deepTextContent().replaceAll(/\s+/g, ' ').trim(),
'--f() { result: red;}');
assert.strictEqual(
sectionBlocks[1].sections[1].element.deepTextContent().replaceAll(/\s+/g, ' ').trim(),
'--f() { result: blue;}');
assert.strictEqual(sectionBlocks[1].sections[0].treeScopeDistance(), 1);
assert.strictEqual(sectionBlocks[1].sections[1].treeScopeDistance(), 2);
});
describe('Animation styles', () => {
function mockGetAnimatedComputedStyles(response: Partial<Protocol.CSS.GetAnimatedStylesForNodeResponse>) {
setMockConnectionResponseHandler('CSS.getAnimatedStylesForNode', () => response);
}
beforeEach(() => {
sinon.stub(PanelsCommon.DOMLinkifier.Linkifier.instance(), 'linkify').returns(html`<div></div>`);
sinon.stub(UI.ViewManager.ViewManager.instance(), 'isViewVisible').returns(false);
updateHostConfig({
devToolsAnimationStylesInStylesTab: {
enabled: true,
},
});
});
it('should not render transition & animation styles when the animations panel is not visible', async () => {
const stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node: sinon.createStubInstance(SDK.DOMModel.DOMNode),
animationStylesPayload: [
{
name: '--animation-name',
style: {
cssProperties: [{
name: 'background-color',
value: 'blue',
}],
shorthandEntries: [],
},
},
],
transitionsStylePayload: {
cssProperties: [{
name: 'color',
value: 'red',
}],
shorthandEntries: [],
},
inheritedAnimatedPayload: [],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 0);
});
it('should render transition & animation styles when the animations panel is not visible but the css-animations-only-when-animations-tab-open setting is disabled',
async () => {
Common.Settings.Settings.instance().moduleSetting('css-animations-only-when-animations-tab-open').set(false);
const stylesSidebarPane = new Elements.StylesSidebarPane.StylesSidebarPane(
new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node: sinon.createStubInstance(SDK.DOMModel.DOMNode),
animationStylesPayload: [
{
name: '--animation-name',
style: {
cssProperties: [{
name: 'background-color',
value: 'blue',
}],
shorthandEntries: [],
},
},
],
transitionsStylePayload: {
cssProperties: [{
name: 'color',
value: 'red',
}],
shorthandEntries: [],
},
inheritedAnimatedPayload: [],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 2);
});
it('should render transition & animation styles in the styles tab when the animations panel is visible',
async () => {
(UI.ViewManager.ViewManager.instance().isViewVisible as sinon.SinonStub).returns(true);
const stylesSidebarPane = new Elements.StylesSidebarPane.StylesSidebarPane(
new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node: sinon.createStubInstance(SDK.DOMModel.DOMNode),
animationStylesPayload: [
{
name: '--animation-name',
style: {
cssProperties: [{
name: 'background-color',
value: 'blue',
}],
shorthandEntries: [],
},
},
{
style: {
cssProperties: [{
name: 'color',
value: 'blue',
}],
shorthandEntries: [],
},
},
],
transitionsStylePayload: {
cssProperties: [{
name: 'color',
value: 'red',
}],
shorthandEntries: [],
},
inheritedAnimatedPayload: [],
});
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 3);
assert.strictEqual(sectionBlocks[0].sections[0].headerText(), 'transitions style');
assert.strictEqual(sectionBlocks[0].sections[1].headerText(), '--animation-name animation');
assert.strictEqual(sectionBlocks[0].sections[2].headerText(), 'animation style');
});
describe('should auto update animated style sections when onComputedStyleChanged called', () => {
beforeEach(() => {
(UI.ViewManager.ViewManager.instance().isViewVisible as sinon.SinonStub).returns(true);
});
describe('transition styles', () => {
it('should trigger re-render when there was no transition style before', async () => {
mockGetAnimatedComputedStyles({
transitionsStyle: {
cssProperties: [{
name: 'color',
value: 'red',
}],
shorthandEntries: [],
},
});
const {node} = createStubbedDomNodeWithModels({nodeId: 1});
const stylesSidebarPane = new Elements.StylesSidebarPane.StylesSidebarPane(
new ComputedStyle.ComputedStyleModel.ComputedStyleModel(node));
const resetUpdateSpy = sinon.spy(stylesSidebarPane, 'scheduleResetUpdateIfNotEditingCalledForTest');
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
transitionsStylePayload: null,
});
stylesSidebarPane.setMatchedStylesForTest(matchedStyles);
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 0);
const handledComputedStyleChanged =
expectCall(sinon.stub(stylesSidebarPane, 'handledComputedStyleChangedForTest'));
stylesSidebarPane.onComputedStyleChanged();
await handledComputedStyleChanged;
sinon.assert.called(resetUpdateSpy);
});
it('should update value only when there was a transition style before', async () => {
mockGetAnimatedComputedStyles({
transitionsStyle: {
cssProperties: [{
name: 'color',
value: 'red',
}],
shorthandEntries: [],
},
});
const {node} = createStubbedDomNodeWithModels({nodeId: 1});
const stylesSidebarPane = new Elements.StylesSidebarPane.StylesSidebarPane(
new ComputedStyle.ComputedStyleModel.ComputedStyleModel(node));
const resetUpdateSpy = sinon.spy(stylesSidebarPane, 'scheduleResetUpdateIfNotEditingCalledForTest');
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
transitionsStylePayload: {
cssProperties: [{
name: 'color',
value: 'blue',
}],
shorthandEntries: [],
},
});
stylesSidebarPane.setMatchedStylesForTest(matchedStyles);
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 1);
assert.include(
sectionBlocks[0].sections[0].propertiesTreeOutline.contentElement.textContent, 'color: blue;');
const handledComputedStyleChanged =
expectCall(sinon.stub(stylesSidebarPane, 'handledComputedStyleChangedForTest'));
stylesSidebarPane.onComputedStyleChanged();
await handledComputedStyleChanged;
assert.include(
sectionBlocks[0].sections[0].propertiesTreeOutline.contentElement.textContent, 'color: red;');
sinon.assert.notCalled(resetUpdateSpy);
});
});
describe('animation styles', () => {
it('should trigger re-render when there was no animation style before', async () => {
mockGetAnimatedComputedStyles({
animationStyles: [{
name: '--animation',
style: {
cssProperties: [{
name: 'color',
value: 'red',
}],
shorthandEntries: [],
},
}],
});
const {node} = createStubbedDomNodeWithModels({nodeId: 1});
const stylesSidebarPane = new Elements.StylesSidebarPane.StylesSidebarPane(
new ComputedStyle.ComputedStyleModel.ComputedStyleModel(node));
const resetUpdateSpy = sinon.spy(stylesSidebarPane, 'scheduleResetUpdateIfNotEditingCalledForTest');
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
animationStylesPayload: [],
});
stylesSidebarPane.setMatchedStylesForTest(matchedStyles);
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 0);
const handledComputedStyleChanged =
expectCall(sinon.stub(stylesSidebarPane, 'handledComputedStyleChangedForTest'));
stylesSidebarPane.onComputedStyleChanged();
await handledComputedStyleChanged;
sinon.assert.called(resetUpdateSpy);
});
it('should trigger re-render when there is no animation style after', async () => {
mockGetAnimatedComputedStyles({
animationStyles: [],
});
const {node} = createStubbedDomNodeWithModels({nodeId: 1});
const stylesSidebarPane = new Elements.StylesSidebarPane.StylesSidebarPane(
new ComputedStyle.ComputedStyleModel.ComputedStyleModel(node));
const resetUpdateSpy = sinon.spy(stylesSidebarPane, 'scheduleResetUpdateIfNotEditingCalledForTest');
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
animationStylesPayload: [{
style: {
cssProperties: [{
name: 'color',
value: 'blue',
}],
shorthandEntries: [],
},
}],
});
stylesSidebarPane.setMatchedStylesForTest(matchedStyles);
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 1);
const handledComputedStyleChanged =
expectCall(sinon.stub(stylesSidebarPane, 'handledComputedStyleChangedForTest'));
stylesSidebarPane.onComputedStyleChanged();
await handledComputedStyleChanged;
sinon.assert.called(resetUpdateSpy);
});
it('should update value only when there was the animation style before', async () => {
mockGetAnimatedComputedStyles({
animationStyles: [{
style: {
cssProperties: [{
name: 'color',
value: 'red',
}],
shorthandEntries: [],
},
}],
});
const {node} = createStubbedDomNodeWithModels({nodeId: 1});
const stylesSidebarPane = new Elements.StylesSidebarPane.StylesSidebarPane(
new ComputedStyle.ComputedStyleModel.ComputedStyleModel(node));
const resetUpdateSpy = sinon.spy(stylesSidebarPane, 'scheduleResetUpdateIfNotEditingCalledForTest');
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
animationStylesPayload: [{
style: {
cssProperties: [{
name: 'color',
value: 'blue',
}],
shorthandEntries: [],
},
}],
});
stylesSidebarPane.setMatchedStylesForTest(matchedStyles);
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 1);
assert.include(
sectionBlocks[0].sections[0].propertiesTreeOutline.contentElement.textContent, 'color: blue;');
const handledComputedStyleChanged =
expectCall(sinon.stub(stylesSidebarPane, 'handledComputedStyleChangedForTest'));
stylesSidebarPane.onComputedStyleChanged();
await handledComputedStyleChanged;
assert.include(
sectionBlocks[0].sections[0].propertiesTreeOutline.contentElement.textContent, 'color: red;');
sinon.assert.notCalled(resetUpdateSpy);
});
});
describe('inherited animated styles', () => {
describe('transition styles', () => {
it('should trigger re-render when there was no inherited transition style but there is a new one now',
async () => {
mockGetAnimatedComputedStyles({
inherited: [{
transitionsStyle: {
cssProperties: [{
name: 'color',
value: 'red',
}],
shorthandEntries: [],
},
}],
});
const {node} = createStubbedDomNodeWithModels({nodeId: 1});
node.parentNode = sinon.createStubInstance(SDK.DOMModel.DOMNode);
const stylesSidebarPane = new Elements.StylesSidebarPane.StylesSidebarPane(
new ComputedStyle.ComputedStyleModel.ComputedStyleModel(node));
const resetUpdateSpy = sinon.spy(stylesSidebarPane, 'scheduleResetUpdateIfNotEditingCalledForTest');
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
});
stylesSidebarPane.setMatchedStylesForTest(matchedStyles);
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 0);
const handledComputedStyleChanged =
expectCall(sinon.stub(stylesSidebarPane, 'handledComputedStyleChangedForTest'));
stylesSidebarPane.onComputedStyleChanged();
await handledComputedStyleChanged;
sinon.assert.called(resetUpdateSpy);
});
it('should not trigger re-render when there was no inherited transition style and the new one does not contain inherited property',
async () => {
mockGetAnimatedComputedStyles({
inherited: [{
transitionsStyle: {
cssProperties: [{
name: 'background-color',
value: 'red',
}],
shorthandEntries: [],
},
}],
});
const {node} = createStubbedDomNodeWithModels({nodeId: 1});
node.parentNode = sinon.createStubInstance(SDK.DOMModel.DOMNode);
const stylesSidebarPane = new Elements.StylesSidebarPane.StylesSidebarPane(
new ComputedStyle.ComputedStyleModel.ComputedStyleModel(node));
const resetUpdateSpy = sinon.spy(stylesSidebarPane, 'scheduleResetUpdateIfNotEditingCalledForTest');
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
});
stylesSidebarPane.setMatchedStylesForTest(matchedStyles);
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[0].sections, 0);
const handledComputedStyleChanged =
expectCall(sinon.stub(stylesSidebarPane, 'handledComputedStyleChangedForTest'));
stylesSidebarPane.onComputedStyleChanged();
await handledComputedStyleChanged;
sinon.assert.notCalled(resetUpdateSpy);
});
it('should update value only when there is no new inherited transition style and the value is updated',
async () => {
mockGetAnimatedComputedStyles({
inherited: [{
transitionsStyle: {
cssProperties: [{
name: 'color',
value: 'red',
}],
shorthandEntries: [],
},
}],
});
const {node} = createStubbedDomNodeWithModels({nodeId: 1});
node.parentNode = sinon.createStubInstance(SDK.DOMModel.DOMNode);
const stylesSidebarPane = new Elements.StylesSidebarPane.StylesSidebarPane(
new ComputedStyle.ComputedStyleModel.ComputedStyleModel(node));
const resetUpdateSpy = sinon.spy(stylesSidebarPane, 'scheduleResetUpdateIfNotEditingCalledForTest');
const matchedStyles = await getMatchedStyles({
cssModel: stylesSidebarPane.cssModel() as SDK.CSSModel.CSSModel,
node,
inheritedPayload: [{
matchedCSSRules: [],
}],
inheritedAnimatedPayload: [{
transitionsStyle: {
cssProperties: [{
name: 'color',
value: 'blue',
}],
shorthandEntries: [],
},
}],
});
stylesSidebarPane.setMatchedStylesForTest(matchedStyles);
const sectionBlocks = await stylesSidebarPane.rebuildSectionsForMatchedStyleRulesForTest(
matchedStyles, new Map(), new Map(), null);
assert.lengthOf(sectionBlocks[1].sections, 1);
assert.include(
sectionBlocks[1].sections[0].propertiesTreeOutline.contentElement.textContent, 'color: blue;');
const handledComputedStyleChanged =
expectCall(sinon.stub(stylesSidebarPane, 'handledComputedStyleChangedForTest'));
stylesSidebarPane.onComputedStyleChanged();
await handledComputedStyleChanged;
assert.include(
sectionBlocks[1].sections[0].propertiesTreeOutline.contentElement.textContent, 'color: red;');
sinon.assert.notCalled(resetUpdateSpy);
});
});
});
});
});
describe('ai code completion provider callbacks', () => {
let stylesWrapper: UI.Widget.VBox;
let stylesSidebarPane: Elements.StylesSidebarPane.StylesSidebarPane;
beforeEach(async () => {
updateHostConfig({
devToolsAiCodeCompletionStyles: {
enabled: true,
},
aidaAvailability: {
enabled: true,
blockedByAge: false,
blockedByGeo: false,
},
});
const aiCodeCompletionProviderStub =
sinon.createStubInstance(TextEditor.AiCodeCompletionProvider.AiCodeCompletionProvider);
aiCodeCompletionProviderStub.extension.returns([]);
sinon.stub(TextEditor.AiCodeCompletionProvider.AiCodeCompletionProvider, 'createInstance')
.returns(aiCodeCompletionProviderStub);
Common.Settings.Settings.instance().createSetting('ai-code-completion-enabled', true);
stylesWrapper = new UI.Widget.VBox();
stylesWrapper.element.classList.add('style-panes-wrapper');
stylesSidebarPane =
new Elements.StylesSidebarPane.StylesSidebarPane(new ComputedStyle.ComputedStyleModel.ComputedStyleModel());
stylesSidebarPane.show(stylesWrapper.element);
});
it('initializes toolbar when the feature is enabled', async () => {
const providerConfig = stylesSidebarPane.aiCodeCompletionConfig;
assert.exists(providerConfig);
providerConfig.onFeatureEnabled();
assert.exists(stylesWrapper.contentElement.querySelector('div.ai-code-completion-summary-toolbar-container'));
});
it('cleans up toolbar when the feature is disabled', async () => {
const providerConfig = stylesSidebarPane.aiCodeCompletionConfig;
assert.exists(providerConfig);
providerConfig.onFeatureEnabled();
assert.exists(stylesWrapper.contentElement.querySelector('div.ai-code-completion-summary-toolbar-container'));
providerConfig.onFeatureDisabled();
assert.notExists(
stylesWrapper.contentElement.querySelector('div.ai-code-completion-summary-toolbar-container'));
});
it('shows a loading state when a request is triggered', async () => {
const setLoadingSpy = sinon.stub(
PanelsCommon.AiCodeCompletionSummaryToolbar.AiCodeCompletionSummaryToolbar.prototype, 'setLoading');
const providerConfig = stylesSidebarPane.aiCodeCompletionConfig;
assert.exists(providerConfig);
providerConfig.onFeatureEnabled();
providerConfig.onRequestTriggered();
sinon.assert.calledOnce(setLoadingSpy);
assert.isTrue(setLoadingSpy.firstCall.args[0]);
});
it('hides the loading indicator when a response is received', async () => {
const setLoadingSpy = sinon.stub(
PanelsCommon.AiCodeCompletionSummaryToolbar.AiCodeCompletionSummaryToolbar.prototype, 'setLoading');
const providerConfig = stylesSidebarPane.aiCodeCompletionConfig;
assert.exists(providerConfig);
providerConfig.onFeatureEnabled();
providerConfig.onRequestTriggered();
sinon.assert.calledOnce(setLoadingSpy);
assert.isTrue(setLoadingSpy.firstCall.args[0]);
providerConfig.onResponseReceived();
sinon.assert.calledTwice(setLoadingSpy);
assert.isFalse(setLoadingSpy.secondCall.args[0]);
});
it('attaches the citations toolbar when a suggestion with citations is accepted', async () => {
const updateCitationsSpy = sinon.spy(
PanelsCommon.AiCodeCompletionSummaryToolbar.AiCodeCompletionSummaryToolbar.prototype, 'updateCitations');
const providerConfig = stylesSidebarPane.aiCodeCompletionConfig;
assert.exists(providerConfig);
providerConfig.onFeatureEnabled();
providerConfig.onResponseReceived();
providerConfig.onSuggestionAccepted([{uri: 'https://example.com/source'}]);
sinon.assert.calledOnce(updateCitationsSpy);
assert.deepEqual(updateCitationsSpy.firstCall.args, [['https://example.com/source']]);
});
it('does not attach the citations toolbar if there are no citations', async () => {
const updateCitationsSpy = sinon.spy(
PanelsCommon.AiCodeCompletionSummaryToolbar.AiCodeCompletionSummaryToolbar.prototype, 'updateCitations');
const providerConfig = stylesSidebarPane.aiCodeCompletionConfig;
assert.exists(providerConfig);
providerConfig.onFeatureEnabled();
providerConfig.onResponseReceived();
providerConfig.onSuggestionAccepted([]);
sinon.assert.notCalled(updateCitationsSpy);
});
});
});
describe('IdleCallbackManager', () => {
// IdleCallbackManager delegates work using requestIdleCallback, which does not generally execute requested callbacks
// in order. This test verifies that callbacks do happen in order even if timeouts are run out.
it('schedules callbacks in order', async () => {
/** Override the default timeout with a very short one **/
class QuickIdleCallbackManager extends Elements.StylesSidebarPane.IdleCallbackManager {
protected override scheduleIdleCallback(_: number): void {
super.scheduleIdleCallback(1);
}
}
const timeout = (time: number) => new Promise<void>(resolve => setTimeout(resolve, time));
const elements: number[] = [];
const callbacks = new QuickIdleCallbackManager();
callbacks.schedule(() => elements.push(0));
callbacks.schedule(() => elements.push(1));
callbacks.schedule(() => elements.push(2));
callbacks.schedule(() => elements.push(3));
await timeout(10);
callbacks.schedule(() => elements.push(4));
callbacks.schedule(() => elements.push(5));
callbacks.schedule(() => elements.push(6));
callbacks.schedule(() => elements.push(7));
await timeout(10);
await callbacks.awaitDone();
assert.deepEqual(elements, [0, 1, 2, 3, 4, 5, 6, 7]);
});
});
describe('CSSPropertyPrompt', () => {
setupLocaleHooks();
const CSSPropertyPrompt = Elements.StylesSidebarPane.CSSPropertyPrompt;
const CSS_VARIABLES_FOR_TEST: Record<string, string> = {
'--rgb-color': 'rgb(0 0 0)',
'--wide-gamut-color': 'lch(0 0 0)',
};
let aiCodeCompletionProvider:
sinon.SinonStubbedInstance<Elements.StylesAiCodeCompletionProvider.StylesAiCodeCompletionProvider>;
let section: sinon.SinonStubbedInstance<Elements.StylePropertiesSection.StylePropertiesSection>;
let mockTreeItem: Elements.StylePropertyTreeElement.StylePropertyTreeElement;
const noop = () => {};
beforeEach(() => {
section = sinon.createStubInstance(Elements.StylePropertiesSection.StylePropertiesSection);
let activeAiSuggestion: Elements.StylePropertiesSection.ActiveAiSuggestion|undefined;
Object.defineProperty(section, 'activeAiSuggestion', {
get: () => activeAiSuggestion,
set: aiSuggestion => {
activeAiSuggestion = aiSuggestion;
},
configurable: true,
});
sinon.stub(section, 'activeAiSuggestion').get(() => activeAiSuggestion);
section.commitActiveAiSuggestion.resolves();
mockTreeItem = {
property: {
name: 'color',
},
node() {
return {
isSVGNode() {
return false;
},
domModel() {
return {
cssModel() {
return {
getComputedStyle() {
return new Map<string, string>();
},
};
},
};
},
};
},
matchedStyles() {
return {
availableCSSVariables(): string[] {
return ['--rgb-color', '--wide-gamut-color'];
},
computeCSSVariable(_: unknown, completion: string): {value: string, declaration: null} |
undefined {
return {value: CSS_VARIABLES_FOR_TEST[completion], declaration: null};
},
};
},
section() {
return section;
},
showGhostTextInValue(_text: string): void{},
stylesContainer() {
const pane = sinon.createStubInstance(Elements.StylesSidebarPane.StylesSidebarPane);
const cssModel = sinon.createStubInstance(SDK.CSSModel.CSSModel);
pane.cssModel.returns(cssModel);
aiCodeCompletionProvider =
sinon.createStubInstance(Elements.StylesAiCodeCompletionProvider.StylesAiCodeCompletionProvider);
pane.aiCodeCompletionProvider = aiCodeCompletionProvider;
return pane;
}
} as unknown as Elements.StylePropertyTreeElement.StylePropertyTreeElement;
});
describeWithEnvironment('value autocompletion', () => {
it('shows autocomplete item with color swatch for CSS variables with RGB color', async () => {
const attachedElement = document.createElement('div');
renderElementIntoDOM(attachedElement);
const cssPropertyPrompt = new CSSPropertyPrompt(mockTreeItem, false);
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
const spyObj = sinon.spy(cssPropertyPrompt.suggestBoxForTest());
cssPropertyPrompt.setText('var(--rgb');
await cssPropertyPrompt.complete(true);
const colorCompletions = spyObj?.updateSuggestions.firstCall.args[1];
const renderedElement = colorCompletions?.[0].subtitleRenderer?.();
assert.instanceOf(renderedElement, InlineEditor.ColorSwatch.ColorSwatch);
});
it('shows autocomplete item with color swatch for CSS variables with wide gamut color', async () => {
const attachedElement = document.createElement('div');
renderElementIntoDOM(attachedElement);
const cssPropertyPrompt = new CSSPropertyPrompt(mockTreeItem, false);
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
const spyObj = sinon.spy(cssPropertyPrompt.suggestBoxForTest());
cssPropertyPrompt.setText('var(--wide');
await cssPropertyPrompt.complete(true);
const colorCompletions = spyObj?.updateSuggestions.firstCall.args[1];
const renderedElement = colorCompletions?.[0].subtitleRenderer?.();
assert.instanceOf(renderedElement, InlineEditor.ColorSwatch.ColorSwatch);
});
it('shows autocomplete property names for CSS aliases', async () => {
const attachedElement = document.createElement('div');
renderElementIntoDOM(attachedElement);
const cssPropertyPrompt = new CSSPropertyPrompt(mockTreeItem, true);
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
const spyObj = sinon.spy(cssPropertyPrompt.suggestBoxForTest());
cssPropertyPrompt.setText('word-wra');
await cssPropertyPrompt.complete(true);
const completions = spyObj?.updateSuggestions.firstCall.args[1];
assert.strictEqual(completions?.[0].text, 'word-wrap');
assert.strictEqual(completions?.[1].text, 'overflow-wrap');
assert.strictEqual(completions?.[1].subtitle, '= word-wrap');
});
it('returns no completions when property name contains invalid characters', async () => {
const attachedElement = document.createElement('div');
renderElementIntoDOM(attachedElement);
const cssPropertyPrompt = new CSSPropertyPrompt(mockTreeItem, true);
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
const suggestBox = cssPropertyPrompt.suggestBoxForTest();
assert.exists(suggestBox);
const spyObj = sinon.spy(suggestBox);
cssPropertyPrompt.setText('height"');
await cssPropertyPrompt.complete(true);
sinon.assert.notCalled(spyObj.updateSuggestions);
});
it('allows completions for valid property names', async () => {
const attachedElement = document.createElement('div');
renderElementIntoDOM(attachedElement);
const cssPropertyPrompt = new CSSPropertyPrompt(mockTreeItem, true);
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
const spyObj = sinon.spy(cssPropertyPrompt.suggestBoxForTest());
cssPropertyPrompt.setText('backgrou');
await cssPropertyPrompt.complete(true);
assert.isTrue(spyObj?.updateSuggestions.called);
const completions = spyObj?.updateSuggestions.firstCall.args[1];
assert.isAbove(completions.length, 0);
});
});
describe('AI code completion', () => {
let attachedElement: HTMLDivElement;
let cssPropertyPrompt: Elements.StylesSidebarPane.CSSPropertyPrompt;
beforeEach(() => {
updateHostConfig({
devToolsAiCodeCompletionStyles: {
enabled: true,
},
aidaAvailability: {
enabled: true,
blockedByAge: false,
blockedByGeo: false,
}
});
attachedElement = document.createElement('div');
renderElementIntoDOM(attachedElement);
cssPropertyPrompt = new Elements.StylesSidebarPane.CSSPropertyPrompt(mockTreeItem, false);
});
it('getCompletionHint returns null if suggestBox is not visible', () => {
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
assert.exists(cssPropertyPrompt.aiCodeCompletionProvider?.getCompletionHint);
assert.isNull(cssPropertyPrompt.aiCodeCompletionProvider.getCompletionHint());
});
it('getCompletionHint returns the correct completion hint', async () => {
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.setText('var(--rgb');
await cssPropertyPrompt.complete(true);
assert.strictEqual(cssPropertyPrompt.aiCodeCompletionProvider?.getCompletionHint?.(), '-color)');
});
it('debounces triggerAiCodeCompletion', async () => {
const clock = sinon.useFakeTimers();
const triggerAiCodeCompletionStub = aiCodeCompletionProvider.triggerAiCodeCompletion.resolves();
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.setText('backgr');
cssPropertyPrompt.onInput(new Event('input'));
cssPropertyPrompt.setText('backgro');
cssPropertyPrompt.onInput(new Event('input'));
cssPropertyPrompt.setText('backgrou');
cssPropertyPrompt.onInput(new Event('input'));
await clock.tickAsync(TextEditor.AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(triggerAiCodeCompletionStub);
assert.strictEqual(triggerAiCodeCompletionStub.firstCall.args[0], 'backgrou');
assert.strictEqual(triggerAiCodeCompletionStub.firstCall.args[1], 8);
clock.restore();
});
it('cancels pending requests and clears suggestions on deletion', async () => {
const clock = sinon.useFakeTimers();
const triggerAiCodeCompletionStub = aiCodeCompletionProvider.triggerAiCodeCompletion.resolves();
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
// First call triggerAiCodeCompletion by typing a letter
cssPropertyPrompt.setText('b');
cssPropertyPrompt.onInput(new Event('input'));
// Perform deletion
cssPropertyPrompt.setText('');
const deleteEvent = new InputEvent('input', {inputType: 'deleteContentBackward'});
cssPropertyPrompt.onInput(deleteEvent);
await clock.tickAsync(TextEditor.AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
// Verify no request was made
sinon.assert.notCalled(triggerAiCodeCompletionStub);
// Verify active suggestion was cleared
assert.isUndefined(mockTreeItem.section().activeAiSuggestion);
clock.restore();
});
it('triggerAiCodeCompletion calls the provider with correct arguments', () => {
const clock = sinon.useFakeTimers();
const triggerAiCodeCompletionStub = aiCodeCompletionProvider.triggerAiCodeCompletion.resolves();
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.setText('backgrou');
cssPropertyPrompt.onInput(new Event('input'));
clock.tick(TextEditor.AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(triggerAiCodeCompletionStub);
assert.strictEqual(triggerAiCodeCompletionStub.firstCall.args[0], 'backgrou');
assert.strictEqual(triggerAiCodeCompletionStub.firstCall.args[1], 8);
clock.restore();
});
it('setAiAutoCompletion sets activeAiSuggestion on the section', async () => {
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.aiCodeCompletionProvider?.setAiAutoCompletion?.({
text: 'color: pink;',
from: 0,
startTime: 0,
clearCachedRequest: () => {},
onImpression: () => {},
citations: [],
});
assert.exists(section.activeAiSuggestion);
assert.deepEqual(section.activeAiSuggestion.properties, [{name: 'color', value: 'pink'}]);
});
it('setAiAutoCompletion correctly parses complex CSS and sets activeAiSuggestion on the section', async () => {
const cssPropertyPrompt = new CSSPropertyPrompt(mockTreeItem, true);
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
const complexCss = `background-image: url("https://example.com/image;v=1?query:part=true");
content: "This is a semicolon; and this is a colon: inside a string";
--custom-property: var(--other, "fallback;value");
width: calc(100% - 20px);
color: pink !important;`;
cssPropertyPrompt.aiCodeCompletionProvider?.setAiAutoCompletion?.({
text: complexCss,
from: 0,
startTime: 0,
clearCachedRequest: () => {},
onImpression: () => {},
citations: [],
});
assert.exists(section.activeAiSuggestion);
assert.deepEqual(section.activeAiSuggestion.properties, [
{name: 'background-image', value: 'url("https://example.com/image;v=1?query:part=true")'},
{name: 'content', value: '"This is a semicolon; and this is a colon: inside a string"'},
{name: '--custom-property', value: 'var(--other, "fallback;value")'},
{name: 'width', value: 'calc(100% - 20px)'}, {name: 'color', value: 'pink !important'}
]);
});
it('only hides suggest box on Escape when suggest box is visible but does not clear AI suggestion', async () => {
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.setText('var(--rgb');
await cssPropertyPrompt.complete(true);
cssPropertyPrompt.aiCodeCompletionProvider?.setAiAutoCompletion?.({
text: 'color: var(--rgb-color);',
from: 0,
startTime: 0,
clearCachedRequest: () => {},
onImpression: () => {},
citations: [],
});
assert.isTrue(cssPropertyPrompt.isSuggestBoxVisible());
const escapeEvent = new KeyboardEvent('keydown', {key: 'Escape'});
cssPropertyPrompt.onKeyDown(escapeEvent);
assert.isFalse(cssPropertyPrompt.isSuggestBoxVisible());
assert.strictEqual(section.activeAiSuggestion?.text, 'color: var(--rgb-color);');
});
it('clears active AI suggestion on ArrowDown', async () => {
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.setText('var(--rgb');
await cssPropertyPrompt.complete(true);
cssPropertyPrompt.aiCodeCompletionProvider?.setAiAutoCompletion?.({
text: 'color: var(--rgb-color);',
from: 0,
startTime: 0,
clearCachedRequest: () => {},
onImpression: () => {},
citations: [],
});
assert.strictEqual(section.activeAiSuggestion?.text, 'color: var(--rgb-color);');
assert.isTrue(cssPropertyPrompt.isSuggestBoxVisible());
const arrowDownEvent = new KeyboardEvent('keydown', {key: 'ArrowDown'});
cssPropertyPrompt.onKeyDown(arrowDownEvent);
assert.notExists(section.activeAiSuggestion);
});
describe('acceptAiCodeComplete', () => {
it('accepts suggestion on Tab when suggest box is hidden', async () => {
cssPropertyPrompt = new Elements.StylesSidebarPane.CSSPropertyPrompt(mockTreeItem, true);
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
const onSuggestionAcceptedStub = aiCodeCompletionProvider.onSuggestionAccepted.returns();
cssPropertyPrompt.aiCodeCompletionProvider?.setAiAutoCompletion?.({
text: 'color: pink;',
from: 0,
startTime: 0,
clearCachedRequest: () => {},
onImpression: () => {},
citations: [{uri: 'https://example.com'}],
sampleId: 1,
rpcGlobalId: 1,
});
const tabEvent = new KeyboardEvent('keydown', {key: 'Tab'});
cssPropertyPrompt.onKeyDown(tabEvent);
// Required to make sure section.commitActiveAiSuggestion resolves
await raf();
sinon.assert.calledOnce(section.commitActiveAiSuggestion);
sinon.assert.calledOnce(onSuggestionAcceptedStub);
assert.deepEqual(onSuggestionAcceptedStub.firstCall.args, [[{uri: 'https://example.com'}], 1, 1]);
});
it('accepts auto complete suggestion and re-applies ghost text on first Tab accept when suggest box is visible',
async () => {
const cssPropertyPrompt = new CSSPropertyPrompt(mockTreeItem, false, ['green']);
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.setText('gre');
await cssPropertyPrompt.complete(true);
cssPropertyPrompt.aiCodeCompletionProvider?.setAiAutoCompletion?.({
text: 'color: greenyellow;',
from: 0,
startTime: 0,
clearCachedRequest: () => {},
onImpression: () => {},
citations: [],
});
assert.isTrue(cssPropertyPrompt.isSuggestBoxVisible());
const applySuggestionSpy = sinon.spy(cssPropertyPrompt, 'applySuggestion');
const tabEvent = new KeyboardEvent('keydown', {key: 'Tab'});
cssPropertyPrompt.onKeyDown(tabEvent);
// On first Tab, the suggestion from auto complete menu is applied.
// And the AI suggestion text is set as ghost text.
assert.strictEqual(applySuggestionSpy.lastCall.args[0]?.text, 'greenyellow');
assert.strictEqual(cssPropertyPrompt.text(), 'green');
});
it('accepts AI suggestion on second Tab when suggest box is visible', async () => {
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.setText('var(--rgb');
await cssPropertyPrompt.complete(true);
cssPropertyPrompt.aiCodeCompletionProvider?.setAiAutoCompletion?.({
text: 'color: var(--rgb-color); background-color: white;',
from: 0,
startTime: 0,
clearCachedRequest: () => {},
onImpression: () => {},
citations: [],
});
assert.isTrue(cssPropertyPrompt.isSuggestBoxVisible());
const tabEvent = new KeyboardEvent('keydown', {key: 'Tab'});
cssPropertyPrompt.onKeyDown(tabEvent);
// On first Tab, the suggestion from auto complete menu is applied.
assert.strictEqual(cssPropertyPrompt.text(), 'var(--rgb-color)');
cssPropertyPrompt.onKeyDown(tabEvent);
// On second Tab, the AI suggestion is committed.
sinon.assert.calledOnce(section.commitActiveAiSuggestion);
});
it('accepts traditional autocomplete suggestion on Tab when suggest box is hidden but inline suggestion is active',
async () => {
const cssPropertyPrompt = new CSSPropertyPrompt(mockTreeItem, true);
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.setText('flex-g');
await cssPropertyPrompt.complete(true);
assert.isFalse(cssPropertyPrompt.isSuggestBoxVisible());
assert.strictEqual(cssPropertyPrompt.currentSuggestion()?.text, 'flex-grow');
const tabEvent = new KeyboardEvent('keydown', {key: 'Tab'});
cssPropertyPrompt.onKeyDown(tabEvent);
assert.strictEqual(cssPropertyPrompt.text(), 'flex-grow');
});
});
describe('updateAiCodeSuggestion', () => {
it('clears suggestion if user input does not match', async () => {
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
assert.exists(cssPropertyPrompt.aiCodeCompletionProvider);
cssPropertyPrompt.aiCodeCompletionProvider.setAiAutoCompletion?.({
text: 'color: pink;',
from: 0,
startTime: 0,
clearCachedRequest: () => {},
onImpression: () => {},
citations: [],
});
assert.exists(section.activeAiSuggestion);
cssPropertyPrompt.setText('bac');
cssPropertyPrompt.onInput(new Event('input'));
assert.isUndefined(section.activeAiSuggestion);
});
it('clears suggestion if cursor is moved before trigger point', async () => {
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.setText('pin');
cssPropertyPrompt.aiCodeCompletionProvider?.setAiAutoCompletion?.({
text: 'color: pink;',
from: 3,
startTime: 0,
clearCachedRequest: () => {},
onImpression: () => {},
citations: [],
});
assert.exists(section.activeAiSuggestion);
const mockSelection = ({
rangeCount: 1,
getRangeAt: () => ({
endOffset: 2,
}),
}) as unknown as Selection;
sinon.stub(cssPropertyPrompt.element(), 'getComponentSelection').returns(mockSelection);
cssPropertyPrompt.onInput(new Event('input'));
assert.isUndefined(section.activeAiSuggestion);
});
it('clears suggestion if suggest box shows inconsistent top suggestion', async () => {
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.setText('var(--rgb');
await cssPropertyPrompt.complete(true);
cssPropertyPrompt.aiCodeCompletionProvider?.setAiAutoCompletion?.({
text: 'color: var(--rgb-background-color);',
from: 0,
startTime: 0,
clearCachedRequest: () => {},
onImpression: () => {},
citations: [],
});
assert.exists(section.activeAiSuggestion);
await cssPropertyPrompt.onInput(new Event('input'));
assert.isUndefined(section.activeAiSuggestion);
});
it('keeps suggestion if input matches', async () => {
cssPropertyPrompt.attachAndStartEditing(attachedElement, noop);
cssPropertyPrompt.setText('p');
cssPropertyPrompt.aiCodeCompletionProvider?.setAiAutoCompletion?.({
text: 'color: pink;',
from: 1,
startTime: 0,
clearCachedRequest: () => {},
onImpression: () => {},
citations: [],
});
assert.exists(section.activeAiSuggestion);
cssPropertyPrompt.setText('pi');
cssPropertyPrompt.onInput(new Event('input'));
assert.exists(section.activeAiSuggestion);
assert.deepEqual(section.activeAiSuggestion.properties, [{name: 'color', value: 'pink'}]);
});
});
});
});
});