From e38287794ebd1688685c7d80960cfe8fbbb5d842 Mon Sep 17 00:00:00 2001 From: Dave Lockhart Date: Tue, 18 Apr 2023 11:42:33 -0400 Subject: [PATCH] spike into visual-diffs --- components/button/test/button-icon.vdiff.js | 33 + components/button/test/button-move.vdiff.js | 57 ++ components/button/test/button-subtle.vdiff.js | 59 ++ components/button/test/button.vdiff.js | 31 + .../button/test/floating-buttons.vdiff.js | 77 ++ components/icons/icon.js | 13 +- components/icons/test/icon.vdiff.js | 80 ++ components/list/test/list.vdiff.js | 904 ++++++++++++++++++ package.json | 4 +- tools/visual-diff-plugin.js | 86 ++ tools/visual-diff-reporter.js | 64 ++ tools/web-test-runner-helpers.js | 168 +++- web-test-runner.config.js | 57 ++ 13 files changed, 1626 insertions(+), 7 deletions(-) create mode 100644 components/button/test/button-icon.vdiff.js create mode 100644 components/button/test/button-move.vdiff.js create mode 100644 components/button/test/button-subtle.vdiff.js create mode 100644 components/button/test/button.vdiff.js create mode 100644 components/button/test/floating-buttons.vdiff.js create mode 100644 components/icons/test/icon.vdiff.js create mode 100644 components/list/test/list.vdiff.js create mode 100644 tools/visual-diff-plugin.js create mode 100644 tools/visual-diff-reporter.js 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` +
+
+

I love Coffee!

+
+ + 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 +
+ + + 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.
+
+
+ + +
+ + + +
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',