fix(ui): [overlays]: enhance adoptStyles fallback and make testable
This commit is contained in:
parent
974a1bd635
commit
af2e0293a1
8 changed files with 356 additions and 80 deletions
5
.changeset/eight-impalas-joke.md
Normal file
5
.changeset/eight-impalas-joke.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/ui': patch
|
||||
---
|
||||
|
||||
[overlays]: fix adoptStyles fallback and make testable
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { adoptStyles } from 'lit';
|
||||
import { overlays } from './singleton.js';
|
||||
import { containFocus } from './utils/contain-focus.js';
|
||||
import { overlayShadowDomStyle } from './overlayShadowDomStyle.js';
|
||||
import { _adoptStyleUtils } from './utils/adopt-styles.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
|
||||
|
|
@ -13,22 +13,6 @@ import { overlayShadowDomStyle } from './overlayShadowDomStyle.js';
|
|||
* @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove'} OverlayPhase
|
||||
*/
|
||||
|
||||
const rootNodeStylesMap = new WeakSet();
|
||||
|
||||
/**
|
||||
* Returns element that adopts stylesheet
|
||||
* @param {Element} shadowOrBodyEl
|
||||
* @returns {ShadowRoot}
|
||||
*/
|
||||
function getRootNodeOrBodyElThatAdoptsStylesheet(shadowOrBodyEl) {
|
||||
const rootNode = /** @type {* & DocumentOrShadowRoot} */ (shadowOrBodyEl.getRootNode());
|
||||
if (rootNode === document) {
|
||||
// @ts-ignore
|
||||
return document.body;
|
||||
}
|
||||
return rootNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* From:
|
||||
* - wrappingDialogNodeL1: `<dialog role="none"/>`
|
||||
|
|
@ -536,16 +520,20 @@ export class OverlayController extends EventTarget {
|
|||
OverlayController.popperModule = preloadPopper();
|
||||
}
|
||||
}
|
||||
this.__initOverlayStyles();
|
||||
this.__handleOverlayStyles({ phase: 'init' });
|
||||
this._handleFeatures({ phase: 'init' });
|
||||
}
|
||||
|
||||
__initOverlayStyles() {
|
||||
const rootNode = getRootNodeOrBodyElThatAdoptsStylesheet(this.contentWrapperNode);
|
||||
if (!rootNodeStylesMap.has(rootNode)) {
|
||||
// TODO: ideally we should also support a teardown
|
||||
adoptStyles(rootNode, [...(rootNode.adoptedStyleSheets || []), overlayShadowDomStyle]);
|
||||
rootNodeStylesMap.add(rootNode);
|
||||
/**
|
||||
* @param {{ phase: OverlayPhase }} config
|
||||
* @private
|
||||
*/
|
||||
__handleOverlayStyles({ phase }) {
|
||||
const rootNode = /** @type {ShadowRoot} */ (this.contentWrapperNode?.getRootNode());
|
||||
if (phase === 'init') {
|
||||
_adoptStyleUtils.adoptStyle(rootNode, overlayShadowDomStyle);
|
||||
} else if (phase === 'teardown') {
|
||||
_adoptStyleUtils.adoptStyle(rootNode, overlayShadowDomStyle, { teardown: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1284,6 +1272,7 @@ export class OverlayController extends EventTarget {
|
|||
}
|
||||
|
||||
teardown() {
|
||||
this.__handleOverlayStyles({ phase: 'teardown' });
|
||||
this._handleFeatures({ phase: 'teardown' });
|
||||
}
|
||||
|
||||
|
|
|
|||
136
packages/ui/components/overlays/src/utils/adopt-styles.js
Normal file
136
packages/ui/components/overlays/src/utils/adopt-styles.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// See: https://github.com/ing-bank/lion/issues/1880
|
||||
|
||||
/**
|
||||
* @typedef {import('lit').CSSResult|CSSStyleSheet} AdoptableStyle
|
||||
* @typedef {(renderRoot:DocumentOrShadowRoot, style: AdoptableStyle, opts?: {teardown?: boolean}) => void} AdoptStyleFn
|
||||
* @typedef {(renderRoot:DocumentOrShadowRoot, styles: AdoptableStyle[], opts?: {teardown?: boolean}) => void} AdoptStylesFn
|
||||
*/
|
||||
|
||||
// Shared protected object that can be spied/mocked in tests
|
||||
export const _adoptStyleUtils = {
|
||||
// Mocking Document.prototype.adoptedStyleSheets seemed impossible
|
||||
supportsAdoptingStyleSheets:
|
||||
window.ShadowRoot &&
|
||||
// @ts-ignore
|
||||
(window.ShadyCSS === undefined || window.ShadyCSS.nativeShadow) &&
|
||||
'adoptedStyleSheets' in Document.prototype &&
|
||||
'replace' in CSSStyleSheet.prototype,
|
||||
/** @type {AdoptStyleFn} */
|
||||
// @ts-ignore
|
||||
adoptStyle: undefined,
|
||||
/** @type {AdoptStylesFn} */
|
||||
// @ts-ignore
|
||||
adoptStyles: undefined,
|
||||
};
|
||||
|
||||
const styleCache = new Map();
|
||||
|
||||
/**
|
||||
* @param {CSSStyleSheet} cssStyleSheet
|
||||
*/
|
||||
export function serializeConstructableStylesheet(cssStyleSheet) {
|
||||
return Array.from(cssStyleSheet.cssRules)
|
||||
.map(r => r.cssText)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DocumentOrShadowRoot} renderRoot
|
||||
* @param {AdoptableStyle} style
|
||||
* @param {{teardown?: boolean}} opts
|
||||
*/
|
||||
function adoptStyleWhenAdoptedStylesheetsNotSupported(
|
||||
renderRoot,
|
||||
style,
|
||||
{ teardown = false } = {},
|
||||
) {
|
||||
const adoptRoot = /** @type {ShadowRoot|Document['body']} */ (
|
||||
renderRoot === document ? document.body : renderRoot
|
||||
);
|
||||
// @ts-ignore
|
||||
const styleText = style.cssText || serializeConstructableStylesheet(style);
|
||||
|
||||
if (!teardown) {
|
||||
const styleEl = document.createElement('style');
|
||||
// keep notation, so it's not renamed in minification/build
|
||||
// eslint-disable-next-line dot-notation
|
||||
const nonce = window['litNonce'];
|
||||
if (nonce !== undefined) {
|
||||
styleEl.setAttribute('nonce', nonce);
|
||||
}
|
||||
styleEl.textContent = styleText;
|
||||
adoptRoot.appendChild(styleEl);
|
||||
} else {
|
||||
const foundStyleEls = Array.from(adoptRoot.querySelectorAll('style'));
|
||||
|
||||
for (const foundStyleEl of foundStyleEls) {
|
||||
if (foundStyleEl.textContent === styleText) {
|
||||
foundStyleEl.remove();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DocumentOrShadowRoot} renderRoot
|
||||
* @param {AdoptableStyle} style
|
||||
* @param {{teardown?: boolean}} opts
|
||||
*/
|
||||
function handleCache(renderRoot, style, { teardown = false } = {}) {
|
||||
let haltFurtherExecution = false;
|
||||
if (!styleCache.has(renderRoot)) {
|
||||
styleCache.set(renderRoot, []);
|
||||
}
|
||||
const addedStylesForRoot = styleCache.get(renderRoot);
|
||||
const foundStyle = addedStylesForRoot.find(
|
||||
(/** @type {import("lit").CSSResultOrNative} */ addedStyle) => style === addedStyle,
|
||||
);
|
||||
|
||||
if (foundStyle && teardown) {
|
||||
addedStylesForRoot.splice(addedStylesForRoot.indexOf(style), 1);
|
||||
} else if (!foundStyle && !teardown) {
|
||||
addedStylesForRoot.push(style);
|
||||
} else if ((foundStyle && !teardown) || (!foundStyle && teardown)) {
|
||||
// Already removed or added. We're done
|
||||
haltFurtherExecution = true;
|
||||
}
|
||||
|
||||
return { haltFurtherExecution };
|
||||
}
|
||||
|
||||
/** @type {AdoptStyleFn} */
|
||||
export function adoptStyle(renderRoot, style, { teardown = false } = {}) {
|
||||
const { haltFurtherExecution } = handleCache(renderRoot, style, { teardown });
|
||||
if (haltFurtherExecution) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_adoptStyleUtils.supportsAdoptingStyleSheets) {
|
||||
adoptStyleWhenAdoptedStylesheetsNotSupported(renderRoot, style, { teardown });
|
||||
return;
|
||||
}
|
||||
|
||||
const sheet = style instanceof CSSStyleSheet ? style : style.styleSheet;
|
||||
if (!sheet) {
|
||||
throw new Error(`Please provide a CSSResultOrNative style`);
|
||||
}
|
||||
|
||||
if (!teardown) {
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
renderRoot.adoptedStyleSheets.push(sheet);
|
||||
} else if (renderRoot.adoptedStyleSheets.includes(sheet)) {
|
||||
renderRoot.adoptedStyleSheets.splice(renderRoot.adoptedStyleSheets.indexOf(sheet), 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {AdoptStylesFn} */
|
||||
export function adoptStyles(renderRoot, styles, { teardown = false } = {}) {
|
||||
for (const style of styles) {
|
||||
_adoptStyleUtils.adoptStyle(renderRoot, style, { teardown });
|
||||
}
|
||||
}
|
||||
|
||||
_adoptStyleUtils.adoptStyle = adoptStyle;
|
||||
_adoptStyleUtils.adoptStyles = adoptStyles;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Useful in tests when no need for wc
|
||||
*/
|
||||
export function createShadowHost() {
|
||||
const shadowHost = document.createElement('div');
|
||||
shadowHost.attachShadow({ mode: 'open' });
|
||||
/** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = `<slot></slot>`;
|
||||
document.body.appendChild(shadowHost);
|
||||
|
||||
return {
|
||||
shadowHost,
|
||||
cleanupShadowHost: () => {
|
||||
document.body.removeChild(shadowHost);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -11,9 +11,10 @@ import {
|
|||
import sinon from 'sinon';
|
||||
import { OverlayController, overlays } from '@lion/ui/overlays.js';
|
||||
import { mimicClick } from '@lion/ui/overlays-test-helpers.js';
|
||||
import { overlayShadowDomStyle } from '../src/overlayShadowDomStyle.js';
|
||||
import { keyCodes } from '../src/utils/key-codes.js';
|
||||
import { simulateTab } from '../src/utils/simulate-tab.js';
|
||||
import { _adoptStyleUtils } from '../src/utils/adopt-styles.js';
|
||||
import { createShadowHost } from '../test-helpers/createShadowHost.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig
|
||||
|
|
@ -22,20 +23,6 @@ import { simulateTab } from '../src/utils/simulate-tab.js';
|
|||
|
||||
const wrappingDialogNodeStyle = 'display: none; z-index: 9999;';
|
||||
|
||||
/**
|
||||
* Returns element that adopts stylesheet
|
||||
* @param {Element} shadowOrBodyEl
|
||||
* @returns {ShadowRoot}
|
||||
*/
|
||||
function getRootNodeOrBodyElThatAdoptsStylesheet(shadowOrBodyEl) {
|
||||
const rootNode = /** @type {* & DocumentOrShadowRoot} */ (shadowOrBodyEl.getRootNode());
|
||||
if (rootNode === document) {
|
||||
// @ts-ignore
|
||||
return document.body;
|
||||
}
|
||||
return rootNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that all browsers serialize html in a similar way
|
||||
* (Firefox tends to output empty style attrs)
|
||||
|
|
@ -97,51 +84,17 @@ describe('OverlayController', () => {
|
|||
});
|
||||
|
||||
describe('Stylesheets', () => {
|
||||
it('adds a stylesheet to the body when contentWrapper is located there', async () => {
|
||||
new OverlayController({
|
||||
...withLocalTestConfig(),
|
||||
});
|
||||
// @ts-ignore
|
||||
if (document.body.adoptedStyleSheets) {
|
||||
// @ts-ignore
|
||||
expect(document.body.adoptedStyleSheets).to.include(overlayShadowDomStyle.styleSheet);
|
||||
}
|
||||
});
|
||||
|
||||
it('adds a stylesheet to the shadowRoot when contentWrappeNode is located there', async () => {
|
||||
it('calls adoptStyles', async () => {
|
||||
const spy = sinon.spy(_adoptStyleUtils, 'adoptStyle');
|
||||
const { shadowHost, cleanupShadowHost } = createShadowHost();
|
||||
const contentNode = /** @type {HTMLElement} */ (await fixture('<div>contentful</div>'));
|
||||
const shadowHost = document.createElement('div');
|
||||
shadowHost.attachShadow({ mode: 'open' });
|
||||
/** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = `<slot></slot>`;
|
||||
shadowHost.appendChild(contentNode);
|
||||
document.body.appendChild(shadowHost);
|
||||
const ctrl = new OverlayController({
|
||||
new OverlayController({
|
||||
...withLocalTestConfig(),
|
||||
contentNode,
|
||||
});
|
||||
|
||||
const rootNodeOrBody = getRootNodeOrBodyElThatAdoptsStylesheet(ctrl.contentWrapperNode);
|
||||
expect(rootNodeOrBody).to.not.equal(document.body);
|
||||
|
||||
if (rootNodeOrBody.adoptedStyleSheets) {
|
||||
expect(rootNodeOrBody.adoptedStyleSheets).to.include(overlayShadowDomStyle.styleSheet);
|
||||
}
|
||||
document.body.removeChild(shadowHost);
|
||||
});
|
||||
|
||||
it('does not add same stylesheet twice', async () => {
|
||||
// @ts-ignore
|
||||
if (!document.body.adoptedStyleSheets) {
|
||||
return;
|
||||
}
|
||||
|
||||
new OverlayController({ ...withLocalTestConfig() });
|
||||
// @ts-ignore
|
||||
const amountOfStylesheetsAfterOneInit = document.body.adoptedStyleSheets.length;
|
||||
|
||||
new OverlayController({ ...withLocalTestConfig() });
|
||||
// @ts-ignore
|
||||
expect(document.body.adoptedStyleSheets.length).to.equal(amountOfStylesheetsAfterOneInit);
|
||||
expect(spy).to.have.been.called;
|
||||
cleanupShadowHost();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { expect, fixture, fixtureSync } from '@open-wc/testing';
|
||||
import { html } from 'lit/static-html.js';
|
||||
import { OverlayController } from '@lion/ui/overlays.js';
|
||||
import { normalizeTransformStyle } from './utils-tests/local-positioning-helpers.js';
|
||||
import { normalizeTransformStyle } from '../test-helpers/normalizeTransformStyle.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
import { expect } from '@open-wc/testing';
|
||||
import { css } from 'lit';
|
||||
import sinon from 'sinon';
|
||||
import {
|
||||
adoptStyle,
|
||||
adoptStyles,
|
||||
serializeConstructableStylesheet,
|
||||
_adoptStyleUtils,
|
||||
} from '../../src/utils/adopt-styles.js';
|
||||
import { createShadowHost } from '../../test-helpers/createShadowHost.js';
|
||||
|
||||
function mockNoSupportAdoptedStylesheets() {
|
||||
_adoptStyleUtils.supportsAdoptingStyleSheets = false;
|
||||
}
|
||||
|
||||
function restoreMockNoSupportAdoptedStylesheets() {
|
||||
_adoptStyleUtils.supportsAdoptingStyleSheets = true;
|
||||
}
|
||||
|
||||
describe('adoptStyle()', () => {
|
||||
it('adds a stylesheet from a CSSResult to the shadowRoot', async () => {
|
||||
const { shadowHost, cleanupShadowHost } = createShadowHost();
|
||||
const myCssResult = css``;
|
||||
const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
|
||||
|
||||
adoptStyle(root, myCssResult);
|
||||
expect(root.adoptedStyleSheets).to.include(myCssResult.styleSheet);
|
||||
|
||||
cleanupShadowHost();
|
||||
});
|
||||
|
||||
it('adds a stylesheet from a CSSStyleSheet to the shadowRoot', async () => {
|
||||
const { shadowHost, cleanupShadowHost } = createShadowHost();
|
||||
const myCSSStyleSheet = new CSSStyleSheet();
|
||||
const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
|
||||
|
||||
adoptStyle(root, myCSSStyleSheet);
|
||||
expect(root.adoptedStyleSheets).to.include(myCSSStyleSheet);
|
||||
|
||||
cleanupShadowHost();
|
||||
});
|
||||
|
||||
it('does not add same stylesheet twice', async () => {
|
||||
const { shadowHost, cleanupShadowHost } = createShadowHost();
|
||||
const myCssResult = css``;
|
||||
const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
|
||||
|
||||
adoptStyle(root, myCssResult);
|
||||
const amountOfStylesheetsAfterOneInit = root.adoptedStyleSheets.length;
|
||||
adoptStyle(root, myCssResult);
|
||||
expect(root.adoptedStyleSheets.length).to.equal(amountOfStylesheetsAfterOneInit);
|
||||
|
||||
cleanupShadowHost();
|
||||
});
|
||||
|
||||
it('works as well when document is the root', async () => {
|
||||
const myCssResult = css``;
|
||||
const root = document;
|
||||
|
||||
adoptStyle(root, myCssResult);
|
||||
expect(root.adoptedStyleSheets).to.include(myCssResult.styleSheet);
|
||||
});
|
||||
|
||||
describe('Teardown', () => {
|
||||
it('removes stylesheets from the shadowRoot', async () => {
|
||||
const { shadowHost, cleanupShadowHost } = createShadowHost();
|
||||
const myCssResult = css``;
|
||||
const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
|
||||
|
||||
adoptStyle(root, myCssResult);
|
||||
expect(root.adoptedStyleSheets).to.include(myCssResult.styleSheet);
|
||||
adoptStyle(root, myCssResult, { teardown: true });
|
||||
expect(root.adoptedStyleSheets).to.not.include(myCssResult.styleSheet);
|
||||
|
||||
const myCSSStyleSheet = new CSSStyleSheet();
|
||||
adoptStyle(root, myCSSStyleSheet);
|
||||
expect(root.adoptedStyleSheets).to.include(myCSSStyleSheet);
|
||||
adoptStyle(root, myCSSStyleSheet, { teardown: true });
|
||||
expect(root.adoptedStyleSheets).to.not.include(myCSSStyleSheet);
|
||||
|
||||
cleanupShadowHost();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback when adoptedStyleSheets are not supported', () => {
|
||||
beforeEach(() => {
|
||||
mockNoSupportAdoptedStylesheets();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreMockNoSupportAdoptedStylesheets();
|
||||
});
|
||||
|
||||
it('adds a "traditional" stylesheet to the shadowRoot', async () => {
|
||||
const { shadowHost, cleanupShadowHost } = createShadowHost();
|
||||
const myCssResult = css`
|
||||
.check {
|
||||
color: blue;
|
||||
}
|
||||
`;
|
||||
const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
|
||||
|
||||
adoptStyle(root, myCssResult);
|
||||
const sheets = Array.from(root.querySelectorAll('style'));
|
||||
const lastAddedSheet = sheets[sheets.length - 1];
|
||||
expect(lastAddedSheet.textContent).to.equal(myCssResult.cssText);
|
||||
|
||||
cleanupShadowHost();
|
||||
});
|
||||
|
||||
it('adds a "traditional" stylesheet to the body', async () => {
|
||||
mockNoSupportAdoptedStylesheets();
|
||||
|
||||
const myCssResult = css`
|
||||
.check {
|
||||
color: blue;
|
||||
}
|
||||
`;
|
||||
const root = document;
|
||||
adoptStyle(root, myCssResult);
|
||||
|
||||
const sheets = Array.from(document.body.querySelectorAll('style'));
|
||||
const lastAddedSheet = sheets[sheets.length - 1];
|
||||
expect(lastAddedSheet.textContent).to.equal(myCssResult.cssText);
|
||||
restoreMockNoSupportAdoptedStylesheets();
|
||||
});
|
||||
|
||||
describe('Teardown', () => {
|
||||
it('removes a "traditional" stylesheet from the shadowRoot', async () => {
|
||||
const { shadowHost, cleanupShadowHost } = createShadowHost();
|
||||
const myCssResult = css`
|
||||
.check {
|
||||
color: blue;
|
||||
}
|
||||
`;
|
||||
const root = /** @type {ShadowRoot} */ (shadowHost.shadowRoot);
|
||||
|
||||
adoptStyle(root, myCssResult);
|
||||
const sheets1 = Array.from(root.querySelectorAll('style'));
|
||||
const lastAddedSheet1 = sheets1[sheets1.length - 1];
|
||||
expect(lastAddedSheet1.textContent).to.equal(myCssResult.cssText);
|
||||
adoptStyle(root, myCssResult, { teardown: true });
|
||||
const sheets2 = Array.from(root.querySelectorAll('style'));
|
||||
const lastAddedSheet2 = sheets2[sheets2.length - 1];
|
||||
expect(lastAddedSheet2?.textContent).to.not.equal(myCssResult.cssText);
|
||||
|
||||
const myCSSStyleSheet = new CSSStyleSheet();
|
||||
myCSSStyleSheet.insertRule('.check { color: blue; }');
|
||||
|
||||
adoptStyle(root, myCSSStyleSheet);
|
||||
const sheets3 = Array.from(root.querySelectorAll('style'));
|
||||
const lastAddedSheet3 = sheets3[sheets3.length - 1];
|
||||
expect(lastAddedSheet3.textContent).to.equal(
|
||||
serializeConstructableStylesheet(myCSSStyleSheet),
|
||||
);
|
||||
adoptStyle(root, myCSSStyleSheet, { teardown: true });
|
||||
const sheets4 = Array.from(root.querySelectorAll('style'));
|
||||
const lastAddedSheet4 = sheets4[sheets4.length - 1];
|
||||
expect(lastAddedSheet4?.textContent).to.not.equal(myCSSStyleSheet);
|
||||
|
||||
cleanupShadowHost();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('adoptStyles()', () => {
|
||||
it('calls "adoptStyle" for all entries in CSSResult|CSSStylesheet[]', async () => {
|
||||
const spy = sinon.spy(_adoptStyleUtils, 'adoptStyle');
|
||||
|
||||
const myCssResult = css``;
|
||||
const myCSSStyleSheet = new CSSStyleSheet();
|
||||
|
||||
adoptStyles(document, [myCssResult, myCSSStyleSheet]);
|
||||
expect(spy).to.have.been.calledTwice;
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue