diff --git a/components/button/test/button-icon.vdiff.js b/components/button/test/button-icon.vdiff.js
new file mode 100644
index 0000000000..df68c9175a
--- /dev/null
+++ b/components/button/test/button-icon.vdiff.js
@@ -0,0 +1,33 @@
+import '../button-icon.js';
+import { fixture, focusWithKeyboard, focusWithMouse, hoverWithMouse, html, screenshotAndCompare } from '../../../tools/web-test-runner-helpers.js';
+
+describe('d2l-button-icon', () => {
+
+ [
+ { category: 'normal', f: html` ` },
+ { category: 'translucent', f: html` `, theme: 'translucent' },
+ { category: 'dark', f: html` `, theme: 'dark' },
+ { category: 'custom', f: html` ` }
+ ].forEach(({ category, f, theme }) => {
+
+ describe(category, () => {
+
+ [
+ { name: 'normal' },
+ { name: 'hover', action: async(elem) => await hoverWithMouse(elem) },
+ { name: 'focus', action: async(elem) => await focusWithKeyboard(elem) },
+ { name: 'click', action: async(elem) => await focusWithMouse(elem) },
+ { name: 'disabled', action: async(elem) => elem.disabled = true }
+ ].forEach(({ action, name }) => {
+ it(name, async function() {
+ const elem = await fixture(f, { theme: theme });
+ if (action) await action(elem);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+ });
+
+ });
+
+ });
+
+});
diff --git a/components/button/test/button-move.vdiff.js b/components/button/test/button-move.vdiff.js
new file mode 100644
index 0000000000..42ee2f2c1a
--- /dev/null
+++ b/components/button/test/button-move.vdiff.js
@@ -0,0 +1,57 @@
+import '../button-move.js';
+import { fixture, focusWithKeyboard, focusWithMouse, hoverWithMouse, html, screenshotAndCompare } from '../../../tools/web-test-runner-helpers.js';
+
+describe('d2l-button-move', () => {
+
+ [ 'normal', 'dark' ].forEach(category => {
+
+ describe(category, () => {
+
+ let elem;
+ beforeEach(async() => {
+ elem = await fixture(
+ html` `,
+ { theme: category === 'dark' ? 'dark' : undefined }
+ );
+ });
+
+ [
+ { name: 'normal' },
+ { name: 'hover', action: async(elem) => await hoverWithMouse(elem) },
+ { name: 'keyboard-focus', action: async(elem) => await focusWithKeyboard(elem) },
+ { name: 'mouse-focus', action: async(elem) => await focusWithMouse(elem) },
+ { name: 'disabled', action: async(elem) => {
+ elem.disabledUp = true;
+ elem.disabledDown = true;
+ elem.disabledLeft = true;
+ elem.disabledRight = true;
+ elem.disabledHome = true;
+ elem.disabledEnd = true;
+ } },
+ { name: 'disabled-up', action: async(elem) => elem.disabledUp = true },
+ { name: 'disabled-up-hover', action: async(elem) => {
+ elem.disabledUp = true;
+ await hoverWithMouse(elem);
+ } },
+ { name: 'disabled-up-keyboard-focus', action: async(elem) => {
+ elem.disabledUp = true;
+ await focusWithKeyboard(elem);
+ } },
+ { name: 'disabled-up-mouse-focus', action: async(elem) => {
+ elem.disabledUp = true;
+ await focusWithMouse(elem);
+ } },
+ { name: 'disabled-down', action: async(elem) => elem.disabledDown = true },
+ ].forEach(({ action, name }) => {
+ it(name, async function() {
+ if (category === 'dark') elem.theme = 'dark';
+ if (action) await action(elem);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+ });
+
+ });
+
+ });
+
+});
diff --git a/components/button/test/button-subtle.vdiff.js b/components/button/test/button-subtle.vdiff.js
new file mode 100644
index 0000000000..8a191a4911
--- /dev/null
+++ b/components/button/test/button-subtle.vdiff.js
@@ -0,0 +1,59 @@
+import '../button-subtle.js';
+import { fixture, focusWithKeyboard, focusWithMouse, hoverWithMouse, html, screenshotAndCompare } from '../../../tools/web-test-runner-helpers.js';
+
+describe('d2l-button-subtle', () => {
+
+ [
+ { category: 'default-normal', f: html` ` },
+ { category: 'default-icon', f: html` `, hasRtl: true },
+ { category: 'default-icon-right', f: html` `, hasRtl: true },
+ { category: 'slim-normal', f: html` ` },
+ { category: 'slim-icon', f: html` `, hasRtl: true },
+ { category: 'slim-icon-right', f: html` `, hasRtl: true }
+ ].forEach(({ category, f, hasRtl }) => {
+
+ describe(category, () => {
+
+ [
+ { name: 'normal' },
+ { name: 'hover', action: async(elem) => await hoverWithMouse(elem) },
+ { name: 'focus', action: async(elem) => await focusWithKeyboard(elem) },
+ { name: 'click', action: async(elem) => await focusWithMouse(elem) },
+ { name: 'disabled', action: async(elem) => elem.disabled = true }
+ ].forEach(({ action, name }) => {
+ it(name, async function() {
+ const elem = await fixture(f);
+ if (action) await action(elem);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+ });
+
+ if (hasRtl) {
+ it('rtl', async function() {
+ const elem = await fixture(f, { rtl: true });
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+ }
+
+ });
+
+ });
+
+ it('h-align', async function() {
+ const elem = await fixture(html`
+
+
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit
+
+
+
+
+
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+});
diff --git a/components/button/test/button.vdiff.js b/components/button/test/button.vdiff.js
new file mode 100644
index 0000000000..3a9fa89326
--- /dev/null
+++ b/components/button/test/button.vdiff.js
@@ -0,0 +1,31 @@
+import '../button.js';
+import { fixture, focusWithKeyboard, focusWithMouse, hoverWithMouse, html, screenshotAndCompare } from '../../../tools/web-test-runner-helpers.js';
+
+describe('d2l-button', () => {
+
+ [
+ { category: 'normal', f: html`Normal Button ` },
+ { category: 'primary', f: html`Primary Button ` }
+ ].forEach(({ category, f }) => {
+
+ describe(category, () => {
+
+ [
+ { name: 'normal' },
+ { name: 'hover', action: async(elem) => await hoverWithMouse(elem) },
+ { name: 'focus', action: async(elem) => await focusWithKeyboard(elem) },
+ { name: 'click', action: async(elem) => await focusWithMouse(elem) },
+ { name: 'disabled', action: async(elem) => elem.disabled = true }
+ ].forEach(({ action, name }) => {
+ it(name, async function() {
+ const elem = await fixture(f);
+ if (action) await action(elem);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+ });
+
+ });
+
+ });
+
+});
diff --git a/components/button/test/floating-buttons.vdiff.js b/components/button/test/floating-buttons.vdiff.js
new file mode 100644
index 0000000000..d4c79ab8c3
--- /dev/null
+++ b/components/button/test/floating-buttons.vdiff.js
@@ -0,0 +1,77 @@
+import '../button.js';
+import '../floating-buttons.js';
+import { fixture, html, screenshotAndCompare } from '../../../tools/web-test-runner-helpers.js';
+
+const lotsaCoffee = Array.from(Array(22).keys()).map(() => html`I love Coffee!
`);
+
+const floatingButtonsFixture = html`
+
+ ${lotsaCoffee}
+
+ Primary Button
+ Secondary Button
+
+
+`;
+const floatingButtonsShortFixture = html`
+
+
+
+ Brew more Coffee!
+
+
+`;
+const floatingButtonsAlwaysFloatFixture = html`
+
+ ${lotsaCoffee}
+
+ Primary Button
+ Secondary Button
+
+
+`;
+
+describe('d2l-floating-buttons', () => {
+
+ it('floats', async function() {
+ const elem = await fixture(floatingButtonsFixture);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('does not float at bottom of container', async function() {
+ const elem = await fixture(floatingButtonsFixture);
+ window.scrollTo(0, document.body.scrollHeight);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('does not float when small amount of content', async function() {
+ const elem = await fixture(floatingButtonsShortFixture);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('floats when content added to dom', async function() {
+ const elem = await fixture(floatingButtonsShortFixture);
+ const contentElem = document.querySelector('#floating-buttons-short-content').querySelector('p');
+ contentElem.innerHTML += ' I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe I love Coffe ';
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('floats at bottom of page when always-float', async function() {
+ const elem = await fixture(floatingButtonsAlwaysFloatFixture);
+ window.scrollTo(0, document.body.scrollHeight);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('is correct with rtl', async function() {
+ const elem = await fixture(floatingButtonsFixture, { rtl: true });
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('floats when bounded', async function() {
+ const elem = await fixture(html`${floatingButtonsFixture}
`);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+});
diff --git a/components/icons/icon.js b/components/icons/icon.js
index 0c490c69e9..079ea0d6cf 100644
--- a/components/icons/icon.js
+++ b/components/icons/icon.js
@@ -6,8 +6,9 @@ import { loadSvg } from '../../generated/icons/presetIconLoader.js';
import { RtlMixin } from '../../mixins/rtl/rtl-mixin.js';
import { runAsync } from '../../directives/run-async/run-async.js';
import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
+import { WaitForMeMixin } from '../../tools/web-test-runner-helpers.js';
-class Icon extends RtlMixin(LitElement) {
+class Icon extends WaitForMeMixin(RtlMixin(LitElement)) {
static get properties() {
return {
@@ -35,9 +36,17 @@ class Icon extends RtlMixin(LitElement) {
`];
}
+ constructor() {
+ super();
+ this._iconWaitHandle = this.setWaitHandle();
+ }
+
render() {
return html`${runAsync(this.icon, () => this._getIcon(), {
- success: (icon) => icon
+ success: (icon) => {
+ this.clearWaitHandle(this._iconWaitHandle);
+ return icon;
+ }
}, { pendingState: false })}`;
}
diff --git a/components/icons/test/icon.vdiff.js b/components/icons/test/icon.vdiff.js
new file mode 100644
index 0000000000..b637b896e4
--- /dev/null
+++ b/components/icons/test/icon.vdiff.js
@@ -0,0 +1,80 @@
+import '../icon.js';
+import '../demo/icon-color-override.js';
+import '../demo/icon-size-override.js';
+import { fixture, html, screenshotAndCompare } from '../../../tools/web-test-runner-helpers.js';
+
+describe('d2l-icon', () => {
+
+ it('tier1', async function() {
+ const elem = await fixture(html` `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('tier2', async function() {
+ const elem = await fixture(html` `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('tier3', async function() {
+ const elem = await fixture(html` `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('prefixed', async function() {
+ const elem = await fixture(html` `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('fill-none', async function() {
+ const elem = await fixture(html` `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('fill-circle', async function() {
+ const elem = await fixture(html` `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('fill-mixed', async function() {
+ const elem = await fixture(html`
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('color-override', async function() {
+ const elem = await fixture(html`
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('size-override', async function() {
+ const elem = await fixture(html`
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('rtl-tier1', async function() {
+ const elem = await fixture(html` `, { rtl: true });
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('rtl-tier2', async function() {
+ const elem = await fixture(html` `, { rtl: true });
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('rtl-tier3', async function() {
+ const elem = await fixture(html` `, { rtl: true });
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+});
diff --git a/components/list/test/list.vdiff.js b/components/list/test/list.vdiff.js
new file mode 100644
index 0000000000..0e4f3a94a4
--- /dev/null
+++ b/components/list/test/list.vdiff.js
@@ -0,0 +1,904 @@
+import '../../button/button-icon.js';
+import '../../dropdown/dropdown.js';
+import '../../dropdown/dropdown-content.js';
+import '../../link/link.js';
+import '../../tooltip/tooltip.js';
+import '../list.js';
+import '../list-controls.js';
+import '../list-item.js';
+import '../list-item-button.js';
+import '../list-item-content.js';
+import { fixture, focusWithKeyboard, hoverWithMouse, html, nextFrame, oneEvent, screenshotAndCompare } from '../../../tools/web-test-runner-helpers.js';
+import { ifDefined } from 'lit/directives/if-defined.js';
+
+const buttonFixture = html`
+
+
+
+ Item 1
+ Secondary info for item 1
+
+
+
+`;
+
+const dropdownTooltipFixture = html`
+
+
+ Item 1
+
+
+ Open
+ Cookie pie apple pie
+ donut gummies
+
+
+
+ Item 2
+ Item 3
+
+`;
+
+const hrefFixture = html`
+
+
+
+ Item 1
+ Secondary info for item 1
+
+
+
+`;
+
+const selectableFixture = html`
+
+ Item 1
+ Item 2
+
+`;
+
+function createSimpleFixture(separatorType, extendSeparators = false) {
+ return html`
+
+ Item 1
+ Item 2
+ Item 3
+
+ `;
+}
+
+function createExpandCollapseFixture(expanded, selectable, draggable) {
+ return html`
+
+
+
+ Level 1, Item 1
+ Supporting text for top level list item
+
+
+
+
+ Level 2, Item 1
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer enim.
+
+
+
+
+ Level 2, Item 2
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer enim.
+
+
+
+
+ Level 3, Item 1
+
+
+
+
+ Level 3, Item 2
+
+
+
+
+
+
+
+ `;
+}
+
+describe('d2l-list', () => {
+
+ describe('general', () => {
+
+ it('simple', async function() {
+ const elem = await fixture(createSimpleFixture());
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('no-padding', async function() {
+ const elem = await fixture(html`
+
+ Item 1
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('illustration', () => {
+
+ it('default', async function() {
+ const elem = await fixture(html`
+
+
+ Item 1
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('separators', () => {
+
+ it('default', async function() {
+ const elem = await fixture(createSimpleFixture());
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('none', async function() {
+ const elem = await fixture(createSimpleFixture('none'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('all', async function() {
+ const elem = await fixture(createSimpleFixture('all'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('between', async function() {
+ const elem = await fixture(createSimpleFixture('between'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('extended', async function() {
+ const elem = await fixture(createSimpleFixture(undefined, true));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('actions', () => {
+
+ it('default', async function() {
+ const elem = await fixture(html`
+
+
+ Item 1
+
+ Action 1
+
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('extended separators', async function() {
+ const elem = await fixture(html`
+
+
+ Item 1
+
+ Action 1
+
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('rtl', async function() {
+ const elem = await fixture(html`
+
+
+
+ Item 1
+
+
+
+
+
+
+ `, { rtl: true });
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('item-content', () => {
+
+ const clampSingleStyle = 'overflow: hidden; overflow-wrap: anywhere; text-overflow: ellipsis; white-space: nowrap;';
+
+ it('all', async function() {
+ const elem = await fixture(html`
+
+
+
+ Item 1
+ Secondary Info for item 1
+ Supporting info for item 1
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('no padding', async function() {
+ const elem = await fixture(html`
+
+
+
+ Item 1
+ Secondary Info for item 1
+ Supporting info for item 1
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('long wrapping', async function() {
+ const elem = await fixture(html`
+
+
+
+ Overflow: wrap. Primary text. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+ Overflow: wrap. Secondary Info. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+ Overflow: wrap. Supporting Info. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('long single line ellipsis', async function() {
+ const elem = await fixture(html`
+
+
+
+ Overflow: single-line, ellipsis. Primary text. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+ Overflow: single-line, ellipsis. Secondary Info. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+ Overflow: single-line, ellipsis. Supporting Info. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('long unbreakable single line ellipsis', async function() {
+ const elem = await fixture(html`
+
+
+
+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
+ ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('long single line ellipsis nested', async function() {
+ const elem = await fixture(html`
+
+
+
+ Overflow: single-line, ellipsis. Primary text. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+ Overflow: single-line, ellipsis. Secondary Info. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+ Overflow: single-line, ellipsis. Supporting Info. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('short single line ellipsis', async function() {
+ const elem = await fixture(html`
+
+
+
+ Overflow: single-line, ellipsis. Primary text.
+ Overflow: single-line, ellipsis. Secondary Info.
+ Overflow: single-line, ellipsis. Supporting Info.
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('long multi line ellipsis', async function() {
+ const clampMultiStyle = '-webkit-box-orient: vertical; display: -webkit-box; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere;';
+ const elem = await fixture(html`
+
+
+
+ Overflow: multi-line, ellipsis. Primary text. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+ Overflow: multi-line, ellipsis. Secondary Info. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+ Overflow: multi-line, ellipsis. Supporting Info. Lookout take a caulk rope's end Jack Ketch Admiral of the Black yard jury mast barque no prey, no pay port.
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('href', () => {
+
+ let elem;
+ beforeEach(async() => {
+ elem = await fixture(hrefFixture);
+ });
+
+ it('default', async function() {
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('focus', async function() {
+ await focusWithKeyboard(elem.querySelector('d2l-list-item').shadowRoot.querySelector('a'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('hover', async function() {
+ await hoverWithMouse(elem.querySelector('d2l-list-item'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ });
+
+ describe('button', () => {
+
+ let elem;
+ beforeEach(async() => {
+ elem = await fixture(buttonFixture);
+ });
+
+ it('default', async function() {
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('focus', async function() {
+ await focusWithKeyboard(elem.querySelector('d2l-list-item-button').shadowRoot.querySelector('button'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('hover', async function() {
+ await hoverWithMouse(elem.querySelector('d2l-list-item-button'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ });
+
+ describe('button-disabled', () => {
+
+ let elem;
+ beforeEach(async() => {
+ elem = await fixture(html`
+
+
+
+ Item 1
+ Secondary info for item 1
+
+
+
+ `);
+ });
+
+ it('default', async function() {
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('focus', async function() {
+ await focusWithKeyboard(elem.querySelector('d2l-list-item-button').shadowRoot.querySelector('button'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('hover', async function() {
+ await hoverWithMouse(elem.querySelector('d2l-list-item-button'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ });
+
+ describe('selectable', () => {
+
+ const selectableButtonFixture = html`
+
+ Item 3
+ Item 4
+
+ `;
+
+ const selectableSelectedFixture = html`
+
+ Item 1
+ Item 2
+
+ `;
+
+ it('not selected', async function() {
+ const elem = await fixture(selectableFixture);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('not selected focus', async function() {
+ const elem = await fixture(selectableFixture);
+ await focusWithKeyboard(elem.querySelector('[key="1"]').shadowRoot.querySelector('d2l-selection-input'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('not selected hover', async function() {
+ const elem = await fixture(selectableFixture);
+ await hoverWithMouse(elem.querySelector('[key="1"]'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('selection-disabled hover', async function() {
+ const elem = await fixture(selectableFixture);
+ await hoverWithMouse(elem.querySelector('[key="2"]'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('button selection-disabled hover', async function() {
+ const elem = await fixture(selectableButtonFixture);
+ await hoverWithMouse(elem.querySelector('[key="3"]'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('button selection-disabled button-disabled hover', async function() {
+ const elem = await fixture(selectableButtonFixture);
+ await hoverWithMouse(elem.querySelector('[key="4"]'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('selected', async function() {
+ const elem = await fixture(selectableSelectedFixture);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('selected focus', async function() {
+ const elem = await fixture(selectableSelectedFixture);
+ await focusWithKeyboard(elem.querySelector('[key="1"]').shadowRoot.querySelector('d2l-selection-input'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('selected focus', async function() {
+ const elem = await fixture(selectableSelectedFixture);
+ await hoverWithMouse(elem.querySelector('[key="1"]'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('item-content', async function() {
+ const elem = await fixture(html`
+
+
+
+ Item 1
+ Secondary info for item 1
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('skeleton', async function() {
+ const elem = await fixture(html`
+
+
+
+ Item 1
+ Secondary info for item 1
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('extended separators', async function() {
+ const elem = await fixture(html`
+
+ Item 1
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('selectableHref', () => {
+
+ const selectableHrefFixture = html`
+
+
+ Introductory Earth Sciences
+
+
+
+
+
+ `;
+
+ it('hover href', async function() {
+ const elem = await fixture(selectableHrefFixture);
+ await hoverWithMouse(elem.querySelector('d2l-list-item'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('hover selection', async function() {
+ const elem = await fixture(selectableHrefFixture);
+ await hoverWithMouse(elem.querySelector('d2l-list-item').shadowRoot.querySelector('[slot="control"]'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('hover secondary action', async function() {
+ const elem = await fixture(selectableHrefFixture);
+ await hoverWithMouse(elem.querySelector('d2l-button-icon'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('controls', () => {
+
+ const stickyFixture = html`
+
+
+
+
+
+ Item 1
+ Supporting info
+
+
+
+
+ Item 2
+ Supporting info
+
+
+
+
+ Item 3
+ Supporting info
+
+
+
+
+ `;
+
+ it('not selectable', async function() {
+ const elem = await fixture(html`
+
+
+
+
+ Item 1
+ Item 2
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('none selected', async function() {
+ const elem = await fixture(html`
+
+
+ Item 1
+ Item 2
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('some selected', async function() {
+ const elem = await fixture(html`
+
+
+ Item 1
+ Item 2
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('all selected', async function() {
+ const elem = await fixture(html`
+
+
+ Item 1
+ Item 2
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('all selected pages', async function() {
+ const elem = await fixture(html`
+
+
+ Item 1
+ Item 2
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('sticky top', async function() {
+ const elem = await fixture(stickyFixture);
+ elem.scrollTo(0, 45);
+ await nextFrame();
+ elem.scrollTo(0, 0);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('sticky scrolled', async function() {
+ const elem = await fixture(stickyFixture);
+ elem.scrollTo(0, 45);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('draggable', () => {
+
+ function createDraggableFixture(selectable = false) {
+ return html`
+
+ Item 1
+ Item 2
+
+ `;
+ }
+
+ it('default', async function() {
+ const elem = await fixture(createDraggableFixture());
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('focus', async function() {
+ const elem = await fixture(createDraggableFixture());
+ await focusWithKeyboard(elem.querySelector('[key="1"]'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('hover', async function() {
+ const elem = await fixture(createDraggableFixture());
+ await hoverWithMouse(elem.querySelector('[key="1"]'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('selectable', async function() {
+ const elem = await fixture(createDraggableFixture(true));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('selectable focus', async function() {
+ const elem = await fixture(createDraggableFixture(true));
+ await focusWithKeyboard(elem.querySelector('[key="1"]').shadowRoot.querySelector('d2l-selection-input'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('selectable hover', async function() {
+ const elem = await fixture(createDraggableFixture(true));
+ await hoverWithMouse(elem.querySelector('[key="1"]'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('extended separators', async function() {
+ const elem = await fixture(html`
+
+ Item 1
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('focus method', () => {
+
+ it('href', async function() {
+ const elem = await fixture(hrefFixture);
+ await focusWithKeyboard(elem.querySelector('d2l-list-item'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('button', async function() {
+ const elem = await fixture(buttonFixture);
+ await focusWithKeyboard(elem.querySelector('d2l-list-item-button'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('selectable', async function() {
+ const elem = await fixture(selectableFixture);
+ await focusWithKeyboard(elem.querySelector('[key="1"]'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('expandable', async function() {
+ const elem = await fixture(createExpandCollapseFixture(false, false, false));
+ await focusWithKeyboard(elem.querySelector('d2l-list-item'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('breakpoitns', () => {
+ [900, 700, 600, 490].forEach((breakpoint) => {
+ it(breakpoint.toString(), async function() {
+ const elem = await fixture(html`
+
+
+
+
+ Introductory Pirate Ipsum
+ Case shot Shiver me timbers gangplank crack Jennys tea cup ballast Blimey lee snow crow's nest rutters.
+
+
+
+
+
+ Introductory Pirate Ipsum
+ Case shot Shiver me timbers gangplank crack Jennys tea cup ballast Blimey lee snow crow's nest rutters.
+
+
+
+ `);
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+ });
+ });
+
+ describe('dropdown', () => {
+
+ it('open down', async function() {
+ const elem = await fixture(dropdownTooltipFixture);
+ const dropdown = elem.querySelector('d2l-dropdown');
+ setTimeout(() => dropdown.toggleOpen());
+ await oneEvent(dropdown, 'd2l-dropdown-open');
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('tooltip', () => {
+
+ it('open down', async function() {
+ const elem = await fixture(dropdownTooltipFixture);
+ const tooltip = elem.querySelector('d2l-tooltip');
+ setTimeout(() => tooltip.show());
+ await oneEvent(tooltip, 'd2l-tooltip-show');
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+ describe('nested', () => {
+
+ function createNestedFixture(selectedType) {
+ return html`
+
+
+
+
+ Level 1, Item 1
+ Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yardarm.
+
+
+ action 1
+ action 2
+
+
+
+
+ Level 2, Item 1
+ Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl.
+
+
+
+
+ Level 2, Item 2
+ Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl.
+
+
+
+
+ Level 3, Item 1
+ Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl.
+
+
+
+
+ Level 3, Item 2
+ Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl.
+
+
+
+
+
+
+
+ `;
+ }
+
+ it('none-selected', async function() {
+ const elem = await fixture(createNestedFixture('none'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('some-selected', async function() {
+ const elem = await fixture(createNestedFixture('some'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ it('all-selected', async function() {
+ const elem = await fixture(createNestedFixture('all'));
+ await screenshotAndCompare(elem, this.test.fullTitle(), { margin: 24 });
+ });
+
+ });
+
+ describe('expand-collapse', () => {
+
+ it('default', async function() {
+ const elem = await fixture(createExpandCollapseFixture(false, false, false));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('default expanded', async function() {
+ const elem = await fixture(createExpandCollapseFixture(true, false, false));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('selectable', async function() {
+ const elem = await fixture(createExpandCollapseFixture(true, true, false));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('draggable', async function() {
+ const elem = await fixture(createExpandCollapseFixture(true, false, true));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('selectable draggable', async function() {
+ const elem = await fixture(createExpandCollapseFixture(true, true, true));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ it('button focus', async function() {
+ const elem = await fixture(createExpandCollapseFixture(false, false, false));
+ await focusWithKeyboard(elem.querySelector('d2l-list-item').shadowRoot.querySelector('d2l-button-icon'));
+ await screenshotAndCompare(elem, this.test.fullTitle());
+ });
+
+ });
+
+});
diff --git a/package.json b/package.json
index c799ffdc01..7b6060fc4f 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,8 @@
"test:axe": "web-test-runner --group aXe",
"test:headless": "web-test-runner --group default",
"test:headless:watch": "web-test-runner --group default --watch",
- "test:translations": "mfv -e -s en -p ./lang/ -i untranslated"
+ "test:translations": "mfv -e -s en -p ./lang/ -i untranslated",
+ "test:visual-diff": "web-test-runner --group visual-diff --playwright --browsers chromium --concurrency 5"
},
"files": [
"custom-elements.json",
@@ -53,6 +54,7 @@
"@rollup/plugin-replace": "^5",
"@web/dev-server": "^0.2",
"@web/test-runner": "^0.16",
+ "@web/test-runner-commands": "^0.6",
"@web/test-runner-playwright": "^0.10",
"axe-core": "^4",
"chalk": "^5",
diff --git a/tools/visual-diff-plugin.js b/tools/visual-diff-plugin.js
new file mode 100644
index 0000000000..1b5d7c9c3a
--- /dev/null
+++ b/tools/visual-diff-plugin.js
@@ -0,0 +1,86 @@
+
+import { access, constants, stat } from 'node:fs/promises';
+import { dirname, join } from 'path';
+import { env } from 'node:process';
+
+const isCI = !!env['CI'];
+const ciDir = isCI ? 'ci' : '';
+const DEFAULT_MARGIN = 10;
+
+async function checkFileExists(fileName) {
+ try {
+ await access(fileName, constants.F_OK);
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+
+function extractTestPartsFromName(name) {
+ name = name.toLowerCase();
+ const parts = name.split(' ');
+ if (parts.length > 1) {
+ let dirName = parts.shift();
+ if (dirName.startsWith('d2l-')) {
+ dirName = dirName.substring(4);
+ }
+ return {
+ dir: dirName,
+ newName: parts.join('-')
+ };
+ }
+ return {
+ dir: '',
+ newName: parts.join('-')
+ };
+}
+
+export function visualDiff() {
+ return {
+ name: 'brightspace-visual-diff',
+ async executeCommand({ command, payload, session }) {
+
+ if (command !== 'brightspace-visual-diff') {
+ return;
+ }
+ if (session.browser.type !== 'playwright') {
+ throw new Error('Visual-diff is only supported for browser type Playwright');
+ }
+
+ const browser = session.browser.name.toLowerCase();
+ const { dir, newName } = extractTestPartsFromName(payload.name);
+ const goldenFileName = `${join(dirname(session.testFile), 'screenshots', ciDir, 'golden', browser, dir, newName)}.png`;
+ const currentFileName = `${join(dirname(session.testFile), 'screenshots', ciDir, 'current', browser, dir, newName)}.png`;
+
+ const opts = payload.opts || {};
+ opts.margin = opts.margin || DEFAULT_MARGIN;
+
+ const page = session.browser.getPage(session.id);
+ await page.screenshot({
+ animations: 'disabled',
+ clip: {
+ x: payload.rect.x - opts.margin,
+ y: payload.rect.y - opts.margin,
+ width: payload.rect.width + (opts.margin * 2),
+ height: payload.rect.height + (opts.margin * 2)
+ },
+ path: currentFileName
+ });
+
+ const goldenExists = await checkFileExists(goldenFileName);
+ if (!goldenExists) {
+ //return { pass: false, message: 'No golden exists' };
+ return { pass: true };
+ }
+
+ const currentInfo = await stat(currentFileName);
+ const goldenInfo = await stat(goldenFileName);
+
+ // TODO: obviously this isn't how to diff against, the golden! Use pixelmatch here.
+ const same = (currentInfo.size === goldenInfo.size);
+
+ return { pass: same, message: 'Does not match golden' };
+
+ }
+ };
+}
diff --git a/tools/visual-diff-reporter.js b/tools/visual-diff-reporter.js
new file mode 100644
index 0000000000..af16cea5e8
--- /dev/null
+++ b/tools/visual-diff-reporter.js
@@ -0,0 +1,64 @@
+export function visualDiffReporter({ reportResults = true, reportProgress = false } = {}) {
+ return {
+ /**
+ * Called once when the test runner starts.
+ */
+ start({ config, sessions, testFiles, browserNames, startTime }) {
+ //console.log('runner start');
+ },
+
+ /**
+ * Called once when the test runner stops. This can be used to write a test
+ * report to disk for regular test runs.
+ */
+ stop({ sessions, testCoverage, focusedTestFile }) {
+ //console.log('runner stop');
+ },
+
+ /**
+ * Called when a test run starts. Each file change in watch mode
+ * triggers a test run.
+ *
+ * @param testRun the test run
+ */
+ onTestRunStarted({ testRun }) {
+ //console.log('testRun start');
+ },
+
+ /**
+ * Called when a test run is finished. Each file change in watch mode
+ * triggers a test run. This can be used to report the end of a test run,
+ * or to write a test report to disk in watch mode for each test run.
+ *
+ * @param testRun the test run
+ */
+ onTestRunFinished({ testRun, sessions, testCoverage, focusedTestFile }) {
+ //console.log('testRun finished');
+ },
+
+ /**
+ * Called when results for a test file can be reported. This is called
+ * when all browsers for a test file are finished, or when switching between
+ * menus in watch mode.
+ *
+ * If your test results are calculated async, you should return a promise from
+ * this function and use the logger to log test results. The test runner will
+ * guard against race conditions when re-running tests in watch mode while reporting.
+ *
+ * @param logger the logger to use for logging tests
+ * @param testFile the test file to report for
+ * @param sessionsForTestFile the sessions for this test file. each browser is a
+ * different session
+ */
+ async reportTestFileResults({ logger, sessionsForTestFile, testFile }) {
+ if (!reportResults) {
+ return;
+ }
+
+ //logger.log(`Results for ${testFile}`);
+ //logger.group();
+ //logger.groupEnd();
+
+ }
+ };
+}
diff --git a/tools/web-test-runner-helpers.js b/tools/web-test-runner-helpers.js
index 96c1a83666..6ba264c5ed 100644
--- a/tools/web-test-runner-helpers.js
+++ b/tools/web-test-runner-helpers.js
@@ -1,12 +1,172 @@
-import { sendKeys, sendMouse } from '@web/test-runner-commands';
+export { aTimeout, defineCE, expect, html, nextFrame, oneEvent, waitUntil } from '@open-wc/testing';
+import { executeServerCommand, sendKeys, sendMouse, setViewport } from '@web/test-runner-commands';
+import { expect, nextFrame, fixture as wcFixture } from '@open-wc/testing';
+import { dedupeMixin } from '@open-wc/dedupe-mixin';
+import { getComposedChildren } from '../helpers/dom.js';
+import { getDocumentLocaleSettings } from '@brightspace-ui/intl/lib/common.js';
+
+const DEFAULT_LANG = 'en',
+ DEFAULT_VIEWPORT_HEIGHT = 800,
+ DEFAULT_VIEWPORT_WIDTH = 800;
+
+let currentLang = DEFAULT_LANG,
+ currentTheme = undefined,
+ currentRtl = false,
+ currentViewportHeight = DEFAULT_VIEWPORT_HEIGHT,
+ currentViewportWidth = DEFAULT_VIEWPORT_WIDTH,
+ mouseResetNeeded = false;
+
+let awaitedFonts = false;
+async function waitForFonts() {
+ if (awaitedFonts) return Promise.resolve();
+ awaitedFonts = true;
+ return await document.fonts.ready;
+}
+
+export const WaitForMeMixin = dedupeMixin(superclass => class extends superclass {
+
+ constructor() {
+ super();
+ this._waitKey = 0;
+ this._waitPromises = new Map();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._waitPromises.forEach((_value, key) => this.clearWaitHandle(key));
+ }
+
+ clearWaitHandle(handle) {
+ const entry = this._waitPromises.get(handle);
+ if (entry !== undefined) {
+ this._waitPromises.delete(handle);
+ entry.resolve();
+ }
+ }
+
+ setWaitHandle() {
+ const handle = ++this._waitKey;
+ let resolve;
+ const promise = new Promise((res) => {
+ resolve = res;
+ });
+ this._waitPromises.set(handle, { promise, resolve });
+ return handle;
+ }
+
+ waitForIt() {
+ const promises = [];
+ this._waitPromises.forEach((value) => promises.push(value.promise));
+ return Promise.all(promises);
+ }
+
+});
+
+async function waitForElem(elem, depth) {
+ const myDepth = depth + 1;
+ const spaces = ' '.repeat(myDepth);
+ let hasUpdateComplete = false;
+
+ const update = elem?.updateComplete;
+ if (typeof update === 'object' && Promise.resolve(update) === update) {
+ if (elem.waitForIt !== undefined) {
+ await elem.waitForIt();
+ }
+ await update;
+ await nextFrame();
+ hasUpdateComplete = true;
+ }
+
+ const children = getComposedChildren(elem);
+ //console.log(`${spaces}${elem.tagName} ${hasUpdateComplete} ${children?.length || 0}`);
+ if (children !== null) {
+ await Promise.all(children.map(e => waitForElem(e, myDepth)));
+ }
+}
+
+export const fixture = async(element, opts) => {
+ await Promise.all([reset(opts), waitForFonts()]);
+ const elem = await wcFixture(element);
+ await waitForElem(elem, -1);
+ return elem;
+};
export const focusWithKeyboard = async(element) => {
- await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Escape' });
element.focus({ focusVisible: true });
};
export const focusWithMouse = async(element) => {
- const { x, y } = element.getBoundingClientRect();
- await sendMouse({ type: 'click', position: [Math.ceil(x), Math.ceil(y)] });
+ const { height, width, x, y } = element.getBoundingClientRect();
+ await sendMouse({ type: 'click', position: [Math.ceil(x + width / 2), Math.ceil(y + height / 2)] });
await sendMouse({ type: 'move', position: [0, 0] });
};
+
+export const hoverWithMouse = async(element) => {
+ mouseResetNeeded = true;
+ const { height, width, x, y } = element.getBoundingClientRect();
+ await sendMouse({ type: 'move', position: [Math.ceil(x + width / 2), Math.ceil(y + height / 2)] });
+};
+
+async function reset(opts) {
+
+ opts = opts || {};
+ opts.lang = opts.lang || DEFAULT_LANG;
+ opts.rtl = opts.lang.startsWith('ar') || !!opts.rtl;
+ opts.viewport = opts.viewport || {};
+ opts.viewport.height = opts.viewport.height || DEFAULT_VIEWPORT_HEIGHT;
+ opts.viewport.width = opts.viewport.width || DEFAULT_VIEWPORT_WIDTH;
+
+ let awaitNextFrame = false;
+
+ window.scroll(0, 0);
+
+ if (mouseResetNeeded) {
+ mouseResetNeeded = false;
+ await sendMouse({ type: 'move', position: [0, 0] });
+ }
+
+ if (opts?.theme !== currentTheme) {
+ if (opts?.theme !== undefined) {
+ document.body.setAttribute('data-theme', opts.theme);
+ } else {
+ document.body.removeAttribute('data-theme');
+ }
+ awaitNextFrame = true;
+ currentTheme = opts?.theme;
+ }
+
+ if (opts.rtl !== currentRtl) {
+ document.documentElement.setAttribute('dir', opts.rtl ? 'rtl' : 'ltr');
+ awaitNextFrame = true;
+ currentRtl = opts.rtl;
+ }
+
+ if (opts.lang !== currentLang) {
+ getDocumentLocaleSettings().language = opts.lang;
+ currentLang = opts.lang;
+ awaitNextFrame = true;
+ }
+
+ if (opts.viewport.height !== currentViewportHeight || opts.viewport.width !== currentViewportWidth) {
+ await setViewport(opts.viewport);
+ awaitNextFrame = true;
+ currentViewportHeight = opts.viewport.height;
+ currentViewportWidth = opts.viewport.width;
+ }
+
+ // TODO: reset focus
+
+ if (awaitNextFrame) {
+ await nextFrame();
+ }
+
+}
+
+export const screenshotAndCompare = async(elem, name, opts) => {
+
+ const rect = elem.getBoundingClientRect();
+ const { pass, message } = await executeServerCommand('brightspace-visual-diff', { name, rect, opts });
+ expect(pass, message).to.be.true;
+
+};
diff --git a/web-test-runner.config.js b/web-test-runner.config.js
index ba7e7f414a..37c5add257 100644
--- a/web-test-runner.config.js
+++ b/web-test-runner.config.js
@@ -1,9 +1,27 @@
+import { argv } from 'node:process';
+import { defaultReporter } from '@web/test-runner';
import { playwrightLauncher } from '@web/test-runner-playwright';
+import { visualDiff } from './tools/visual-diff-plugin.js';
+import { visualDiffReporter } from './tools/visual-diff-reporter.js';
function getPattern(type) {
return `+(components|controllers|directives|helpers|mixins|templates)/**/*.${type}.js`;
}
+function getBrowsers() {
+ const browsers = ['chromium'];
+ if (argv.indexOf('firefox') > -1) {
+ browsers.push('firefox');
+ }
+ if (argv.indexOf('webkit') > -1) {
+ browsers.push('webkit');
+ }
+ return browsers.map((b) => playwrightLauncher({
+ product: b,
+ createBrowserContext: ({ browser }) => browser.newContext({ deviceScaleFactor: 2, reducedMotion: 'reduce' })
+ }));
+}
+
export default {
files: getPattern('test'),
nodeResolve: true,
@@ -20,8 +38,47 @@ export default {
}
})
]
+ },
+ {
+ name: 'visual-diff',
+ files: getPattern('vdiff'),
+ browsers: getBrowsers(),
+ testRunnerHtml: testFramework =>
+ `
+
+
+
+
+
+
+
+
+
+ `
}
],
+ plugins: [
+ visualDiff()
+ ],
+ reporters: [
+ defaultReporter(),
+ visualDiffReporter()
+ ],
testFramework: {
config: {
ui: 'bdd',