diff --git a/.travis.yml b/.travis.yml index 4c05c4bda00..9cd98363fc7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,9 @@ language: node_js branches: + # Only run Travis on: + # A) Commits made directly to the `master` branch (i.e., merged PRs); and + # B) PRs that will eventually be merged into `master`. + # This prevents excessive resource usage and CI slowness. only: - master matrix: @@ -28,9 +32,14 @@ matrix: - node_js: 8 env: - TEST_SUITE=screenshot + git: + depth: 200 script: npm run screenshot:test -- --no-fetch -before_install: - # Source the script to run it in the same shell process. This ensures that any environment variables set by the - # script are visible to subsequent Travis CLI commands. - # https://superuser.com/a/176788/62792 - - source test/screenshot/commands/travis.sh + before_install: + # Source the script to run it in the same shell process. This ensures that any environment variables set by the + # script are visible to subsequent Travis CLI commands. + # https://superuser.com/a/176788/62792 + - source test/screenshot/infra/commands/travis.sh + install: + - npm install + #- npm ls # Noisy output, but useful for debugging npm package dependency version issues diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e741615c2..d84ea0a126d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ + +## [0.37.1](https://github.com/material-components/material-components-web/compare/v0.37.0...v0.37.1) (2018-07-16) + + +### Bug Fixes + +* hot-patching closure annotations. ([#3024](https://github.com/material-components/material-components-web/issues/3024)) ([d5b95ab](https://github.com/material-components/material-components-web/commit/d5b95ab)) +* **button:** Remove dense/stroked line-height tweaks to improve alignment ([#3028](https://github.com/material-components/material-components-web/issues/3028)) ([8b5f595](https://github.com/material-components/material-components-web/commit/8b5f595)) +* **notched-outline:** Remove unused dependency from scss ([#3044](https://github.com/material-components/material-components-web/issues/3044)) ([85ecf11](https://github.com/material-components/material-components-web/commit/85ecf11)) +* **typography:** Update variable reference to work for newer versions of ruby-sass ([#3047](https://github.com/material-components/material-components-web/issues/3047)) ([0dfad9a](https://github.com/material-components/material-components-web/commit/0dfad9a)) + + + # [0.37.0](https://github.com/material-components/material-components-web/compare/v0.36.0...v0.37.0) (2018-07-02) diff --git a/README.md b/README.md index eadba73973f..4fab9b966c7 100644 --- a/README.md +++ b/README.md @@ -109,10 +109,8 @@ This will produce a Material Design ripple on the button! We officially support the last two versions of every major browser. Specifically, we test on the following browsers: -- Chrome -- Safari -- Firefox -- IE 11/Edge -- Opera -- Mobile Safari -- Chrome on Android +- **Chrome** on Android, Windows, macOS, and Linux +- **Firefox** on Windows, macOS, and Linux +- **Safari** on iOS and macOS +- **Edge** on Windows +- **IE 11** on Windows diff --git a/demos/index.html b/demos/index.html index 1ab46be89d9..c3867cd716e 100644 --- a/demos/index.html +++ b/demos/index.html @@ -70,7 +70,7 @@ Dialog - Secondary text + Implements a modal dialog window diff --git a/demos/list.html b/demos/list.html index 6b4e520cdbe..819e3d7b8e9 100644 --- a/demos/list.html +++ b/demos/list.html @@ -39,7 +39,7 @@
+
+

Leading Checkbox

+ +

Avatar List

+
+

Trailing Checkbox

+ +

Avatar + Metadata

``` +### Single Selection List + +MDC List can handle selecting/deselecting list elements based on click or keyboard action. When enabled, the `space` and `enter` keys (or `click` event) will trigger an +single list item to become selected or deselected. + +```html + +``` + +```js +var listEle = document.getElementById('my-list'); +var list = new mdc.list.MDCList(listEle); +list.singleSelection = true; +``` + +#### Pre-selected list item + +When rendering the list with a pre-selected list item, the list item that needs to be selected should contain +the `mdc-list-item--selected` class and `aria-selected="true"` attribute before creating the list. + +```html + +``` + +```js +var listEle = document.getElementById('my-list'); +var list = new mdc.list.MDCList(listEle); +list.singleSelection = true; +``` + ## Style Customization ### CSS Classes @@ -163,7 +201,7 @@ CSS Class | Description > NOTE: the difference between selected and activated states: -* *Selected* state should be implemented on the `.list-item` when it is likely to change soon. Eg., selecting one or more photos to share in Google Photos. +* *Selected* state should be implemented on the `.mdc-list-item` when it is likely to change soon. Eg., selecting one or more photos to share in Google Photos. * Multiple items can be selected at the same time when using the *selected* state. * *Activated* state is similar to selected state, however should only be implemented once within a specific list. * *Activated* state is more permanent than selected state, and will **NOT** change soon relative to the lifetime of the page. @@ -188,9 +226,10 @@ within the list component. You should not add `tabindex` to any of the `li` elem As the user navigates through the list, any `button` or `a` elements within the list will receive `tabindex="-1"` when the list item is not focused. When the list item receives focus, the child `button` and `a` elements will -receive `tabIndex="0"`. This allows for the user to tab through list items elements and then tab to the +receive `tabIndex="0"`. This allows for the user to tab through list item elements and then tab to the first element after the list. The `Arrow`, `Home`, and `End` keys should be used for navigating internal list elements. -The MDCList will perform the following actions for each key press +If `singleSelection=true`, the list will allow the user to use the `Space` or `Enter` keys to select or deselect +a list item. The MDCList will perform the following actions for each key press Key | Action --- | --- @@ -200,11 +239,50 @@ Key | Action `ArrowRight` | When the list is in a horizontal orientation (default), it will cause the next list item to receive focus. `Home` | Will cause the first list item in the list to receive focus. `End` | Will cause the last list item in the list to receive focus. +`Space` | Will cause the currently focused list item to become selected/deselected if `singleSelection=true`. +`Enter` | Will cause the currently focused list item to become selected/deselected if `singleSelection=true`. ## Usage within Web Frameworks If you are using a JavaScript framework, such as React or Angular, you can create a List for your framework. Depending on your needs, you can use the _Simple Approach: Wrapping MDC Web Vanilla Components_, or the _Advanced Approach: Using Foundations and Adapters_. Please follow the instructions [here](../../docs/integrating-into-frameworks.md). +### Considerations for Advanced Approach + +The `MDCListFoundation` expects the HTML to be setup a certain way before being used. This setup is a part of the `layout()` and `singleSelection()` functions within the `index.js`. + +#### Setup in `layout()` + +The default component requires that every list item receives a `tabindex` value so that it can receive focus +(`li` elements cannot receive focus at all without a `tabindex` value). Any element not already containing a +`tabindex` attribute will receive `tabindex=-1`. The first list item should have `tabindex="0"` so that the +user can find the first element using the `tab` key, but subsequent `tab` keys strokes will cause focus to +skip over the entire list. If the list items contain sub-elements that are focusable (`button` or `a` elements), +these should also receive `tabIndex="-1"`. + +```html + +``` + +#### Setup in `singleSelection()` + +When implementing a component that will use the single selection variant, the HTML should be modified to include +the `aria-selected` attribute, the `mdc-list-item--selected` class should be added, and the `tabindex` of the selected +element should be `0`. The first list item should have the `tabindex` updated to `-1`. The foundation method +`setSelectedIndex()` should be called with the initially selected element immediately after the foundation is +instantiated. + +```html + +``` + ### `MDCListAdapter` Method Signature | Description @@ -212,8 +290,12 @@ Method Signature | Description `getListItemCount() => Number` | Returns the total number of list items (elements with `mdc-list-item` class) that are direct children of the `root_` element. `getFocusedElementIndex() => Number` | Returns the `index` value of the currently focused element. `getListItemIndex(ele: Element) => Number` | Returns the `index` value of the provided `ele` element. -`focusItemAtIndex(ndx: Number) => void` | Focuses the list item at the `ndx` value specified. -`setTabIndexForListItemChildren(ndx: Number, value: Number) => void` | Sets the `tabindex` attribute to `value` for each child `button` and `a` element in the list item at the `ndx` specified. +`setAttributeForElementIndex(index: Number, attr: String, value: String) => void` | Sets the `attr` attribute to `value` for the list item at `index`. +`addClassForElementIndex(index: Number, className: String) => void` | Adds the `className` class to the list item at `index`. +`removeClassForElementIndex(index: Number, className: String) => void` | Removes the `className` class to the list item at `index`. +`focusItemAtIndex(index: Number) => void` | Focuses the list item at the `index` value specified. +`isElementFocusable(ele: Element) => boolean` | Returns true if `ele` contains a focusable child element. +`setTabIndexForListItemChildren(index: Number, value: Number) => void` | Sets the `tabindex` attribute to `value` for each child `button` and `a` element in the list item at the `index` specified. ### `MDCListFoundation` @@ -221,9 +303,12 @@ Method Signature | Description --- | --- `setWrapFocus(value: Boolean) => void` | Sets the list to allow the up arrow on the first element to focus the last element of the list and vice versa. `setVerticalOrientation(value: Boolean) => void` | Sets the list to an orientation causing the keys used for navigation to change. `true` results in the Up/Down arrow keys being used. `false` results in the Left/Right arrow keys being used. +`setSingleSelection(value: Boolean) => void` | Sets the list to be a selection list. Enables the `enter` and `space` keys for selecting/deselecting a list item. +`setSelectedIndex(index: Number) => void` | Toggles the `selected` state of the list item at index `index`. `handleFocusIn(evt: Event) => void` | Handles the changing of `tabindex` to `0` for all `button` and `a` elements when a list item receives focus. `handleFocusOut(evt: Event) => void` | Handles the changing of `tabindex` to `-1` for all `button` and `a` elements when a list item loses focus. `handleKeydown(evt: Event) => void` | Handles determining if a focus action should occur when a key event is triggered. +`handleClick(evt: Event) => void` | Handles toggling the selected/deselected state for a list item when clicked. This method is only used by the single selection list. `focusNextElement(index: Number) => void` | Handles focusing the next element using the current `index`. `focusPrevElement(index: Number) => void` | Handles focusing the previous element using the current `index`. `focusFirstElement() => void` | Handles focusing the first element in a list. diff --git a/packages/mdc-list/adapter.js b/packages/mdc-list/adapter.js index dfe82fa5895..243d9031300 100644 --- a/packages/mdc-list/adapter.js +++ b/packages/mdc-list/adapter.js @@ -31,29 +31,66 @@ * @record */ class MDCListAdapter { - /** @return {Number} */ + /** @return {number} */ getListItemCount() {} /** - * @return {Number} */ + * @return {number} */ getFocusedElementIndex() {} /** @param {Element} node */ getListItemIndex(node) {} + /** + * @param {number} index + * @param {string} attribute + * @param {string} value + */ + setAttributeForElementIndex(index, attribute, value) {} + + /** + * @param {number} index + * @param {string} attribute + */ + removeAttributeForElementIndex(index, attribute) {} + + /** + * @param {number} index + * @param {string} className + */ + addClassForElementIndex(index, className) {} + + /** + * @param {number} index + * @param {string} className + */ + removeClassForElementIndex(index, className) {} + /** * Focuses list item at the index specified. - * @param {Number} ndx + * @param {number} index + */ + focusItemAtIndex(index) {} + + /** + * Checks if the provided element is a focusable sub-element. + * @param {Element} ele + */ + isElementFocusable(ele) {} + + /** + * Checks if the provided element is contains the mdc-list-item class. + * @param {Element} ele */ - focusItemAtIndex(ndx) {} + isListItem(ele) {} /** * Sets the tabindex to the value specified for all button/a element children of * the list item at the index specified. - * @param {Number} listItemIndex - * @param {Number} tabIndexValue + * @param {number} listItemIndex + * @param {number} tabIndexValue */ setTabIndexForListItemChildren(listItemIndex, tabIndexValue) {} } -export {MDCListAdapter}; +export default MDCListAdapter; diff --git a/packages/mdc-list/constants.js b/packages/mdc-list/constants.js index 2952ccf590b..536a4bedd0f 100644 --- a/packages/mdc-list/constants.js +++ b/packages/mdc-list/constants.js @@ -18,14 +18,16 @@ /** @enum {string} */ const cssClasses = { LIST_ITEM_CLASS: 'mdc-list-item', + LIST_ITEM_SELECTED_CLASS: 'mdc-list-item--selected', }; /** @enum {string} */ const strings = { ARIA_ORIENTATION: 'aria-orientation', ARIA_ORIENTATION_VERTICAL: 'vertical', + ARIA_SELECTED: 'aria-selected', FOCUSABLE_CHILD_ELEMENTS: 'button:not(:disabled), a', - ITEMS_SELECTOR: '.mdc-list-item', + ENABLED_ITEMS_SELECTOR: '.mdc-list-item:not(.mdc-list-item--disabled)', }; export {strings, cssClasses}; diff --git a/packages/mdc-list/foundation.js b/packages/mdc-list/foundation.js index 641b2e401cc..5771679cced 100644 --- a/packages/mdc-list/foundation.js +++ b/packages/mdc-list/foundation.js @@ -16,25 +16,39 @@ */ import MDCFoundation from '@material/base/foundation'; +import MDCListAdapter from './adapter'; import {strings, cssClasses} from './constants'; const ELEMENTS_KEY_ALLOWED_IN = ['input', 'button', 'textarea', 'select']; class MDCListFoundation extends MDCFoundation { + /** @return enum {string} */ static get strings() { return strings; } + /** @return enum {string} */ static get cssClasses() { return cssClasses; } + /** + * {@see MDCListAdapter} for typing information on parameters and return + * types. + * @return {!MDCListAdapter} + */ static get defaultAdapter() { - return /** {MDCListAdapter */ ({ + return /** @type {!MDCListAdapter} */ ({ getListItemCount: () => {}, getFocusedElementIndex: () => {}, getListItemIndex: () => {}, + setAttributeForElementIndex: () => {}, + removeAttributeForElementIndex: () => {}, + addClassForElementIndex: () => {}, + removeClassForElementIndex: () => {}, focusItemAtIndex: () => {}, + isElementFocusable: () => {}, + isListItem: () => {}, setTabIndexForListItemChildren: () => {}, }); } @@ -45,6 +59,10 @@ class MDCListFoundation extends MDCFoundation { this.wrapFocus_ = false; /** {boolean} */ this.isVertical_ = true; + /** {boolean} */ + this.isSingleSelectionList_ = false; + /** {number} */ + this.selectedIndex_ = -1; } /** @@ -63,6 +81,48 @@ class MDCListFoundation extends MDCFoundation { this.isVertical_ = value; } + /** + * Sets the isSingleSelectionList_ private variable. + * @param {boolean} value + */ + setSingleSelection(value) { + this.isSingleSelectionList_ = value; + } + + /** @param {number} index */ + setSelectedIndex(index) { + if (index === this.selectedIndex_) { + this.adapter_.removeAttributeForElementIndex(this.selectedIndex_, strings.ARIA_SELECTED); + this.adapter_.removeClassForElementIndex(this.selectedIndex_, cssClasses.LIST_ITEM_SELECTED_CLASS); + + // Used to reset the first element to tabindex=0 when deselecting a list item. + // If already on the first list item, leave tabindex at 0. + if (this.selectedIndex_ >= 0) { + this.adapter_.setAttributeForElementIndex(this.selectedIndex_, 'tabindex', -1); + this.adapter_.setAttributeForElementIndex(0, 'tabindex', 0); + } + this.selectedIndex_ = -1; + return; + } + + if (this.selectedIndex_ >= 0) { + this.adapter_.removeAttributeForElementIndex(this.selectedIndex_, strings.ARIA_SELECTED); + this.adapter_.removeClassForElementIndex(this.selectedIndex_, cssClasses.LIST_ITEM_SELECTED_CLASS); + this.adapter_.setAttributeForElementIndex(this.selectedIndex_, 'tabindex', -1); + } + + if (index >= 0 && this.adapter_.getListItemCount() > index) { + this.selectedIndex_ = index; + this.adapter_.setAttributeForElementIndex(this.selectedIndex_, strings.ARIA_SELECTED, true); + this.adapter_.addClassForElementIndex(this.selectedIndex_, cssClasses.LIST_ITEM_SELECTED_CLASS); + this.adapter_.setAttributeForElementIndex(this.selectedIndex_, 'tabindex', 0); + + if (this.selectedIndex_ !== 0) { + this.adapter_.setAttributeForElementIndex(0, 'tabindex', -1); + } + } + } + /** * Focus in handler for the list items. * @param evt @@ -71,7 +131,11 @@ class MDCListFoundation extends MDCFoundation { const listItem = this.getListItem_(evt.target); if (!listItem) return; - this.adapter_.setTabIndexForListItemChildren(this.adapter_.getListItemIndex(listItem), 0); + const listItemIndex = this.adapter_.getListItemIndex(listItem); + + if (listItemIndex >= 0) { + this.adapter_.setTabIndexForListItemChildren(listItemIndex, 0); + } } /** @@ -81,8 +145,11 @@ class MDCListFoundation extends MDCFoundation { handleFocusOut(evt) { const listItem = this.getListItem_(evt.target); if (!listItem) return; + const listItemIndex = this.adapter_.getListItemIndex(listItem); - this.adapter_.setTabIndexForListItemChildren(this.adapter_.getListItemIndex(listItem), -1); + if (listItemIndex >= 0) { + this.adapter_.setTabIndexForListItemChildren(listItemIndex, -1); + } } /** @@ -96,6 +163,9 @@ class MDCListFoundation extends MDCFoundation { const arrowDown = evt.key === 'ArrowDown' || evt.keyCode === 40; const isHome = evt.key === 'Home' || evt.keyCode === 36; const isEnd = evt.key === 'End' || evt.keyCode === 35; + const isEnter = evt.key === 'Enter' || evt.keyCode === 13; + const isSpace = evt.key === 'Space' || evt.keyCode === 32; + let currentIndex = this.adapter_.getFocusedElementIndex(); if (currentIndex === -1) { @@ -120,9 +190,26 @@ class MDCListFoundation extends MDCFoundation { } else if (isEnd) { this.preventDefaultEvent_(evt); this.focusLastElement(); + } else if (this.isSingleSelectionList_ && (isEnter || isSpace)) { + this.preventDefaultEvent_(evt); + // Check if the space key was pressed on the list item or a child element. + if (this.adapter_.isListItem(evt.target)) { + this.setSelectedIndex(currentIndex); + } } } + /** + * Click handler for the list. + */ + handleClick() { + const currentIndex = this.adapter_.getFocusedElementIndex(); + + if (currentIndex === -1) return; + + this.setSelectedIndex(currentIndex); + } + /** * Ensures that preventDefault is only called if the containing element doesn't * consume the event, and it will cause an unintended scroll. @@ -138,7 +225,7 @@ class MDCListFoundation extends MDCFoundation { /** * Focuses the next element on the list. - * @param {Number} index + * @param {number} index */ focusNextElement(index) { const count = this.adapter_.getListItemCount(); @@ -156,7 +243,7 @@ class MDCListFoundation extends MDCFoundation { /** * Focuses the previous element on the list. - * @param {Number} index + * @param {number} index */ focusPrevElement(index) { let prevIndex = index - 1; @@ -191,7 +278,7 @@ class MDCListFoundation extends MDCFoundation { * @private */ getListItem_(target) { - while (!target.classList.contains(cssClasses.LIST_ITEM_CLASS)) { + while (!this.adapter_.isListItem(target)) { if (!target.parentElement) return null; target = target.parentElement; } @@ -199,4 +286,4 @@ class MDCListFoundation extends MDCFoundation { } } -export {MDCListFoundation}; +export default MDCListFoundation; diff --git a/packages/mdc-list/index.js b/packages/mdc-list/index.js index f27a251fba4..948d93cd41a 100644 --- a/packages/mdc-list/index.js +++ b/packages/mdc-list/index.js @@ -16,19 +16,22 @@ */ import MDCComponent from '@material/base/component'; -import {MDCListFoundation} from './foundation'; -import {strings} from './constants'; +import MDCListFoundation from './foundation'; +import MDCListAdapter from './adapter'; +import {cssClasses, strings} from './constants'; /** * @extends MDCComponent */ -export class MDCList extends MDCComponent { +class MDCList extends MDCComponent { /** @param {...?} args */ constructor(...args) { super(...args); /** @private {!Function} */ this.handleKeydown_; /** @private {!Function} */ + this.handleClick_; + /** @private {!Function} */ this.focusInEventListener_; /** @private {!Function} */ this.focusOutEventListener_; @@ -44,12 +47,14 @@ export class MDCList extends MDCComponent { destroy() { this.root_.removeEventListener('keydown', this.handleKeydown_); + this.root_.removeEventListener('click', this.handleClick_); this.root_.removeEventListener('focusin', this.focusInEventListener_); this.root_.removeEventListener('focusout', this.focusOutEventListener_); } initialSyncWithDOM() { this.handleKeydown_ = this.foundation_.handleKeydown.bind(this.foundation_); + this.handleClick_ = this.foundation_.handleClick.bind(this.foundation_); this.focusInEventListener_ = this.foundation_.handleFocusIn.bind(this.foundation_); this.focusOutEventListener_ = this.foundation_.handleFocusOut.bind(this.foundation_); this.root_.addEventListener('keydown', this.handleKeydown_); @@ -80,8 +85,7 @@ export class MDCList extends MDCComponent { /** @return Array*/ get listElements_() { - return [].slice.call(this.root_.querySelectorAll(strings.ITEMS_SELECTOR)) - .filter((ele) => ele.parentElement === this.root_); + return [].slice.call(this.root_.querySelectorAll(strings.ENABLED_ITEMS_SELECTOR)); } /** @param {boolean} value */ @@ -89,18 +93,79 @@ export class MDCList extends MDCComponent { this.foundation_.setWrapFocus(value); } + /** @param {boolean} isSingleSelectionList */ + set singleSelection(isSingleSelectionList) { + if (isSingleSelectionList) { + this.root_.addEventListener('click', this.handleClick_); + } else { + this.root_.removeEventListener('click', this.handleClick_); + } + + this.foundation_.setSingleSelection(isSingleSelectionList); + const selectedElement = this.root_.querySelector('.mdc-list-item--selected'); + + if (selectedElement) { + this.selectedIndex = this.listElements_.indexOf(selectedElement); + } + } + + /** @param {number} index */ + set selectedIndex(index) { + this.foundation_.setSelectedIndex(index); + } + /** @return {!MDCListFoundation} */ getDefaultFoundation() { - return new MDCListFoundation(/** @type {!MDCListAdapter} */{ + return new MDCListFoundation(/** @type {!MDCListAdapter} */ (Object.assign({ getListItemCount: () => this.listElements_.length, getFocusedElementIndex: () => this.listElements_.indexOf(document.activeElement), getListItemIndex: (node) => this.listElements_.indexOf(node), - focusItemAtIndex: (ndx) => this.listElements_[ndx].focus(), + setAttributeForElementIndex: (index, attr, value) => { + const element = this.listElements_[index]; + if (element) { + element.setAttribute(attr, value); + } + }, + removeAttributeForElementIndex: (index, attr) => { + const element = this.listElements_[index]; + if (element) { + element.removeAttribute(attr); + } + }, + addClassForElementIndex: (index, className) => { + const element = this.listElements_[index]; + if (element) { + element.classList.add(className); + } + }, + removeClassForElementIndex: (index, className) => { + const element = this.listElements_[index]; + if (element) { + element.classList.remove(className); + } + }, + isListItem: (target) => target.classList.contains(cssClasses.LIST_ITEM_CLASS), + focusItemAtIndex: (index) => { + const element = this.listElements_[index]; + if (element) { + element.focus(); + } + }, + isElementFocusable: (ele) => { + if (!ele) return false; + let matches = Element.prototype.matches; + if (!matches) { // IE uses a different name for the same functionality + matches = Element.prototype.msMatchesSelector; + } + return matches.call(ele, strings.FOCUSABLE_CHILD_ELEMENTS); + }, setTabIndexForListItemChildren: (listItemIndex, tabIndexValue) => { - const listItemChildren = [].slice.call(this.listElements_[listItemIndex] - .querySelectorAll(strings.FOCUSABLE_CHILD_ELEMENTS)); + const element = this.listElements_[listItemIndex]; + const listItemChildren = [].slice.call(element.querySelectorAll(strings.FOCUSABLE_CHILD_ELEMENTS)); listItemChildren.forEach((ele) => ele.setAttribute('tabindex', tabIndexValue)); }, - }); + }))); } } + +export {MDCList, MDCListFoundation}; diff --git a/packages/mdc-list/mdc-list.scss b/packages/mdc-list/mdc-list.scss index 7898323fc72..72e73f263be 100644 --- a/packages/mdc-list/mdc-list.scss +++ b/packages/mdc-list/mdc-list.scss @@ -77,6 +77,10 @@ @include mdc-list-item-graphic-ink-color(primary); } +.mdc-list-item--disabled { + @include mdc-list-item-primary-text-ink-color(text-disabled-on-background); +} + .mdc-list-item__graphic { @include mdc-list-graphic-size_(24px); @@ -127,16 +131,6 @@ border-radius: 50%; } -// List items should support states by default, but it should be possible to opt out. -// Direct child combinator is necessary for non-interactive modifier on parent to not match this selector. -:not(.mdc-list--non-interactive) > .mdc-list-item { - @include mdc-ripple-surface; - @include mdc-ripple-radius-bounded; - @include mdc-states; - @include mdc-states-activated(primary); - @include mdc-states-selected(primary); -} - .mdc-list--two-line .mdc-list-item { height: 72px; } @@ -152,6 +146,16 @@ .mdc-list--avatar-list.mdc-list--dense .mdc-list-item__graphic { @include mdc-list-graphic-size_(36px); } + +// List items should support states by default, but it should be possible to opt out. +// Direct child combinator is necessary for non-interactive modifier on parent to not match this selector. +:not(.mdc-list--non-interactive) > :not(.mdc-list-item--disabled).mdc-list-item { + @include mdc-ripple-surface; + @include mdc-ripple-radius-bounded; + @include mdc-states; + @include mdc-states-activated(primary); + @include mdc-states-selected(primary); +} // postcss-bem-linter: end // Override anchor tag styles for the use-case of a list being used for navigation diff --git a/packages/mdc-list/package.json b/packages/mdc-list/package.json index f02172f4eef..50de312b9f9 100644 --- a/packages/mdc-list/package.json +++ b/packages/mdc-list/package.json @@ -1,7 +1,7 @@ { "name": "@material/list", "description": "The Material Components for the web list component", - "version": "0.37.0", + "version": "0.37.1", "license": "Apache-2.0", "keywords": [ "material components", @@ -14,9 +14,9 @@ }, "dependencies": { "@material/base": "^0.35.0", - "@material/ripple": "^0.37.0", + "@material/ripple": "^0.37.1", "@material/rtl": "^0.36.0", "@material/theme": "^0.35.0", - "@material/typography": "^0.35.0" + "@material/typography": "^0.37.1" } } diff --git a/packages/mdc-menu/package.json b/packages/mdc-menu/package.json index 33967b1b6af..854d81641d7 100644 --- a/packages/mdc-menu/package.json +++ b/packages/mdc-menu/package.json @@ -1,6 +1,6 @@ { "name": "@material/menu", - "version": "0.36.1", + "version": "0.37.1", "description": "The Material Components for the web menu component", "license": "Apache-2.0", "keywords": [ @@ -18,6 +18,6 @@ "@material/base": "^0.35.0", "@material/elevation": "^0.36.1", "@material/theme": "^0.35.0", - "@material/typography": "^0.35.0" + "@material/typography": "^0.37.1" } } diff --git a/packages/mdc-notched-outline/package.json b/packages/mdc-notched-outline/package.json index 073871a9a5e..311efe632c4 100644 --- a/packages/mdc-notched-outline/package.json +++ b/packages/mdc-notched-outline/package.json @@ -1,7 +1,7 @@ { "name": "@material/notched-outline", "description": "The Material Components for the web notched-outline component", - "version": "0.35.0", + "version": "0.37.1", "license": "Apache-2.0", "keywords": [ "material components", diff --git a/packages/mdc-radio/package.json b/packages/mdc-radio/package.json index e47760b5701..c1ce70239ea 100644 --- a/packages/mdc-radio/package.json +++ b/packages/mdc-radio/package.json @@ -1,7 +1,7 @@ { "name": "@material/radio", "description": "The Material Components for the web radio component", - "version": "0.37.0", + "version": "0.37.1", "license": "Apache-2.0", "keywords": [ "material components", @@ -16,8 +16,8 @@ "dependencies": { "@material/animation": "^0.34.0", "@material/base": "^0.35.0", - "@material/ripple": "^0.37.0", - "@material/selection-control": "^0.37.0", + "@material/ripple": "^0.37.1", + "@material/selection-control": "^0.37.1", "@material/theme": "^0.35.0" } } diff --git a/packages/mdc-ripple/package.json b/packages/mdc-ripple/package.json index 6c5ad4b2416..5f8c252811b 100644 --- a/packages/mdc-ripple/package.json +++ b/packages/mdc-ripple/package.json @@ -1,7 +1,7 @@ { "name": "@material/ripple", "description": "The Material Components for the web Ink Ripple effect for web element interactions", - "version": "0.37.0", + "version": "0.37.1", "license": "Apache-2.0", "keywords": [ "material components", diff --git a/packages/mdc-select/package.json b/packages/mdc-select/package.json index ca6ee673bb6..1e26beb5d9c 100644 --- a/packages/mdc-select/package.json +++ b/packages/mdc-select/package.json @@ -1,7 +1,7 @@ { "name": "@material/select", "description": "The Material Components web select (text field drop-down) component", - "version": "0.37.0", + "version": "0.37.1", "license": "Apache-2.0", "keywords": [ "material components", @@ -17,12 +17,12 @@ "dependencies": { "@material/animation": "^0.34.0", "@material/base": "^0.35.0", - "@material/floating-label": "^0.36.0", + "@material/floating-label": "^0.37.1", "@material/line-ripple": "^0.35.0", - "@material/notched-outline": "^0.35.0", - "@material/ripple": "^0.37.0", + "@material/notched-outline": "^0.37.1", + "@material/ripple": "^0.37.1", "@material/rtl": "^0.36.0", "@material/theme": "^0.35.0", - "@material/typography": "^0.35.0" + "@material/typography": "^0.37.1" } } diff --git a/packages/mdc-selection-control/package.json b/packages/mdc-selection-control/package.json index 99ff95ce6c1..8efa677b6af 100644 --- a/packages/mdc-selection-control/package.json +++ b/packages/mdc-selection-control/package.json @@ -1,7 +1,7 @@ { "name": "@material/selection-control", "description": "The set of base classes for Material selection controls", - "version": "0.37.0", + "version": "0.37.1", "license": "Apache-2.0", "main": "index.js", "repository": { @@ -9,7 +9,7 @@ "url": "https://github.com/material-components/material-components-web.git" }, "dependencies": { - "@material/ripple": "^0.37.0" + "@material/ripple": "^0.37.1" }, "publishConfig": { "access": "public" diff --git a/packages/mdc-shape/README.md b/packages/mdc-shape/README.md index d596d54a9bb..4f1ac7f5001 100644 --- a/packages/mdc-shape/README.md +++ b/packages/mdc-shape/README.md @@ -61,6 +61,7 @@ unelevated component. ### Styles ```scss +@import "@material/shape/mdc-shape"; // The base shape styles need to be imported once in the page or application @import "@material/shape/mixins"; .my-shape-container { @@ -72,11 +73,10 @@ unelevated component. ### Outlined Angled Corners -Outlined angled corners involve the same markup and styles as above, with the addition of including a mixin for outline: +Outlined angled corners involve the same markup and styles/imports as above, with the addition of including a mixin for +outline: ```scss -@import "@material/shape/mixins"; - .my-shape-container { @include mdc-shape-angled-corner(#fff, 10px); @include mdc-shape-angled-corner-outline(2px, blue); diff --git a/packages/mdc-snackbar/package.json b/packages/mdc-snackbar/package.json index f6bc565abd7..2723725b3fc 100644 --- a/packages/mdc-snackbar/package.json +++ b/packages/mdc-snackbar/package.json @@ -1,7 +1,7 @@ { "name": "@material/snackbar", "description": "The Material Components for the web snackbar component", - "version": "0.36.0", + "version": "0.37.1", "license": "Apache-2.0", "keywords": [ "material components", @@ -18,6 +18,6 @@ "@material/base": "^0.35.0", "@material/rtl": "^0.36.0", "@material/theme": "^0.35.0", - "@material/typography": "^0.35.0" + "@material/typography": "^0.37.1" } } diff --git a/packages/mdc-tab/package.json b/packages/mdc-tab/package.json index 60321f48b83..1caf3f5594d 100644 --- a/packages/mdc-tab/package.json +++ b/packages/mdc-tab/package.json @@ -1,7 +1,7 @@ { "name": "@material/tab", "description": "The Material Components for the web tab component", - "version": "0.37.0", + "version": "0.37.1", "license": "Apache-2.0", "private": true, "keywords": [ @@ -16,10 +16,10 @@ }, "dependencies": { "@material/base": "^0.35.0", - "@material/ripple": "^0.37.0", + "@material/ripple": "^0.37.1", "@material/rtl": "^0.30.0", "@material/theme": "^0.30.0", - "@material/typography": "^0.35.0" + "@material/typography": "^0.37.1" }, "publishConfig": { "access": "public" diff --git a/packages/mdc-tabs/package.json b/packages/mdc-tabs/package.json index 305e6b1a7ae..556c5e72d51 100644 --- a/packages/mdc-tabs/package.json +++ b/packages/mdc-tabs/package.json @@ -1,6 +1,6 @@ { "name": "@material/tabs", - "version": "0.37.0", + "version": "0.37.1", "description": "The Material Components for the web tabs component", "license": "Apache-2.0", "repository": { @@ -18,9 +18,9 @@ "dependencies": { "@material/animation": "^0.34.0", "@material/base": "^0.35.0", - "@material/ripple": "^0.37.0", + "@material/ripple": "^0.37.1", "@material/rtl": "^0.36.0", "@material/theme": "^0.35.0", - "@material/typography": "^0.35.0" + "@material/typography": "^0.37.1" } } diff --git a/packages/mdc-textfield/package.json b/packages/mdc-textfield/package.json index ced319890e3..7daf454510d 100644 --- a/packages/mdc-textfield/package.json +++ b/packages/mdc-textfield/package.json @@ -1,7 +1,7 @@ { "name": "@material/textfield", "description": "The Material Components for the web text field component", - "version": "0.37.0", + "version": "0.37.1", "license": "Apache-2.0", "keywords": [ "material components", @@ -17,12 +17,12 @@ "dependencies": { "@material/animation": "^0.34.0", "@material/base": "^0.35.0", - "@material/floating-label": "^0.36.0", + "@material/floating-label": "^0.37.1", "@material/line-ripple": "^0.35.0", - "@material/notched-outline": "^0.35.0", - "@material/ripple": "^0.37.0", + "@material/notched-outline": "^0.37.1", + "@material/ripple": "^0.37.1", "@material/rtl": "^0.36.0", "@material/theme": "^0.35.0", - "@material/typography": "^0.35.0" + "@material/typography": "^0.37.1" } } diff --git a/packages/mdc-theme/README.md b/packages/mdc-theme/README.md index c9c4eb7afc6..f757529d00c 100644 --- a/packages/mdc-theme/README.md +++ b/packages/mdc-theme/README.md @@ -146,11 +146,11 @@ Determines whether to use light or dark text on top of a given color. @debug mdc-theme-contrast-tone(#9c27b0); // light ``` -#### `mdc-theme-prop-value($property)` +#### `mdc-theme-prop-value($style)` -If `$property` is a literal color value (e.g., `blue`, `#fff`), it is returned verbatim. Otherwise, the value of the -corresponding theme property (from `$mdc-theme-property-values`) is returned. If `$property` is not a color and no -such theme property exists, an error is thrown. +If `$style` is a color (a literal color value, `currentColor`, or a CSS custom property), it is returned verbatim. +Otherwise, `$style` is treated as a theme property name, and the corresponding value from `$mdc-theme-property-values` +is returned. If this also fails, an error is thrown. This is mainly useful in situations where `mdc-theme-prop` cannot be used directly (e.g., `box-shadow`). diff --git a/packages/mdc-theme/_mixins.scss b/packages/mdc-theme/_mixins.scss index 24732f75824..9510df2dd8f 100644 --- a/packages/mdc-theme/_mixins.scss +++ b/packages/mdc-theme/_mixins.scss @@ -18,11 +18,11 @@ // Applies the correct theme color style to the specified property. // $property is typically color or background-color, but can be any CSS property that accepts color values. -// $style should be one of the map keys in $mdc-theme-property-values (_variables.scss), or a literal color value. +// $style should be one of the map keys in $mdc-theme-property-values (_variables.scss), or a color value. // $edgeOptOut controls whether to feature-detect around Edge to avoid emitting CSS variables for it, // intended for use in cases where interactions with pseudo-element styles cause problems due to Edge bugs. @mixin mdc-theme-prop($property, $style, $important: false, $edgeOptOut: false) { - @if type-of($style) == "color" or $style == "currentColor" { + @if mdc-theme-is-valid-theme-prop-value_($style) { @if $important { #{$property}: $style !important; } @else { diff --git a/packages/mdc-theme/_variables.scss b/packages/mdc-theme/_variables.scss index a46d2d88f0d..8c962f39226 100644 --- a/packages/mdc-theme/_variables.scss +++ b/packages/mdc-theme/_variables.scss @@ -98,28 +98,28 @@ $mdc-theme-property-values: ( text-icon-on-dark: mdc-theme-ink-color-for-fill_(icon, dark) ); -// If `$property` is a literal color value (e.g., `blue`, `#fff`), it is returned verbatim. Otherwise, the value of the -// corresponding theme property (from `$mdc-theme-property-values`) is returned. If `$property` is not a color and no -// such theme property exists, an error is thrown. +// If `$style` is a color (a literal color value, `currentColor`, or a CSS custom property), it is returned verbatim. +// Otherwise, `$style` is treated as a theme property name, and the corresponding value from +// `$mdc-theme-property-values` is returned. If this also fails, an error is thrown. // // This is mainly useful in situations where `mdc-theme-prop` cannot be used directly (e.g., `box-shadow`). // // Examples: // -// 1. mdc-theme-prop-value(primary) => "#3f51b5" +// 1. mdc-theme-prop-value(primary) => "#6200ee" // 2. mdc-theme-prop-value(blue) => "blue" // // NOTE: This function must be defined in _variables.scss instead of _functions.scss to avoid circular imports. -@function mdc-theme-prop-value($property) { - @if type-of($property) == "color" or $property == "currentColor" { - @return $property; +@function mdc-theme-prop-value($style) { + @if mdc-theme-is-valid-theme-prop-value_($style) { + @return $style; } - @if not map-has-key($mdc-theme-property-values, $property) { - @error "Invalid theme property: '#{$property}'. Choose one of: #{map-keys($mdc-theme-property-values)}"; + @if not map-has-key($mdc-theme-property-values, $style) { + @error "Invalid theme property: '#{$style}'. Choose one of: #{map-keys($mdc-theme-property-values)}"; } - @return map-get($mdc-theme-property-values, $property); + @return map-get($mdc-theme-property-values, $style); } // NOTE: This function must be defined in _variables.scss instead of _functions.scss to avoid circular imports. @@ -133,3 +133,8 @@ $mdc-theme-property-values: ( @return map-get($color-map-for-tone, $text-style); } + +// NOTE: This function is depended upon by mdc-theme-prop-value (above) and thus must be defined in this file. +@function mdc-theme-is-valid-theme-prop-value_($style) { + @return type-of($style) == "color" or $style == "currentColor" or str_slice($style, 1, 4) == "var("; +} diff --git a/packages/mdc-toolbar/package.json b/packages/mdc-toolbar/package.json index 9dfcc92aeb4..7c24a4c5520 100644 --- a/packages/mdc-toolbar/package.json +++ b/packages/mdc-toolbar/package.json @@ -1,6 +1,6 @@ { "name": "@material/toolbar", - "version": "0.37.0", + "version": "0.37.1", "description": "The Material Components for the web toolbar component", "license": "Apache-2.0", "repository": { @@ -15,10 +15,10 @@ "dependencies": { "@material/base": "^0.35.0", "@material/elevation": "^0.36.1", - "@material/ripple": "^0.37.0", + "@material/ripple": "^0.37.1", "@material/rtl": "^0.36.0", "@material/theme": "^0.35.0", - "@material/typography": "^0.35.0" + "@material/typography": "^0.37.1" }, "publishConfig": { "access": "public" diff --git a/packages/mdc-top-app-bar/package.json b/packages/mdc-top-app-bar/package.json index 0b9fdf5dbf5..449e8365048 100644 --- a/packages/mdc-top-app-bar/package.json +++ b/packages/mdc-top-app-bar/package.json @@ -1,6 +1,6 @@ { "name": "@material/top-app-bar", - "version": "0.37.0", + "version": "0.37.1", "description": "The Material Components for the web top app bar component", "license": "Apache-2.0", "repository": { @@ -18,10 +18,10 @@ "@material/animation": "^0.34.0", "@material/base": "^0.35.0", "@material/elevation": "^0.36.1", - "@material/ripple": "^0.37.0", + "@material/ripple": "^0.37.1", "@material/rtl": "^0.36.0", "@material/theme": "^0.35.0", - "@material/typography": "^0.35.0" + "@material/typography": "^0.37.1" }, "publishConfig": { "access": "public" diff --git a/packages/mdc-typography/package.json b/packages/mdc-typography/package.json index fddb579248b..2d5af6089c4 100644 --- a/packages/mdc-typography/package.json +++ b/packages/mdc-typography/package.json @@ -1,7 +1,7 @@ { "name": "@material/typography", "description": "Typography classes, mixins, and variables for Material Components for the web", - "version": "0.35.0", + "version": "0.37.1", "license": "Apache-2.0", "keywords": [ "material components", diff --git a/scripts/rewrite-decl-statements-for-closure-test.js b/scripts/rewrite-decl-statements-for-closure-test.js index 897b6f16bb1..898df912443 100644 --- a/scripts/rewrite-decl-statements-for-closure-test.js +++ b/scripts/rewrite-decl-statements-for-closure-test.js @@ -211,7 +211,12 @@ function transform(srcFile, rootDir) { } // Specify goog.module after the @license comment and append newline at the end of the file. - const pos = outputCode.indexOf(' */') + 3; + // First, get the first occurence of a multiline comment terminator with 0 or more preceding whitespace characters. + const result = /\s*\*\//.exec(outputCode); + // Then, get the index of that first matching character set plus the length of the matching characters, plus one + // extra character for more space. We now have the position at which we need to inject the "goog.module(...)" + // declaration and can assemble the module-declared code. Yay! + const pos = result.index + result[0].length + 1; outputCode = outputCode.substr(0, pos) + '\ngoog.module(\'' + packageStr + '\');\n' + outputCode.substr(pos); fs.writeFileSync(srcFile, outputCode, 'utf8'); logProgress(`[rewrite] ${srcFile}`); diff --git a/test/screenshot/.gitignore b/test/screenshot/.gitignore index 5b60b587817..dcaf71693e4 100644 --- a/test/screenshot/.gitignore +++ b/test/screenshot/.gitignore @@ -1,3 +1 @@ -report.html -report.json -snapshot.json +index.html diff --git a/test/screenshot/README.md b/test/screenshot/README.md index 7920683a909..be4eefa961d 100644 --- a/test/screenshot/README.md +++ b/test/screenshot/README.md @@ -6,6 +6,9 @@ Prevent visual regressions by running screenshot tests on every PR. ### API credentials +CBT credentials can be found on the [CrossBrowserTesting.com > Account](https://crossbrowsertesting.com/account) page. \ +Your `Authkey` is listed under the `User Profile` section. + Add the following to your `~/.bash_profile` or `~/.bashrc` file: ```bash @@ -13,12 +16,17 @@ export MDC_CBT_USERNAME='you@example.com' export MDC_CBT_AUTHKEY='example' ``` -Credentials can be found here: +Make the env vars available to existing terminal(s): + +```bash +[[ -f ~/.bash_profile ]] && source ~/.bash_profile || source ~/.bashrc +``` + +Then authorize your GCP account: -* [CrossBrowserTesting.com > Account](https://crossbrowsertesting.com/account) \ - `Authkey` is listed under the `User Profile` section -* [Google Cloud Console > IAM & admin > Service accounts](https://console.cloud.google.com/iam-admin/serviceaccounts?project=material-components-web) \ - Click the `︙` icon on the right side of the service account, then choose `Create key` +```bash +gcloud auth login +``` ### Test your changes @@ -45,6 +53,18 @@ https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/03_37 ## Basic usage +### Local dev server + +The deprecated `npm run dev` command has been replaced by: + +```bash +npm start +``` + +Open http://localhost:8080/ in your browser to view the test pages. + +Source files are automatically recompiled when they change. + ### Updating "golden" screenshots On the @@ -98,18 +118,6 @@ These options are treated as regular expressions, so partial matches are possibl See `test/screenshot/browser.json` for the full list of supported browsers. -### Local dev server - -The deprecated `npm run dev` command has been replaced by: - -```bash -npm start -``` - -Open http://localhost:8080/ in your browser to view the test pages. - -Source files are automatically recompiled when they change. - ## Advanced usage Use `--help` to see all available CLI options: @@ -124,6 +132,67 @@ npm run screenshot:test -- --help **IMPORTANT:** Note the `--` between the script name and its arguments. This is required by `npm`. +### Creating new screenshot tests + +The easiest way to create new screenshot tests is to copy an existing test page or directory, and modify it for your +component. + +For example, to create tests for `mdc-radio`, start by copying and renaming the `mdc-checkbox` tests: + +```bash +cp -r test/screenshot/spec/mdc-{checkbox,radio} +sed -i '' 's/-checkbox/-radio/g' test/screenshot/spec/mdc-radio/*.* test/screenshot/spec/mdc-radio/*/*.* +vim ... +``` + +#### Component sizes + +There are two types of components: + +1. **Large** fullpage components (dialog, drawer, top app bar, etc.) +2. **Small** widget components (button, checkbox, linear progress, etc.) + +Test pages for **small** components must have a `test-main--mobile-viewport` class on the `
` element: + +```html +
+``` + +This class ensures that all components on the page fit inside an "average" mobile viewport without scrolling. +This is necessary because most browsers' WebDriver implementations do not support taking screenshots of the entire +`document`. + +Test pages for **large** components, however, must _not_ use the `--mobile-viewport` class: + +```html +
+``` + +For **small** components, you also need to specify the dimensions of the `test-cell--FOO` class in your component's +`fixture.scss` file: + +```css +.test-cell--button { + width: 171px; + height: 71px; +} +``` + +The dimensions should be large enough to fit all variants of your component, with an extra ~`10px` or so of wiggle room. +This prevents noisy diffs in the event that your component's `height` or `margin` changes unexpectedly. + +#### CSS classes + +CSS Class | Description +--- | --- +`test-main` | Mandatory. Wraps all page content. +`test-main--mobile-viewport` | Mandatory (**small** components only). Ensures that all page content fits in a mobile viewport. +`test-cell--` | Mandatory (**small** components only). Sets the dimensions of cells in the grid. +`custom---` | Mandatory (mixin test pages only). Calls a single Sass theme mixin. + +\* _`` is the name of the component, minus the `mdc-` prefix. E.g.: `radio`, `top-app-bar`._ \ +\* _`` is the name of the Sass mixin, minus the `mdc--` prefix. E.g.: `container-fill-color`._ + ### Public demos ```bash @@ -223,20 +292,32 @@ For example: $ echo '.mdc-button:not(:disabled){color:red}' >> packages/mdc-button/mdc-button.scss ``` -6. Rerun the tests locally: +6. Rerun the tests locally until you're satisfied with how they look: ```bash $ npm run screenshot:test -- --url=mdc-button --retries=0 --offline 30 screenshots changed! + Diff report: http://localhost:9000/advorak/2018/07/15/04_11_46_560/report/report.html + $ git add test/screenshot/golden.json + $ git commit -m 'Update golden.json with offline screenshots' + ``` + +7. Once you're happy with your changes, revert the offline-generated golden images (because they won't match CBT): + + ```bash + $ git fetch + $ git checkout origin/master -- test/screenshot/golden.json + $ git add test/screenshot/golden.json + $ git commit -m 'Revert offline changes to golden.json' ``` -7. Run the tests remotely and create a PR: +8. Run the tests remotely on CBT and create a PR: ```bash - $ npm run screenshot:test -- --url=mdc-button --retries=0 + $ npm run screenshot:test -- --url=mdc-button $ npm run screenshot:approve -- --all --report=https://.../report.json $ git add test/screenshot/golden.json - $ git commit -m 'feat(button): Fancy' + $ git commit -m 'feat(button): Fancy variant' $ git push -u origin ``` diff --git a/test/screenshot/commands/build.js b/test/screenshot/commands/build.js deleted file mode 100644 index 0d0ccc8acb1..00000000000 --- a/test/screenshot/commands/build.js +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2018 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const CleanCommand = require('./clean'); -const Cli = require('../lib/cli'); -const Logger = require('../lib/logger'); -const ProcessManager = require('../lib/process-manager'); - -const logger = new Logger(__filename); -const processManager = new ProcessManager(); - -module.exports = { - async runAsync() { - const webpackArgs = []; - const shouldBuild = await this.shouldBuild_(); - const shouldWatch = await this.shouldWatch_(); - - if (!shouldBuild) { - return; - } - - if (shouldWatch) { - webpackArgs.push('--watch'); - } - - await CleanCommand.runAsync(); - - logger.foldStart('screenshot.build', 'Compiling source files'); - processManager.spawnChildProcessSync('npm', ['run', 'screenshot:proto']); - processManager.spawnChildProcessSync('npm', ['run', 'screenshot:webpack', '--', ...webpackArgs]); - logger.foldEnd('screenshot.build'); - }, - - /** - * @return {!Promise} - * @private - */ - async shouldBuild_() { - const cli = new Cli(); - if (cli.skipBuild) { - console.error('Skipping build step'); - return false; - } - - const pid = await this.getExistingProcessId_(); - if (pid) { - console.log(`Build is already running (pid ${pid})`); - return false; - } - - return true; - }, - - /** - * @return {!Promise} - * @private - */ - async shouldWatch_() { - const cli = new Cli(); - return cli.watch; - }, - - /** - * TODO(acvdorak): Store PID in local text file instead of scanning through running processes - * @return {!Promise} - * @private - */ - async getExistingProcessId_() { - /** @type {!Array} */ - const allProcs = await processManager.getRunningProcessesInPwdAsync('node', 'build'); - const buildProcs = allProcs.filter((proc) => { - const [script, command] = proc.arguments; - return ( - script.endsWith('/run.js') && - command === 'build' - ); - }); - - return buildProcs.length > 0 ? buildProcs[0].pid : null; - }, -}; diff --git a/test/screenshot/commands/test.js b/test/screenshot/commands/test.js deleted file mode 100644 index 71f26c56777..00000000000 --- a/test/screenshot/commands/test.js +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2018 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const BuildCommand = require('./build'); -const Controller = require('../lib/controller'); -const GitHubApi = require('../lib/github-api'); - -module.exports = { - async runAsync() { - await BuildCommand.runAsync(); - const controller = new Controller(); - const gitHubApi = new GitHubApi(); - - /** @type {!mdc.proto.ReportData} */ - const reportData = await controller.initForCapture(); - - const {isTestable, prNumber} = controller.checkIsTestable(reportData); - if (!isTestable) { - console.log(`PR #${prNumber} does not contain any testable source file changes.\nSkipping screenshot tests.`); - return; - } - - await gitHubApi.setPullRequestStatus(reportData); - - try { - await controller.uploadAllAssets(reportData); - await controller.captureAllPages(reportData); - await controller.compareAllScreenshots(reportData); - await controller.generateReportPage(reportData); - await gitHubApi.setPullRequestStatus(reportData); - } catch (err) { - await gitHubApi.setPullRequestError(); - throw err; - } - - return await controller.getTestExitCode(reportData); - }, -}; diff --git a/test/screenshot/commands/travis.sh b/test/screenshot/commands/travis.sh deleted file mode 100755 index cd49eee9df6..00000000000 --- a/test/screenshot/commands/travis.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash - -function extract_api_credentials() { - openssl aes-256-cbc -K $encrypted_eead2343bb54_key -iv $encrypted_eead2343bb54_iv \ - -in test/screenshot/auth/travis.tar.enc -out test/screenshot/auth/travis.tar -d - - tar -xf test/screenshot/auth/travis.tar -C test/screenshot/auth/ - - echo - echo 'git status:' - echo - git status - echo - env | grep TRAVIS - echo -} - -function install_google_cloud_sdk() { - if [ ! -d $HOME/google-cloud-sdk ]; then - curl -o /tmp/gcp-sdk.bash https://sdk.cloud.google.com - chmod +x /tmp/gcp-sdk.bash - /tmp/gcp-sdk.bash --disable-prompts - fi - - export PATH=$PATH:$HOME/google-cloud-sdk/bin - export CLOUDSDK_CORE_DISABLE_PROMPTS=1 - - gcloud auth activate-service-account --key-file test/screenshot/auth/gcs.json - gcloud config set project material-components-web - gcloud components install gsutil - - which gsutil 2>&1 > /dev/null - if [ $? != 0 ]; then - pip install --upgrade pip - pip install gsutil - fi -} - -if [ "$TEST_SUITE" == 'screenshot' ]; then - extract_api_credentials - install_google_cloud_sdk -fi diff --git a/test/screenshot/diffing.json b/test/screenshot/diffing.json index 756690018bc..fa9a67bdf24 100644 --- a/test/screenshot/diffing.json +++ b/test/screenshot/diffing.json @@ -5,6 +5,7 @@ } }, "flaky_tests": { - "min_changed_pixel_count": 15 + "min_changed_pixel_count": 15, + "max_auto_retry_changed_pixel_fraction": 0.10 } } diff --git a/test/screenshot/fixture.js b/test/screenshot/fixture.js deleted file mode 100644 index a2d66135158..00000000000 --- a/test/screenshot/fixture.js +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2018 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* eslint-disable no-var */ - -window.mdc = window.mdc || {}; - -window.mdc.testFixture = { - attachFontObserver: function() { - var fontsLoadedPromise = new Promise(function(resolve) { - var robotoFont = new FontFaceObserver('Roboto'); - var materialIconsFont = new FontFaceObserver('Material Icons'); - - // The `load()` method accepts an optional string of text to ensure that those specific glyphs are available. - // For the Material Icons font, we need to pass it one of the icon names. - Promise.all([robotoFont.load(), materialIconsFont.load('star_border')]).then(function() { - resolve(); - }); - - setTimeout(function() { - resolve(); - }, 3000); // TODO(acdvorak): Create a constant for font loading timeout values - }); - fontsLoadedPromise.then(function() { - document.body.setAttribute('data-fonts-loaded', ''); - }); - }, -}; diff --git a/test/screenshot/golden.json b/test/screenshot/golden.json index 18ac295065f..d57ae5718be 100644 --- a/test/screenshot/golden.json +++ b/test/screenshot/golden.json @@ -1,218 +1,290 @@ { - "mdc-button/classes/baseline-button-with-icons.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/baseline-button-with-icons.html", + "spec/mdc-button/classes/baseline-button-with-icons.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/classes/baseline-button-with-icons.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/13/15_50_29_237/mdc-button/classes/baseline-button-with-icons.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/12_02_54_125/mdc-button/classes/baseline-button-with-icons.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/baseline-button-with-icons.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/06_12_41_520/mdc-button/classes/baseline-button-with-icons.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-button-with-icons.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-button-with-icons.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-button-with-icons.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-button-with-icons.html.windows_ie_11.png" } }, - "mdc-button/classes/baseline-button-without-icons.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/baseline-button-without-icons.html", + "spec/mdc-button/classes/baseline-button-without-icons.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/classes/baseline-button-without-icons.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/baseline-button-without-icons.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/baseline-button-without-icons.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/baseline-button-without-icons.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/baseline-button-without-icons.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-button-without-icons.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-button-without-icons.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-button-without-icons.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-button-without-icons.html.windows_ie_11.png" } }, - "mdc-button/classes/baseline-link-with-icons.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/baseline-link-with-icons.html", + "spec/mdc-button/classes/baseline-link-with-icons.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/classes/baseline-link-with-icons.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/13/15_50_29_237/mdc-button/classes/baseline-link-with-icons.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/09_26_36_096/mdc-button/classes/baseline-link-with-icons.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/baseline-link-with-icons.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/baseline-link-with-icons.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-link-with-icons.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-link-with-icons.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-link-with-icons.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-link-with-icons.html.windows_ie_11.png" } }, - "mdc-button/classes/baseline-link-without-icons.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/baseline-link-without-icons.html", + "spec/mdc-button/classes/baseline-link-without-icons.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/classes/baseline-link-without-icons.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/baseline-link-without-icons.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/baseline-link-without-icons.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/baseline-link-without-icons.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/baseline-link-without-icons.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-link-without-icons.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-link-without-icons.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-link-without-icons.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/baseline-link-without-icons.html.windows_ie_11.png" } }, - "mdc-button/classes/dense-button-with-icons.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/dense-button-with-icons.html", + "spec/mdc-button/classes/dense-button-with-icons.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/classes/dense-button-with-icons.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/13/15_50_29_237/mdc-button/classes/dense-button-with-icons.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/12_02_54_125/mdc-button/classes/dense-button-with-icons.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/dense-button-with-icons.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/dense-button-with-icons.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-button-with-icons.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-button-with-icons.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-button-with-icons.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-button-with-icons.html.windows_ie_11.png" } }, - "mdc-button/classes/dense-button-without-icons.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/dense-button-without-icons.html", + "spec/mdc-button/classes/dense-button-without-icons.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/classes/dense-button-without-icons.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/dense-button-without-icons.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/dense-button-without-icons.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/dense-button-without-icons.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/dense-button-without-icons.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-button-without-icons.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-button-without-icons.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-button-without-icons.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-button-without-icons.html.windows_ie_11.png" } }, - "mdc-button/classes/dense-link-with-icons.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/dense-link-with-icons.html", + "spec/mdc-button/classes/dense-link-with-icons.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/classes/dense-link-with-icons.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/13/15_50_29_237/mdc-button/classes/dense-link-with-icons.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/12_02_54_125/mdc-button/classes/dense-link-with-icons.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/dense-link-with-icons.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/dense-link-with-icons.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-link-with-icons.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-link-with-icons.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-link-with-icons.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-link-with-icons.html.windows_ie_11.png" } }, - "mdc-button/classes/dense-link-without-icons.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/classes/dense-link-without-icons.html", + "spec/mdc-button/classes/dense-link-without-icons.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/classes/dense-link-without-icons.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/dense-link-without-icons.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/dense-link-without-icons.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/dense-link-without-icons.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/classes/dense-link-without-icons.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-link-without-icons.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-link-without-icons.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-link-without-icons.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/classes/dense-link-without-icons.html.windows_ie_11.png" } }, - "mdc-button/mixins/container-fill-color.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/05/22/16_23_58_618/af3d7271f/mdc-button/mixins/container-fill-color.html", + "spec/mdc-button/mixins/container-fill-color.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/mixins/container-fill-color.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/container-fill-color.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/06_16_21_090/mdc-button/mixins/container-fill-color.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/container-fill-color.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/container-fill-color.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/container-fill-color.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/container-fill-color.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/container-fill-color.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/container-fill-color.html.windows_ie_11.png" } }, - "mdc-button/mixins/corner-radius.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/05/22/16_23_58_618/af3d7271f/mdc-button/mixins/corner-radius.html", + "spec/mdc-button/mixins/corner-radius.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/mixins/corner-radius.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/13/15_50_29_237/mdc-button/mixins/corner-radius.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/12_02_54_125/mdc-button/mixins/corner-radius.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/corner-radius.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/corner-radius.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/corner-radius.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/corner-radius.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/corner-radius.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/corner-radius.html.windows_ie_11.png" } }, - "mdc-button/mixins/filled-accessible.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/05/22/16_23_58_618/af3d7271f/mdc-button/mixins/filled-accessible.html", + "spec/mdc-button/mixins/filled-accessible.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/mixins/filled-accessible.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/filled-accessible.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/12_02_54_125/mdc-button/mixins/filled-accessible.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/filled-accessible.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/filled-accessible.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/filled-accessible.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/filled-accessible.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/filled-accessible.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/filled-accessible.html.windows_ie_11.png" } }, - "mdc-button/mixins/horizontal-padding-baseline.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/05/22/16_23_58_618/af3d7271f/mdc-button/mixins/horizontal-padding-baseline.html", + "spec/mdc-button/mixins/horizontal-padding-baseline.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/mixins/horizontal-padding-baseline.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/mixins/horizontal-padding-baseline.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/mixins/horizontal-padding-baseline.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/mixins/horizontal-padding-baseline.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/mixins/horizontal-padding-baseline.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/horizontal-padding-baseline.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/horizontal-padding-baseline.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/horizontal-padding-baseline.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/horizontal-padding-baseline.html.windows_ie_11.png" } }, - "mdc-button/mixins/horizontal-padding-dense.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/05/22/16_23_58_618/af3d7271f/mdc-button/mixins/horizontal-padding-dense.html", + "spec/mdc-button/mixins/horizontal-padding-dense.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/mixins/horizontal-padding-dense.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/mixins/horizontal-padding-dense.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/mixins/horizontal-padding-dense.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/mixins/horizontal-padding-dense.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/14/06_00_21_336/mdc-button/mixins/horizontal-padding-dense.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/horizontal-padding-dense.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/horizontal-padding-dense.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/horizontal-padding-dense.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/horizontal-padding-dense.html.windows_ie_11.png" } }, - "mdc-button/mixins/icon-color.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/05/22/16_23_58_618/af3d7271f/mdc-button/mixins/icon-color.html", + "spec/mdc-button/mixins/icon-color.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/mixins/icon-color.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/icon-color.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/12_02_54_125/mdc-button/mixins/icon-color.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/icon-color.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/icon-color.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/icon-color.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/icon-color.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/icon-color.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/icon-color.html.windows_ie_11.png" } }, - "mdc-button/mixins/ink-color.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/05/22/16_23_58_618/af3d7271f/mdc-button/mixins/ink-color.html", + "spec/mdc-button/mixins/ink-color.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/mixins/ink-color.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/ink-color.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/12_02_54_125/mdc-button/mixins/ink-color.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/ink-color.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/ink-color.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/ink-color.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/ink-color.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/ink-color.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/ink-color.html.windows_ie_11.png" } }, - "mdc-button/mixins/stroke-color.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/05/22/16_23_58_618/af3d7271f/mdc-button/mixins/stroke-color.html", + "spec/mdc-button/mixins/stroke-color.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/mixins/stroke-color.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/13/15_50_29_237/mdc-button/mixins/stroke-color.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/stroke-color.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/stroke-color.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/stroke-color.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/stroke-color.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/stroke-color.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/stroke-color.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/stroke-color.html.windows_ie_11.png" } }, - "mdc-button/mixins/stroke-width.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/05/22/16_23_58_618/af3d7271f/mdc-button/mixins/stroke-width.html", + "spec/mdc-button/mixins/stroke-width.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-button/mixins/stroke-width.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/13/15_50_29_237/mdc-button/mixins/stroke-width.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/stroke-width.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/stroke-width.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-button/mixins/stroke-width.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/stroke-width.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/stroke-width.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/stroke-width.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-button/mixins/stroke-width.html.windows_ie_11.png" } }, - "mdc-fab/classes/baseline.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/05/25/21_14_58_299/6b0f3e00/mdc-fab/classes/baseline.html", + "spec/mdc-checkbox/classes/baseline.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/mattgoo/2018/07/18/19_29_49_289/spec/mdc-checkbox/classes/baseline.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-fab/classes/baseline.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/12_02_54_125/mdc-fab/classes/baseline.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-fab/classes/baseline.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-fab/classes/baseline.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/mattgoo/2018/07/18/19_29_49_289/spec/mdc-checkbox/classes/baseline.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/mattgoo/2018/07/18/19_29_49_289/spec/mdc-checkbox/classes/baseline.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/mattgoo/2018/07/18/19_29_49_289/spec/mdc-checkbox/classes/baseline.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/mattgoo/2018/07/18/19_29_49_289/spec/mdc-checkbox/classes/baseline.html.windows_ie_11.png" } }, - "mdc-fab/classes/extended.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/abhiomkar/2018/06/19/19_52_01_078/mdc-fab/classes/extended.html", + "spec/mdc-drawer/classes/permanent.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/21_05_09_679/spec/mdc-drawer/classes/permanent.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-fab/classes/extended.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/12_02_54_125/mdc-fab/classes/extended.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-fab/classes/extended.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-fab/classes/extended.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/permanent.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/permanent.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/permanent.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/permanent.html.windows_ie_11.png" } }, - "mdc-fab/classes/mini.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/05/25/21_14_58_299/6b0f3e00/mdc-fab/classes/mini.html", + "spec/mdc-drawer/classes/persistent.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/21_05_09_679/spec/mdc-drawer/classes/persistent.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-fab/classes/mini.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/12_02_54_125/mdc-fab/classes/mini.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-fab/classes/mini.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-fab/classes/mini.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/persistent.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/persistent.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/persistent.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/persistent.html.windows_ie_11.png" } }, - "mdc-fab/mixins/extended-padding.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/13/22_35_48_118/mdc-fab/mixins/extended-padding.html", + "spec/mdc-drawer/classes/temporary.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/21_05_09_679/spec/mdc-drawer/classes/temporary.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/13/22_35_48_118/mdc-fab/mixins/extended-padding.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/13/22_35_48_118/mdc-fab/mixins/extended-padding.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/13/22_35_48_118/mdc-fab/mixins/extended-padding.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/13/22_35_48_118/mdc-fab/mixins/extended-padding.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/temporary.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/temporary.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/temporary.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/classes/temporary.html.windows_ie_11.png" } }, - "mdc-icon-button/classes/baseline.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/williamernest/2018/05/30/14_38_21_131/4dc98079/mdc-icon-button/classes/baseline.html", + "spec/mdc-drawer/mixins/fill-color-accessible.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/21_05_09_679/spec/mdc-drawer/mixins/fill-color-accessible.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/classes/baseline.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/classes/baseline.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/classes/baseline.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/classes/baseline.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/fill-color-accessible.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/fill-color-accessible.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/fill-color-accessible.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/fill-color-accessible.html.windows_ie_11.png" } }, - "mdc-icon-button/mixins/icon-size.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/williamernest/2018/05/30/14_38_21_131/4dc98079/mdc-icon-button/mixins/icon-size.html", + "spec/mdc-drawer/mixins/fill-color.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/21_05_09_679/spec/mdc-drawer/mixins/fill-color.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/mixins/icon-size.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/mixins/icon-size.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/mixins/icon-size.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/mixins/icon-size.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/fill-color.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/fill-color.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/fill-color.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/fill-color.html.windows_ie_11.png" } }, - "mdc-icon-button/mixins/ink-color.html": { - "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/williamernest/2018/05/30/14_38_21_131/4dc98079/mdc-icon-button/mixins/ink-color.html", + "spec/mdc-drawer/mixins/ink-color.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/21_05_09_679/spec/mdc-drawer/mixins/ink-color.html", "screenshots": { - "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/mixins/ink-color.html.windows_chrome_67.png", - "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/mixins/ink-color.html.windows_edge_17.png", - "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/mixins/ink-color.html.windows_firefox_61.png", - "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/05_44_01_282/mdc-icon-button/mixins/ink-color.html.windows_ie_11.png" + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/ink-color.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/ink-color.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/ink-color.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/19/21_11_42_130/spec/mdc-drawer/mixins/ink-color.html.windows_ie_11.png" + } + }, + "spec/mdc-fab/classes/baseline.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-fab/classes/baseline.html", + "screenshots": { + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/baseline.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/baseline.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/baseline.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/baseline.html.windows_ie_11.png" + } + }, + "spec/mdc-fab/classes/extended.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-fab/classes/extended.html", + "screenshots": { + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/extended.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/extended.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/extended.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/extended.html.windows_ie_11.png" + } + }, + "spec/mdc-fab/classes/mini.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-fab/classes/mini.html", + "screenshots": { + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/mini.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/mini.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/mini.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/classes/mini.html.windows_ie_11.png" + } + }, + "spec/mdc-fab/mixins/extended-padding.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-fab/mixins/extended-padding.html", + "screenshots": { + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/mixins/extended-padding.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/mixins/extended-padding.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/mixins/extended-padding.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-fab/mixins/extended-padding.html.windows_ie_11.png" + } + }, + "spec/mdc-icon-button/classes/baseline.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-icon-button/classes/baseline.html", + "screenshots": { + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/classes/baseline.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/classes/baseline.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/classes/baseline.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/classes/baseline.html.windows_ie_11.png" + } + }, + "spec/mdc-icon-button/mixins/icon-size.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-icon-button/mixins/icon-size.html", + "screenshots": { + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/mixins/icon-size.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/mixins/icon-size.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/mixins/icon-size.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/mixins/icon-size.html.windows_ie_11.png" + } + }, + "spec/mdc-icon-button/mixins/ink-color.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/07_29_10_421/spec/mdc-icon-button/mixins/ink-color.html", + "screenshots": { + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/mixins/ink-color.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/mixins/ink-color.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/mixins/ink-color.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/17/22_50_36_536/spec/mdc-icon-button/mixins/ink-color.html.windows_ie_11.png" + } + }, + "spec/mdc-textfield/classes/baseline-textfield.html": { + "public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/18/20_19_24_759/spec/mdc-textfield/classes/baseline-textfield.html", + "screenshots": { + "desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/18/20_19_24_759/spec/mdc-textfield/classes/baseline-textfield.html.windows_chrome_67.png", + "desktop_windows_edge@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/18/20_19_24_759/spec/mdc-textfield/classes/baseline-textfield.html.windows_edge_17.png", + "desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/18/20_19_24_759/spec/mdc-textfield/classes/baseline-textfield.html.windows_firefox_61.png", + "desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/kfranqueiro/2018/07/18/20_19_24_759/spec/mdc-textfield/classes/baseline-textfield.html.windows_ie_11.png" } } } diff --git a/test/screenshot/auth/.gitignore b/test/screenshot/infra/auth/.gitignore similarity index 100% rename from test/screenshot/auth/.gitignore rename to test/screenshot/infra/auth/.gitignore diff --git a/test/screenshot/auth/travis.tar.enc b/test/screenshot/infra/auth/travis.tar.enc similarity index 100% rename from test/screenshot/auth/travis.tar.enc rename to test/screenshot/infra/auth/travis.tar.enc diff --git a/test/screenshot/commands/approve.js b/test/screenshot/infra/commands/approve.js similarity index 82% rename from test/screenshot/commands/approve.js rename to test/screenshot/infra/commands/approve.js index 90309b732e0..36fda3e0917 100644 --- a/test/screenshot/commands/approve.js +++ b/test/screenshot/infra/commands/approve.js @@ -18,10 +18,15 @@ const Controller = require('../lib/controller'); -module.exports = { +class ApproveCommand { + /** + * @return {!Promise} Process exit code. If no exit code is returned, `0` is assumed. + */ async runAsync() { const controller = new Controller(); const reportData = await controller.initForApproval(); await controller.approveChanges(reportData); - }, -}; + } +} + +module.exports = ApproveCommand; diff --git a/test/screenshot/infra/commands/build.js b/test/screenshot/infra/commands/build.js new file mode 100644 index 00000000000..6e15aacc5a0 --- /dev/null +++ b/test/screenshot/infra/commands/build.js @@ -0,0 +1,164 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const chokidar = require('chokidar'); +const debounce = require('debounce'); + +const CleanCommand = require('./clean'); +const Cli = require('../lib/cli'); +const IndexCommand = require('./index'); +const Logger = require('../lib/logger'); +const ProcessManager = require('../lib/process-manager'); +const ProtoCommand = require('./proto'); +const {TEST_DIR_RELATIVE_PATH} = require('../lib/constants'); + +class BuildCommand { + constructor() { + this.logger_ = new Logger(__filename); + this.processManager_ = new ProcessManager(); + + this.cleanCommand_ = new CleanCommand(); + this.indexCommand_ = new IndexCommand(); + this.protoCommand_ = new ProtoCommand(); + } + + /** + * @return {!Promise} Process exit code. If no exit code is returned, `0` is assumed. + */ + async runAsync() { + // Travis sometimes forgets to emit this + this.logger_.foldEnd('install.npm'); + + const shouldBuild = await this.shouldBuild_(); + const shouldWatch = await this.shouldWatch_(); + + if (!shouldBuild) { + return; + } + + await this.cleanCommand_.runAsync(); + + this.logger_.foldStart('screenshot.build', 'Compiling source files'); + + this.buildProtoFiles_(shouldWatch); + this.buildHtmlFiles_(shouldWatch); + + if (shouldWatch) { + this.processManager_.spawnChildProcess('npm', ['run', 'screenshot:webpack', '--', '--watch']); + } else { + this.processManager_.spawnChildProcessSync('npm', ['run', 'screenshot:webpack']); + } + + this.logger_.foldEnd('screenshot.build'); + } + + /** + * @param {boolean} shouldWatch + * @private + */ + buildProtoFiles_(shouldWatch) { + const buildRightNow = () => this.protoCommand_.runAsync(); + const buildDelayed = debounce(buildRightNow, 1000); + + if (!shouldWatch) { + buildRightNow(); + return; + } + + const watcher = chokidar.watch('**/*.proto', { + cwd: TEST_DIR_RELATIVE_PATH, + awaitWriteFinish: true, + }); + + watcher.on('add', buildDelayed); + watcher.on('change', buildDelayed); + } + + /** + * @param {boolean} shouldWatch + * @private + */ + buildHtmlFiles_(shouldWatch) { + const buildRightNow = () => this.indexCommand_.runAsync(); + const buildDelayed = debounce(buildRightNow, 1000); + + if (!shouldWatch) { + buildRightNow(); + return; + } + + const watcher = chokidar.watch('**/*.html', { + cwd: TEST_DIR_RELATIVE_PATH, + awaitWriteFinish: true, + ignored: ['**/report/report.html', '**/index.html'], + }); + + watcher.on('add', buildDelayed); + watcher.on('unlink', buildDelayed); + } + + /** + * @return {!Promise} + * @private + */ + async shouldBuild_() { + const cli = new Cli(); + if (cli.skipBuild) { + console.error('Skipping build step'); + return false; + } + + const pid = await this.getExistingProcessId_(); + if (pid) { + console.log(`Build is already running (pid ${pid})`); + return false; + } + + return true; + } + + /** + * @return {!Promise} + * @private + */ + async shouldWatch_() { + const cli = new Cli(); + return cli.watch; + } + + /** + * TODO(acvdorak): Store PID in local text file instead of scanning through running processes + * @return {!Promise} + * @private + */ + async getExistingProcessId_() { + /** @type {!Array} */ + const allProcs = await this.processManager_.getRunningProcessesInPwdAsync('node', 'build'); + const buildProcs = allProcs.filter((proc) => { + const [script, command] = proc.arguments; + return ( + script.endsWith('/run.js') && + command === 'build' + ); + }); + + return buildProcs.length > 0 ? buildProcs[0].pid : null; + } +} + +module.exports = BuildCommand; diff --git a/test/screenshot/commands/clean.js b/test/screenshot/infra/commands/clean.js similarity index 79% rename from test/screenshot/commands/clean.js rename to test/screenshot/infra/commands/clean.js index ca8523e9a8c..fdc57785160 100644 --- a/test/screenshot/commands/clean.js +++ b/test/screenshot/infra/commands/clean.js @@ -19,14 +19,20 @@ const del = require('del'); const mkdirp = require('mkdirp'); const path = require('path'); + const {TEST_DIR_RELATIVE_PATH} = require('../lib/constants'); -module.exports = { +class CleanCommand { + /** + * @return {!Promise} Process exit code. If no exit code is returned, `0` is assumed. + */ async runAsync() { - const relativePathPatterns = ['out'].map((filename) => { + const relativePathPatterns = ['out', '**/index.html'].map((filename) => { return path.join(TEST_DIR_RELATIVE_PATH, filename); }); await del(relativePathPatterns); mkdirp.sync(path.join(TEST_DIR_RELATIVE_PATH, 'out')); - }, -}; + } +} + +module.exports = CleanCommand; diff --git a/test/screenshot/commands/demo.js b/test/screenshot/infra/commands/demo.js similarity index 77% rename from test/screenshot/commands/demo.js rename to test/screenshot/infra/commands/demo.js index 1242dd885b7..dabfc0dc9cf 100644 --- a/test/screenshot/commands/demo.js +++ b/test/screenshot/infra/commands/demo.js @@ -19,11 +19,19 @@ const BuildCommand = require('./build'); const Controller = require('../lib/controller'); -module.exports = { +class DemoCommand { + /** + * @return {!Promise} Process exit code. If no exit code is returned, `0` is assumed. + */ async runAsync() { - await BuildCommand.runAsync(); + const buildCommand = new BuildCommand(); const controller = new Controller(); + + await buildCommand.runAsync(); + const reportData = await controller.initForDemo(); await controller.uploadAllAssets(reportData); - }, -}; + } +} + +module.exports = DemoCommand; diff --git a/test/screenshot/infra/commands/index.js b/test/screenshot/infra/commands/index.js new file mode 100644 index 00000000000..6aa24cab361 --- /dev/null +++ b/test/screenshot/infra/commands/index.js @@ -0,0 +1,139 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const path = require('path'); + +const LocalStorage = require('../lib/local-storage'); +const {TEST_DIR_RELATIVE_PATH} = require('../lib/constants'); + +// TODO(acdvorak): Clean up this entire file. It's gross. +class IndexCommand { + /** + * @return {!Promise} Process exit code. If no exit code is returned, `0` is assumed. + */ + async runAsync() { + const localStorage = new LocalStorage(); + + const fakeReportHtmlFilePath = path.join(TEST_DIR_RELATIVE_PATH, 'report/report.html'); + const fakeReportJsonFilePath = path.join(TEST_DIR_RELATIVE_PATH, 'report/report.json'); + + await localStorage.writeTextFile(fakeReportHtmlFilePath, ''); + await localStorage.writeTextFile(fakeReportJsonFilePath, '{}'); + + async function walkDir(parentDirPath, depth = 0) { + const parentDirName = path.basename(parentDirPath); + + const realChildDirNames = localStorage.globDirs('*', parentDirPath) + .map((dirName) => dirName.replace(new RegExp('/+$'), '')); + + const nonHtmlFileNames = localStorage.globFiles('*', parentDirPath) + .filter((name) => !name.endsWith('.html')); + + const deepHtmlFileNames = localStorage.globFiles('**/*.html', parentDirPath) + .filter((name) => path.basename(name) !== 'index.html'); + + for (const dirName of realChildDirNames) { + await walkDir(path.join(parentDirPath, dirName), depth + 1); + } + + const printableChildDirNames = depth === 0 ? [...realChildDirNames] : ['..', ...realChildDirNames]; + const dirLinks = printableChildDirNames.map((childDirName) => { + return ` +
  • ${childDirName}/
  • +`; + }); + + const htmlFileLinks = deepHtmlFileNames.map((deepHtmlFileName) => { + const fileNameMarkup = deepHtmlFileName.split(path.sep).map((part) => { + if (/^mdc-.+$/.test(part) || /\.html$/.test(part)) { + return `${part}`; + } + return part; + }).join(path.sep); + return ` +
  • ${fileNameMarkup}
  • +`; + }); + + const otherFileLinks = nonHtmlFileNames.map((nonHtmlFileName) => { + return ` +
  • ${nonHtmlFileName}
  • +`; + }); + + const linkMarkup = [dirLinks, htmlFileLinks, otherFileLinks] + .filter((links) => links.length > 0) + .map((links) => `
      ${links.join('\n')}
    `) + .join('\n
    \n') + ; + + const html = ` + + + + ${parentDirName} + + + +
    + ${linkMarkup} +
    + + + `; + + await localStorage.writeTextFile(path.join(parentDirPath, 'index.html'), html); + } + + await walkDir(path.join(TEST_DIR_RELATIVE_PATH)); + + await localStorage.delete([fakeReportHtmlFilePath, fakeReportJsonFilePath]); + } +} + +module.exports = IndexCommand; diff --git a/test/screenshot/commands/proto.js b/test/screenshot/infra/commands/proto.js similarity index 84% rename from test/screenshot/commands/proto.js rename to test/screenshot/infra/commands/proto.js index d7701f78588..fa2a22cbb9f 100644 --- a/test/screenshot/commands/proto.js +++ b/test/screenshot/infra/commands/proto.js @@ -22,10 +22,12 @@ const path = require('path'); const ProcessManager = require('../lib/process-manager'); const {TEST_DIR_RELATIVE_PATH} = require('../lib/constants'); -const processManager = new ProcessManager(); - -module.exports = { +class ProtoCommand { + /** + * @return {!Promise} Process exit code. If no exit code is returned, `0` is assumed. + */ async runAsync() { + const processManager = new ProcessManager(); const protoFilePaths = glob.sync(path.join(TEST_DIR_RELATIVE_PATH, '**/*.proto')); const cmd = 'pbjs'; @@ -35,5 +37,7 @@ module.exports = { const jsFilePath = protoFilePath.replace(/.proto$/, '.pb.js'); processManager.spawnChildProcessSync(cmd, args.concat(`--out=${jsFilePath}`, protoFilePath)); } - }, -}; + } +} + +module.exports = ProtoCommand; diff --git a/test/screenshot/commands/serve.js b/test/screenshot/infra/commands/serve.js similarity index 66% rename from test/screenshot/commands/serve.js rename to test/screenshot/infra/commands/serve.js index 4063635a5c9..0762a80c25a 100644 --- a/test/screenshot/commands/serve.js +++ b/test/screenshot/infra/commands/serve.js @@ -16,6 +16,8 @@ 'use strict'; +/** @type {!CliColor} */ +const colors = require('colors'); const detectPort = require('detect-port'); const express = require('express'); const serveIndex = require('serve-index'); @@ -24,7 +26,10 @@ const Cli = require('../lib/cli'); const {ExitCode} = require('../lib/constants'); const {TEST_DIR_RELATIVE_PATH} = require('../lib/constants'); -module.exports = { +class ServeCommand { + /** + * @return {!Promise} Process exit code. If no exit code is returned, `0` is assumed. + */ async runAsync() { const cli = new Cli(); const {port} = cli; @@ -39,11 +44,18 @@ module.exports = { app.use('/', express.static(TEST_DIR_RELATIVE_PATH), serveIndex(TEST_DIR_RELATIVE_PATH)); app.listen(port, () => { - console.log(` -========================================================== -Local development server running on http://localhost:${port}/ -========================================================== -`); + const urlPlain = `http://localhost:${port}/`; + const urlColor = colors.bold.underline(urlPlain); + const noticePlain = `Local development server running on ${urlPlain}`; + const noticeColor = `Local development server running on ${urlColor}`; + const borderColor = colors.green(''.padStart(noticePlain.length, '=')); + console.log((` +${borderColor} +${noticeColor} +${borderColor} +`)); }); - }, -}; + } +} + +module.exports = ServeCommand; diff --git a/test/screenshot/infra/commands/test.js b/test/screenshot/infra/commands/test.js new file mode 100644 index 00000000000..591ef012e7a --- /dev/null +++ b/test/screenshot/infra/commands/test.js @@ -0,0 +1,513 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const VError = require('verror'); + +const mdcProto = require('../proto/mdc.pb').mdc.proto; +const GitRevision = mdcProto.GitRevision; + +const BuildCommand = require('./build'); +const Cli = require('../lib/cli'); +const CliColor = require('../lib/logger').colors; +const Controller = require('../lib/controller'); +const DiffBaseParser = require('../lib/diff-base-parser'); +const Duration = require('../lib/duration'); +const GitHubApi = require('../lib/github-api'); +const ImageDiffer = require('../lib/image-differ'); +const Logger = require('../lib/logger'); +const getStackTrace = require('../lib/stacktrace')('TestCommand'); +const {ExitCode} = require('../lib/constants'); + +// TODO(acdvorak): Refactor most of this class out into a separate file +class TestCommand { + constructor() { + this.cli_ = new Cli(); + this.diffBaseParser_ = new DiffBaseParser(); + this.gitHubApi_ = new GitHubApi(); + this.imageDiffer_ = new ImageDiffer(); + this.logger_ = new Logger(__filename); + } + + /** + * @return {!Promise} Process exit code. If no exit code is returned, `0` is assumed. + */ + async runAsync() { + await this.build_(); + + if (this.isExternalPr_()) { + this.logExternalPr_(); + return ExitCode.OK; + } + + /** @type {!mdc.proto.DiffBase} */ + const snapshotDiffBase = await this.diffBaseParser_.parseGoldenDiffBase(); + const snapshotGitRev = snapshotDiffBase.git_revision; + + const isTravisPr = snapshotGitRev && snapshotGitRev.type === GitRevision.Type.TRAVIS_PR; + const isTestable = isTravisPr ? snapshotGitRev.pr_file_paths.length > 0 : true; + + if (!isTestable) { + this.logUntestablePr_(snapshotGitRev.pr_number); + return ExitCode.OK; + } + + // TODO(acdvorak): Find a better word than "local" + /** @type {!mdc.proto.ReportData} */ + const localDiffReportData = await this.diffAgainstLocal_(snapshotDiffBase); + const localDiffExitCode = this.getExitCode_(localDiffReportData); + if (localDiffExitCode !== ExitCode.OK) { + this.logTestResults_(localDiffReportData); + return localDiffExitCode; + } + + if (isTravisPr) { + /** @type {!mdc.proto.ReportData} */ + const masterDiffReportData = await this.diffAgainstMaster_({localDiffReportData, snapshotGitRev}); + this.logTestResults_(localDiffReportData); + this.logTestResults_(masterDiffReportData); + } else { + this.logTestResults_(localDiffReportData); + } + + // Diffs against master shouldn't fail the Travis job. + return ExitCode.OK; + } + + async build_() { + const buildCommand = new BuildCommand(); + await buildCommand.runAsync(); + } + + /** + * @param {!mdc.proto.DiffBase} goldenDiffBase + * @return {!Promise} + * @private + */ + async diffAgainstLocal_(goldenDiffBase) { + const controller = new Controller(); + + /** @type {!mdc.proto.ReportData} */ + const reportData = await controller.initForCapture(goldenDiffBase); + + try { + await this.gitHubApi_.setPullRequestStatusAuto(reportData); + await controller.uploadAllAssets(reportData); + await controller.captureAllPages(reportData); + + controller.populateMaps(reportData); + + await controller.uploadAllImages(reportData); + await controller.generateReportPage(reportData); + + await this.gitHubApi_.setPullRequestStatusAuto(reportData); + + this.logComparisonResults_(reportData); + } catch (err) { + await this.gitHubApi_.setPullRequestError(); + throw new VError(err, getStackTrace('diffAgainstLocal_')); + } + + return reportData; + } + + /** + * TODO(acdvorak): Rename this method + * @param {!mdc.proto.DiffBase} goldenDiffBase + * @param {!Array} capturedScreenshots + * @param {string} startTimeIsoUtc + * @return {!Promise} + * @private + */ + async diffAgainstMasterImpl_({goldenDiffBase, capturedScreenshots, startTimeIsoUtc}) { + const controller = new Controller(); + + /** @type {!mdc.proto.ReportData} */ + const reportData = await controller.initForCapture(goldenDiffBase); + + try { + await controller.uploadAllAssets(reportData); + await this.copyAndCompareScreenshots_({reportData, capturedScreenshots, startTimeIsoUtc}); + + controller.populateMaps(reportData); + + await controller.uploadAllImages(reportData); + await controller.generateReportPage(reportData); + + this.logComparisonResults_(reportData); + } catch (err) { + await this.gitHubApi_.setPullRequestError(); + throw new VError(err, getStackTrace('diffAgainstMasterImpl_')); + } + + return reportData; + } + + /** + * TODO(acdvorak): Rename this method + * @param {!mdc.proto.ReportData} localDiffReportData + * @param {!mdc.proto.GitRevision} snapshotGitRev + * @return {!Promise} + * @private + */ + async diffAgainstMaster_({localDiffReportData, snapshotGitRev}) { + const localScreenshots = localDiffReportData.screenshots; + + /** @type {!Array} */ + const capturedScreenshots = [].concat( + localScreenshots.changed_screenshot_list, + localScreenshots.added_screenshot_list, + localScreenshots.removed_screenshot_list, + localScreenshots.unchanged_screenshot_list, + ); + + /** @type {!mdc.proto.DiffBase} */ + const masterDiffBase = await this.diffBaseParser_.parseMasterDiffBase(); + + /** @type {!mdc.proto.ReportData} */ + const masterDiffReportData = await this.diffAgainstMasterImpl_({ + goldenDiffBase: masterDiffBase, + capturedScreenshots, + startTimeIsoUtc: localDiffReportData.meta.start_time_iso_utc, + }); + + const prNumber = snapshotGitRev.pr_number; + const comment = this.getPrComment_({masterDiffReportData, snapshotGitRev}); + await this.gitHubApi_.createPullRequestComment({prNumber, comment}); + + return masterDiffReportData; + } + + /** + * @param {!mdc.proto.ReportData} reportData + * @param {!Array} capturedScreenshots + * @param {string} startTimeIsoUtc + * @return {!Promise} + * @private + */ + async copyAndCompareScreenshots_({reportData, capturedScreenshots, startTimeIsoUtc}) { + const num = capturedScreenshots.length; + const plural = num === 1 ? '' : 's'; + this.logger_.foldStart('screenshot.compare_master', `Comparing ${num} screenshot${plural} to master`); + + const promises = []; + const screenshots = reportData.screenshots; + const masterScreenshots = screenshots.actual_screenshot_list; + + for (const masterScreenshot of masterScreenshots) { + for (const capturedScreenshot of capturedScreenshots) { + if (capturedScreenshot.html_file_path !== masterScreenshot.html_file_path || + capturedScreenshot.user_agent.alias !== masterScreenshot.user_agent.alias) { + continue; + } + promises.push(new Promise(async (resolve) => { + masterScreenshot.actual_html_file = capturedScreenshot.actual_html_file; + masterScreenshot.actual_image_file = capturedScreenshot.actual_image_file; + masterScreenshot.capture_state = capturedScreenshot.capture_state; + + /** @type {!mdc.proto.DiffImageResult} */ + const diffImageResult = await this.imageDiffer_.compareOneScreenshot({ + meta: reportData.meta, + screenshot: masterScreenshot, + }); + + masterScreenshot.diff_image_result = diffImageResult; + masterScreenshot.diff_image_file = diffImageResult.diff_image_file; + + if (diffImageResult.has_changed) { + reportData.screenshots.changed_screenshot_list.push(masterScreenshot); + } else { + reportData.screenshots.unchanged_screenshot_list.push(masterScreenshot); + } + reportData.screenshots.comparable_screenshot_list.push(masterScreenshot); + + resolve(); + })); + } + } + + await Promise.all(promises); + + const endTimeIsoUtc = new Date().toISOString(); + reportData.meta.start_time_iso_utc = startTimeIsoUtc; + reportData.meta.end_time_iso_utc = endTimeIsoUtc; + reportData.meta.duration_ms = Duration.elapsed(startTimeIsoUtc, endTimeIsoUtc).toMillis(); + + this.logger_.foldEnd('screenshot.compare_master'); + } + + /** + * @param {!mdc.proto.ReportData} masterDiffReportData + * @param {!mdc.proto.GitRevision} snapshotGitRev + * @return {string} + * @private + */ + getPrComment_({masterDiffReportData, snapshotGitRev}) { + const reportPageUrl = masterDiffReportData.meta.report_html_file.public_url; + const masterScreenshots = masterDiffReportData.screenshots; + + const listMarkdown = [ + this.getChangelistMarkdown_( + 'Changed', masterScreenshots.changed_screenshot_list, masterScreenshots.changed_screenshot_page_map + ), + this.getChangelistMarkdown_( + 'Added', masterScreenshots.added_screenshot_list, masterScreenshots.added_screenshot_page_map + ), + this.getChangelistMarkdown_( + 'Removed', masterScreenshots.removed_screenshot_list, masterScreenshots.removed_screenshot_page_map + ), + ].filter((str) => Boolean(str)).join('\n\n'); + + let contentMarkdown; + + const numChanged = + masterScreenshots.changed_screenshot_list.length + + masterScreenshots.added_screenshot_list.length + + masterScreenshots.removed_screenshot_list.length; + + if (listMarkdown) { + contentMarkdown = ` +
    + ${numChanged} screenshot${numChanged === 1 ? '' : 's'} changed ⚠️ +
    + +${listMarkdown} + +
    +
    +`.trim(); + } else { + contentMarkdown = '### No diffs! 💯🎉'; + } + + return ` +🤖 Beep boop! + +### Screenshot test report + +Commit ${snapshotGitRev.commit} vs. \`master\`: + +* ${reportPageUrl} + +${contentMarkdown} +`.trim(); + } + + /** + * @param {string} verb + * @param {!Array} screenshotArray + * @param {!Object} screenshotMap + */ + getChangelistMarkdown_(verb, screenshotArray, screenshotMap) { + const numHtmlFiles = Object.keys(screenshotMap).length; + if (numHtmlFiles === 0) { + return null; + } + + const listItemMarkdown = Object.entries(screenshotMap).map(([htmlFilePath, screenshotList]) => { + const browserIconMarkup = this.getAllBrowserIcons_(screenshotList.screenshots); + const firstScreenshot = screenshotList.screenshots[0]; + const htmlFileUrl = (firstScreenshot.actual_html_file || firstScreenshot.expected_html_file).public_url; + + return ` +* [\`${htmlFilePath}\`](${htmlFileUrl}) ${browserIconMarkup} +`.trim(); + }).join('\n'); + + return ` +#### ${screenshotArray.length} ${verb}: + +${listItemMarkdown} +`; + } + + /** + * @param {!Array} screenshotArray + * @return {string} + * @private + */ + getAllBrowserIcons_(screenshotArray) { + return screenshotArray.map((screenshot) => { + return this.getOneBrowserIcon_(screenshot); + }).join(' '); + } + + /** + * @param screenshot + * @return {string} + * @private + */ + getOneBrowserIcon_(screenshot) { + const imgFile = screenshot.diff_image_file || screenshot.actual_image_file || screenshot.expected_image_file; + const linkUrl = imgFile.public_url; + + const untrimmed = ` + + + +`; + return untrimmed.trim().replace(/>\n *<'); + } + + /** + * @param {!mdc.proto.ReportData} reportData + * @return {!ExitCode|number} + */ + getExitCode_(reportData) { + // TODO(acdvorak): Store this directly in the proto so we don't have to recalculate it all over the place + const numChanges = + reportData.screenshots.changed_screenshot_list.length + + reportData.screenshots.added_screenshot_list.length + + reportData.screenshots.removed_screenshot_list.length; + + const isOnline = this.cli_.isOnline(); + if (isOnline && numChanges > 0) { + return ExitCode.CHANGES_FOUND; + } + return ExitCode.OK; + } + + /** + * @return {boolean} + * @private + */ + isExternalPr_() { + return Boolean( + process.env.TRAVIS_PULL_REQUEST_SLUG && + !process.env.TRAVIS_PULL_REQUEST_SLUG.startsWith('material-components/') + ); + } + + /** + * @private + */ + logExternalPr_() { + this.logger_.warn(` + +${CliColor.bold.red('Screenshot tests are not supported on external PRs for security reasons.')} + +See ${CliColor.underline('https://docs.travis-ci.com/user/pull-requests/#Pull-Requests-and-Security-Restrictions')} +for more information. + +${CliColor.bold.red('Skipping screenshot tests.')} +`); + } + + /** + * @param {number} prNumber + * @private + */ + logUntestablePr_(prNumber) { + this.logger_.warn(` + +${CliColor.underline(`PR #${prNumber}`)} does not contain any testable source file changes. + +${CliColor.bold.green('Skipping screenshot tests.')} +`); + } + + /** + * @param {!mdc.proto.ReportData} reportData + * @private + */ + logComparisonResults_(reportData) { + this.logger_.foldStart('screenshot.diff_results', 'Diff results'); + this.logComparisonResultSet_('Skipped', reportData.screenshots.skipped_screenshot_list); + this.logComparisonResultSet_('Unchanged', reportData.screenshots.unchanged_screenshot_list); + this.logComparisonResultSet_('Removed', reportData.screenshots.removed_screenshot_list); + this.logComparisonResultSet_('Added', reportData.screenshots.added_screenshot_list); + this.logComparisonResultSet_('Changed', reportData.screenshots.changed_screenshot_list); + this.logger_.foldEnd('screenshot.diff_results'); + } + + /** + * @param {!mdc.proto.ReportData} reportData + * @private + */ + logTestResults_(reportData) { + // TODO(acdvorak): Store this directly in the proto so we don't have to recalculate it all over the place + const numChanges = + reportData.screenshots.changed_screenshot_list.length + + reportData.screenshots.added_screenshot_list.length + + reportData.screenshots.removed_screenshot_list.length; + + const boldRed = CliColor.bold.red; + const boldGreen = CliColor.bold.green; + const color = numChanges === 0 ? boldGreen : boldRed; + + const goldenDisplayName = this.getDisplayName_(reportData.meta.golden_diff_base); + const snapshotDisplayName = this.getDisplayName_(reportData.meta.snapshot_diff_base); + + const changedMsg = `${numChanges} screenshot${numChanges === 1 ? '' : 's'} changed!`; + const reportPageUrl = reportData.meta.report_html_file.public_url; + + const headingPlain = 'Screenshot Test Results'; + const headingColor = CliColor.bold(headingPlain); + const headingUnderline = ''.padEnd(headingPlain.length, '='); + + this.logger_.log(` + +${headingColor} +${headingUnderline} + + - Golden: ${goldenDisplayName} + - Snapshot: ${snapshotDisplayName} + - Changes: ${color(changedMsg)} + - Report: ${color(reportPageUrl)} +`.trimRight()); + } + + /** + * @param {!mdc.proto.DiffBase} diffBase + * @return {string} + * @private + */ + getDisplayName_(diffBase) { + const gitRev = diffBase.git_revision; + if (gitRev) { + const commitShort = gitRev.commit.substr(0, 7); + if (gitRev.pr_number) { + return `PR #${gitRev.pr_number} (commit ${commitShort})`; + } + if (gitRev.tag) { + return `${gitRev.tag} (commit ${commitShort})`; + } + if (gitRev.branch) { + return `${gitRev.remote ? `${gitRev.remote}/${gitRev.branch}` : gitRev.branch} (commit ${commitShort})`; + } + return commitShort; + } + return diffBase.local_file_path || diffBase.public_url; + } + + /** + * @param {string} title + * @param {!Array} screenshots + * @private + */ + logComparisonResultSet_(title, screenshots) { + const num = screenshots.length; + console.log(`${title} ${num} screenshot${num === 1 ? '' : 's'}${num > 0 ? ':' : ''}`); + for (const screenshot of screenshots) { + console.log(` - ${screenshot.html_file_path} > ${screenshot.user_agent.alias}`); + } + console.log(''); + } +} + +module.exports = TestCommand; diff --git a/test/screenshot/infra/commands/travis.sh b/test/screenshot/infra/commands/travis.sh new file mode 100755 index 00000000000..a91db094439 --- /dev/null +++ b/test/screenshot/infra/commands/travis.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash + +function print_to_stderr() { + echo "$@" >&2 +} + +function log_error() { + if [[ $# -gt 0 ]]; then + print_to_stderr -e "\033[31m\033[1m$@\033[0m" + else + print_to_stderr + fi +} + +function print_travis_env_vars() { + echo + env | grep TRAVIS + echo +} + +function maybe_extract_api_credentials() { + if [[ -z "$encrypted_eead2343bb54_key" ]] || [[ -z "$encrypted_eead2343bb54_iv" ]]; then + log_error + log_error "Missing decryption keys for API credentials." + log_error + + if [[ -n "$TRAVIS_PULL_REQUEST_SLUG" ]] && [[ ! "$TRAVIS_PULL_REQUEST_SLUG" =~ ^material-components/ ]]; then + log_error "See https://docs.travis-ci.com/user/pull-requests/#Pull-Requests-and-Security-Restrictions for more information" + log_error + fi + + return + fi + + openssl aes-256-cbc -K "$encrypted_eead2343bb54_key" -iv "$encrypted_eead2343bb54_iv" \ + -in test/screenshot/infra/auth/travis.tar.enc -out test/screenshot/infra/auth/travis.tar -d + + tar -xf test/screenshot/infra/auth/travis.tar -C test/screenshot/infra/auth/ +} + +function install_and_authorize_gcloud_sdk() { + which gcloud 2>&1 > /dev/null + + if [[ $? == 0 ]]; then + echo 'gcloud already installed' + echo + else + echo 'gcloud not installed' + echo + + rm -rf $HOME/google-cloud-sdk + curl -o /tmp/gcp-sdk.bash https://sdk.cloud.google.com + chmod +x /tmp/gcp-sdk.bash + + # The gcloud installer runs `tar -C "$install_dir" -zxvf "$download_dst"`, which generates a lot of noisy output. + # Filter out all lines from `tar`. + /tmp/gcp-sdk.bash | grep -v -E '^google-cloud-sdk/' + fi + + gcloud auth activate-service-account --key-file test/screenshot/infra/auth/gcs.json + gcloud config set project material-components-web + gcloud components install gsutil + gcloud components update gsutil +} + +function maybe_install_gcloud_sdk() { + export PATH=$PATH:$HOME/google-cloud-sdk/bin + export CLOUDSDK_CORE_DISABLE_PROMPTS=1 + + if [[ -f test/screenshot/infra/auth/gcs.json ]]; then + install_and_authorize_gcloud_sdk + else + log_error + log_error "Missing Google Cloud credentials file: test/screenshot/infra/auth/gcs.json" + log_error + + if [[ -n "$TRAVIS_PULL_REQUEST_SLUG" ]] && [[ ! "$TRAVIS_PULL_REQUEST_SLUG" =~ ^material-components/ ]]; then + log_error "See https://docs.travis-ci.com/user/pull-requests/#Pull-Requests-and-Security-Restrictions for more information" + log_error + fi + fi +} + +if [[ "$TEST_SUITE" == 'screenshot' ]]; then + print_travis_env_vars + maybe_extract_api_credentials + maybe_install_gcloud_sdk +fi diff --git a/test/screenshot/lib/cbt-api.js b/test/screenshot/infra/lib/cbt-api.js similarity index 61% rename from test/screenshot/lib/cbt-api.js rename to test/screenshot/infra/lib/cbt-api.js index f6bb15c2d36..7e0213f81a9 100644 --- a/test/screenshot/lib/cbt-api.js +++ b/test/screenshot/infra/lib/cbt-api.js @@ -14,6 +14,7 @@ * limitations under the License. */ +const VError = require('verror'); const request = require('request-promise-native'); const mdcProto = require('../proto/mdc.pb').mdc.proto; @@ -25,17 +26,35 @@ const {FormFactorType, OsVendorType, BrowserVendorType, BrowserVersionType} = Us const {CbtAccount, CbtActiveTestCounts, CbtConcurrencyStats} = cbtProto; const {RawCapabilities} = seleniumProto; +const Cli = require('./cli'); +const CliColor = require('./logger').colors; +const DiffBaseParser = require('./diff-base-parser'); +const Duration = require('./duration'); +const getStackTrace = require('./stacktrace')('CbtApi'); + const MDC_CBT_USERNAME = process.env.MDC_CBT_USERNAME; const MDC_CBT_AUTHKEY = process.env.MDC_CBT_AUTHKEY; const REST_API_BASE_URL = 'https://crossbrowsertesting.com/api/v3'; const SELENIUM_SERVER_URL = `http://${MDC_CBT_USERNAME}:${MDC_CBT_AUTHKEY}@hub.crossbrowsertesting.com:80/wd/hub`; -const {ExitCode} = require('./constants'); +const {ExitCode, SELENIUM_STALLED_TIME_MS} = require('./constants'); /** @type {?Promise>} */ let allBrowsersPromise; class CbtApi { constructor() { + /** + * @type {!Cli} + * @private + */ + this.cli_ = new Cli(); + + /** + * @type {!DiffBaseParser} + * @private + */ + this.diffBaseParser_ = new DiffBaseParser(); + this.validateEnvVars_(); } @@ -81,8 +100,8 @@ https://crossbrowsertesting.com/account */ async fetchConcurrencyStats() { const [accountJson, activesJson] = await Promise.all([ - this.sendRequest_('GET', '/account'), - this.sendRequest_('GET', '/account/activeTestCounts'), + this.sendRequest_(getStackTrace('fetchConcurrencyStats'), 'GET', '/account'), + this.sendRequest_(getStackTrace('fetchConcurrencyStats'), 'GET', '/account/activeTestCounts'), ]); const account = CbtAccount.fromObject(accountJson); @@ -109,28 +128,41 @@ https://crossbrowsertesting.com/account console.log('Fetching browsers from CBT...'); - allBrowsersPromise = this.sendRequest_('GET', '/selenium/browsers'); + const stackTrace = getStackTrace('fetchAvailableDevices'); + allBrowsersPromise = this.sendRequest_(stackTrace, 'GET', '/selenium/browsers'); return allBrowsersPromise; } + /** + * @param {string} seleniumSessionId + * @param {!Array} changedScreenshots + * @return {!Promise} + */ + async setTestScore({seleniumSessionId, changedScreenshots}) { + const stackTrace = getStackTrace('fetchAvailableDevices'); + await this.sendRequest_(stackTrace, 'PUT', `/selenium/${seleniumSessionId}`, { + action: 'set_score', + score: changedScreenshots.length === 0 ? 'pass' : 'fail', + }); + } + /** * @param {!mdc.proto.ReportMeta} meta * @param {!mdc.proto.UserAgent} userAgent * @return {!Promise} - * @private */ - async getDesiredCapabilities_({meta, userAgent}) { + async getDesiredCapabilities({meta, userAgent}) { // TODO(acdvorak): Create a type for this /** @type {{device: !cbt.proto.CbtDevice, browser: !cbt.proto.CbtBrowser}} */ const matchingCbtUserAgent = await this.getMatchingCbtUserAgent_(userAgent); + const {cbtBuildName, cbtTestName} = await this.getCbtTestNameAndBuildNameForReport_(meta); + /** @type {!selenium.proto.RawCapabilities} */ const defaultCaps = { - // TODO(acdvorak): Implement - name: undefined, - // TODO(acdvorak): Figure out why this value is an empty string - build: meta.snapshot_diff_base.input_string, + name: `${cbtTestName} - `, + build: cbtBuildName, // TODO(acdvorak): Expose these as CLI flags record_video: true, @@ -288,23 +320,138 @@ https://crossbrowsertesting.com/account } /** + * @param {!mdc.proto.ReportMeta} meta + * @return {{cbtBuildName: string, cbtTestName: string}} + * @private + */ + async getCbtTestNameAndBuildNameForReport_(meta) { + /** @type {?mdc.proto.GitRevision} */ + const travisGitRev = await this.diffBaseParser_.getTravisGitRevision(); + if (travisGitRev) { + return this.getCbtTestNameAndBuildNameForGitRev_(travisGitRev); + } + + const snapshotDiffBase = meta.snapshot_diff_base; + const snapshotGitRev = snapshotDiffBase.git_revision; + if (snapshotGitRev) { + return this.getCbtTestNameAndBuildNameForGitRev_(snapshotGitRev); + } + + const serialized = JSON.stringify(meta, null, 2); + throw new Error(`Unable to generate CBT test/build name for metadata:\n${serialized}`); + } + + /** + * @param {!mdc.proto.GitRevision} gitRev + * @return {{cbtBuildName: string, cbtTestName: string}} + * @private + */ + getCbtTestNameAndBuildNameForGitRev_(gitRev) { + const nameParts = [ + gitRev.author.email, + gitRev.commit ? gitRev.commit.substr(0, 7) : null, + gitRev.branch ? gitRev.branch : null, + gitRev.tag ? gitRev.tag : null, + gitRev.pr_number ? `PR #${gitRev.pr_number}` : null, + ].filter((part) => part); + return { + cbtTestName: nameParts.slice(0, -1).join(' - '), + cbtBuildName: nameParts.slice(-1)[0], + }; + } + + /** + * @return {!Promise} + */ + async killStalledSeleniumTests() { + // NOTE: This only returns Selenium tests running on the authenticated CBT user's account. + // It does NOT return Selenium tests running under other users. + /** @type {!CbtSeleniumListResponse} */ + const listResponse = await this.sendRequest_( + getStackTrace('killStalledSeleniumTests'), + 'GET', '/selenium?active=true&num=100' + ); + + const activeSeleniumTestIds = listResponse.selenium.map((test) => test.selenium_test_id); + + /** @type {!Array} */ + const infoResponses = await Promise.all(activeSeleniumTestIds.map((seleniumTestId) => { + const infoStackTrace = getStackTrace('killStalledSeleniumTests'); + return this.sendRequest_(infoStackTrace, 'GET', `/selenium/${seleniumTestId}`); + })); + + const stalledSeleniumTestIds = []; + + for (const infoResponse of infoResponses) { + const lastCommand = infoResponse.commands[infoResponse.commands.length - 1]; + if (!lastCommand) { + continue; + } + + const commandTimestampMs = new Date(lastCommand.date_issued).getTime(); + if (!Duration.hasElapsed(SELENIUM_STALLED_TIME_MS, commandTimestampMs)) { + continue; + } + + stalledSeleniumTestIds.push(infoResponse.selenium_test_id); + } + + await this.killSeleniumTests(stalledSeleniumTestIds); + } + + /** + * @param {!Array} seleniumTestIds + * @param {boolean=} silent + * @return {!Promise} + */ + async killSeleniumTests(seleniumTestIds, silent = false) { + await Promise.all(seleniumTestIds.map(async (seleniumTestId) => { + if (!silent) { + console.log(`${CliColor.magenta('Killing')} stalled Selenium test ${CliColor.bold(seleniumTestId)}`); + } + const stackTrace = getStackTrace('killSeleniumTests'); + return await this.sendRequest_(stackTrace, 'DELETE', `/selenium/${seleniumTestId}`).catch((err) => { + if (!silent) { + console.warn(`${CliColor.red('Failed')} to kill stalled Selenium test ${CliColor.bold(seleniumTestId)}:`); + console.warn(err); + } + }); + })); + } + + /** + * @param {string} stackTrace * @param {string} method * @param {string} endpoint * @param {!Object=} body * @return {!Promise|!Array<*>>} * @private */ - async sendRequest_(method, endpoint, body = undefined) { - return request({ - method, - uri: `${REST_API_BASE_URL}${endpoint}`, - auth: { - username: MDC_CBT_USERNAME, - password: MDC_CBT_AUTHKEY, - }, - body, - json: true, // Automatically stringify the request body and parse the response body as JSON - }); + async sendRequest_(stackTrace, method, endpoint, body = undefined) { + const uri = `${REST_API_BASE_URL}${endpoint}`; + + if (this.cli_.isOffline()) { + console.warn( + `${CliColor.magenta('WARNING')}:`, + new Error('CbtApi#sendRequest_() should not be called in --offline mode') + ); + return []; + } + + try { + return await request({ + method, + uri, + auth: { + username: MDC_CBT_USERNAME, + password: MDC_CBT_AUTHKEY, + }, + body, + json: true, // Automatically stringify the request body and parse the response body as JSON + }); + } catch (err) { + throw new VError(err, `CBT API request failed: ${method} ${uri}:\n${stackTrace}`); + } } } diff --git a/test/screenshot/lib/cli.js b/test/screenshot/infra/lib/cli.js similarity index 56% rename from test/screenshot/lib/cli.js rename to test/screenshot/infra/lib/cli.js index d5e2a316954..557ea71139a 100644 --- a/test/screenshot/lib/cli.js +++ b/test/screenshot/infra/lib/cli.js @@ -17,39 +17,19 @@ 'use strict'; const mdcProto = require('../proto/mdc.pb').mdc.proto; -const {ApprovalId, DiffBase, GitRevision} = mdcProto; +const {ApprovalId} = mdcProto; const argparse = require('argparse'); const checkIsOnline = require('is-online'); -const fs = require('mz/fs'); -const {GOLDEN_JSON_RELATIVE_PATH} = require('./constants'); const Duration = require('./duration'); -const GitHubApi = require('./github-api'); -const GitRepo = require('./git-repo'); +const {GOLDEN_JSON_RELATIVE_PATH} = require('./constants'); -const HTTP_URL_REGEX = new RegExp('^https?://'); +/** @type {?boolean} */ +let isOnlineCached; class Cli { constructor() { - /** - * @type {!GitHubApi} - * @private - */ - this.gitHubApi_ = new GitHubApi(); - - /** - * @type {!GitRepo} - * @private - */ - this.gitRepo_ = new GitRepo(); - - /** - * @type {?boolean} - * @private - */ - this.isOnlineCached_ = null; - /** * @type {!ArgumentParser} * @private @@ -71,6 +51,7 @@ class Cli { this.initBuildCommand_(); this.initCleanCommand_(); this.initDemoCommand_(); + this.initIndexCommand_(); this.initProtoCommand_(); this.initServeCommand_(); this.initTestCommand_(); @@ -78,12 +59,27 @@ class Cli { this.args_ = this.rootParser_.parseArgs(); } + /** + * @return {!Promise} + */ + async checkIsOnline() { + if (this.offline) { + return false; + } + if (typeof isOnlineCached === 'undefined') { + isOnlineCached = await checkIsOnline({timeout: Duration.seconds(5).toMillis()}); + } + return isOnlineCached; + } + /** * @param {!ArgumentParser|!ActionContainer} parser * @param {!CliOptionConfig} config * @private */ addArg_(parser, config) { + const metaval = config.exampleValue || config.defaultValue; + const metavar = metaval === 0 ? '0' : (metaval || (config.type || '').toUpperCase() || 'VALUE'); parser.addArgument(config.optionNames, { help: config.description.trim(), dest: config.optionNames[config.optionNames.length - 1], @@ -91,7 +87,7 @@ class Cli { action: config.type === 'array' ? 'append' : (config.type === 'boolean' ? 'storeTrue' : 'store'), required: config.isRequired || false, defaultValue: config.defaultValue, - metavar: config.exampleValue || config.defaultValue, + metavar, }); } @@ -214,6 +210,12 @@ If a local dev server is not already running, one will be started for the durati this.addNoBuildArg_(subparser); } + initIndexCommand_() { + this.commandParsers_.addParser('index', { + description: 'Generates static index.html directory listing files.', + }); + } + initProtoCommand_() { this.commandParsers_.addParser('proto', { description: 'Compiles Protocol Buffer source files (*.proto) to JavaScript (*.pb.js).', @@ -242,6 +244,30 @@ If a local dev server is not already running, one will be started for the durati this.addNoFetchArg_(subparser); this.addOfflineArg_(subparser); + this.addArg_(subparser, { + optionNames: ['--parallels'], + type: 'integer', + defaultValue: 0, + description: ` +Maximum number of browser VMs to run in parallel (subject to our CBT plan limit and VM availability). +A value of '0' will start 3 browsers if nobody else is running tests, or 1 browser if other tests are already running. +IMPORTANT: To ensure that multiple developers can run their tests simultaneously, do not set this value higher than 1 +during normal business hours when other people are likely to be running tests. +`, + }); + + this.addArg_(subparser, { + optionNames: ['--retries'], + type: 'integer', + defaultValue: 3, + description: ` +Number of times to retry a screenshot that comes back with diffs. If you're not expecting any diffs, automatically +retrying screenshots can help decrease noise from flaky browser rendering. However, if you're making a change that +intentionally affects the rendered output, there's no point slowing down the test by retrying a bunch of screenshots +that you know are going to have diffs. +`, + }); + this.addArg_(subparser, { optionNames: ['--diff-base'], defaultValue: GOLDEN_JSON_RELATIVE_PATH, @@ -250,7 +276,7 @@ File path, URL, or Git ref of a 'golden.json' file to diff against. Typically a branch name or commit hash, but may also be a local file path or public URL. Git refs may optionally be suffixed with ':path/to/golden.json' (the default is '${GOLDEN_JSON_RELATIVE_PATH}'). E.g., '${GOLDEN_JSON_RELATIVE_PATH}' (default), 'HEAD', 'master', 'origin/master', 'feat/foo/bar', '01abc11e0', -'/tmp/golden.json', 'https://storage.googleapis.com/.../test/screenshot/golden.json'. +'/tmp/golden.json', 'https://storage.googleapis.com/.../golden.json'. `, }); @@ -279,30 +305,6 @@ To negate a pattern, prefix it with a '-' character. E.g.: '--browser=chrome,-mobile' will test Chrome on desktop, but not on mobile. Passing this option more than once is equivalent to passing a single comma-separated value. E.g.: '--browser=chrome,-mobile' is the same as '--browser=chrome --browser=-mobile'. -`, - }); - - this.addArg_(subparser, { - optionNames: ['--max-parallels'], - type: 'boolean', - description: ` -If this option is present, CBT tests will run the maximum number of parallel browser VMs allowed by our plan. -The default behavior is to start 3 browsers if nobody else is running tests, or 1 browser if other tests are running. -IMPORTANT: To ensure that other developers can run their tests too, only use this option during off-peak hours when you -know nobody else is going to be running tests. -This option is capped by A) our CBT account allowance, and B) the number of available VMs. -`, - }); - - this.addArg_(subparser, { - optionNames: ['--retries'], - type: 'integer', - defaultValue: 3, - description: ` -Number of times to retry a screenshot that comes back with diffs. If you're not expecting any diffs, automatically -retrying screenshots can help decrease noise from flaky browser rendering. However, if you're making a change that -intentionally affects the rendered output, there's no point slowing down the test by retrying a bunch of screenshots -that you know are going to have diffs. `, }); } @@ -337,9 +339,9 @@ that you know are going to have diffs. return this.args_['--diff-base']; } - /** @return {boolean} */ - get maxParallels() { - return this.args_['--max-parallels']; + /** @return {number} */ + get parallels() { + return this.args_['--parallels']; } /** @return {number} */ @@ -353,8 +355,8 @@ that you know are going to have diffs. } /** @return {boolean} */ - get shouldFetch() { - return !this.args_['--no-fetch']; + get skipFetch() { + return this.args_['--no-fetch']; } /** @return {?string} */ @@ -463,280 +465,17 @@ that you know are going to have diffs. } /** - * @return {!Promise} + * @return {boolean} */ - async isOnline() { - if (this.offline) { - return false; - } - - if (typeof this.isOnlineCached_ !== 'boolean') { - this.isOnlineCached_ = await checkIsOnline({timeout: Duration.seconds(5).toMillis()}); - } - - return this.isOnlineCached_; + isOnline() { + return isOnlineCached === true; } /** - * TODO(acdvorak): Move this method out of Cli class - it doesn't belong here. - * @return {!Promise} - */ - async parseGoldenDiffBase() { - /** @type {?mdc.proto.GitRevision} */ - const travisGitRevision = await this.getTravisGitRevision_(); - if (travisGitRevision) { - return DiffBase.create({ - type: DiffBase.Type.GIT_REVISION, - git_revision: travisGitRevision, - }); - } - return this.parseDiffBase(); - } - - /** - * TODO(acdvorak): Move this method out of Cli class - it doesn't belong here. - * @param {string} rawDiffBase - * @return {!Promise} - */ - async parseDiffBase(rawDiffBase = this.diffBase) { - const isOnline = await this.isOnline(); - const isRealBranch = (branch) => Boolean(branch) && !['master', 'origin/master', 'HEAD'].includes(branch); - - /** @type {!mdc.proto.DiffBase} */ - const parsedDiffBase = await this.parseDiffBase_(rawDiffBase); - const parsedBranch = parsedDiffBase.git_revision ? parsedDiffBase.git_revision.branch : null; - - if (isOnline && isRealBranch(parsedBranch)) { - const prNumber = await this.gitHubApi_.getPullRequestNumber(parsedBranch); - if (prNumber) { - parsedDiffBase.git_revision.pr_number = prNumber; - } - } - - return parsedDiffBase; - } - - /** - * TODO(acdvorak): Move this method out of Cli class - it doesn't belong here. - * @param {string} rawDiffBase - * @return {!Promise} - * @private - */ - async parseDiffBase_(rawDiffBase = this.diffBase) { - // Diff against a public `golden.json` URL. - // E.g.: `--diff-base=https://storage.googleapis.com/.../golden.json` - const isUrl = HTTP_URL_REGEX.test(rawDiffBase); - if (isUrl) { - return this.createPublicUrlDiffBase_(rawDiffBase); - } - - // Diff against a local `golden.json` file. - // E.g.: `--diff-base=/tmp/golden.json` - const isLocalFile = await fs.exists(rawDiffBase); - if (isLocalFile) { - return this.createLocalFileDiffBase_(rawDiffBase); - } - - const [inputGoldenRef, inputGoldenPath] = rawDiffBase.split(':'); - const goldenFilePath = inputGoldenPath || GOLDEN_JSON_RELATIVE_PATH; - const fullGoldenRef = await this.gitRepo_.getFullSymbolicName(inputGoldenRef); - - // Diff against a specific git commit. - // E.g.: `--diff-base=abcd1234` - if (!fullGoldenRef) { - return this.createCommitDiffBase_(inputGoldenRef, goldenFilePath); - } - - const {remoteRef, localRef, tagRef} = this.getRefType_(fullGoldenRef); - - // Diff against a remote git branch. - // E.g.: `--diff-base=origin/master` or `--diff-base=origin/feat/button/my-fancy-feature` - if (remoteRef) { - return this.createRemoteBranchDiffBase_(remoteRef, goldenFilePath); - } - - // Diff against a remote git tag. - // E.g.: `--diff-base=v0.34.1` - if (tagRef) { - return this.createRemoteTagDiffBase_(tagRef, goldenFilePath); - } - - // Diff against a local git branch. - // E.g.: `--diff-base=master` or `--diff-base=HEAD` - return this.createLocalBranchDiffBase_(localRef, goldenFilePath); - } - - /** - * @return {?Promise} - * @private - */ - async getTravisGitRevision_() { - const travisBranch = process.env.TRAVIS_BRANCH; - const travisTag = process.env.TRAVIS_TAG; - const travisPrNumber = Number(process.env.TRAVIS_PULL_REQUEST); - const travisPrBranch = process.env.TRAVIS_PULL_REQUEST_BRANCH; - const travisPrSha = process.env.TRAVIS_PULL_REQUEST_SHA; - - if (travisPrNumber) { - return GitRevision.create({ - type: GitRevision.Type.TRAVIS_PR, - golden_json_file_path: GOLDEN_JSON_RELATIVE_PATH, - commit: await this.gitRepo_.getFullCommitHash(travisPrSha), - branch: travisPrBranch || travisBranch, - pr_number: travisPrNumber, - }); - } - - if (travisTag) { - return GitRevision.create({ - type: GitRevision.Type.REMOTE_TAG, - golden_json_file_path: GOLDEN_JSON_RELATIVE_PATH, - commit: await this.gitRepo_.getFullCommitHash(travisTag), - tag: travisTag, - }); - } - - if (travisBranch) { - return GitRevision.create({ - type: GitRevision.Type.LOCAL_BRANCH, - golden_json_file_path: GOLDEN_JSON_RELATIVE_PATH, - commit: await this.gitRepo_.getFullCommitHash(travisBranch), - branch: travisBranch, - }); - } - - return null; - } - - /** - * @param {string} publicUrl - * @return {!mdc.proto.DiffBase} - * @private - */ - createPublicUrlDiffBase_(publicUrl) { - return DiffBase.create({ - type: DiffBase.Type.PUBLIC_URL, - input_string: publicUrl, - public_url: publicUrl, - }); - } - - /** - * @param {string} localFilePath - * @return {!mdc.proto.DiffBase} - * @private - */ - createLocalFileDiffBase_(localFilePath) { - return DiffBase.create({ - type: DiffBase.Type.FILE_PATH, - input_string: localFilePath, - local_file_path: localFilePath, - is_default_local_file: localFilePath === GOLDEN_JSON_RELATIVE_PATH, - }); - } - - /** - * @param {string} commit - * @param {string} goldenJsonFilePath - * @return {!mdc.proto.DiffBase} - * @private - */ - createCommitDiffBase_(commit, goldenJsonFilePath) { - return DiffBase.create({ - type: DiffBase.Type.GIT_REVISION, - git_revision: GitRevision.create({ - type: GitRevision.Type.COMMIT, - input_string: `${commit}:${goldenJsonFilePath}`, // TODO(acdvorak): Document the ':' separator format - golden_json_file_path: goldenJsonFilePath, - commit: commit, - }), - }); - } - - /** - * @param {string} remoteRef - * @param {string} goldenJsonFilePath - * @return {!mdc.proto.DiffBase} - * @private - */ - async createRemoteBranchDiffBase_(remoteRef, goldenJsonFilePath) { - const allRemoteNames = await this.gitRepo_.getRemoteNames(); - const remote = allRemoteNames.find((curRemoteName) => remoteRef.startsWith(curRemoteName + '/')); - const branch = remoteRef.substr(remote.length + 1); // add 1 for forward-slash separator - const commit = await this.gitRepo_.getFullCommitHash(remoteRef); - - return DiffBase.create({ - type: DiffBase.Type.GIT_REVISION, - git_revision: GitRevision.create({ - type: GitRevision.Type.REMOTE_BRANCH, - input_string: `${remoteRef}:${goldenJsonFilePath}`, // TODO(acdvorak): Document the ':' separator format - golden_json_file_path: goldenJsonFilePath, - commit, - remote, - branch, - }), - }); - } - - /** - * @param {string} tagRef - * @param {string} goldenJsonFilePath - * @return {!mdc.proto.DiffBase} - * @private - */ - async createRemoteTagDiffBase_(tagRef, goldenJsonFilePath) { - const commit = await this.gitRepo_.getFullCommitHash(tagRef); - - return DiffBase.create({ - type: DiffBase.Type.GIT_REVISION, - git_revision: GitRevision.create({ - type: GitRevision.Type.REMOTE_TAG, - input_string: `${tagRef}:${goldenJsonFilePath}`, // TODO(acdvorak): Document the ':' separator format - golden_json_file_path: goldenJsonFilePath, - commit, - remote: 'origin', - tag: tagRef, - }), - }); - } - - /** - * @param {string} branch - * @param {string} goldenJsonFilePath - * @return {!mdc.proto.DiffBase} - * @private - */ - async createLocalBranchDiffBase_(branch, goldenJsonFilePath) { - const commit = await this.gitRepo_.getFullCommitHash(branch); - return DiffBase.create({ - type: DiffBase.Type.GIT_REVISION, - git_revision: GitRevision.create({ - type: GitRevision.Type.LOCAL_BRANCH, - input_string: `${branch}:${goldenJsonFilePath}`, // TODO(acdvorak): Document the ':' separator format - golden_json_file_path: goldenJsonFilePath, - commit, - branch, - }), - }); - } - - /** - * @param {string} fullRef - * @return {{remoteRef: string, localRef: string, tagRef: string}} - * @private + * @return {boolean} */ - getRefType_(fullRef) { - const getShortGoldenRef = (type) => { - const regex = new RegExp(`^refs/${type}s/(.+)$`); - const match = regex.exec(fullRef) || []; - return match[1]; - }; - - const remoteRef = getShortGoldenRef('remote'); - const localRef = getShortGoldenRef('head'); - const tagRef = getShortGoldenRef('tag'); - - return {remoteRef, localRef, tagRef}; + isOffline() { + return !this.isOnline(); } } diff --git a/test/screenshot/lib/cloud-storage.js b/test/screenshot/infra/lib/cloud-storage.js similarity index 97% rename from test/screenshot/lib/cloud-storage.js rename to test/screenshot/infra/lib/cloud-storage.js index a3c5c2e71ed..d94d28b20e2 100644 --- a/test/screenshot/lib/cloud-storage.js +++ b/test/screenshot/infra/lib/cloud-storage.js @@ -80,8 +80,8 @@ class CloudStorage { */ async uploadDirectory_(noun, reportData, localSourceDir) { const isSourceDirEmpty = glob.sync('**/*', {cwd: localSourceDir, nodir: true}).length === 0; - const isOnline = await this.cli_.isOnline(); - if (isSourceDirEmpty || !isOnline) { + const isOffline = this.cli_.isOffline(); + if (isSourceDirEmpty || isOffline) { return; } diff --git a/test/screenshot/lib/constants.js b/test/screenshot/infra/lib/constants.js similarity index 77% rename from test/screenshot/lib/constants.js rename to test/screenshot/infra/lib/constants.js index 1e59d15557a..a534bdd4d9f 100644 --- a/test/screenshot/lib/constants.js +++ b/test/screenshot/infra/lib/constants.js @@ -51,17 +51,29 @@ module.exports = { * Number of milliseconds to wait for fonts to load on a test page in Selenium before giving up. * @type {number} */ - SELENIUM_FONT_LOAD_WAIT_MS: 3000, + SELENIUM_FONT_LOAD_WAIT_MS: 3 * 1000, // 3 seconds + + /** + * Number of milliseconds a Selenium test should wait to receive commands before being considered "stalled". + * @type {number} + */ + SELENIUM_STALLED_TIME_MS: 2 * 60 * 1000, // 2 minutes ExitCode: { OK: 0, - UNKNOWN_ERROR: 11, - SIGINT: 12, // ctrl-c - SIGTERM: 13, // kill - UNSUPPORTED_CLI_COMMAND: 14, - HTTP_PORT_ALREADY_IN_USE: 15, - MISSING_ENV_VAR: 16, - UNHANDLED_PROMISE_REJECTION: 17, - CHANGES_FOUND: 18, + + /** ctrl-c */ + SIGINT: 11, + + /** kill */ + SIGTERM: 12, + + UNKNOWN_ERROR: 13, + UNCAUGHT_EXCEPTION: 14, + UNHANDLED_PROMISE_REJECTION: 15, + UNSUPPORTED_CLI_COMMAND: 16, + MISSING_ENV_VAR: 17, + HTTP_PORT_ALREADY_IN_USE: 18, + CHANGES_FOUND: 19, }, }; diff --git a/test/screenshot/infra/lib/controller.js b/test/screenshot/infra/lib/controller.js new file mode 100644 index 00000000000..098e43f3544 --- /dev/null +++ b/test/screenshot/infra/lib/controller.js @@ -0,0 +1,178 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const VError = require('verror'); + +const CbtApi = require('./cbt-api'); +const Cli = require('./cli'); +const CloudStorage = require('./cloud-storage'); +const Duration = require('./duration'); +const GoldenIo = require('./golden-io'); +const Logger = require('./logger'); +const ReportBuilder = require('./report-builder'); +const ReportWriter = require('./report-writer'); +const SeleniumApi = require('./selenium-api'); +const getStackTrace = require('./stacktrace')('Controller'); + +class Controller { + constructor() { + /** + * @type {!CbtApi} + * @private + */ + this.cbtApi_ = new CbtApi(); + + /** + * @type {!Cli} + * @private + */ + this.cli_ = new Cli(); + + /** + * @type {!CloudStorage} + * @private + */ + this.cloudStorage_ = new CloudStorage(); + + /** + * @type {!GoldenIo} + * @private + */ + this.goldenIo_ = new GoldenIo(); + + /** + * @type {!Logger} + * @private + */ + this.logger_ = new Logger(__filename); + + /** + * @type {!ReportBuilder} + * @private + */ + this.reportBuilder_ = new ReportBuilder(); + + /** + * @type {!ReportWriter} + * @private + */ + this.reportWriter_ = new ReportWriter(); + + /** + * @type {!SeleniumApi} + * @private + */ + this.seleniumApi_ = new SeleniumApi(); + } + + /** + * @return {!Promise} + */ + async initForApproval() { + const runReportJsonUrl = this.cli_.runReportJsonUrl; + return await this.reportBuilder_.initForApproval({runReportJsonUrl}); + } + + /** + * @param {!mdc.proto.DiffBase} goldenDiffBase + * @return {!Promise} + */ + async initForCapture(goldenDiffBase) { + const isOnline = this.cli_.isOnline(); + if (isOnline) { + await this.cbtApi_.killStalledSeleniumTests(); + } + return await this.reportBuilder_.initForCapture(goldenDiffBase); + } + + /** + * @return {!Promise} + */ + async initForDemo() { + return await this.reportBuilder_.initForDemo(); + } + + /** + * @param {!mdc.proto.ReportData} reportData + */ + async uploadAllAssets(reportData) { + this.logger_.foldStart('screenshot.upload_assets', 'Controller#uploadAllAssets()'); + await this.cloudStorage_.uploadAllAssets(reportData); + this.logger_.foldEnd('screenshot.upload_assets'); + } + + /** + * @param {!mdc.proto.ReportData} reportData + */ + async captureAllPages(reportData) { + this.logger_.foldStart('screenshot.capture_images', 'Controller#captureAllPages()'); + + let stackTrace; + + try { + stackTrace = getStackTrace('captureAllPages'); + await this.seleniumApi_.captureAllPages(reportData); + } catch (err) { + throw new VError(err, stackTrace); + } + + const meta = reportData.meta; + meta.end_time_iso_utc = new Date().toISOString(); + meta.duration_ms = Duration.elapsed(meta.start_time_iso_utc, meta.end_time_iso_utc).toMillis(); + + this.logger_.foldEnd('screenshot.capture_images'); + } + + /** + * @param {!mdc.proto.ReportData} reportData + */ + populateMaps(reportData) { + this.reportBuilder_.populateMaps(reportData.user_agents, reportData.screenshots); + } + + /** + * @param {!mdc.proto.ReportData} reportData + */ + async uploadAllImages(reportData) { + this.logger_.foldStart('screenshot.upload_images', 'Controller#uploadAllImages()'); + await this.cloudStorage_.uploadAllScreenshots(reportData); + await this.cloudStorage_.uploadAllDiffs(reportData); + this.logger_.foldEnd('screenshot.upload_images'); + } + + /** + * @param {!mdc.proto.ReportData} reportData + */ + async generateReportPage(reportData) { + this.logger_.foldStart('screenshot.generate_report', 'Controller#generateReportPage()'); + await this.reportWriter_.generateReportPage(reportData); + await this.cloudStorage_.uploadDiffReport(reportData); + this.logger_.foldEnd('screenshot.generate_report'); + } + + /** + * @param {!mdc.proto.ReportData} reportData + */ + async approveChanges(reportData) { + /** @type {!GoldenFile} */ + const newGoldenFile = await this.reportBuilder_.approveChanges(reportData); + await this.goldenIo_.writeToLocalFile(newGoldenFile); + } +} + +module.exports = Controller; diff --git a/test/screenshot/infra/lib/diff-base-parser.js b/test/screenshot/infra/lib/diff-base-parser.js new file mode 100644 index 00000000000..be9d97b9f90 --- /dev/null +++ b/test/screenshot/infra/lib/diff-base-parser.js @@ -0,0 +1,409 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const mdcProto = require('../proto/mdc.pb').mdc.proto; +const {DiffBase, GitRevision} = mdcProto; + +const fs = require('mz/fs'); +const {GOLDEN_JSON_RELATIVE_PATH} = require('./constants'); + +const Cli = require('./cli'); +const GitHubApi = require('./github-api'); +const GitRepo = require('./git-repo'); + +const HTTP_URL_REGEX = new RegExp('^https?://'); + +class DiffBaseParser { + constructor() { + /** + * @type {!Cli} + * @private + */ + this.cli_ = new Cli(); + + /** + * @type {!GitHubApi} + * @private + */ + this.gitHubApi_ = new GitHubApi(); + + /** + * @type {!GitRepo} + * @private + */ + this.gitRepo_ = new GitRepo(); + } + + /** + * @return {!Promise} + */ + async parseGoldenDiffBase() { + return await this.getTravisDiffBase_() || await this.parseDiffBase(this.cli_.diffBase); + } + + /** + * @return {!Promise} + */ + async parseSnapshotDiffBase() { + return await this.getTravisDiffBase_() || await this.parseDiffBase('HEAD'); + } + + /** + * @return {!Promise} + */ + async parseMasterDiffBase() { + /** @type {!mdc.proto.DiffBase} */ + const goldenDiffBase = await this.parseGoldenDiffBase(); + const prNumber = goldenDiffBase.git_revision ? goldenDiffBase.git_revision.pr_number : null; + let baseBranch = 'origin/master'; + if (prNumber) { + if (process.env.TRAVIS_BRANCH) { + baseBranch = `origin/${process.env.TRAVIS_BRANCH}`; + } else { + baseBranch = await this.gitHubApi_.getPullRequestBaseBranch(prNumber); + } + } + return this.parseDiffBase(baseBranch); + } + + /** + * @param {string} rawDiffBase + * @return {!Promise} + */ + async parseDiffBase(rawDiffBase) { + const isOnline = this.cli_.isOnline(); + const isRealBranch = (branch) => Boolean(branch) && !['master', 'origin/master', 'HEAD'].includes(branch); + + /** @type {!mdc.proto.DiffBase} */ + const parsedDiffBase = await this.parseDiffBase_(rawDiffBase); + const parsedBranch = parsedDiffBase.git_revision ? parsedDiffBase.git_revision.branch : null; + + if (isOnline && isRealBranch(parsedBranch)) { + const prNumber = await this.gitHubApi_.getPullRequestNumber(parsedBranch); + if (prNumber) { + parsedDiffBase.git_revision.pr_number = prNumber; + } + } + + return parsedDiffBase; + } + + /** + * @param {string} rawDiffBase + * @return {!Promise} + * @private + */ + async parseDiffBase_(rawDiffBase) { + // Diff against a public `golden.json` URL. + // E.g.: `--diff-base=https://storage.googleapis.com/.../golden.json` + const isUrl = HTTP_URL_REGEX.test(rawDiffBase); + if (isUrl) { + return this.createPublicUrlDiffBase_(rawDiffBase); + } + + // Diff against a local `golden.json` file. + // E.g.: `--diff-base=/tmp/golden.json` + const isLocalFile = await fs.exists(rawDiffBase); + if (isLocalFile) { + return this.createLocalFileDiffBase_(rawDiffBase); + } + + const [inputGoldenRef, inputGoldenPath] = rawDiffBase.split(':'); + const goldenFilePath = inputGoldenPath || GOLDEN_JSON_RELATIVE_PATH; + + const isRemoteBranch = inputGoldenRef.startsWith('origin/'); + const isVersionTag = /^v[0-9.]+$/.test(inputGoldenRef); + const isFetchable = isRemoteBranch || isVersionTag; + const skipFetch = this.cli_.skipFetch; + const isOnline = this.cli_.isOnline(); + if (isFetchable && !skipFetch && isOnline) { + await this.gitRepo_.fetch(); + } + + const fullGoldenRef = await this.gitRepo_.getFullSymbolicName(inputGoldenRef); + + // Diff against a specific git commit. + // E.g.: `--diff-base=abcd1234` + if (!fullGoldenRef) { + return this.createCommitDiffBase_(inputGoldenRef, goldenFilePath); + } + + const {remoteRef, localRef, tagRef} = this.getRefType_(fullGoldenRef); + + // Diff against a remote git branch. + // E.g.: `--diff-base=origin/master` or `--diff-base=origin/feat/button/my-fancy-feature` + if (remoteRef) { + return this.createRemoteBranchDiffBase_(remoteRef, goldenFilePath); + } + + // Diff against a remote git tag. + // E.g.: `--diff-base=v0.34.1` + if (tagRef) { + return this.createRemoteTagDiffBase_(tagRef, goldenFilePath); + } + + // Diff against a local git branch. + // E.g.: `--diff-base=master` or `--diff-base=HEAD` + return this.createLocalBranchDiffBase_(localRef, goldenFilePath); + } + + /** + * @return {!Promise} + * @private + */ + async getTravisDiffBase_() { + /** @type {?mdc.proto.GitRevision} */ + const travisGitRevision = await this.getTravisGitRevision(); + if (!travisGitRevision) { + return null; + } + + let generatedInputString; + if (travisGitRevision.pr_number) { + generatedInputString = `travis/pr/${travisGitRevision.pr_number}`; + } else if (travisGitRevision.tag) { + generatedInputString = `travis/tag/${travisGitRevision.tag}`; + } else if (travisGitRevision.branch) { + generatedInputString = `travis/branch/${travisGitRevision.branch}`; + } else { + generatedInputString = `travis/commit/${travisGitRevision.commit}`; + } + + return DiffBase.create({ + type: DiffBase.Type.GIT_REVISION, + input_string: generatedInputString, + git_revision: travisGitRevision, + }); + } + + /** + * @return {?Promise} + */ + async getTravisGitRevision() { + const travisBranch = process.env.TRAVIS_BRANCH; + const travisTag = process.env.TRAVIS_TAG; + const travisPrNumber = Number(process.env.TRAVIS_PULL_REQUEST); + const travisPrBranch = process.env.TRAVIS_PULL_REQUEST_BRANCH; + const travisPrSha = process.env.TRAVIS_PULL_REQUEST_SHA; + + if (travisPrNumber) { + const commit = await this.gitRepo_.getFullCommitHash(travisPrSha); + const author = await this.gitRepo_.getCommitAuthor(commit); + return GitRevision.create({ + type: GitRevision.Type.TRAVIS_PR, + golden_json_file_path: GOLDEN_JSON_RELATIVE_PATH, + commit, + author, + branch: travisPrBranch || travisBranch, + pr_number: travisPrNumber, + pr_file_paths: await this.getTestablePrFilePaths_(travisPrNumber), + }); + } + + if (travisTag) { + const commit = await this.gitRepo_.getFullCommitHash(travisTag); + const author = await this.gitRepo_.getCommitAuthor(commit); + return GitRevision.create({ + type: GitRevision.Type.REMOTE_TAG, + golden_json_file_path: GOLDEN_JSON_RELATIVE_PATH, + commit, + author, + tag: travisTag, + }); + } + + if (travisBranch) { + const commit = await this.gitRepo_.getFullCommitHash(travisBranch); + const author = await this.gitRepo_.getCommitAuthor(commit); + return GitRevision.create({ + type: GitRevision.Type.LOCAL_BRANCH, + golden_json_file_path: GOLDEN_JSON_RELATIVE_PATH, + commit, + author, + branch: travisBranch, + }); + } + + return null; + } + + /** + * @param {number} prNumber + * @return {!Promise>} + * @private + */ + async getTestablePrFilePaths_(prNumber) { + /** @type {!Array} */ + const allPrFiles = await this.gitHubApi_.getPullRequestFiles(prNumber); + + return allPrFiles + .filter((prFile) => { + const isMarkdownFile = () => prFile.filename.endsWith('.md'); + const isDemosFile = () => prFile.filename.startsWith('demos/'); + const isDocsFile = () => prFile.filename.startsWith('docs/'); + const isUnitTestFile = () => prFile.filename.startsWith('test/unit/'); + const isIgnoredFile = isMarkdownFile() || isDemosFile() || isDocsFile() || isUnitTestFile(); + return !isIgnoredFile; + }) + .map((prFile) => prFile.filename) + ; + } + + /** + * @param {string} publicUrl + * @return {!mdc.proto.DiffBase} + * @private + */ + createPublicUrlDiffBase_(publicUrl) { + return DiffBase.create({ + type: DiffBase.Type.PUBLIC_URL, + input_string: publicUrl, + public_url: publicUrl, + }); + } + + /** + * @param {string} localFilePath + * @return {!mdc.proto.DiffBase} + * @private + */ + createLocalFileDiffBase_(localFilePath) { + return DiffBase.create({ + type: DiffBase.Type.FILE_PATH, + input_string: localFilePath, + local_file_path: localFilePath, + is_default_local_file: localFilePath === GOLDEN_JSON_RELATIVE_PATH, + }); + } + + /** + * @param {string} commit + * @param {string} goldenJsonFilePath + * @return {!mdc.proto.DiffBase} + * @private + */ + async createCommitDiffBase_(commit, goldenJsonFilePath) { + const author = await this.gitRepo_.getCommitAuthor(commit); + + return DiffBase.create({ + type: DiffBase.Type.GIT_REVISION, + input_string: `${commit}:${goldenJsonFilePath}`, // TODO(acdvorak): Document the ':' separator format + git_revision: GitRevision.create({ + type: GitRevision.Type.COMMIT, + golden_json_file_path: goldenJsonFilePath, + commit, + author, + }), + }); + } + + /** + * @param {string} remoteRef + * @param {string} goldenJsonFilePath + * @return {!mdc.proto.DiffBase} + * @private + */ + async createRemoteBranchDiffBase_(remoteRef, goldenJsonFilePath) { + const allRemoteNames = await this.gitRepo_.getRemoteNames(); + const remote = allRemoteNames.find((curRemoteName) => remoteRef.startsWith(curRemoteName + '/')); + const branch = remoteRef.substr(remote.length + 1); // add 1 for forward-slash separator + const commit = await this.gitRepo_.getFullCommitHash(remoteRef); + const author = await this.gitRepo_.getCommitAuthor(commit); + + return DiffBase.create({ + type: DiffBase.Type.GIT_REVISION, + input_string: `${remoteRef}:${goldenJsonFilePath}`, // TODO(acdvorak): Document the ':' separator format + git_revision: GitRevision.create({ + type: GitRevision.Type.REMOTE_BRANCH, + golden_json_file_path: goldenJsonFilePath, + commit, + author, + remote, + branch, + }), + }); + } + + /** + * @param {string} tagRef + * @param {string} goldenJsonFilePath + * @return {!mdc.proto.DiffBase} + * @private + */ + async createRemoteTagDiffBase_(tagRef, goldenJsonFilePath) { + const commit = await this.gitRepo_.getFullCommitHash(tagRef); + const author = await this.gitRepo_.getCommitAuthor(commit); + + return DiffBase.create({ + type: DiffBase.Type.GIT_REVISION, + input_string: `${tagRef}:${goldenJsonFilePath}`, // TODO(acdvorak): Document the ':' separator format + git_revision: GitRevision.create({ + type: GitRevision.Type.REMOTE_TAG, + golden_json_file_path: goldenJsonFilePath, + commit, + author, + remote: 'origin', + tag: tagRef, + }), + }); + } + + /** + * @param {string} branch + * @param {string} goldenJsonFilePath + * @return {!mdc.proto.DiffBase} + * @private + */ + async createLocalBranchDiffBase_(branch, goldenJsonFilePath) { + const commit = await this.gitRepo_.getFullCommitHash(branch); + const author = await this.gitRepo_.getCommitAuthor(commit); + + return DiffBase.create({ + type: DiffBase.Type.GIT_REVISION, + input_string: `${branch}:${goldenJsonFilePath}`, // TODO(acdvorak): Document the ':' separator format + git_revision: GitRevision.create({ + type: GitRevision.Type.LOCAL_BRANCH, + golden_json_file_path: goldenJsonFilePath, + commit, + author, + branch, + }), + }); + } + + /** + * @param {string} fullRef + * @return {{remoteRef: string, localRef: string, tagRef: string}} + * @private + */ + getRefType_(fullRef) { + const getShortGoldenRef = (type) => { + const regex = new RegExp(`^refs/${type}s/(.+)$`); + const match = regex.exec(fullRef) || []; + return match[1]; + }; + + const remoteRef = getShortGoldenRef('remote'); + const localRef = getShortGoldenRef('head'); + const tagRef = getShortGoldenRef('tag'); + + return {remoteRef, localRef, tagRef}; + } +} + +module.exports = DiffBaseParser; diff --git a/test/screenshot/lib/duration.js b/test/screenshot/infra/lib/duration.js similarity index 97% rename from test/screenshot/lib/duration.js rename to test/screenshot/infra/lib/duration.js index 5ca579906b3..0ae66ad1242 100644 --- a/test/screenshot/lib/duration.js +++ b/test/screenshot/infra/lib/duration.js @@ -83,6 +83,13 @@ class Duration { return this.ms_; } + /** + * @return {number} + */ + toNanos() { + return this.ms_ * 1000 * 1000; + } + /** * TODO(acdvorak): Create `toHumanLong` method that outputs "4d 23h 5m 11s" (or w/e) * @param {number=} numDecimalDigits diff --git a/test/screenshot/lib/file-cache.js b/test/screenshot/infra/lib/file-cache.js similarity index 87% rename from test/screenshot/lib/file-cache.js rename to test/screenshot/infra/lib/file-cache.js index 807a5021c4f..7c74275d2f1 100644 --- a/test/screenshot/lib/file-cache.js +++ b/test/screenshot/infra/lib/file-cache.js @@ -14,7 +14,6 @@ * limitations under the License. */ -const fs = require('mz/fs'); const mkdirp = require('mkdirp'); const os = require('os'); const path = require('path'); @@ -37,7 +36,7 @@ class FileCache { this.tempDirPath_ = path.join(os.tmpdir(), 'mdc-web/url-cache'); /** - * @type {@LocalStorage} + * @type {!LocalStorage} * @private */ this.localStorage_ = new LocalStorage(); @@ -51,9 +50,10 @@ class FileCache { async downloadUrlToDisk(uri, encoding = null) { mkdirp.sync(this.tempDirPath_); - const fakeRelativePath = uri.replace(/.*\/mdc-/, 'mdc-'); // TODO(acdvorak): Document this hack + // TODO(acdvorak): Document this hack + const fakeRelativePath = uri.replace(/.*\/spec\/mdc-/, 'spec/mdc-'); - if (await fs.exists(uri)) { + if (await this.localStorage_.exists(uri)) { return TestFile.create({ absolute_path: path.resolve(uri), relative_path: fakeRelativePath, @@ -63,7 +63,7 @@ class FileCache { const fileName = this.getFilename_(uri); const filePath = path.resolve(this.tempDirPath_, fileName); - if (await fs.exists(filePath)) { + if (await this.localStorage_.exists(filePath)) { return TestFile.create({ absolute_path: filePath, relative_path: fakeRelativePath, @@ -72,12 +72,12 @@ class FileCache { } const buffer = await request({uri, encoding}); - await fs.writeFile(filePath, buffer, {encoding}) + await this.localStorage_.writeBinaryFile(filePath, buffer, encoding) .catch(async (err) => { console.error(`downloadUrlToDisk("${uri}"):`); console.error(err); - if (await fs.exists(filePath)) { - await fs.unlink(filePath); + if (await this.localStorage_.exists(filePath)) { + await this.localStorage_.delete(filePath); } }); diff --git a/test/screenshot/lib/git-repo.js b/test/screenshot/infra/lib/git-repo.js similarity index 62% rename from test/screenshot/lib/git-repo.js rename to test/screenshot/infra/lib/git-repo.js index 072331e9981..da8a26d94d9 100644 --- a/test/screenshot/lib/git-repo.js +++ b/test/screenshot/infra/lib/git-repo.js @@ -16,8 +16,14 @@ 'use strict'; +const VError = require('verror'); const simpleGit = require('simple-git/promise'); +const mdcProto = require('../proto/mdc.pb').mdc.proto; +const {User} = mdcProto; + +let hasFetched = false; + class GitRepo { constructor(workingDirPath = undefined) { /** @@ -46,6 +52,11 @@ class GitRepo { * @return {!Promise} */ async fetch(args = []) { + if (hasFetched) { + return; + } + hasFetched = true; + console.log('Fetching remote git commits...'); const prFetchRef = '+refs/pull/*/head:refs/remotes/origin/pr/*'; @@ -55,7 +66,12 @@ class GitRepo { await this.exec_('raw', ['config', '--add', 'remote.origin.fetch', prFetchRef]); } - await this.repo_.fetch(['--tags', ...args]); + try { + await this.repo_.fetch(['--tags', ...args]); + } catch (err) { + const serialized = JSON.stringify(args); + throw new VError(err, `Failed to run GitRepo.fetch(${serialized})`); + } } /** @@ -96,21 +112,34 @@ class GitRepo { * @return {!Promise} */ async getFileAtRevision(filePath, revision = 'master') { - return this.repo_.show([`${revision}:${filePath}`]); + try { + return this.repo_.show([`${revision}:${filePath}`]); + } catch (err) { + const serialized = JSON.stringify(args); + throw new VError(err, `Failed to run GitRepo.getFileAtRevision(${serialized})`); + } } /** * @return {!Promise>} */ async getRemoteNames() { - return (await this.repo_.getRemotes()).map((remote) => remote.name); + try { + return (await this.repo_.getRemotes()).map((remote) => remote.name); + } catch (err) { + throw new VError(err, 'Failed to run GitRepo.getRemoteNames()'); + } } /** * @return {!Promise} */ async getStatus() { - return this.repo_.status(); + try { + return this.repo_.status(); + } catch (err) { + throw new VError(err, 'Failed to run GitRepo.getStatus()'); + } } /** @@ -118,8 +147,13 @@ class GitRepo { * @return {!Promise>} */ async getLog(args = []) { - const logEntries = await this.repo_.log([...args]); - return logEntries.all.concat(); // convert TypeScript ReadonlyArray to mutable Array + try { + const logEntries = await this.repo_.log([...args]); + return logEntries.all.concat(); // convert TypeScript ReadonlyArray to mutable Array + } catch (err) { + const serialized = JSON.stringify(args); + throw new VError(err, `Failed to run GitRepo.getLog(${serialized})`); + } } /** @@ -127,7 +161,25 @@ class GitRepo { * @return {!Promise>} */ async getIgnoredPaths(filePaths) { - return this.repo_.checkIgnore(filePaths); + try { + return this.repo_.checkIgnore(filePaths); + } catch (err) { + throw new VError(err, `Failed to run GitRepo.getIgnoredPaths(${filePaths.length} file paths)`); + } + } + + /** + * @param {string=} commit + * @return {!Promise} + */ + async getCommitAuthor(commit = undefined) { + /** @type {!Array} */ + const logEntries = await this.getLog([commit]); + const logEntry = logEntries[0]; + return User.create({ + name: logEntry.author_name, + email: logEntry.author_email, + }); } /** @@ -137,7 +189,12 @@ class GitRepo { * @private */ async exec_(cmd, argList = []) { - return (await this.repo_[cmd](argList) || '').trim(); + try { + return (await this.repo_[cmd](argList) || '').trim(); + } catch (err) { + const serialized = JSON.stringify([cmd, ...argList]); + throw new VError(err, `Failed to run GitRepo.exec_(${serialized})`); + } } } diff --git a/test/screenshot/infra/lib/github-api.js b/test/screenshot/infra/lib/github-api.js new file mode 100644 index 00000000000..0a9d490a61c --- /dev/null +++ b/test/screenshot/infra/lib/github-api.js @@ -0,0 +1,285 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const VError = require('verror'); +const debounce = require('debounce'); +const octokit = require('@octokit/rest'); + +const GitRepo = require('./git-repo'); +const getStackTrace = require('./stacktrace')('GitHubApi'); + +class GitHubApi { + constructor() { + this.gitRepo_ = new GitRepo(); + this.octokit_ = octokit(); + this.authenticate_(); + } + + /** + * @private + */ + authenticate_() { + let token; + + try { + token = require('../auth/github.json').api_key.personal_access_token; + } catch (err) { + // Not running on Travis + return; + } + + this.octokit_.authenticate({ + type: 'oauth', + token: token, + }); + + const throttle = (fn, delay) => { + let lastCall = 0; + return (...args) => { + const now = (new Date).getTime(); + if (now - lastCall < delay) { + return; + } + lastCall = now; + return fn(...args); + }; + }; + + const createStatusDebounced = debounce((...args) => { + return this.createStatusUnthrottled_(...args); + }, 2500); + const createStatusThrottled = throttle((...args) => { + return this.createStatusUnthrottled_(...args); + }, 5000); + this.createStatusThrottled_ = (...args) => { + createStatusDebounced(...args); + createStatusThrottled(...args); + }; + } + + /** + * @return {{PENDING: string, SUCCESS: string, FAILURE: string, ERROR: string}} + * @constructor + */ + static get PullRequestState() { + return { + PENDING: 'pending', + SUCCESS: 'success', + FAILURE: 'failure', + ERROR: 'error', + }; + } + + /** + * @param {string} state + * @param {string} description + */ + setPullRequestStatusManual({state, description}) { + if (process.env.TRAVIS !== 'true') { + return; + } + + this.createStatusThrottled_({ + state, + targetUrl: `https://travis-ci.org/material-components/material-components-web/jobs/${process.env.TRAVIS_JOB_ID}`, + description, + }); + } + + /** + * @param {!mdc.proto.ReportData} reportData + * @return {!Promise<*>} + */ + async setPullRequestStatusAuto(reportData) { + if (process.env.TRAVIS !== 'true') { + return; + } + + const meta = reportData.meta; + const screenshots = reportData.screenshots; + const numUnchanged = screenshots.unchanged_screenshot_list.length; + const numChanged = + screenshots.changed_screenshot_list.length + + screenshots.added_screenshot_list.length + + screenshots.removed_screenshot_list.length; + const reportFileUrl = meta.report_html_file ? meta.report_html_file.public_url : null; + + let state; + let targetUrl; + let description; + + if (reportFileUrl) { + if (numChanged > 0) { + state = GitHubApi.PullRequestState.FAILURE; + description = `${numChanged.toLocaleString()} screenshots differ from PR's golden.json`; + } else { + state = GitHubApi.PullRequestState.SUCCESS; + description = `All ${numUnchanged.toLocaleString()} screenshots match PR's golden.json`; + } + + targetUrl = meta.report_html_file.public_url; + } else { + const runnableScreenshots = screenshots.runnable_screenshot_list; + const numTotal = runnableScreenshots.length; + + state = GitHubApi.PullRequestState.PENDING; + targetUrl = `https://travis-ci.org/material-components/material-components-web/jobs/${process.env.TRAVIS_JOB_ID}`; + description = `Running ${numTotal.toLocaleString()} screenshots...`; + } + + return await this.createStatusUnthrottled_({state, targetUrl, description}); + } + + async setPullRequestError() { + if (process.env.TRAVIS !== 'true') { + return; + } + + return await this.createStatusUnthrottled_({ + state: GitHubApi.PullRequestState.ERROR, + targetUrl: `https://travis-ci.org/material-components/material-components-web/jobs/${process.env.TRAVIS_JOB_ID}`, + description: 'Error running screenshot tests', + }); + } + + /** + * @param {string} state + * @param {string} targetUrl + * @param {string=} description + * @return {!Promise<*>} + * @private + */ + async createStatusUnthrottled_({state, targetUrl, description = undefined}) { + const sha = process.env.TRAVIS_PULL_REQUEST_SHA || await this.gitRepo_.getFullCommitHash(); + let stackTrace; + + try { + stackTrace = getStackTrace('createStatusUnthrottled_'); + return await this.octokit_.repos.createStatus({ + owner: 'material-components', + repo: 'material-components-web', + sha, + state, + target_url: targetUrl, + description, + context: 'screenshot-test/butter-bot', + }); + } catch (err) { + throw new VError(err, `Failed to set commit status:\n${stackTrace}`); + } + } + + /** + * @param {string=} branch + * @return {!Promise} + */ + async getPullRequestNumber(branch = undefined) { + branch = branch || await this.gitRepo_.getBranchName(); + + let allPrsResponse; + let stackTrace; + + try { + stackTrace = getStackTrace('getPullRequestNumber'); + allPrsResponse = await this.octokit_.pullRequests.getAll({ + owner: 'material-components', + repo: 'material-components-web', + per_page: 100, + }); + } catch (err) { + throw new VError(err, `Failed to get pull request number for branch "${branch}":\n${stackTrace}`); + } + + const filteredPRs = allPrsResponse.data.filter((pr) => pr.head.ref === branch); + + const pr = filteredPRs[0]; + return pr ? pr.number : null; + } + + /** + * @param prNumber + * @return {!Promise>} + */ + async getPullRequestFiles(prNumber) { + /** @type {!github.proto.PullRequestFileResponse} */ + let fileResponse; + let stackTrace; + + try { + stackTrace = getStackTrace('getPullRequestFiles'); + fileResponse = await this.octokit_.pullRequests.getFiles({ + owner: 'material-components', + repo: 'material-components-web', + number: prNumber, + per_page: 300, + }); + } catch (err) { + throw new VError(err, `Failed to get file list for PR #${prNumber}:\n${stackTrace}`); + } + + return fileResponse.data; + } + + /** + * @param {number} prNumber + * @return {!Promise} + */ + async getPullRequestBaseBranch(prNumber) { + let prResponse; + let stackTrace; + + try { + stackTrace = getStackTrace('getPullRequestBaseBranch'); + prResponse = await this.octokit_.pullRequests.get({ + owner: 'material-components', + repo: 'material-components-web', + number: prNumber, + }); + } catch (err) { + throw new VError(err, `Failed to get the base branch for PR #${prNumber}:\n${stackTrace}`); + } + + if (!prResponse.data) { + const serialized = JSON.stringify(prResponse, null, 2); + throw new Error(`Unable to fetch data for GitHub PR #${prNumber}:\n${serialized}`); + } + + return `origin/${prResponse.data.base.ref}`; + } + + /** + * @param {number} prNumber + * @param {string} comment + * @return {!Promise<*>} + */ + async createPullRequestComment({prNumber, comment}) { + let stackTrace; + + try { + stackTrace = getStackTrace('createPullRequestComment'); + return await this.octokit_.issues.createComment({ + owner: 'material-components', + repo: 'material-components-web', + number: prNumber, + body: comment, + }); + } catch (err) { + throw new VError(err, `Failed to create comment on PR #${prNumber}:\n${stackTrace}`); + } + } +} + +module.exports = GitHubApi; diff --git a/test/screenshot/lib/golden-file.js b/test/screenshot/infra/lib/golden-file.js similarity index 97% rename from test/screenshot/lib/golden-file.js rename to test/screenshot/infra/lib/golden-file.js index 6019b094c0b..c1d667dce51 100644 --- a/test/screenshot/lib/golden-file.js +++ b/test/screenshot/infra/lib/golden-file.js @@ -82,7 +82,7 @@ class GoldenFile { if (!this.suiteJson_[htmlFilePath]) { this.suiteJson_[htmlFilePath] = { - publicUrl: htmlFileUrl, + public_url: htmlFileUrl, screenshots: {}, }; } @@ -123,7 +123,9 @@ class GoldenFile { for (const userAgentAlias of Object.keys(testPage.screenshots)) { const screenshotImageUrl = testPage.screenshots[userAgentAlias]; - const screenshotImagePath = screenshotImageUrl.replace(/.*\/mdc-/, 'mdc-'); // TODO(acdvorak): Document the hack + + // TODO(acdvorak): Document this hack + const screenshotImagePath = screenshotImageUrl.replace(/.*\/spec\/mdc-/, 'spec/mdc-'); goldenScreenshots.push(GoldenScreenshot.create({ html_file_path: htmlFilePath, diff --git a/test/screenshot/lib/golden-io.js b/test/screenshot/infra/lib/golden-io.js similarity index 59% rename from test/screenshot/lib/golden-io.js rename to test/screenshot/infra/lib/golden-io.js index 0dc8dfe371e..d63b847ddad 100644 --- a/test/screenshot/lib/golden-io.js +++ b/test/screenshot/infra/lib/golden-io.js @@ -14,13 +14,14 @@ * limitations under the License. */ -const fs = require('mz/fs'); const request = require('request-promise-native'); const stringify = require('json-stable-stringify'); const Cli = require('./cli'); +const DiffBaseParser = require('./diff-base-parser'); const GitRepo = require('./git-repo'); const GoldenFile = require('./golden-file'); +const LocalStorage = require('./local-storage'); const {GOLDEN_JSON_RELATIVE_PATH} = require('./constants'); /** @@ -34,12 +35,24 @@ class GoldenIo { */ this.cli_ = new Cli(); + /** + * @type {!DiffBaseParser} + * @private + */ + this.diffBaseParser_ = new DiffBaseParser(); + /** * @type {!GitRepo} * @private */ this.gitRepo_ = new GitRepo(); + /** + * @type {!LocalStorage} + * @private + */ + this.localStorage_ = new LocalStorage(); + /** * @type {!Object} * @private @@ -51,34 +64,32 @@ class GoldenIo { * @return {!Promise} */ async readFromLocalFile() { - return new GoldenFile(JSON.parse(await fs.readFile(GOLDEN_JSON_RELATIVE_PATH, {encoding: 'utf8'}))); + return new GoldenFile(JSON.parse(await this.localStorage_.readTextFile(GOLDEN_JSON_RELATIVE_PATH))); } /** * Parses the `golden.json` file specified by the `--diff-base` CLI arg. - * @param {string=} rawDiffBase + * @param {!mdc.proto.DiffBase} goldenDiffBase * @return {!Promise} */ - async readFromDiffBase(rawDiffBase = this.cli_.diffBase) { - if (!this.cachedGoldenJsonMap_[rawDiffBase]) { - const goldenJson = JSON.parse(await this.readFromDiffBase_(rawDiffBase)); - this.cachedGoldenJsonMap_[rawDiffBase] = new GoldenFile(goldenJson); + async readFromDiffBase(goldenDiffBase) { + const key = goldenDiffBase.input_string; + if (!this.cachedGoldenJsonMap_[key]) { + const goldenJson = JSON.parse(await this.readFromDiffBase_(goldenDiffBase)); + this.cachedGoldenJsonMap_[key] = new GoldenFile(goldenJson); } // Deep copy to avoid mutating shared state - return new GoldenFile(this.cachedGoldenJsonMap_[rawDiffBase].toJSON()); + return new GoldenFile(this.cachedGoldenJsonMap_[key].toJSON()); } /** - * @param {string} rawDiffBase + * @param {!mdc.proto.DiffBase} goldenDiffBase * @return {!Promise} * @private */ - async readFromDiffBase_(rawDiffBase) { - /** @type {!mdc.proto.DiffBase} */ - const parsedDiffBase = await this.cli_.parseDiffBase(rawDiffBase); - - const publicUrl = parsedDiffBase.public_url; + async readFromDiffBase_(goldenDiffBase) { + const publicUrl = goldenDiffBase.public_url; if (publicUrl) { return request({ method: 'GET', @@ -86,19 +97,22 @@ class GoldenIo { }); } - const localFilePath = parsedDiffBase.local_file_path; + const localFilePath = goldenDiffBase.local_file_path; if (localFilePath) { - return fs.readFile(localFilePath, {encoding: 'utf8'}); + return this.localStorage_.readTextFile(localFilePath); } - const rev = parsedDiffBase.git_revision; + const rev = goldenDiffBase.git_revision; if (rev) { return this.gitRepo_.getFileAtRevision(rev.golden_json_file_path, rev.commit); } - const serialized = JSON.stringify({parsedDiffBase, meta}, null, 2); + const serialized = JSON.stringify({goldenDiffBase}, null, 2); throw new Error( - `Unable to parse '--diff-base=${rawDiffBase}': Expected a URL, local file path, or git ref. ${serialized}` + ` +Unable to parse '--diff-base=${goldenDiffBase.input_string}': Expected a URL, local file path, or git ref. +${serialized} +`.trim() ); } @@ -110,7 +124,7 @@ class GoldenIo { const goldenJsonFilePath = GOLDEN_JSON_RELATIVE_PATH; const goldenJsonFileContent = await this.stringify_(newGoldenFile); - await fs.writeFile(goldenJsonFilePath, goldenJsonFileContent); + await this.localStorage_.writeTextFile(goldenJsonFilePath, goldenJsonFileContent); console.log(`DONE updating "${goldenJsonFilePath}"!`); } @@ -122,22 +136,6 @@ class GoldenIo { async stringify_(object) { return stringify(object, {space: ' '}) + '\n'; } - - /** - * Creates a deep clone of the given `source` object's own enumerable properties. - * Non-JSON-serializable properties (such as functions or symbols) are silently discarded. - * The returned value is structurally equivalent, but not referentially equal, to the input. - * In Java parlance: - * clone.equals(source) // true - * clone == source // false - * @param {!T} source JSON object to clone - * @return {!T} Deep clone of `source` object - * @template T - * @private - */ - deepCloneJson_(source) { - return JSON.parse(JSON.stringify(source)); - } } module.exports = GoldenIo; diff --git a/test/screenshot/lib/image-cropper.js b/test/screenshot/infra/lib/image-cropper.js similarity index 96% rename from test/screenshot/lib/image-cropper.js rename to test/screenshot/infra/lib/image-cropper.js index d3fd663e2af..473b6bf69f9 100644 --- a/test/screenshot/lib/image-cropper.js +++ b/test/screenshot/infra/lib/image-cropper.js @@ -15,7 +15,6 @@ */ const Jimp = require('jimp'); -const fs = require('mz/fs'); const TRIM_COLOR_CSS_VALUE = '#abc123'; // Value must match `$test-trim-color` in `fixture.scss` @@ -24,7 +23,7 @@ const TRIM_COLOR_CSS_VALUE = '#abc123'; // Value must match `$test-trim-color` i * match the trim color in order for that row or column to be cropped out. * @type {number} */ -const TRIM_COLOR_PIXEL_MATCH_PCT = 0.05; +const TRIM_COLOR_PIXEL_MATCH_FRACTION = 0.05; /** * Maximum distance (0 to 255 inclusive) that a pixel's R, G, and B color channels can be from the corresponding @@ -54,7 +53,6 @@ class ImageCropper { * @return {!Promise} Cropped image buffer */ async autoCropImage(imageData) { - await fs.writeFile('/tmp/image.png', imageData, {encoding: null}); return Jimp.read(imageData) .then( (jimpImage) => { @@ -149,7 +147,7 @@ class ImageCropper { let foundTrimColor = false; for (const [rowIndex, row] of rows.entries()) { - const isTrimColor = this.getMatchPercentage_(row) >= TRIM_COLOR_PIXEL_MATCH_PCT; + const isTrimColor = this.getMatchPercentage_(row) >= TRIM_COLOR_PIXEL_MATCH_FRACTION; if (isTrimColor) { foundTrimColor = true; diff --git a/test/screenshot/lib/image-differ.js b/test/screenshot/infra/lib/image-differ.js similarity index 89% rename from test/screenshot/lib/image-differ.js rename to test/screenshot/infra/lib/image-differ.js index 5293c9f86d4..38f5b30d0ce 100644 --- a/test/screenshot/lib/image-differ.js +++ b/test/screenshot/infra/lib/image-differ.js @@ -18,17 +18,25 @@ const Jimp = require('jimp'); const compareImages = require('resemblejs/compareImages'); -const fs = require('mz/fs'); -const mkdirp = require('mkdirp'); const path = require('path'); const mdcProto = require('../proto/mdc.pb').mdc.proto; const {DiffImageResult, Dimensions, TestFile} = mdcProto; +const LocalStorage = require('./local-storage'); + /** * Computes the difference between two screenshot images and generates an image that highlights the pixels that changed. */ class ImageDiffer { + constructor() { + /** + * @type {!LocalStorage} + * @private + */ + this.localStorage_ = new LocalStorage(); + } + /** * @param {!mdc.proto.ReportMeta} meta * @param {!mdc.proto.Screenshot} screenshot @@ -50,7 +58,7 @@ class ImageDiffer { * @private */ async compareOneImage_({meta, actualImageFile, expectedImageFile}) { - const actualImageBuffer = await fs.readFile(actualImageFile.absolute_path); + const actualImageBuffer = await this.localStorage_.readBinaryFile(actualImageFile.absolute_path); if (!expectedImageFile) { const actualJimpImage = await Jimp.read(actualImageBuffer); @@ -62,7 +70,7 @@ class ImageDiffer { }); } - const expectedImageBuffer = await fs.readFile(expectedImageFile.absolute_path); + const expectedImageBuffer = await this.localStorage_.readBinaryFile(expectedImageFile.absolute_path); /** @type {!ResembleApiComparisonResult} */ const resembleComparisonResult = await this.computeDiff_({actualImageBuffer, expectedImageBuffer}); @@ -75,8 +83,7 @@ class ImageDiffer { }); diffImageResult.diff_image_file = diffImageFile; - mkdirp.sync(path.dirname(diffImageFile.absolute_path)); - await fs.writeFile(diffImageFile.absolute_path, diffImageBuffer, {encoding: null}); + await this.localStorage_.writeBinaryFile(diffImageFile.absolute_path, diffImageBuffer); return diffImageResult; } @@ -88,7 +95,7 @@ class ImageDiffer { * @private */ async computeDiff_({actualImageBuffer, expectedImageBuffer}) { - const options = require('../diffing.json').resemble_config; + const options = require('../../diffing.json').resemble_config; return await compareImages( actualImageBuffer, expectedImageBuffer, @@ -124,7 +131,7 @@ class ImageDiffer { const diffPixelRoundPercentage = roundPercentage(diffPixelRawPercentage); const diffPixelFraction = diffPixelRawPercentage / 100; const diffPixelCount = Math.ceil(diffPixelFraction * diffJimpImage.bitmap.width * diffJimpImage.bitmap.height); - const minChangedPixelCount = require('../diffing.json').flaky_tests.min_changed_pixel_count; + const minChangedPixelCount = require('../../diffing.json').flaky_tests.min_changed_pixel_count; const hasChanged = diffPixelCount >= minChangedPixelCount; const diffImageResult = DiffImageResult.create({ expected_image_dimensions: Dimensions.create({ diff --git a/test/screenshot/lib/local-storage.js b/test/screenshot/infra/lib/local-storage.js similarity index 55% rename from test/screenshot/lib/local-storage.js rename to test/screenshot/infra/lib/local-storage.js index 9b19bbdf42d..2c6fc255839 100644 --- a/test/screenshot/lib/local-storage.js +++ b/test/screenshot/infra/lib/local-storage.js @@ -16,6 +16,8 @@ 'use strict'; +const VError = require('verror'); +const del = require('del'); const fs = require('mz/fs'); const fsx = require('fs-extra'); const glob = require('glob'); @@ -58,11 +60,15 @@ class LocalStorage { /** * @param {!mdc.proto.ReportMeta} reportMeta - * @return {!Promise>} File paths relative to the git repo. E.g.: "test/screenshot/browser.json". + * @return {!Promise>} */ async getTestPageDestinationPaths(reportMeta) { const cwd = reportMeta.local_asset_base_dir; - return glob.sync('**/mdc-*/**/*.html', {cwd, nodir: true}); + try { + return glob.sync('**/spec/mdc-*/**/*.html', {cwd, nodir: true, ignore: ['**/index.html']}); + } catch (err) { + throw new VError(err, `Failed to run LocalStorage.getTestPageDestinationPaths(${cwd})`); + } } /** @@ -71,18 +77,29 @@ class LocalStorage { * @return {!Promise} */ async writeTextFile(filePath, fileContent) { - mkdirp.sync(path.dirname(filePath)); - await fs.writeFile(filePath, fileContent, {encoding: 'utf8'}); + try { + mkdirp.sync(path.dirname(filePath)); + await fs.writeFile(filePath, fileContent, {encoding: 'utf8'}); + } catch (err) { + const serialized = JSON.stringify({filePath, fileContent: fileContent.length + ' bytes'}); + throw new VError(err, `Failed to run LocalStorage.writeTextFile(${serialized})`); + } } /** * @param {string} filePath * @param {!Buffer} fileContent + * @param {?string=} encoding * @return {!Promise} */ - async writeBinaryFile(filePath, fileContent) { - mkdirp.sync(path.dirname(filePath)); - await fs.writeFile(filePath, fileContent, {encoding: null}); + async writeBinaryFile(filePath, fileContent, encoding = null) { + try { + mkdirp.sync(path.dirname(filePath)); + await fs.writeFile(filePath, fileContent, {encoding}); + } catch (err) { + const serialized = JSON.stringify({filePath, fileContent: fileContent.length + ' bytes', encoding}); + throw new VError(err, `Failed to run LocalStorage.writeBinaryFile(${serialized})`); + } } /** @@ -90,12 +107,16 @@ class LocalStorage { * @return {!Promise} */ async readTextFile(filePath) { - return fs.readFile(filePath, {encoding: 'utf8'}); + try { + return await fs.readFile(filePath, {encoding: 'utf8'}); + } catch (err) { + throw new VError(err, `Failed to run LocalStorage.readTextFile(${filePath})`); + } } /** * @param {string} filePath - * @return {!Promise} + * @return {string} */ readTextFileSync(filePath) { return fs.readFileSync(filePath, {encoding: 'utf8'}); @@ -107,7 +128,11 @@ class LocalStorage { * @return {!Promise} */ async readBinaryFile(filePath, encoding = null) { - return fs.readFile(filePath, {encoding}); + try { + return await fs.readFile(filePath, {encoding}); + } catch (err) { + throw new VError(err, `Failed to run LocalStorage.readBinaryFile(${filePath}, ${encoding})`); + } } /** @@ -115,17 +140,74 @@ class LocalStorage { * @param {string=} cwd * @return {!Array} */ - glob(pattern, cwd = process.cwd()) { + globFiles(pattern, cwd = process.cwd()) { + if (pattern.endsWith('/')) { + pattern = pattern.replace(new RegExp('/+$'), ''); + } return glob.sync(pattern, {cwd, nodir: true}); } + /** + * @param {string} pattern + * @param {string=} cwd + * @return {!Array} + */ + globDirs(pattern, cwd = process.cwd()) { + if (!pattern.endsWith('/')) { + pattern += '/'; + } + return glob.sync(pattern, {cwd, nodir: false}); + } + /** * @param {string} src * @param {string} dest * @return {!Promise} */ async copy(src, dest) { - return fsx.copy(src, dest); + try { + return await fsx.copy(src, dest); + } catch (err) { + throw new VError(err, `Failed to run LocalStorage.copy(${src}, ${dest})`); + } + } + + /** + * @param {string|!Array} pathPatterns + * @return {!Promise<*>} + */ + async delete(pathPatterns) { + try { + return await del(pathPatterns); + } catch (err) { + throw new VError(err, `Failed to run LocalStorage.delete(${pathPatterns} patterns)`); + } + } + + /** + * @param {string} filePath + * @return {!Promise} + */ + async exists(filePath) { + try { + return await fs.exists(filePath); + } catch (err) { + throw new VError(err, `Failed to run LocalStorage.exists(${filePath})`); + } + } + + /** + * @param {string} filePaths + */ + mkdirpForFilesSync(...filePaths) { + filePaths.forEach((filePath) => mkdirp.sync(path.dirname(filePath))); + } + + /** + * @param {string} dirPaths + */ + mkdirpForDirsSync(...dirPaths) { + dirPaths.forEach((dirPath) => mkdirp.sync(dirPath)); } /** @@ -149,9 +231,11 @@ class LocalStorage { const ignoredTopLevelFilesAndDirs = await this.gitRepo_.getIgnoredPaths(relativePaths); return relativePaths.filter((relativePath) => { - const isBuildOutputDir = relativePath.split(path.sep).includes('out'); + const pathParts = relativePath.split(path.sep); + const isBuildOutputDir = pathParts.includes('out'); + const isIndexHtmlFile = pathParts[pathParts.length - 1] === 'index.html'; const isIgnoredFile = ignoredTopLevelFilesAndDirs.includes(relativePath); - return isBuildOutputDir || !isIgnoredFile; + return isBuildOutputDir || isIndexHtmlFile || !isIgnoredFile; }); } } diff --git a/test/screenshot/lib/logger.js b/test/screenshot/infra/lib/logger.js similarity index 65% rename from test/screenshot/lib/logger.js rename to test/screenshot/infra/lib/logger.js index e54b3c09a38..06c07605fb0 100644 --- a/test/screenshot/lib/logger.js +++ b/test/screenshot/infra/lib/logger.js @@ -20,6 +20,8 @@ const colors = require('colors/safe'); const crypto = require('crypto'); const path = require('path'); +const Duration = require('./duration'); + /** * @typedef {(function(string):string|{ * enable: !CliColor, @@ -86,7 +88,7 @@ class Logger { * @type {!Map} * @private */ - this.foldStartTimes_ = new Map(); + this.foldStartTimesMs_ = new Map(); } /** @@ -121,20 +123,26 @@ class Logger { * @param {string} shortMessage */ foldStart(foldId, shortMessage) { - const hash = this.getFoldHash_(foldId); + const timerId = this.getFoldTimerId_(foldId); const colorMessage = colors.bold.yellow(shortMessage); - this.foldStartTimes_.set(foldId, Date.now()); + this.foldStartTimesMs_.set(foldId, Date.now()); - console.log(''); - if (this.isTravisJob_()) { - // See https://github.com/travis-ci/docs-travis-ci-com/issues/949#issuecomment-276755003 - console.log(`travis_fold:start:${foldId}\n${colorMessage}`); - console.log(`travis_time:start:${hash}`); - } else { + if (!this.isTravisJob_()) { + console.log(''); console.log(colorMessage); + console.log(colors.reset('')); + return; } + + // Undocumented Travis CI job logging features. See: + // https://github.com/travis-ci/docs-travis-ci-com/issues/949#issuecomment-276755003 + // https://github.com/rspec/rspec-support/blob/5a1c6756a9d8322fc18639b982e00196f452974d/script/travis_functions.sh console.log(''); + console.log(`travis_fold:start:${foldId}`); + console.log(`travis_time:start:${timerId}`); + console.log(colorMessage); + console.log(colors.reset('')); } /** @@ -145,48 +153,49 @@ class Logger { return; } - const hash = this.getFoldHash_(foldId); - const startNanos = this.foldStartTimes_.get(foldId) * 1000; - const finishNanos = Date.now() * 1000; - const durationNanos = finishNanos - startNanos; - - // See https://github.com/travis-ci/docs-travis-ci-com/issues/949#issuecomment-276755003 + // Undocumented Travis CI job logging feature. See: + // https://github.com/travis-ci/docs-travis-ci-com/issues/949#issuecomment-276755003 console.log(`travis_fold:end:${foldId}`); - console.log(`travis_time:end:${hash}:start=${startNanos},finish=${finishNanos},duration=${durationNanos}`); - } - getFoldHash_(foldId) { - const sha1Sum = crypto.createHash('sha1'); - sha1Sum.update(foldId); - return sha1Sum.digest('hex').substr(0, 8); + const startMs = this.foldStartTimesMs_.get(foldId); + if (startMs) { + const timerId = this.getFoldTimerId_(foldId); + const startNanos = Duration.millis(startMs).toNanos(); + const finishNanos = Duration.millis(Date.now()).toNanos(); + const durationNanos = finishNanos - startNanos; + + // Undocumented Travis CI job logging feature. See: + // https://github.com/rspec/rspec-support/blob/5a1c6756a9d8322fc18639b982e00196f452974d/script/travis_functions.sh + console.log(`travis_time:end:${timerId}:start=${startNanos},finish=${finishNanos},duration=${durationNanos}`); + } } /** - * @param {!Array<*>} args + * @param {*} args */ log(...args) { - console.log(`[log][${this.id_}]`, ...args); + console.log(...args); } /** - * @param {!Array<*>} args + * @param {*} args */ info(...args) { - console.info(`[${colors.blue('info')}][${this.id_}]`, ...args); + console.info(...args); } /** - * @param {!Array<*>} args + * @param {*} args */ warn(...args) { - console.warn(`[${colors.yellow('warn')}][${this.id_}]`, ...args); + console.warn(...args); } /** - * @param {!Array<*>} args + * @param {*} args */ error(...args) { - console.error(`[${colors.bold.red('error')}][${this.id_}]`, ...args); + console.error(...args); } /** @@ -196,6 +205,17 @@ class Logger { isTravisJob_() { return process.env.TRAVIS === 'true'; } + + /** + * @param {string} foldId + * @return {string} + * @private + */ + getFoldTimerId_(foldId) { + const sha1Sum = crypto.createHash('sha1'); + sha1Sum.update(foldId); + return sha1Sum.digest('hex').substr(0, 8); + } } module.exports = Logger; diff --git a/test/screenshot/lib/process-manager.js b/test/screenshot/infra/lib/process-manager.js similarity index 81% rename from test/screenshot/lib/process-manager.js rename to test/screenshot/infra/lib/process-manager.js index d64ae2d21b8..13971a1257a 100644 --- a/test/screenshot/lib/process-manager.js +++ b/test/screenshot/infra/lib/process-manager.js @@ -20,6 +20,28 @@ const childProcess = require('child_process'); const ps = require('ps-node'); class ProcessManager { + /** + * @param {string} cmd + * @param {!Array} args + * @param {!ChildProcessSpawnOptions=} opts + * @return {!ChildProcess} + */ + spawnChildProcess(cmd, args, opts = {}) { + /** @type {!ChildProcessSpawnOptions} */ + const defaultOpts = { + stdio: 'inherit', + shell: true, + windowsHide: true, + }; + + /** @type {!ChildProcessSpawnOptions} */ + const mergedOpts = Object.assign({}, defaultOpts, opts); + + console.log(`${cmd} ${args.join(' ')}`); + + return childProcess.spawn(cmd, args, mergedOpts); + } + /** * @param {string} cmd * @param {!Array} args diff --git a/test/screenshot/lib/report-builder.js b/test/screenshot/infra/lib/report-builder.js similarity index 92% rename from test/screenshot/lib/report-builder.js rename to test/screenshot/infra/lib/report-builder.js index 46000800185..3671f4cd129 100644 --- a/test/screenshot/lib/report-builder.js +++ b/test/screenshot/infra/lib/report-builder.js @@ -20,18 +20,19 @@ const Jimp = require('jimp'); const childProcess = require('mz/child_process'); const detectPort = require('detect-port'); const express = require('express'); -const mkdirp = require('mkdirp'); const os = require('os'); const osName = require('os-name'); const path = require('path'); const serveIndex = require('serve-index'); const mdcProto = require('../proto/mdc.pb').mdc.proto; -const {Approvals, DiffImageResult, Dimensions, GitRevision, GitStatus, GoldenScreenshot, LibraryVersion} = mdcProto; +const {Approvals, DiffImageResult, Dimensions, GitStatus, GoldenScreenshot, LibraryVersion} = mdcProto; const {ReportData, ReportMeta, Screenshot, Screenshots, ScreenshotList, TestFile, User, UserAgents} = mdcProto; const {InclusionType, CaptureState} = Screenshot; +const CbtApi = require('./cbt-api'); const Cli = require('./cli'); +const DiffBaseParser = require('./diff-base-parser'); const FileCache = require('./file-cache'); const GitHubApi = require('./github-api'); const GitRepo = require('./git-repo'); @@ -46,12 +47,24 @@ const TEMP_DIR = os.tmpdir(); class ReportBuilder { constructor() { + /** + * @type {!CbtApi} + * @private + */ + this.cbtApi_ = new CbtApi(); + /** * @type {!Cli} * @private */ this.cli_ = new Cli(); + /** + * @type {!DiffBaseParser} + * @private + */ + this.diffBaseParser_ = new DiffBaseParser(); + /** * @type {!FileCache} * @private @@ -105,32 +118,35 @@ class ReportBuilder { /** @type {!mdc.proto.ReportData} */ const reportData = ReportData.fromObject(require(runReportJsonFile.absolute_path)); reportData.approvals = Approvals.create(); - this.populateScreenshotMaps(reportData.user_agents, reportData.screenshots); + this.populateMaps(reportData.user_agents, reportData.screenshots); this.populateApprovals_(reportData); return reportData; } /** + * @param {!mdc.proto.DiffBase} goldenDiffBase * @return {!Promise} */ - async initForCapture() { + async initForCapture(goldenDiffBase) { this.logger_.foldStart('screenshot.init', 'ReportBuilder#initForCapture()'); - /** @type {boolean} */ - const isOnline = await this.cli_.isOnline(); + if (this.cli_.isOnline()) { + await this.cbtApi_.fetchAvailableDevices(); + } + /** @type {!mdc.proto.ReportMeta} */ - const reportMeta = await this.createReportMetaProto_(); + const reportMeta = await this.createReportMetaProto_(goldenDiffBase); /** @type {!mdc.proto.UserAgents} */ const userAgents = await this.createUserAgentsProto_(); await this.localStorage_.copyAssetsToTempDir(reportMeta); // In offline mode, we start a local web server to test on instead of using GCS. - if (!isOnline) { + if (this.cli_.isOffline()) { await this.startTemporaryHttpServer_(reportMeta); } - const screenshots = await this.createScreenshotsProto_({reportMeta, userAgents}); + const screenshots = await this.createScreenshotsProto_({reportMeta, userAgents, goldenDiffBase}); const reportData = ReportData.create({ meta: reportMeta, @@ -160,7 +176,7 @@ class ReportBuilder { * @param {!mdc.proto.UserAgents} userAgents * @param {!mdc.proto.Screenshots} screenshots */ - populateScreenshotMaps(userAgents, screenshots) { + populateMaps(userAgents, screenshots) { // TODO(acdvorak): Figure out why the report page is randomly sorted. E.g.: // https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/04_49_09_427/report/report.html // https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2018/07/12/04_48_52_974/report/report.html @@ -290,7 +306,7 @@ class ReportBuilder { */ async prefetchGoldenImages_(reportData) { // TODO(acdvorak): Figure out how to handle offline mode for prefetching and diffing - console.log('Prefetching golden images...'); + console.log('Fetching golden images...'); await Promise.all( reportData.screenshots.expected_screenshot_list.map((expectedScreenshot) => { return this.prefetchScreenshotImages_(expectedScreenshot); @@ -365,11 +381,12 @@ class ReportBuilder { } /** + * @param {!mdc.proto.DiffBase} goldenDiffBase * @return {!Promise} * @private */ - async createReportMetaProto_() { - const isOnline = await this.cli_.isOnline(); + async createReportMetaProto_(goldenDiffBase) { + const isOnline = this.cli_.isOnline(); // We only need to start up a local web server if the user is running in offline mode. // Otherwise, HTML files are uploaded to (and served) by GCS. @@ -387,42 +404,20 @@ class ReportBuilder { const localDiffImageBaseDir = path.join(TEMP_DIR, 'mdc-web/diffs', remoteUploadBaseDir); const localReportBaseDir = path.join(TEMP_DIR, 'mdc-web/report', remoteUploadBaseDir); - // TODO(acdvorak): Centralize file writing and automatically call mkdirp - mkdirp.sync(path.dirname(localAssetBaseDir)); - mkdirp.sync(path.dirname(localScreenshotImageBaseDir)); - mkdirp.sync(path.dirname(localDiffImageBaseDir)); - mkdirp.sync(path.dirname(localReportBaseDir)); + this.localStorage_.mkdirpForDirsSync( + localAssetBaseDir, + localScreenshotImageBaseDir, + localDiffImageBaseDir, + localReportBaseDir, + ); - const mdcVersionString = require('../../../lerna.json').version; + const mdcVersionString = require('../../../../lerna.json').version; const hostOsName = osName(os.platform(), os.release()); const gitStatus = GitStatus.fromObject(await this.gitRepo_.getStatus()); /** @type {!mdc.proto.DiffBase} */ - const goldenDiffBase = await this.cli_.parseGoldenDiffBase(); - - /** @type {!mdc.proto.DiffBase} */ - const snapshotDiffBase = await this.cli_.parseDiffBase('HEAD'); - - /** @type {!mdc.proto.GitRevision} */ - const goldenGitRevision = goldenDiffBase.git_revision; - - if (goldenGitRevision && goldenGitRevision.type === GitRevision.Type.TRAVIS_PR) { - /** @type {!Array} */ - const allPrFiles = await this.gitHubApi_.getPullRequestFiles(goldenGitRevision.pr_number); - - goldenGitRevision.pr_file_paths = allPrFiles - .filter((prFile) => { - const isMarkdownFile = () => prFile.filename.endsWith('.md'); - const isDemosFile = () => prFile.filename.startsWith('demos/'); - const isDocsFile = () => prFile.filename.startsWith('docs/'); - const isUnitTestFile = () => prFile.filename.startsWith('test/unit/'); - const isIgnoredFile = isMarkdownFile() || isDemosFile() || isDocsFile() || isUnitTestFile(); - return !isIgnoredFile; - }) - .map((prFile) => prFile.filename) - ; - } + const snapshotDiffBase = await this.diffBaseParser_.parseSnapshotDiffBase(); return ReportMeta.create({ start_time_iso_utc: new Date().toISOString(), @@ -459,7 +454,6 @@ class ReportBuilder { }), mdc_version: LibraryVersion.create({ version_string: mdcVersionString, - commit_offset: await this.getCommitDistance_(mdcVersionString), }), }); } @@ -518,14 +512,6 @@ class ReportBuilder { return stdOut[0].trim().replace(/^v/, ''); // `node --version` returns "v8.11.0", so we strip the leading 'v' } - /** - * @param {string} mdcVersion - * @return {!Promise} - */ - async getCommitDistance_(mdcVersion) { - return (await this.gitRepo_.getLog([`v${mdcVersion}..HEAD`])).length; - } - /** * @return {!Promise} * @private @@ -543,12 +529,13 @@ class ReportBuilder { /** * @param {!mdc.proto.ReportMeta} reportMeta * @param {!mdc.proto.UserAgents} allUserAgents + * @param {!mdc.proto.DiffBase} goldenDiffBase * @return {!Promise} * @private */ - async createScreenshotsProto_({reportMeta, userAgents}) { + async createScreenshotsProto_({reportMeta, userAgents, goldenDiffBase}) { /** @type {!GoldenFile} */ - const goldenFile = await this.goldenIo_.readFromDiffBase(); + const goldenFile = await this.goldenIo_.readFromDiffBase(goldenDiffBase); /** @type {!Array} */ const expectedScreenshots = await this.getExpectedScreenshots_(goldenFile); @@ -580,7 +567,7 @@ class ReportBuilder { this.setAllStates_(screenshots.removed_screenshot_list, InclusionType.REMOVE, CaptureState.SKIPPED); this.setAllStates_(screenshots.comparable_screenshot_list, InclusionType.COMPARE, CaptureState.QUEUED); - this.populateScreenshotMaps(userAgents, screenshots); + this.populateMaps(userAgents, screenshots); return screenshots; } @@ -679,6 +666,7 @@ class ReportBuilder { }); for (const userAgent of allUserAgents) { + const maxRetries = this.cli_.isOnline() ? this.cli_.retries : 0; const userAgentAlias = userAgent.alias; const isScreenshotRunnable = isHtmlFileRunnable && userAgent.is_runnable; const expectedScreenshotImageUrl = goldenFile.getScreenshotImageUrl({htmlFilePath, userAgentAlias}); @@ -697,7 +685,7 @@ class ReportBuilder { actual_html_file: actualHtmlFile, expected_image_file: expectedImageFile, retry_count: 0, - max_retries: this.cli_.retries, + max_retries: maxRetries, })); } } diff --git a/test/screenshot/lib/report-writer.js b/test/screenshot/infra/lib/report-writer.js similarity index 98% rename from test/screenshot/lib/report-writer.js rename to test/screenshot/infra/lib/report-writer.js index 19f38abf406..8c7d3e6e6e1 100644 --- a/test/screenshot/lib/report-writer.js +++ b/test/screenshot/infra/lib/report-writer.js @@ -302,7 +302,7 @@ class ReportWriter { /** @private */ registerPartials_() { - const partialFilePaths = this.localStorage_.glob(path.join(TEST_DIR_RELATIVE_PATH, 'report/_*.hbs')); + const partialFilePaths = this.localStorage_.globFiles(path.join(TEST_DIR_RELATIVE_PATH, 'report/_*.hbs')); for (const partialFilePath of partialFilePaths) { // TODO(acdvorak): What about hyphen/dash characters? const name = path.basename(partialFilePath) @@ -393,7 +393,7 @@ class ReportWriter { function getIconHtml(userAgent) { const title = userAgent.navigator ? userAgent.navigator.full_name : userAgent.alias; return ` - + `.trim(); } @@ -417,8 +417,6 @@ class ReportWriter { return new Handlebars.SafeString(`${diffBase.public_url}`); } - const rev = diffBase.git_revision; - if (diffBase.local_file_path) { const localFilePathMarkup = diffBase.is_default_local_file ? `${diffBase.local_file_path}` @@ -427,6 +425,7 @@ class ReportWriter { return new Handlebars.SafeString(`${localFilePathMarkup} (local file)`); } + const rev = diffBase.git_revision; if (rev) { const prMarkup = rev.pr_number ? `(PR #${rev.pr_number})` @@ -460,7 +459,7 @@ ${prMarkup} } const serialized = JSON.stringify({diffBase, meta}, null, 2); - throw new Error(`Unable to generate markup for invalid diff source: ${serialized}`); + throw new Error(`Unable to generate markup for invalid diff source:\n${serialized}`); } /** diff --git a/test/screenshot/infra/lib/selenium-api.js b/test/screenshot/infra/lib/selenium-api.js new file mode 100644 index 00000000000..9608ba94b29 --- /dev/null +++ b/test/screenshot/infra/lib/selenium-api.js @@ -0,0 +1,796 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const Jimp = require('jimp'); +const VError = require('verror'); +const UserAgentParser = require('useragent'); +const colors = require('colors/safe'); +const path = require('path'); + +const mdcProto = require('../proto/mdc.pb').mdc.proto; +const seleniumProto = require('../proto/selenium.pb').selenium.proto; + +const {Screenshot, TestFile, UserAgent} = mdcProto; +const {CaptureState, InclusionType} = Screenshot; +const {BrowserVendorType, Navigator} = UserAgent; +const {RawCapabilities} = seleniumProto; + +const CbtApi = require('./cbt-api'); +const Cli = require('./cli'); +const Constants = require('./constants'); +const Duration = require('./duration'); +const GitHubApi = require('./github-api'); +const ImageCropper = require('./image-cropper'); +const ImageDiffer = require('./image-differ'); +const LocalStorage = require('./local-storage'); +const {Browser, Builder, By, logging, until} = require('selenium-webdriver'); +const {CBT_CONCURRENCY_POLL_INTERVAL_MS, CBT_CONCURRENCY_MAX_WAIT_MS, ExitCode} = Constants; +const {SELENIUM_FONT_LOAD_WAIT_MS} = Constants; + +/** + * @typedef {{ + * name: string, + * color: !CliColor, + * }} CliStatus + */ + +const CliStatuses = { + ACTIVE: {name: 'Active', color: colors.bold.cyan}, + QUEUED: {name: 'Queued', color: colors.cyan}, + WAITING: {name: 'Waiting', color: colors.magenta}, + STARTING: {name: 'Starting', color: colors.green}, + STARTED: {name: 'Started', color: colors.bold.green}, + GET: {name: 'Get', color: colors.bold.white}, + CROP: {name: 'Crop', color: colors.white}, + PASS: {name: 'Pass', color: colors.green}, + ADD: {name: 'Add', color: colors.bgGreen.black}, + FAIL: {name: 'Fail', color: colors.red}, + RETRY: {name: 'Retry', color: colors.magenta}, + CAPTURED: {name: 'Captured', color: colors.bold.grey}, + FINISHED: {name: 'Finished', color: colors.bold.green}, + FAILED: {name: 'Failed', color: colors.bold.red}, + QUITTING: {name: 'Quitting', color: colors.white}, +}; + +class SeleniumApi { + constructor() { + /** + * @type {!CbtApi} + * @private + */ + this.cbtApi_ = new CbtApi(); + + /** + * @type {!Cli} + * @private + */ + this.cli_ = new Cli(); + + /** + * @type {!GitHubApi} + * @private + */ + this.gitHubApi_ = new GitHubApi(); + + /** + * @type {!ImageCropper} + * @private + */ + this.imageCropper_ = new ImageCropper(); + + /** + * @type {!ImageDiffer} + * @private + */ + this.imageDiffer_ = new ImageDiffer(); + + /** + * @type {!LocalStorage} + * @private + */ + this.localStorage_ = new LocalStorage(); + + /** + * @type {!Set} + * @private + */ + this.seleniumSessionIds_ = new Set(); + + /** + * @type {number} + * @private + */ + this.numPending_ = 0; + + /** + * @type {number} + * @private + */ + this.numCompleted_ = 0; + + /** + * @type {number} + * @private + */ + this.numChanged_ = 0; + + /** + * @type {boolean} + * @private + */ + this.isKilled_ = false; + + if (this.cli_.isOnline()) { + this.killBrowsersOnExit_(); + } + } + + /** + * @param {!mdc.proto.ReportData} reportData + * @return {!Promise} + */ + async captureAllPages(reportData) { + const runnableUserAgents = reportData.user_agents.runnable_user_agents; + let queuedUserAgents = runnableUserAgents.slice(); + let runningUserAgents; + + this.numPending_ = reportData.screenshots.runnable_screenshot_list.length; + this.numCompleted_ = 0; + + function getLoggableAliases(userAgentAliases) { + return userAgentAliases.length > 0 ? userAgentAliases.join(', ') : '(none)'; + } + + while (queuedUserAgents.length > 0) { + const maxParallelTests = await this.getMaxParallelTests_(); + runningUserAgents = queuedUserAgents.slice(0, maxParallelTests); + queuedUserAgents = queuedUserAgents.slice(maxParallelTests); + const runningUserAgentAliases = runningUserAgents.map((ua) => ua.alias); + const queuedUserAgentAliases = queuedUserAgents.map((ua) => ua.alias); + const runningUserAgentLoggable = getLoggableAliases(runningUserAgentAliases); + const queuedUserAgentLoggable = getLoggableAliases(queuedUserAgentAliases); + this.logStatus_(CliStatuses.ACTIVE, runningUserAgentLoggable); + this.logStatus_(CliStatuses.QUEUED, queuedUserAgentLoggable); + await this.captureAllPagesInAllBrowsers_({reportData, userAgents: runningUserAgents}); + } + + console.log(''); + + return reportData; + } + + /** + * @param {!mdc.proto.ReportData} reportData + * @param {!Array} userAgents + * @return {!Promise} + * @private + */ + async captureAllPagesInAllBrowsers_({reportData, userAgents}) { + const promises = []; + for (const userAgent of userAgents) { + promises.push(this.captureAllPagesInOneBrowser_({reportData, userAgent})); + } + await Promise.all(promises); + } + + /** + * @param {!mdc.proto.ReportData} reportData + * @param {!mdc.proto.UserAgent} userAgent + * @return {!Promise} + * @private + */ + async captureAllPagesInOneBrowser_({reportData, userAgent}) { + /** @type {!IWebDriver} */ + const driver = await this.createWebDriver_({reportData, userAgent}); + + /** @type {!Session} */ + const session = await driver.getSession(); + const seleniumSessionId = session.getId(); + let changedScreenshots; + + this.seleniumSessionIds_.add(seleniumSessionId); + + const logResult = (status, ...args) => { + /* eslint-disable camelcase */ + const {os_name, os_version, browser_name, browser_version} = userAgent.navigator; + this.logStatus_(status, `${browser_name} ${browser_version} on ${os_name} ${os_version}!`, ...args); + /* eslint-enable camelcase */ + }; + + try { + changedScreenshots = (await this.driveBrowser_({reportData, userAgent, driver})).changedScreenshots; + await this.printBrowserConsoleLogs_(driver); + logResult(CliStatuses.FINISHED); + } catch (err) { + logResult(CliStatuses.FAILED); + await this.killBrowsers_(); + throw new VError(err, 'Failed to drive web browser'); + } finally { + logResult(CliStatuses.QUITTING); + await driver.quit(); + this.seleniumSessionIds_.delete(seleniumSessionId); + } + + if (this.cli_.isOnline()) { + await this.cbtApi_.setTestScore({ + seleniumSessionId, + changedScreenshots, + }); + } + } + + /** + * @param {!IWebDriver} driver + * @return {!Promise} + * @private + */ + async printBrowserConsoleLogs_(driver) { + const log = driver.manage().logs(); + + // Chrome is the only browser that supports logging as of 2018-07-20. + const logEntries = (await log.get(logging.Type.BROWSER).catch(() => [])).filter((logEntry) => { + // Ignore messages about missing favicon + return logEntry.message.indexOf('favicon.ico') === -1; + }); + + if (logEntries.length > 0) { + const messageColor = colors.bold.red('Browser console log:'); + console.log(`\n\n${messageColor}\n`, JSON.stringify(logEntries, null, 2), '\n'); + } + } + + /** + * @return {!Promise} + * @private + */ + async getMaxParallelTests_() { + if (this.cli_.isOffline()) { + return 1; + } + + const startTimeMs = Date.now(); + + while (true) { + /** @type {!cbt.proto.CbtConcurrencyStats} */ + const stats = await this.cbtApi_.fetchConcurrencyStats(); + const active = stats.active_concurrent_selenium_tests; + const max = stats.max_concurrent_selenium_tests; + const available = max - active; + + if (!available) { + const elapsedTimeMs = Date.now() - startTimeMs; + const elapsedTimeHuman = Duration.millis(elapsedTimeMs).toHumanShort(); + if (elapsedTimeMs > CBT_CONCURRENCY_MAX_WAIT_MS) { + throw new Error(`Timed out waiting for CBT resources to become available after ${elapsedTimeHuman}`); + } + + const waitTimeMs = CBT_CONCURRENCY_POLL_INTERVAL_MS; + const waitTimeHuman = Duration.millis(waitTimeMs).toHumanShort(); + this.logStatus_( + CliStatuses.WAITING, + `Parallel execution limit reached. ${max} tests are already running on CBT. Will retry in ${waitTimeHuman}...` + ); + await this.sleep_(waitTimeMs); + continue; + } + + const requested = Math.min(this.cli_.parallels, available); + + // If nobody else is running any tests, run half the number of concurrent tests allowed by our CBT account. + // This gives us _some_ parallelism while still allowing other users to run their tests. + // If someone else is already running tests, only run one test at a time. + const half = active === 0 ? Math.ceil(max / 2) : 1; + + return requested === 0 ? half : requested; + } + } + + /** + * @param {!mdc.proto.ReportData} reportData + * @param {!mdc.proto.UserAgent} userAgent + * @return {!Promise} + */ + async createWebDriver_({reportData, userAgent}) { + const meta = reportData.meta; + const driverBuilder = new Builder(); + + /** @type {!selenium.proto.RawCapabilities} */ + const desiredCapabilities = await this.getDesiredCapabilities_({meta, userAgent}); + userAgent.desired_capabilities = desiredCapabilities; + driverBuilder.withCapabilities(desiredCapabilities); + + const isOnline = this.cli_.isOnline(); + if (isOnline) { + driverBuilder.usingServer(this.cbtApi_.getSeleniumServerUrl()); + } + + this.logStatus_(CliStatuses.STARTING, `${userAgent.alias}...`); + + /** @type {!IWebDriver} */ + const driver = await this.buildWebDriverWithRetries_(driverBuilder); + + /** @type {!selenium.proto.RawCapabilities} */ + const actualCapabilities = await this.getActualCapabilities_(driver); + + /** @type {!mdc.proto.UserAgent.Navigator} */ + const navigator = await this.getUserAgentNavigator_(driver); + + /* eslint-disable camelcase */ + const {os_name, os_version, browser_name, browser_version} = navigator; + + userAgent.navigator = navigator; + userAgent.actual_capabilities = actualCapabilities; + userAgent.browser_version_value = browser_version; + userAgent.image_filename_suffix = this.getImageFileNameSuffix_(userAgent); + + this.logStatus_(CliStatuses.STARTED, `${browser_name} ${browser_version} on ${os_name} ${os_version}!`); + /* eslint-enable camelcase */ + + return driver; + } + + /** + * @param {!Builder} driverBuilder + * @param {number=} startTimeMs + * @return {!Promise} + * @private + */ + async buildWebDriverWithRetries_(driverBuilder, startTimeMs = Date.now()) { + try { + return await driverBuilder.build(); + } catch (err) { + if (err.message.indexOf('maximum number of parallel') === -1) { + throw new VError(err, 'WebDriver instance could not be created'); + } + } + + /** @type {!cbt.proto.CbtConcurrencyStats} */ + const concurrencyStats = await this.cbtApi_.fetchConcurrencyStats(); + const max = concurrencyStats.max_concurrent_selenium_tests; + + // TODO(acdvorak): De-dupe this with getMaxParallelTests_() + const elapsedTimeMs = Date.now() - startTimeMs; + const elapsedTimeHuman = Duration.millis(elapsedTimeMs).toHumanShort(); + if (elapsedTimeMs > CBT_CONCURRENCY_MAX_WAIT_MS) { + throw new Error(`Timed out waiting for CBT resources to become available after ${elapsedTimeHuman}`); + } + + // TODO(acdvorak): De-dupe this with getMaxParallelTests_() + const waitTimeMs = CBT_CONCURRENCY_POLL_INTERVAL_MS; + const waitTimeHuman = Duration.millis(waitTimeMs).toHumanShort(); + this.logStatus_( + CliStatuses.WAITING, + `Parallel execution limit reached. ${max} tests are already running on CBT. Will retry in ${waitTimeHuman}...` + ); + await this.sleep_(waitTimeMs); + + return this.buildWebDriverWithRetries_(driverBuilder, startTimeMs); + } + + /** + * @param {!mdc.proto.ReportMeta} meta + * @param {!mdc.proto.UserAgent} userAgent + * @return {!selenium.proto.RawCapabilities} + * @private + */ + async getDesiredCapabilities_({meta, userAgent}) { + if (this.cli_.isOnline()) { + return this.cbtApi_.getDesiredCapabilities({meta, userAgent}); + } + return this.createDesiredCapabilitiesOffline_({userAgent}); + } + + /** + * @param {!mdc.proto.UserAgent} userAgent + * @return {!selenium.proto.RawCapabilities} + * @private + */ + createDesiredCapabilitiesOffline_({userAgent}) { + const browserVendorMap = { + [BrowserVendorType.CHROME]: Browser.CHROME, + [BrowserVendorType.EDGE]: Browser.EDGE, + [BrowserVendorType.FIREFOX]: Browser.FIREFOX, + [BrowserVendorType.IE]: Browser.IE, + [BrowserVendorType.SAFARI]: Browser.SAFARI, + }; + + return RawCapabilities.create({ + browserName: browserVendorMap[userAgent.browser_vendor_type], + }); + } + + /** + * @param {!IWebDriver} driver + * @return {!Promise} + * @private + */ + async getActualCapabilities_(driver) { + /** @type {!Capabilities} */ + const driverCaps = await driver.getCapabilities(); + + /** @type {!selenium.proto.RawCapabilities} */ + const actualCaps = RawCapabilities.create(); + + for (const key of driverCaps.keys()) { + actualCaps[key] = driverCaps.get(key); + } + + return actualCaps; + } + + /** + * @param {!IWebDriver} driver + * @return {mdc.proto.UserAgent.Navigator} + * @private + */ + async getUserAgentNavigator_(driver) { + const uaString = await driver.executeScript('return window.navigator.userAgent;'); + const uaParsed = UserAgentParser.parse(uaString); + + // TODO(acdvorak): Clean this up + const navigator = Navigator.create({ + os_name: uaParsed.os.family.toLowerCase().startsWith('mac') ? 'Mac' : uaParsed.os.family, + os_version: uaParsed.os.toVersion().replace(/(?:\.0)+$/, ''), + browser_name: uaParsed.family.replace(/\s*Mobile\s*/, ''), + browser_version: uaParsed.toVersion().replace(/(?:\.0)+$/, ''), + }); + + // TODO(acdvorak): De-dupe + /* eslint-disable camelcase */ + const {browser_name, browser_version, os_name, os_version} = navigator; + navigator.full_name = [browser_name, browser_version, 'on', os_name, os_version].join(' '); + /* eslint-enable camelcase */ + + return navigator; + } + + /** + * @param {!mdc.proto.UserAgent} userAgent + * @return {string} + * @private + */ + getImageFileNameSuffix_(userAgent) { + /* eslint-disable camelcase */ + const {os_name, browser_name, browser_version} = userAgent.navigator; + return [os_name, browser_name, browser_version].map((value) => { + // TODO(acdvorak): Clean this up + return value.toLowerCase().replace(/\..+$/, '').replace(/[^a-z0-9]+/ig, ''); + }).join('_'); + /* eslint-enable camelcase */ + } + /** + * @param {!mdc.proto.ReportData} reportData + * @param {!mdc.proto.UserAgent} userAgent + * @param {!IWebDriver} driver + * @return {Promise<{ + * changedScreenshots: !Array, + * unchangedScreenshots: !Array, + * }>} + * @private + */ + async driveBrowser_({reportData, userAgent, driver}) { + const meta = reportData.meta; + + /** @type {!Array} */ const changedScreenshots = []; + /** @type {!Array} */ const unchangedScreenshots = []; + + /** @type {!Array} */ + const screenshotQueueAll = reportData.screenshots.runnable_screenshot_browser_map[userAgent.alias].screenshots; + + // TODO(acdvorak): Find a better way to do this + const screenshotQueues = [ + [true, screenshotQueueAll.filter((screenshot) => this.isSmallComponent_(screenshot.html_file_path))], + [false, screenshotQueueAll.filter((screenshot) => !this.isSmallComponent_(screenshot.html_file_path))], + ]; + + for (const [isSmallComponent, screenshotQueue] of screenshotQueues) { + if (screenshotQueue.length === 0) { + continue; + } + + await this.resizeWindow_({driver, isSmallComponent}); + + for (const screenshot of screenshotQueue) { + screenshot.capture_state = CaptureState.RUNNING; + + const diffImageResult = await this.takeScreenshotWithRetries_({driver, userAgent, screenshot, meta}); + + screenshot.capture_state = CaptureState.DIFFED; + screenshot.diff_image_result = diffImageResult; + screenshot.diff_image_file = diffImageResult.diff_image_file; + + this.numPending_--; + this.numCompleted_++; + + const message = `${screenshot.actual_html_file.public_url} > ${screenshot.user_agent.alias}`; + + if (diffImageResult.has_changed) { + changedScreenshots.push(screenshot); + this.numChanged_++; + this.logStatus_(CliStatuses.FAIL, message); + } else { + unchangedScreenshots.push(screenshot); + + if (screenshot.inclusion_type === InclusionType.ADD) { + this.logStatus_(CliStatuses.ADD, message); + } else { + this.logStatus_(CliStatuses.PASS, message); + } + } + } + } + + reportData.screenshots.changed_screenshot_list.push(...changedScreenshots); + reportData.screenshots.unchanged_screenshot_list.push(...unchangedScreenshots); + + return {changedScreenshots, unchangedScreenshots}; + } + + /** + * @param {string} url + * @return {boolean} + * @private + */ + isSmallComponent_(url) { + // TODO(acdvorak): Find a better way to do this + const smallComponentNames = [ + 'animation', 'button', 'card', 'checkbox', 'chips', 'elevation', 'fab', 'icon-button', 'icon-toggle', + 'list', 'menu', 'radio', 'ripple', 'select', 'switch', 'textfield', 'theme', 'tooltip', 'typography', + ]; + return new RegExp(`/mdc-(${smallComponentNames.join('|')})/`).test(url); + } + + /** + * @param {!IWebDriver} driver + * @param {boolean} isSmallComponent + * @return {!Promise<{x: number, y: number, width: number, height: number}>} + * @private + */ + async resizeWindow_({driver, isSmallComponent}) { + /** @type {!Window} */ + const window = driver.manage().window(); + const rect = isSmallComponent + ? {x: 0, y: 0, width: 400, height: 768} + : {x: 0, y: 0, width: 1366, height: 768} + ; + await window.setRect(rect).catch(() => undefined); + return rect; + } + + /** + * @param {!IWebDriver} driver + * @param {!mdc.proto.UserAgent} userAgent + * @param {!mdc.proto.Screenshot} screenshot + * @param {!mdc.proto.ReportMeta} meta + * @return {!Promise} + * @private + */ + async takeScreenshotWithRetries_({driver, userAgent, screenshot, meta}) { + let delayMs = 0; + + /** @type {?mdc.proto.DiffImageResult} */ + let diffImageResult = null; + let changedPixelCount = 0; + let changedPixelFraction = 0; + const maxPixelFraction = require('../../diffing.json').flaky_tests.max_auto_retry_changed_pixel_fraction; + + while (screenshot.retry_count <= screenshot.max_retries && changedPixelFraction <= maxPixelFraction) { + if (screenshot.retry_count > 0) { + const {width, height} = diffImageResult.diff_image_dimensions; + const whichMsg = `${screenshot.actual_html_file.public_url} > ${userAgent.alias}`; + const countMsg = `attempt ${screenshot.retry_count} of ${screenshot.max_retries}`; + const pixelMsg = `${changedPixelCount.toLocaleString()} pixels differed`; + const deltaMsg = `${diffImageResult.changed_pixel_percentage}% of ${width}x${height}`; + this.logStatus_(CliStatuses.RETRY, `${whichMsg} (${countMsg}). ${pixelMsg} (${deltaMsg})`); + delayMs = 500; + } + + screenshot.actual_image_file = await this.takeScreenshotWithoutRetries_({ + meta, screenshot, userAgent, driver, delayMs, + }); + diffImageResult = await this.imageDiffer_.compareOneScreenshot({meta, screenshot}); + + if (!diffImageResult.has_changed) { + break; + } + + changedPixelCount = diffImageResult.changed_pixel_count; + changedPixelFraction = diffImageResult.changed_pixel_fraction; + screenshot.retry_count++; + } + + return diffImageResult; + } + + /** + * @param {!mdc.proto.ReportMeta} meta + * @param {!mdc.proto.Screenshot} screenshot + * @param {!mdc.proto.UserAgent} userAgent + * @param {!IWebDriver} driver + * @param {number=} delayMs + * @return {!Promise} + * @private + */ + async takeScreenshotWithoutRetries_({meta, screenshot, userAgent, driver, delayMs = 0}) { + const htmlFilePath = screenshot.html_file_path; + const htmlFileUrl = screenshot.actual_html_file.public_url; + const imageBuffer = await this.capturePageAsPng_({driver, userAgent, url: htmlFileUrl, delayMs}); + const imageFileNameSuffix = userAgent.image_filename_suffix; + const imageFilePathRelative = `${htmlFilePath}.${imageFileNameSuffix}.png`; + const imageFilePathAbsolute = path.resolve(meta.local_screenshot_image_base_dir, imageFilePathRelative); + + await this.localStorage_.writeBinaryFile(imageFilePathAbsolute, imageBuffer); + + return TestFile.create({ + relative_path: imageFilePathRelative, + absolute_path: imageFilePathAbsolute, + public_url: meta.remote_upload_base_url + meta.remote_upload_base_dir + imageFilePathRelative, + }); + } + + /** + * @param {!IWebDriver} driver + * @param {!mdc.proto.UserAgent} userAgent + * @param {string} url + * @param {number=} delayMs + * @return {!Promise} Buffer containing PNG image data for the cropped screenshot image + * @private + */ + async capturePageAsPng_({driver, userAgent, url, delayMs = 0}) { + this.logStatus_(CliStatuses.GET, `${url} > ${userAgent.alias}...`); + + const isOnline = this.cli_.isOnline(); + const fontTimeoutMs = isOnline ? SELENIUM_FONT_LOAD_WAIT_MS : 500; + + await driver.get(url); + await driver.wait(until.elementLocated(By.css('[data-fonts-loaded]')), fontTimeoutMs).catch(() => 0); + + if (delayMs > 0) { + await driver.sleep(delayMs); + } + + const uncroppedImageBuffer = Buffer.from(await driver.takeScreenshot(), 'base64'); + const croppedImageBuffer = await this.imageCropper_.autoCropImage(uncroppedImageBuffer); + + const uncroppedJimpImage = await Jimp.read(uncroppedImageBuffer); + const croppedJimpImage = await Jimp.read(croppedImageBuffer); + + const {width: uncroppedWidth, height: uncroppedHeight} = uncroppedJimpImage.bitmap; + const {width: croppedWidth, height: croppedHeight} = croppedJimpImage.bitmap; + + const message = `${url} > ${userAgent.alias} screenshot from ` + + `${uncroppedWidth}x${uncroppedHeight} to ${croppedWidth}x${croppedHeight}`; + this.logStatus_(CliStatuses.CROP, message); + + return croppedImageBuffer; + } + + /** @private */ + killBrowsersOnExit_() { + // catches ctrl+c event + process.on('SIGINT', () => { + const exit = () => process.exit(ExitCode.SIGINT); + this.killBrowsers_().then(exit, exit); + }); + + // catches "kill pid" + process.on('SIGTERM', () => { + const exit = () => process.exit(ExitCode.SIGTERM); + this.killBrowsers_().then(exit, exit); + }); + + process.on('uncaughtException', (err) => { + console.error(err); + const exit = () => process.exit(ExitCode.UNCAUGHT_EXCEPTION); + this.killBrowsers_().then(exit, exit); + }); + + process.on('unhandledRejection', (err) => { + console.error(err); + const exit = () => process.exit(ExitCode.UNHANDLED_PROMISE_REJECTION); + this.killBrowsers_().then(exit, exit); + }); + } + + /** @private */ + async killBrowsers_() { + if (this.cli_.isOffline()) { + return; + } + + const ids = Array.from(this.seleniumSessionIds_); + const wasAlreadyKilled = this.isKilled_; + + if (!wasAlreadyKilled) { + console.log('\n'); + } + + this.isKilled_ = true; + + await this.cbtApi_.killSeleniumTests(ids, /* silent */ wasAlreadyKilled); + + if (!wasAlreadyKilled && ids.length > 0) { + console.log('\nWaiting for CBT cancellation requests to complete...'); + await this.sleep_(Duration.seconds(4).toMillis()); + } + } + + /** + * @param {number} ms + * @return {!Promise} + * @private + */ + async sleep_(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * @param {!CliStatus} status + * @param {*} args + * @private + */ + logStatus_(status, ...args) { + // Don't output misleading errors + if (this.isKilled_) { + return; + } + + // https://stackoverflow.com/a/6774395/467582 + const escape = String.fromCodePoint(27); + const eraseCurrentLine = `\r${escape}[K`; + const maxStatusWidth = Object.values(CliStatuses).map((status) => status.name.length).sort().reverse()[0]; + const statusName = status.name.toUpperCase(); + const paddingSpaces = ''.padStart(maxStatusWidth - statusName.length, ' '); + + console.log(eraseCurrentLine + paddingSpaces + status.color(statusName) + ':', ...args); + + const numDone = this.numCompleted_; + const strDone = numDone.toLocaleString(); + + const numTotal = numDone + this.numPending_; + const strTotal = numTotal.toLocaleString(); + + const numChanged = this.numChanged_; + const strChanged = numChanged.toLocaleString(); + + const numPercent = numTotal > 0 ? (100 * numDone / numTotal) : 0; + const strPercent = numPercent.toFixed(1); + + if (process.env.TRAVIS === 'true') { + this.gitHubApi_.setPullRequestStatusManual({ + state: GitHubApi.PullRequestState.PENDING, + description: `${strDone} of ${strTotal} (${strPercent}%) - ${strChanged} diffs`, + }); + return; + } + + const pending = this.numPending_; + const completed = numDone; + const total = pending + completed; + const percent = (total === 0 ? 0 : (100 * completed / total).toFixed(1)); + + const colorCaptured = CliStatuses.CAPTURED.color(CliStatuses.CAPTURED.name.toUpperCase()); + const colorCompleted = colors.bold.white(completed.toLocaleString()); + const colorTotal = colors.bold.white(total.toLocaleString()); + const colorPercent = colors.bold.white(`${percent}%`); + + process.stdout.write(`${colorCaptured}: ${colorCompleted} of ${colorTotal} screenshots (${colorPercent} complete)`); + } +} + +module.exports = SeleniumApi; diff --git a/test/screenshot/infra/lib/stacktrace.js b/test/screenshot/infra/lib/stacktrace.js new file mode 100644 index 00000000000..fbfe5741d1b --- /dev/null +++ b/test/screenshot/infra/lib/stacktrace.js @@ -0,0 +1,145 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** @type {!CliColor} */ +const colors = require('colors'); + +/** + * @param {string} className + * @return {function(methodName: string): string} + */ +module.exports = function(className) { + /** + * @param {string} methodName + * @return {string} + */ + function getStackTrace(methodName) { + const fullStack = new Error(`${className}.${methodName}()`).stack; + // Remove THIS function from the stack trace because it's not useful + return fullStack.split('\n').filter((line, index) => index !== 1).join('\n'); + } + + return getStackTrace; +}; + +module.exports.formatError = formatError; + +/** + * @param {!Error|!VError|*} err + * @return {string} + */ +function formatError(err) { + return formatErrorInternal(err) + .replace(/^([^\n]+)/, (fullMatch, line) => colors.bold.red(line)) + ; +} + +/** + * @param {!Error|!VError|*} err + * @return {string} + */ +function formatErrorInternal(err) { + const parentStr = stringifyError(err); + if (err.jse_cause) { + const childStr = formatError(err.jse_cause); + return `${childStr}\n\n${colors.italic('called from:')}\n\n${parentStr}`; + } + return parentStr; +} + +/** + * @param {!Error|!VError|*} err + * @return {string} + */ +function stringifyError(err) { + if (err.toString !== Error.prototype.toString) { + return sanitizeErrorString(err.toString()); + } + + const lines = [err.code, err.message, err.stack].filter((str) => Boolean(str)); + return sanitizeErrorString(lines.join('\n')); +} + +/** + * @param {string} errorStr + * @return {string} + */ +function sanitizeErrorString(errorStr) { + const lines = errorStr.replace(/^((VError|Error|):\s*)+/i, '').split('\n'); + if (lines[1] && lines[1].includes(lines[0].replace(/\(\):?$/, ''))) { + lines.splice(0, 1); + } + return lines + .map((line) => { + let formatted = line; + formatted = formatClassMethod(formatted); + formatted = formatNamedFunction(formatted); + formatted = formatAnonymousFunction(formatted); + return formatted; + }) + .join('\n') + .replace(/^ +at +/, '') + ; +} + +/** + * @param {string} errorLine + * @return {string} + */ +function formatClassMethod(errorLine) { + return errorLine + .replace(/^( +)(at) (\w+)\.(\w+)(.+)$/, (fullMatch, leadingSpaces, atPrefix, className, methodName, rest) => { + if (className === 'process' && methodName === '_tickCallback') { + return colors.dim(fullMatch); + } + rest = formatFileNameAndLineNumber(rest); + return `${leadingSpaces}${atPrefix} ${colors.underline(className)}.${colors.bold(methodName)}${rest}`; + }) + ; +} + +/** + * @param {string} errorLine + * @return {string} + */ +function formatNamedFunction(errorLine) { + return errorLine + .replace(/^( +)(at) (\w+)([^.].+)$/, (fullMatch, leadingSpaces, atPrefix, functionName, rest) => { + rest = formatFileNameAndLineNumber(rest); + return `${leadingSpaces}${atPrefix} ${colors.bold(functionName)}${rest}`; + }) + ; +} + +/** + * @param {string} errorLine + * @return {string} + */ +function formatAnonymousFunction(errorLine) { + return errorLine.replace(/^ +at .*$/, (fullMatch) => { + return colors.dim(fullMatch); + }); +} + +/** + * @param {string} errorLine + * @return {string} + */ +function formatFileNameAndLineNumber(errorLine) { + return errorLine.replace(/\/([^/]+\.\w+):(\d+):(\d+)(\).*)$/, (fullMatch, fileName, lineNumber, colNumber, rest) => { + return `/${colors.underline(fileName)}:${colors.bold(lineNumber)}:${colNumber}${rest}`; + }); +} diff --git a/test/screenshot/lib/user-agent-store.js b/test/screenshot/infra/lib/user-agent-store.js similarity index 97% rename from test/screenshot/lib/user-agent-store.js rename to test/screenshot/infra/lib/user-agent-store.js index 13506a7be93..ec7f2122b6b 100644 --- a/test/screenshot/lib/user-agent-store.js +++ b/test/screenshot/infra/lib/user-agent-store.js @@ -60,7 +60,7 @@ class UserAgentStore { * @private */ getAllAliases_() { - return require('../browser.json').user_agent_aliases; + return require('../../browser.json').user_agent_aliases; } /** @@ -82,7 +82,7 @@ Expected format: 'desktop_windows_chrome@latest'. const [, formFactorName, osVendorName, browserVendorName, browserVersionName] = matchArray; const getEnumKeysLowerCase = (enumeration) => { - return Object.keys(enumeration).map((key) => key.toLowerCase()); + return Object.keys(enumeration).filter((key) => key !== 'UNKNOWN').map((key) => key.toLowerCase()); }; // In proto3, the first enum value must always be 0. @@ -132,7 +132,7 @@ Expected browser vendor to be one of [${validBrowserVendors}], but got '${browse ); } - const isOnline = await this.cli_.isOnline(); + const isOnline = this.cli_.isOnline(); const isEnabledByCli = this.isAliasEnabled_(alias); const isAvailableLocally = await this.isAvailableLocally_(browserVendorType); const isRunnable = isEnabledByCli && (isOnline || isAvailableLocally); diff --git a/test/screenshot/proto/cbt.pb.js b/test/screenshot/infra/proto/cbt.pb.js similarity index 100% rename from test/screenshot/proto/cbt.pb.js rename to test/screenshot/infra/proto/cbt.pb.js diff --git a/test/screenshot/proto/cbt.proto b/test/screenshot/infra/proto/cbt.proto similarity index 100% rename from test/screenshot/proto/cbt.proto rename to test/screenshot/infra/proto/cbt.proto diff --git a/test/screenshot/proto/github.pb.js b/test/screenshot/infra/proto/github.pb.js similarity index 100% rename from test/screenshot/proto/github.pb.js rename to test/screenshot/infra/proto/github.pb.js diff --git a/test/screenshot/proto/github.proto b/test/screenshot/infra/proto/github.proto similarity index 100% rename from test/screenshot/proto/github.proto rename to test/screenshot/infra/proto/github.proto diff --git a/test/screenshot/proto/mdc.pb.js b/test/screenshot/infra/proto/mdc.pb.js similarity index 99% rename from test/screenshot/proto/mdc.pb.js rename to test/screenshot/infra/proto/mdc.pb.js index ef8e8605762..680c85befba 100644 --- a/test/screenshot/proto/mdc.pb.js +++ b/test/screenshot/infra/proto/mdc.pb.js @@ -1427,6 +1427,8 @@ $root.mdc = (function() { * @property {string|null} [tag] GitRevision tag * @property {number|null} [pr_number] GitRevision pr_number * @property {Array.|null} [pr_file_paths] GitRevision pr_file_paths + * @property {mdc.proto.IUser|null} [author] GitRevision author + * @property {mdc.proto.IUser|null} [committer] GitRevision committer */ /** @@ -1509,6 +1511,22 @@ $root.mdc = (function() { */ GitRevision.prototype.pr_file_paths = $util.emptyArray; + /** + * GitRevision author. + * @member {mdc.proto.IUser|null|undefined} author + * @memberof mdc.proto.GitRevision + * @instance + */ + GitRevision.prototype.author = null; + + /** + * GitRevision committer. + * @member {mdc.proto.IUser|null|undefined} committer + * @memberof mdc.proto.GitRevision + * @instance + */ + GitRevision.prototype.committer = null; + /** * Creates a new GitRevision instance using the specified properties. * @function create @@ -1550,6 +1568,10 @@ $root.mdc = (function() { if (message.pr_file_paths != null && message.pr_file_paths.length) for (var i = 0; i < message.pr_file_paths.length; ++i) writer.uint32(/* id 8, wireType 2 =*/66).string(message.pr_file_paths[i]); + if (message.author != null && message.hasOwnProperty("author")) + $root.mdc.proto.User.encode(message.author, writer.uint32(/* id 9, wireType 2 =*/74).fork()).ldelim(); + if (message.committer != null && message.hasOwnProperty("committer")) + $root.mdc.proto.User.encode(message.committer, writer.uint32(/* id 10, wireType 2 =*/82).fork()).ldelim(); return writer; }; @@ -1610,6 +1632,12 @@ $root.mdc = (function() { message.pr_file_paths = []; message.pr_file_paths.push(reader.string()); break; + case 9: + message.author = $root.mdc.proto.User.decode(reader, reader.uint32()); + break; + case 10: + message.committer = $root.mdc.proto.User.decode(reader, reader.uint32()); + break; default: reader.skipType(tag & 7); break; @@ -1682,6 +1710,16 @@ $root.mdc = (function() { if (!$util.isString(message.pr_file_paths[i])) return "pr_file_paths: string[] expected"; } + if (message.author != null && message.hasOwnProperty("author")) { + var error = $root.mdc.proto.User.verify(message.author); + if (error) + return "author." + error; + } + if (message.committer != null && message.hasOwnProperty("committer")) { + var error = $root.mdc.proto.User.verify(message.committer); + if (error) + return "committer." + error; + } return null; }; @@ -1742,6 +1780,16 @@ $root.mdc = (function() { for (var i = 0; i < object.pr_file_paths.length; ++i) message.pr_file_paths[i] = String(object.pr_file_paths[i]); } + if (object.author != null) { + if (typeof object.author !== "object") + throw TypeError(".mdc.proto.GitRevision.author: object expected"); + message.author = $root.mdc.proto.User.fromObject(object.author); + } + if (object.committer != null) { + if (typeof object.committer !== "object") + throw TypeError(".mdc.proto.GitRevision.committer: object expected"); + message.committer = $root.mdc.proto.User.fromObject(object.committer); + } return message; }; @@ -1768,6 +1816,8 @@ $root.mdc = (function() { object.branch = ""; object.tag = ""; object.pr_number = 0; + object.author = null; + object.committer = null; } if (message.type != null && message.hasOwnProperty("type")) object.type = options.enums === String ? $root.mdc.proto.GitRevision.Type[message.type] : message.type; @@ -1788,6 +1838,10 @@ $root.mdc = (function() { for (var j = 0; j < message.pr_file_paths.length; ++j) object.pr_file_paths[j] = message.pr_file_paths[j]; } + if (message.author != null && message.hasOwnProperty("author")) + object.author = $root.mdc.proto.User.toObject(message.author, options); + if (message.committer != null && message.hasOwnProperty("committer")) + object.committer = $root.mdc.proto.User.toObject(message.committer, options); return object; }; @@ -2066,7 +2120,6 @@ $root.mdc = (function() { * @memberof mdc.proto * @interface ILibraryVersion * @property {string|null} [version_string] LibraryVersion version_string - * @property {number|null} [commit_offset] LibraryVersion commit_offset */ /** @@ -2092,14 +2145,6 @@ $root.mdc = (function() { */ LibraryVersion.prototype.version_string = ""; - /** - * LibraryVersion commit_offset. - * @member {number} commit_offset - * @memberof mdc.proto.LibraryVersion - * @instance - */ - LibraryVersion.prototype.commit_offset = 0; - /** * Creates a new LibraryVersion instance using the specified properties. * @function create @@ -2126,8 +2171,6 @@ $root.mdc = (function() { writer = $Writer.create(); if (message.version_string != null && message.hasOwnProperty("version_string")) writer.uint32(/* id 1, wireType 2 =*/10).string(message.version_string); - if (message.commit_offset != null && message.hasOwnProperty("commit_offset")) - writer.uint32(/* id 2, wireType 0 =*/16).int32(message.commit_offset); return writer; }; @@ -2165,9 +2208,6 @@ $root.mdc = (function() { case 1: message.version_string = reader.string(); break; - case 2: - message.commit_offset = reader.int32(); - break; default: reader.skipType(tag & 7); break; @@ -2206,9 +2246,6 @@ $root.mdc = (function() { if (message.version_string != null && message.hasOwnProperty("version_string")) if (!$util.isString(message.version_string)) return "version_string: string expected"; - if (message.commit_offset != null && message.hasOwnProperty("commit_offset")) - if (!$util.isInteger(message.commit_offset)) - return "commit_offset: integer expected"; return null; }; @@ -2226,8 +2263,6 @@ $root.mdc = (function() { var message = new $root.mdc.proto.LibraryVersion(); if (object.version_string != null) message.version_string = String(object.version_string); - if (object.commit_offset != null) - message.commit_offset = object.commit_offset | 0; return message; }; @@ -2244,14 +2279,10 @@ $root.mdc = (function() { if (!options) options = {}; var object = {}; - if (options.defaults) { + if (options.defaults) object.version_string = ""; - object.commit_offset = 0; - } if (message.version_string != null && message.hasOwnProperty("version_string")) object.version_string = message.version_string; - if (message.commit_offset != null && message.hasOwnProperty("commit_offset")) - object.commit_offset = message.commit_offset; return object; }; diff --git a/test/screenshot/proto/mdc.proto b/test/screenshot/infra/proto/mdc.proto similarity index 96% rename from test/screenshot/proto/mdc.proto rename to test/screenshot/infra/proto/mdc.proto index be35736f21a..131859bf75d 100644 --- a/test/screenshot/proto/mdc.proto +++ b/test/screenshot/infra/proto/mdc.proto @@ -93,6 +93,12 @@ message GitRevision { string tag = 6; uint32 pr_number = 7; repeated string pr_file_paths = 8; + + // "author" is the person who created the PR; "committer" is the person who created the commit. + // E.g., if Alice creates a PR and Bob clicks "Update branch" in the GitHub UI, then + // author = "Alice" and committer = "Bob". + User author = 9; + User committer = 10; } message User { @@ -103,7 +109,6 @@ message User { message LibraryVersion { string version_string = 1; - int32 commit_offset = 2; } message UserAgents { diff --git a/test/screenshot/proto/selenium.pb.js b/test/screenshot/infra/proto/selenium.pb.js similarity index 100% rename from test/screenshot/proto/selenium.pb.js rename to test/screenshot/infra/proto/selenium.pb.js diff --git a/test/screenshot/proto/selenium.proto b/test/screenshot/infra/proto/selenium.proto similarity index 100% rename from test/screenshot/proto/selenium.proto rename to test/screenshot/infra/proto/selenium.proto diff --git a/test/screenshot/infra/types/cbt-api-externs.js b/test/screenshot/infra/types/cbt-api-externs.js new file mode 100644 index 00000000000..7056a98f3dd --- /dev/null +++ b/test/screenshot/infra/types/cbt-api-externs.js @@ -0,0 +1,162 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * CBT (CrossBrowserTesting.com) + */ + + +/** + * @typedef {{ + * width: number, + * height: number, + * desktop_width: number, + * desktop_height: number, + * name: number, + * requested_name: number, + * }} CbtSeleniumResolution + */ + +/** + * @typedef {{ + * name: string, + * type: string, + * version: string, + * api_name: string, + * device: string, + * device_type: ?string, + * icon_class: string, + * requested_api_name: string, + * }} CbtSeleniumOs + */ + +/** + * @typedef {{ + * name: string, + * type: string, + * version: string, + * api_name: string, + * icon_class: string, + * requested_api_name: string, + * }} CbtSeleniumBrowser + */ + +/** + * @typedef {{ + * hash: string, + * date_added: string, + * is_finished: boolean, + * description: ?string, + * tags: !Array, + * show_result_web_url: ?string, + * show_result_public_url: ?string, + * video: ?string, + * image: ?string, + * thumbnail_image: ?string, + * }} CbtSeleniumVideo + */ + +/** + * @typedef {{ + * hash: string, + * date_added: string, + * is_finished: boolean, + * description: ?string, + * tags: !Array, + * show_result_web_url: ?string, + * show_result_public_url: ?string, + * video: ?string, + * pcap: ?string, + * har: ?string, + * }} CbtSeleniumNetwork + */ + +/** + * @typedef {{ + * body: string, + * method: string, + * path: string, + * date_issued: string, + * hash: ?string, + * response_code: number, + * response_body: ?string, + * step_number: ?number, + * }} CbtSeleniumCommand + */ + +/** + * @typedef {{ + * selenium: !Array, + * }} CbtSeleniumListResponse + */ + +/** + * @typedef {{ + * selenium_test_id: string, + * selenium_session_id: string, + * start_date: string, + * finish_date: ?string, + * test_score: string, + * active: boolean, + * state: string, + * show_result_web_url: ?string, + * show_result_public_url: ?string, + * download_results_zip_url: ?string, + * download_results_zip_public_url: ?string, + * launch_live_test_url: ?string, + * resolution: !CbtSeleniumResolution, + * os: !CbtSeleniumOs, + * browser: !CbtSeleniumBrowser, + * }} CbtSeleniumListItem + */ + +/** + * @typedef {{ + * selenium_test_id: string, + * selenium_session_id: string, + * start_date: string, + * finish_date: ?string, + * test_score: string, + * active: boolean, + * state: string, + * startup_finish_date: string, + * url: string, + * client_platform: string, + * client_browser: string, + * use_copyrect: boolean, + * scale: string, + * is_packet_capturing: number, + * tunnel_id: number, + * archived: number, + * selenium_version: string, + * show_result_web_url: ?string, + * show_result_public_url: ?string, + * download_results_zip_url: ?string, + * download_results_zip_public_url: ?string, + * launch_live_test_url: ?string, + * resolution: !CbtSeleniumResolution, + * os: !CbtSeleniumOs, + * browser: !CbtSeleniumBrowser, + * requestMethod: string, + * api_version: string, + * description: ?string, + * tags: !Array, + * videos: !Array, + * snapshots: !Array, + * networks: !Array, + * commands: !Array, + * }} CbtSeleniumInfoResponse + */ diff --git a/test/screenshot/infra/types/cli-types.js b/test/screenshot/infra/types/cli-types.js new file mode 100644 index 00000000000..71e5821eefa --- /dev/null +++ b/test/screenshot/infra/types/cli-types.js @@ -0,0 +1,26 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @typedef {{ + * optionNames: !Array, + * description: string, + * isRequired: ?boolean, + * type: ?string, + * defaultValue: ?*, + * exampleValue: ?string, + * }} CliOptionConfig + */ diff --git a/test/screenshot/infra/types/node-api-externs.js b/test/screenshot/infra/types/node-api-externs.js new file mode 100644 index 00000000000..081021d010f --- /dev/null +++ b/test/screenshot/infra/types/node-api-externs.js @@ -0,0 +1,90 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Node.js API + */ + + +/** + * @typedef {{ + * cwd: ?string, + * env: ?Object, + * argv0: ?string, + * stdio: ?Array, + * detached: ?boolean, + * uid: ?number, + * gid: ?number, + * shell: ?boolean, + * windowsVerbatimArguments: ?boolean, + * windowsHide: ?boolean, + * }} ChildProcessSpawnOptions + */ + +/** + * @typedef {{ + * status: number, + * signal: ?string, + * pid: number, + * }} ChildProcessSpawnResult + */ + + +/* + * Resemble.js API + */ + + +/** + * @typedef {{ + * rawMisMatchPercentage: number, + * misMatchPercentage: string, + * diffBounds: !ResembleApiBoundingBox, + * analysisTime: number, + * getImageDataUrl: function(text: string): string, + * getBuffer: function(includeOriginal: boolean): !Buffer, + * }} ResembleApiComparisonResult + */ + +/** + * @typedef {{ + * top: number, + * left: number, + * bottom: number, + * right: number, + * }} ResembleApiBoundingBox + */ + +/** + * @typedef {{ + * r: number, g: number, b: number, a: number + * }} RGBA + */ + + +/* + * ps-node API + */ + + +/** + * @typedef {{ + * pid: number, + * ppid: number, + * command: string, + * arguments: !Array, + * }} PsNodeProcess + */ diff --git a/test/screenshot/infra/types/report-ui-types.js b/test/screenshot/infra/types/report-ui-types.js new file mode 100644 index 00000000000..e4884d1fbb3 --- /dev/null +++ b/test/screenshot/infra/types/report-ui-types.js @@ -0,0 +1,69 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Report UI + */ + + +/** + * @typedef {{ + * checkedUserAgentCbEls: !Array, + * uncheckedUserAgentCbEls: !Array, + * unreviewedUserAgentCbEls: !Array, + * changelistDict: !ReportUiChangelistDict, + * reviewStatusCountDict: !ReportUiReviewStatusCountDict, + * }} ReportUiState + */ + +/** + * @typedef {{ + * changed: !ReportUiChangelistState, + * added: !ReportUiChangelistState, + * removed: !ReportUiChangelistState, + * }} ReportUiChangelistDict + */ + +/** + * @typedef {{ + * cbEl: !HTMLInputElement, + * countEl: !HTMLElement, + * reviewStatusEl: !HTMLElement, + * checkedUserAgentCbEls: !Array, + * uncheckedUserAgentCbEls: !Array, + * reviewStatusCountDict: !ReportUiReviewStatusCountDict, + * pageDict: !ReportUiPageDict, + * }} ReportUiChangelistState + */ + +/** + * @typedef {!Object} ReportUiPageDict + */ + +/** + * @typedef {{ + * cbEl: !HTMLInputElement, + * countEl: !HTMLElement, + * reviewStatusEl: !HTMLElement, + * checkedUserAgentCbEls: !Array, + * uncheckedUserAgentCbEls: !Array, + * reviewStatusCountDict: !ReportUiReviewStatusCountDict, + * }} ReportUiPageState + */ + +/** + * @typedef {!Object} ReportUiReviewStatusCountDict + */ diff --git a/test/screenshot/lib/controller.js b/test/screenshot/lib/controller.js deleted file mode 100644 index 0d3c226d4c3..00000000000 --- a/test/screenshot/lib/controller.js +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Copyright 2018 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const mdcProto = require('../proto/mdc.pb').mdc.proto; -const {GitRevision} = mdcProto; - -const Cli = require('./cli'); -const CloudStorage = require('./cloud-storage'); -const Duration = require('./duration'); -const GitRepo = require('./git-repo'); -const GoldenIo = require('./golden-io'); -const Logger = require('./logger'); -const ReportBuilder = require('./report-builder'); -const ReportWriter = require('./report-writer'); -const SeleniumApi = require('./selenium-api'); -const {ExitCode} = require('./constants'); - -class Controller { - constructor() { - /** - * @type {!Cli} - * @private - */ - this.cli_ = new Cli(); - - /** - * @type {!CloudStorage} - * @private - */ - this.cloudStorage_ = new CloudStorage(); - - /** - * @type {!GitRepo} - * @private - */ - this.gitRepo_ = new GitRepo(); - - /** - * @type {!GoldenIo} - * @private - */ - this.goldenIo_ = new GoldenIo(); - - /** - * @type {!Logger} - * @private - */ - this.logger_ = new Logger(__filename); - - /** - * @type {!ReportBuilder} - * @private - */ - this.reportBuilder_ = new ReportBuilder(); - - /** - * @type {!ReportWriter} - * @private - */ - this.reportWriter_ = new ReportWriter(); - - /** - * @type {!SeleniumApi} - * @private - */ - this.seleniumApi_ = new SeleniumApi(); - } - - /** - * @return {!Promise} - */ - async initForApproval() { - const runReportJsonUrl = this.cli_.runReportJsonUrl; - return this.reportBuilder_.initForApproval({runReportJsonUrl}); - } - - /** - * @return {!Promise} - */ - async initForCapture() { - const isOnline = await this.cli_.isOnline(); - const shouldFetch = this.cli_.shouldFetch; - if (isOnline && shouldFetch) { - await this.gitRepo_.fetch(); - } - return this.reportBuilder_.initForCapture(); - } - - /** - * @return {!Promise} - */ - async initForDemo() { - const isOnline = await this.cli_.isOnline(); - const shouldFetch = this.cli_.shouldFetch; - if (isOnline && shouldFetch) { - await this.gitRepo_.fetch(); - } - return this.reportBuilder_.initForDemo(); - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @return {{isTestable: boolean, prNumber: ?number}} - */ - checkIsTestable(reportData) { - const goldenGitRevision = reportData.meta.golden_diff_base.git_revision; - const shouldSkipScreenshotTests = - goldenGitRevision && - goldenGitRevision.type === GitRevision.Type.TRAVIS_PR && - goldenGitRevision.pr_file_paths.length === 0; - - return { - isTestable: !shouldSkipScreenshotTests, - prNumber: goldenGitRevision ? goldenGitRevision.pr_number : null, - }; - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @return {!Promise} - */ - async uploadAllAssets(reportData) { - this.logger_.foldStart('screenshot.upload', 'Controller#uploadAllAssets()'); - await this.cloudStorage_.uploadAllAssets(reportData); - this.logger_.foldEnd('screenshot.upload'); - return reportData; - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @return {!Promise} - */ - async captureAllPages(reportData) { - this.logger_.foldStart('screenshot.capture', 'Controller#captureAllPages()'); - await this.seleniumApi_.captureAllPages(reportData); - await this.cloudStorage_.uploadAllScreenshots(reportData); - this.logger_.foldEnd('screenshot.capture'); - return reportData; - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @return {!Promise} - */ - async compareAllScreenshots(reportData) { - this.logger_.foldStart('screenshot.compare', 'Controller#compareAllScreenshots()'); - - await this.reportBuilder_.populateScreenshotMaps(reportData.user_agents, reportData.screenshots); - await this.cloudStorage_.uploadAllDiffs(reportData); - - this.logComparisonResults_(reportData); - - // TODO(acdvorak): Where should this go? - const meta = reportData.meta; - meta.end_time_iso_utc = new Date().toISOString(); - meta.duration_ms = Duration.elapsed(meta.start_time_iso_utc, meta.end_time_iso_utc).toMillis(); - - this.logger_.foldEnd('screenshot.compare'); - - return reportData; - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @return {!Promise} - */ - async generateReportPage(reportData) { - this.logger_.foldStart('screenshot.report', 'Controller#generateReportPage()'); - - await this.reportWriter_.generateReportPage(reportData); - await this.cloudStorage_.uploadDiffReport(reportData); - - this.logger_.foldEnd('screenshot.report'); - - // TODO(acdvorak): Store this directly in the proto so we don't have to recalculate it all over the place - const numChanges = - reportData.screenshots.changed_screenshot_list.length + - reportData.screenshots.added_screenshot_list.length + - reportData.screenshots.removed_screenshot_list.length; - - if (numChanges > 0) { - this.logger_.error(`\n\n${numChanges} screenshot${numChanges === 1 ? '' : 's'} changed!\n`); - } else { - this.logger_.log(`\n\n${numChanges} screenshot${numChanges === 1 ? '' : 's'} changed!\n`); - } - this.logger_.log('Diff report:', Logger.colors.bold.red(reportData.meta.report_html_file.public_url)); - - return reportData; - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @return {!Promise} - */ - async getTestExitCode(reportData) { - const isOnline = await this.cli_.isOnline(); - - // TODO(acdvorak): Store this directly in the proto so we don't have to recalculate it all over the place - const numChanges = - reportData.screenshots.changed_screenshot_list.length + - reportData.screenshots.added_screenshot_list.length + - reportData.screenshots.removed_screenshot_list.length; - - if (numChanges === 0) { - return ExitCode.OK; - } - - if (isOnline) { - if (process.env.TRAVIS === 'true') { - return ExitCode.OK; - } - return ExitCode.CHANGES_FOUND; - } - - // Allow the report HTTP server to keep running by waiting for a promise that never resolves. - console.log('\nPress Ctrl-C to kill the report server'); - await new Promise(() => {}); - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @return {!Promise} - */ - async approveChanges(reportData) { - /** @type {!GoldenFile} */ - const newGoldenFile = await this.reportBuilder_.approveChanges(reportData); - await this.goldenIo_.writeToLocalFile(newGoldenFile); - return reportData; - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @private - */ - logComparisonResults_(reportData) { - console.log(''); - this.logComparisonResultSet_('Skipped', reportData.screenshots.skipped_screenshot_list); - this.logComparisonResultSet_('Unchanged', reportData.screenshots.unchanged_screenshot_list); - this.logComparisonResultSet_('Removed', reportData.screenshots.removed_screenshot_list); - this.logComparisonResultSet_('Added', reportData.screenshots.added_screenshot_list); - this.logComparisonResultSet_('Changed', reportData.screenshots.changed_screenshot_list); - } - - /** - * @param {!Array} screenshots - * @private - */ - logComparisonResultSet_(title, screenshots) { - console.log(`${title} ${screenshots.length} screenshot${screenshots.length === 1 ? '' : 's'}:`); - for (const screenshot of screenshots) { - console.log(` - ${screenshot.html_file_path} > ${screenshot.user_agent.alias}`); - } - console.log(''); - } -} - -module.exports = Controller; diff --git a/test/screenshot/lib/externs.js b/test/screenshot/lib/externs.js deleted file mode 100644 index bff9ae87d27..00000000000 --- a/test/screenshot/lib/externs.js +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright 2018 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* eslint-disable no-unused-vars */ - - -/* - * Report UI - */ - - -/** - * @typedef {{ - * checkedUserAgentCbEls: !Array, - * uncheckedUserAgentCbEls: !Array, - * unreviewedUserAgentCbEls: !Array, - * changelistDict: !ReportUiChangelistDict, - * reviewStatusCountDict: !ReportUiReviewStatusCountDict, - * }} - */ -let ReportUiState; - -/** - * @typedef {{ - * changed: !ReportUiChangelistState, - * added: !ReportUiChangelistState, - * removed: !ReportUiChangelistState, - * }} - */ -let ReportUiChangelistDict; - -/** - * @typedef {{ - * cbEl: !HTMLInputElement, - * countEl: !HTMLElement, - * reviewStatusEl: !HTMLElement, - * checkedUserAgentCbEls: !Array, - * uncheckedUserAgentCbEls: !Array, - * reviewStatusCountDict: !ReportUiReviewStatusCountDict, - * pageDict: !ReportUiPageDict, - * }} - */ -let ReportUiChangelistState; - -/** - * @typedef {!Object} - */ -let ReportUiPageDict; - -/** - * @typedef {{ - * cbEl: !HTMLInputElement, - * countEl: !HTMLElement, - * reviewStatusEl: !HTMLElement, - * checkedUserAgentCbEls: !Array, - * uncheckedUserAgentCbEls: !Array, - * reviewStatusCountDict: !ReportUiReviewStatusCountDict, - * }} - */ -let ReportUiPageState; - -/** - * @typedef {!Object} - */ -let ReportUiReviewStatusCountDict; - - -/* - * CLI args - */ - - -/** - * @typedef {{ - * optionNames: !Array, - * description: string, - * isRequired: ?boolean, - * type: ?string, - * defaultValue: ?*, - * exampleValue: ?string, - * }} - */ -let CliOptionConfig; - - -/* - * Resemble.js API externs - */ - - -/** - * @typedef {{ - * rawMisMatchPercentage: number, - * misMatchPercentage: string, - * diffBounds: !ResembleApiBoundingBox, - * analysisTime: number, - * getImageDataUrl: function(text: string): string, - * getBuffer: function(includeOriginal: boolean): !Buffer, - * }} - */ -let ResembleApiComparisonResult; - -/** - * @typedef {{ - * top: number, - * left: number, - * bottom: number, - * right: number, - * }} - */ -let ResembleApiBoundingBox; - - -/* - * ps-node API externs - */ - - -/** - * @typedef {{ - * pid: number, - * ppid: number, - * command: string, - * arguments: !Array, - * }} - */ -let PsNodeProcess; - - -/* - * Node.js API - */ - - -/** - * @typedef {{ - * cwd: ?string, - * env: ?Object, - * argv0: ?string, - * stdio: ?Array, - * detached: ?boolean, - * uid: ?number, - * gid: ?number, - * shell: ?boolean, - * windowsVerbatimArguments: ?boolean, - * windowsHide: ?boolean, - * }} - */ -let ChildProcessSpawnOptions; - -/** - * @typedef {{ - * status: number, - * signal: ?string, - * pid: number, - * }} - */ -let ChildProcessSpawnResult; - - -/* - * Image cropping - */ - - -/** - * @typedef {{r: number, g: number, b: number, a: number}} - */ -let RGBA; diff --git a/test/screenshot/lib/github-api.js b/test/screenshot/lib/github-api.js deleted file mode 100644 index 33567af491c..00000000000 --- a/test/screenshot/lib/github-api.js +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright 2018 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -const octocat = require('@octokit/rest'); -const GitRepo = require('./git-repo'); - -class GitHubApi { - constructor() { - this.gitRepo_ = new GitRepo(); - this.octocat_ = octocat(); - this.authenticate_(); - } - - /** - * @private - */ - authenticate_() { - let token; - - try { - token = require('../auth/github.json').api_key.personal_access_token; - } catch (err) { - // Not running on Travis - return; - } - - this.octocat_.authenticate({ - type: 'oauth', - token: token, - }); - } - - /** - * @return {{PENDING: string, SUCCESS: string, FAILURE: string, ERROR: string}} - * @constructor - */ - static get PullRequestState() { - return { - PENDING: 'pending', - SUCCESS: 'success', - FAILURE: 'failure', - ERROR: 'error', - }; - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @return {Promise} - */ - async setPullRequestStatus(reportData) { - const meta = reportData.meta; - const prNumber = Number(process.env.TRAVIS_PULL_REQUEST); - if (!prNumber) { - return; - } - - const screenshots = reportData.screenshots; - const numChanges = - screenshots.changed_screenshot_list.length + - screenshots.added_screenshot_list.length + - screenshots.removed_screenshot_list.length; - const reportFileUrl = meta.report_html_file ? meta.report_html_file.public_url : null; - - let state; - let targetUrl; - let description; - - if (reportFileUrl) { - if (numChanges > 0) { - state = GitHubApi.PullRequestState.FAILURE; - description = `${numChanges} screenshots differ from PR's golden.json`; - } else { - state = GitHubApi.PullRequestState.SUCCESS; - description = "All screenshots match PR's golden.json"; - } - - targetUrl = meta.report_html_file.public_url; - } else { - const numScreenshotsFormatted = screenshots.runnable_screenshot_list.length.toLocaleString(); - state = GitHubApi.PullRequestState.PENDING; - targetUrl = `https://travis-ci.org/material-components/material-components-web/jobs/${process.env.TRAVIS_JOB_ID}`; - description = `Running ${numScreenshotsFormatted} screenshot tests`; - } - - return await this.createStatus_({state, targetUrl, description}); - } - - async setPullRequestError() { - const prNumber = Number(process.env.TRAVIS_PULL_REQUEST); - if (!prNumber) { - return; - } - - return await this.createStatus_({ - state: GitHubApi.PullRequestState.ERROR, - target_url: `https://travis-ci.org/material-components/material-components-web/jobs/${process.env.TRAVIS_JOB_ID}`, - description: 'Error running screenshot tests', - }); - } - - /** - * @param {string} state - * @param {string} targetUrl - * @param {string=} description - * @return {!Promise<*>} - * @private - */ - async createStatus_({state, targetUrl, description = undefined}) { - return await this.octocat_.repos.createStatus({ - owner: 'material-components', - repo: 'material-components-web', - sha: await this.gitRepo_.getFullCommitHash(process.env.TRAVIS_PULL_REQUEST_SHA), - state, - target_url: targetUrl, - description, - context: 'screenshot-test/butter-bot', - }); - } - - /** - * @param {string=} branch - * @return {!Promise} - */ - async getPullRequestNumber(branch = undefined) { - branch = branch || await this.gitRepo_.getBranchName(); - - const allPRs = await this.octocat_.pullRequests.getAll({ - owner: 'material-components', - repo: 'material-components-web', - per_page: 100, - }); - - const filteredPRs = allPRs.data.filter((pr) => pr.head.ref === branch); - - const pr = filteredPRs[0]; - return pr ? pr.number : null; - } - - /** - * @param prNumber - * @return {!Promise>} - */ - async getPullRequestFiles(prNumber) { - /** @type {!github.proto.PullRequestFileResponse} */ - const fileResponse = await this.octocat_.pullRequests.getFiles({ - owner: 'material-components', - repo: 'material-components-web', - number: prNumber, - per_page: 300, - }); - return fileResponse.data; - } -} - -module.exports = GitHubApi; diff --git a/test/screenshot/lib/selenium-api.js b/test/screenshot/lib/selenium-api.js deleted file mode 100644 index 03a4b785e96..00000000000 --- a/test/screenshot/lib/selenium-api.js +++ /dev/null @@ -1,475 +0,0 @@ -/* - * Copyright 2018 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict'; - -const Jimp = require('jimp'); -const UserAgentParser = require('useragent'); -const fs = require('mz/fs'); -const mkdirp = require('mkdirp'); -const path = require('path'); - -const mdcProto = require('../proto/mdc.pb').mdc.proto; -const seleniumProto = require('../proto/selenium.pb').selenium.proto; - -const {Screenshot, TestFile, UserAgent} = mdcProto; -const {CaptureState} = Screenshot; -const {BrowserVendorType, FormFactorType, Navigator} = UserAgent; -const {RawCapabilities} = seleniumProto; - -const CbtApi = require('./cbt-api'); -const Cli = require('./cli'); -const Constants = require('./constants'); -const Duration = require('./duration'); -const ImageCropper = require('./image-cropper'); -const ImageDiffer = require('./image-differ'); -const {Browser, Builder, By, until} = require('selenium-webdriver'); -const {CBT_CONCURRENCY_POLL_INTERVAL_MS, CBT_CONCURRENCY_MAX_WAIT_MS} = Constants; -const {SELENIUM_FONT_LOAD_WAIT_MS} = Constants; - -class SeleniumApi { - constructor() { - /** - * @type {!CbtApi} - * @private - */ - this.cbtApi_ = new CbtApi(); - - /** - * @type {!Cli} - * @private - */ - this.cli_ = new Cli(); - - /** - * @type {!ImageCropper} - * @private - */ - this.imageCropper_ = new ImageCropper(); - - /** - * @type {!ImageDiffer} - * @private - */ - this.imageDiffer_ = new ImageDiffer(); - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @return {!Promise} - */ - async captureAllPages(reportData) { - const runnableUserAgents = reportData.user_agents.runnable_user_agents; - let queuedUserAgents = runnableUserAgents.slice(); - let runningUserAgents; - - function getLoggableAliases(userAgentAliases) { - return userAgentAliases.length > 0 ? userAgentAliases.join(', ') : '(none)'; - } - - while (queuedUserAgents.length > 0) { - const maxParallelTests = await this.getMaxParallelTests_(); - runningUserAgents = queuedUserAgents.slice(0, maxParallelTests); - queuedUserAgents = queuedUserAgents.slice(maxParallelTests); - const runningUserAgentAliases = runningUserAgents.map((ua) => ua.alias); - const queuedUserAgentAliases = queuedUserAgents.map((ua) => ua.alias); - const runningUserAgentLoggable = getLoggableAliases(runningUserAgentAliases); - const queuedUserAgentLoggable = getLoggableAliases(queuedUserAgentAliases); - console.log('Running user agents:', runningUserAgentLoggable); - console.log('Queued user agents:', queuedUserAgentLoggable); - await this.captureAllPagesInAllBrowsers_({reportData, userAgents: runningUserAgents}); - } - - return reportData; - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @param {!Array} userAgents - * @return {!Promise} - * @private - */ - async captureAllPagesInAllBrowsers_({reportData, userAgents}) { - const promises = []; - for (const userAgent of userAgents) { - promises.push(this.captureAllPagesInOneBrowser_({reportData, userAgent})); - } - await Promise.all(promises); - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @param {!mdc.proto.UserAgent} userAgent - * @return {!Promise} - * @private - */ - async captureAllPagesInOneBrowser_({reportData, userAgent}) { - /** @type {!IWebDriver} */ - const driver = await this.createWebDriver_({reportData, userAgent}); - - const logResult = (verb) => { - /* eslint-disable camelcase */ - const {os_name, os_version, browser_name, browser_version} = userAgent.navigator; - console.log(`${verb} ${browser_name} ${browser_version} on ${os_name} ${os_version}!`); - /* eslint-enable camelcase */ - }; - - try { - await this.driveBrowser_({reportData, userAgent, driver}); - logResult('Finished'); - } catch (err) { - logResult('Failed'); - throw err; - } finally { - logResult('Quitting'); - await driver.quit(); - } - } - - /** - * @return {!Promise} - * @private - */ - async getMaxParallelTests_() { - const isOnline = await this.cli_.isOnline(); - if (!isOnline) { - return 1; - } - - const startTimeMs = Date.now(); - - while (true) { - /** @type {!mdc.proto.cbt.CbtConcurrencyStats} */ - const stats = await this.cbtApi_.fetchConcurrencyStats(); - const active = stats.active_concurrent_selenium_tests; - const max = stats.max_concurrent_selenium_tests; - - if (active === max) { - const elapsedTimeMs = Date.now() - startTimeMs; - const elapsedTimeHuman = Duration.millis(elapsedTimeMs).toHumanShort(); - if (elapsedTimeMs > CBT_CONCURRENCY_MAX_WAIT_MS) { - throw new Error(`Timed out waiting for CBT resources to become available after ${elapsedTimeHuman}`); - } - - const waitTimeMs = CBT_CONCURRENCY_POLL_INTERVAL_MS; - const waitTimeHuman = Duration.millis(waitTimeMs).toHumanShort(); - console.warn( - `Parallel execution limit reached. ${max} tests are already running on CBT. Will retry in ${waitTimeHuman}...` - ); - await this.sleep_(waitTimeMs); - continue; - } - - if (this.cli_.maxParallels) { - return max - active; - } - - // If nobody else is running any tests, run half the number of concurrent tests allowed by our CBT account. - // This gives us _some_ parallelism while still allowing other users to run their tests. - // If someone else is already running tests, only run one test at a time. - return active === 0 ? Math.ceil(max / 2) : 1; - } - } - - /** - * @param {!mdc.proto.ReportData} reportData - * @param {!mdc.proto.UserAgent} userAgent - * @return {!Promise} - */ - async createWebDriver_({reportData, userAgent}) { - const meta = reportData.meta; - const driverBuilder = new Builder(); - - /** @type {!selenium.proto.RawCapabilities} */ - const desiredCapabilities = await this.getDesiredCapabilities_({meta, userAgent}); - userAgent.desired_capabilities = desiredCapabilities; - driverBuilder.withCapabilities(desiredCapabilities); - - const isOnline = await this.cli_.isOnline(); - if (isOnline) { - driverBuilder.usingServer(this.cbtApi_.getSeleniumServerUrl()); - } - - console.log(`Starting ${userAgent.alias}...`); - - /** @type {!IWebDriver} */ - const driver = await driverBuilder.build(); - - /** @type {!selenium.proto.RawCapabilities} */ - const actualCapabilities = await this.getActualCapabilities_(driver); - - /** @type {!mdc.proto.UserAgent.Navigator} */ - const navigator = await this.getUserAgentNavigator_(driver); - - /* eslint-disable camelcase */ - const {os_name, os_version, browser_name, browser_version} = navigator; - - userAgent.navigator = navigator; - userAgent.actual_capabilities = actualCapabilities; - userAgent.browser_version_value = browser_version; - userAgent.image_filename_suffix = this.getImageFileNameSuffix_(userAgent); - - console.log(`Started ${browser_name} ${browser_version} on ${os_name} ${os_version}!`); - /* eslint-enable camelcase */ - - return driver; - } - - /** - * @param {!mdc.proto.ReportMeta} meta - * @param {!mdc.proto.UserAgent} userAgent - * @return {!selenium.proto.RawCapabilities} - * @private - */ - async getDesiredCapabilities_({meta, userAgent}) { - const isOnline = await this.cli_.isOnline(); - if (isOnline) { - return await this.cbtApi_.getDesiredCapabilities_({meta, userAgent}); - } - - const browserVendorMap = { - [BrowserVendorType.CHROME]: Browser.CHROME, - [BrowserVendorType.EDGE]: Browser.EDGE, - [BrowserVendorType.FIREFOX]: Browser.FIREFOX, - [BrowserVendorType.IE]: Browser.IE, - [BrowserVendorType.SAFARI]: Browser.SAFARI, - }; - - return RawCapabilities.create({ - browserName: browserVendorMap[userAgent.browser_vendor_type], - }); - } - - /** - * @param {!IWebDriver} driver - * @return {!Promise} - * @private - */ - async getActualCapabilities_(driver) { - /** @type {!Capabilities} */ - const driverCaps = await driver.getCapabilities(); - - /** @type {!selenium.proto.RawCapabilities} */ - const actualCaps = RawCapabilities.create(); - - for (const key of driverCaps.keys()) { - actualCaps[key] = driverCaps.get(key); - } - - return actualCaps; - } - - /** - * @param {!IWebDriver} driver - * @return {mdc.proto.UserAgent.Navigator} - * @private - */ - async getUserAgentNavigator_(driver) { - const uaString = await driver.executeScript('return window.navigator.userAgent;'); - const uaParsed = UserAgentParser.parse(uaString); - - // TODO(acdvorak): Clean this up - const navigator = Navigator.create({ - os_name: uaParsed.os.family.toLowerCase().startsWith('mac') ? 'Mac' : uaParsed.os.family, - os_version: uaParsed.os.toVersion().replace(/(?:\.0)+$/, ''), - browser_name: uaParsed.family.replace(/\s*Mobile\s*/, ''), - browser_version: uaParsed.toVersion().replace(/(?:\.0)+$/, ''), - }); - - // TODO(acdvorak): De-dupe - /* eslint-disable camelcase */ - const {browser_name, browser_version, os_name, os_version} = navigator; - navigator.full_name = [browser_name, browser_version, 'on', os_name, os_version].join(' '); - /* eslint-enable camelcase */ - - return navigator; - } - - /** - * @param {!mdc.proto.UserAgent} userAgent - * @return {string} - * @private - */ - getImageFileNameSuffix_(userAgent) { - /* eslint-disable camelcase */ - const {os_name, browser_name, browser_version} = userAgent.navigator; - return [os_name, browser_name, browser_version].map((value) => { - // TODO(acdvorak): Clean this up - return value.toLowerCase().replace(/\..+$/, '').replace(/[^a-z0-9]+/ig, ''); - }).join('_'); - /* eslint-enable camelcase */ - } - /** - * @param {!mdc.proto.ReportData} reportData - * @param {!mdc.proto.UserAgent} userAgent - * @param {!IWebDriver} driver - * @return {!Promise} - * @private - */ - async driveBrowser_({reportData, userAgent, driver}) { - if (userAgent.form_factor_type === FormFactorType.DESKTOP) { - /** @type {!Window} */ - const window = driver.manage().window(); - - // Resize the browser window to roughly match a mobile browser. - // This reduces the byte size of the screenshot image, which speeds up the test significantly. - // TODO(acdvorak): Set this value dynamically - await window.setRect({x: 0, y: 0, width: 400, height: 800}).catch(() => undefined); - } - - const meta = reportData.meta; - - /** @type {!Array} */ - const screenshotQueue = reportData.screenshots.runnable_screenshot_browser_map[userAgent.alias].screenshots; - - for (const screenshot of screenshotQueue) { - screenshot.capture_state = CaptureState.RUNNING; - - const diffImageResult = await this.takeScreenshotWithRetries_({driver, userAgent, screenshot, meta}); - - screenshot.capture_state = CaptureState.DIFFED; - screenshot.diff_image_result = diffImageResult; - screenshot.diff_image_file = diffImageResult.diff_image_file; - - if (diffImageResult.has_changed) { - reportData.screenshots.changed_screenshot_list.push(screenshot); - } else { - reportData.screenshots.unchanged_screenshot_list.push(screenshot); - } - } - } - - /** - * @param {!IWebDriver} driver - * @param {!mdc.proto.UserAgent} userAgent - * @param {!mdc.proto.Screenshot} screenshot - * @param {!mdc.proto.ReportMeta} meta - * @return {!Promise} - * @private - */ - async takeScreenshotWithRetries_({driver, userAgent, screenshot, meta}) { - const htmlFilePath = screenshot.html_file_path; - let delayMs = 0; - - /** @type {?mdc.proto.DiffImageResult} */ - let diffImageResult = null; - - /** @type {?number} */ - let changedPixelCount = null; - - while (screenshot.retry_count <= screenshot.max_retries) { - if (screenshot.retry_count > 0) { - const {width, height} = diffImageResult.diff_image_dimensions; - const retryMsg = `Retrying ${htmlFilePath} > ${userAgent.alias}`; - const countMsg = `attempt ${screenshot.retry_count} of ${screenshot.max_retries}`; - const pixelMsg = `${changedPixelCount.toLocaleString()} pixels differed`; - const deltaMsg = `${diffImageResult.changed_pixel_percentage}% of ${width}x${height}`; - console.warn(`${retryMsg} (${countMsg}). ${pixelMsg} (${deltaMsg})`); - delayMs = 1000; - } - - screenshot.actual_image_file = await this.takeScreenshotWithoutRetries_({ - meta, screenshot, userAgent, driver, delayMs, - }); - diffImageResult = await this.imageDiffer_.compareOneScreenshot({meta, screenshot}); - - if (!diffImageResult.has_changed) { - break; - } - - changedPixelCount = diffImageResult.changed_pixel_count; - screenshot.retry_count++; - } - - return diffImageResult; - } - - /** - * @param {!mdc.proto.ReportMeta} meta - * @param {!mdc.proto.Screenshot} screenshot - * @param {!mdc.proto.UserAgent} userAgent - * @param {!IWebDriver} driver - * @param {number=} delayMs - * @return {!Promise} - * @private - */ - async takeScreenshotWithoutRetries_({meta, screenshot, userAgent, driver, delayMs = 0}) { - const htmlFilePath = screenshot.html_file_path; - const htmlFileUrl = screenshot.actual_html_file.public_url; - const imageBuffer = await this.capturePageAsPng_({driver, userAgent, url: htmlFileUrl, delayMs}); - const imageFileNameSuffix = userAgent.image_filename_suffix; - const imageFilePathRelative = `${htmlFilePath}.${imageFileNameSuffix}.png`; - const imageFilePathAbsolute = path.resolve(meta.local_screenshot_image_base_dir, imageFilePathRelative); - - mkdirp.sync(path.dirname(imageFilePathAbsolute)); - await fs.writeFile(imageFilePathAbsolute, imageBuffer, {encoding: null}); - - return TestFile.create({ - relative_path: imageFilePathRelative, - absolute_path: imageFilePathAbsolute, - public_url: meta.remote_upload_base_url + meta.remote_upload_base_dir + imageFilePathRelative, - }); - } - - /** - * @param {!IWebDriver} driver - * @param {!mdc.proto.UserAgent} userAgent - * @param {string} url - * @param {number=} delayMs - * @return {!Promise} Buffer containing PNG image data for the cropped screenshot image - * @private - */ - async capturePageAsPng_({driver, userAgent, url, delayMs = 0}) { - console.log(`GET ${url} > ${userAgent.alias}...`); - - const isOnline = await this.cli_.isOnline(); - const fontTimeoutMs = isOnline ? SELENIUM_FONT_LOAD_WAIT_MS : 1; - - await driver.get(url); - await driver.executeScript('window.mdc.testFixture.attachFontObserver();'); - await driver.wait(until.elementLocated(By.css('[data-fonts-loaded]')), fontTimeoutMs).catch(() => 0); - - if (delayMs > 0) { - await driver.sleep(delayMs); - } - - const uncroppedImageBuffer = Buffer.from(await driver.takeScreenshot(), 'base64'); - const croppedImageBuffer = await this.imageCropper_.autoCropImage(uncroppedImageBuffer); - - const uncroppedJimpImage = await Jimp.read(uncroppedImageBuffer); - const croppedJimpImage = await Jimp.read(croppedImageBuffer); - - const {width: uncroppedWidth, height: uncroppedHeight} = uncroppedJimpImage.bitmap; - const {width: croppedWidth, height: croppedHeight} = croppedJimpImage.bitmap; - - console.info(` -Cropped ${url} > ${userAgent.alias} image from ${uncroppedWidth}x${uncroppedHeight} to ${croppedWidth}x${croppedHeight} -`.trim()); - - return croppedImageBuffer; - } - - /** - * @param {number} ms - * @return {!Promise} - * @private - */ - async sleep_(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } -} - -module.exports = SeleniumApi; diff --git a/test/screenshot/mdc-button/fixture.scss b/test/screenshot/mdc-button/fixture.scss deleted file mode 100644 index 05771f18ae4..00000000000 --- a/test/screenshot/mdc-button/fixture.scss +++ /dev/null @@ -1,20 +0,0 @@ -// -// Copyright 2018 Google Inc. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -.test-cell--button { - width: 171px; - height: 71px; -} diff --git a/test/screenshot/report/_collection.hbs b/test/screenshot/report/_collection.hbs index 66ff1e280dd..bb037ce104e 100644 --- a/test/screenshot/report/_collection.hbs +++ b/test/screenshot/report/_collection.hbs @@ -82,6 +82,7 @@ user_agent.alias }}{{/createCheckboxElement}} @@ -102,6 +103,9 @@ ../html_file_path user_agent.alias }}{{/createApprovalStatusElement}} + #
    diff --git a/test/screenshot/report/_footer.hbs b/test/screenshot/report/_footer.hbs index b7869b6170c..6a28ef74723 100644 --- a/test/screenshot/report/_footer.hbs +++ b/test/screenshot/report/_footer.hbs @@ -46,10 +46,10 @@ Collapse:
    @@ -72,6 +72,6 @@ - + diff --git a/test/screenshot/mdc-button/classes/baseline-link-with-icons.html b/test/screenshot/spec/mdc-button/classes/baseline-link-with-icons.html similarity index 94% rename from test/screenshot/mdc-button/classes/baseline-link-with-icons.html rename to test/screenshot/spec/mdc-button/classes/baseline-link-with-icons.html index b791bfaa1c4..589fdb8faf7 100644 --- a/test/screenshot/mdc-button/classes/baseline-link-with-icons.html +++ b/test/screenshot/spec/mdc-button/classes/baseline-link-with-icons.html @@ -19,13 +19,13 @@ Baseline Button Link With Icons - MDC Web Screenshot Test - - - + + + -
    +
    @@ -97,6 +97,6 @@ - + diff --git a/test/screenshot/mdc-button/classes/baseline-link-without-icons.html b/test/screenshot/spec/mdc-button/classes/baseline-link-without-icons.html similarity index 88% rename from test/screenshot/mdc-button/classes/baseline-link-without-icons.html rename to test/screenshot/spec/mdc-button/classes/baseline-link-without-icons.html index 97b3e98740d..a6fc7a758c3 100644 --- a/test/screenshot/mdc-button/classes/baseline-link-without-icons.html +++ b/test/screenshot/spec/mdc-button/classes/baseline-link-without-icons.html @@ -19,13 +19,13 @@ Baseline Button Link Without Icons - MDC Web Screenshot Test - - - + + + -
    +
    Link @@ -59,6 +59,6 @@ - + diff --git a/test/screenshot/mdc-button/classes/dense-button-with-icons.html b/test/screenshot/spec/mdc-button/classes/dense-button-with-icons.html similarity index 96% rename from test/screenshot/mdc-button/classes/dense-button-with-icons.html rename to test/screenshot/spec/mdc-button/classes/dense-button-with-icons.html index 4de789fbae0..e2bf0599a7c 100644 --- a/test/screenshot/mdc-button/classes/dense-button-with-icons.html +++ b/test/screenshot/spec/mdc-button/classes/dense-button-with-icons.html @@ -19,13 +19,13 @@ Dense Button Element With Icons - MDC Web Screenshot Test - - - + + + -
    +
    @@ -72,6 +72,6 @@ - + diff --git a/test/screenshot/mdc-button/classes/dense-link-with-icons.html b/test/screenshot/spec/mdc-button/classes/dense-link-with-icons.html similarity index 94% rename from test/screenshot/mdc-button/classes/dense-link-with-icons.html rename to test/screenshot/spec/mdc-button/classes/dense-link-with-icons.html index 92f125ceb1b..907a60fe354 100644 --- a/test/screenshot/mdc-button/classes/dense-link-with-icons.html +++ b/test/screenshot/spec/mdc-button/classes/dense-link-with-icons.html @@ -19,13 +19,13 @@ Dense Button Link With Icons - MDC Web Screenshot Test - - - + + + -
    +
    @@ -105,6 +105,6 @@ - + diff --git a/test/screenshot/mdc-button/classes/dense-link-without-icons.html b/test/screenshot/spec/mdc-button/classes/dense-link-without-icons.html similarity index 89% rename from test/screenshot/mdc-button/classes/dense-link-without-icons.html rename to test/screenshot/spec/mdc-button/classes/dense-link-without-icons.html index 278fca89ca1..992785d7537 100644 --- a/test/screenshot/mdc-button/classes/dense-link-without-icons.html +++ b/test/screenshot/spec/mdc-button/classes/dense-link-without-icons.html @@ -19,13 +19,13 @@ Dense Button Link Without Icons - MDC Web Screenshot Test - - - + + + -
    +
    Button @@ -59,6 +59,6 @@ - + diff --git a/test/screenshot/mdc-button/custom.scss b/test/screenshot/spec/mdc-button/fixture.scss similarity index 90% rename from test/screenshot/mdc-button/custom.scss rename to test/screenshot/spec/mdc-button/fixture.scss index 62d71251cf8..94f97789d69 100644 --- a/test/screenshot/mdc-button/custom.scss +++ b/test/screenshot/spec/mdc-button/fixture.scss @@ -14,14 +14,19 @@ // limitations under the License. // -@import "../../../packages/mdc-button/mixins"; -@import "../../../packages/mdc-theme/color-palette"; +@import "../../../../packages/mdc-button/mixins"; +@import "../../../../packages/mdc-theme/color-palette"; $custom-button-color: $material-color-red-300; $custom-button-custom-corner-radius: 8px; $custom-button-custom-outline-width: 4px; $custom-button-custom-horizontal-padding: 24px; +.test-cell--button { + width: 171px; + height: 71px; +} + .custom-button--ink-color { @include mdc-button-ink-color($custom-button-color); } diff --git a/test/screenshot/mdc-button/mixins/container-fill-color.html b/test/screenshot/spec/mdc-button/mixins/container-fill-color.html similarity index 84% rename from test/screenshot/mdc-button/mixins/container-fill-color.html rename to test/screenshot/spec/mdc-button/mixins/container-fill-color.html index 46e3d4fbee6..b429d2f8b2f 100644 --- a/test/screenshot/mdc-button/mixins/container-fill-color.html +++ b/test/screenshot/spec/mdc-button/mixins/container-fill-color.html @@ -19,14 +19,13 @@ container-fill-color Button Mixin - MDC Web Screenshot Test - - - - + + + -
    +
    @@ -44,6 +43,6 @@ - + diff --git a/test/screenshot/mdc-button/mixins/corner-radius.html b/test/screenshot/spec/mdc-button/mixins/corner-radius.html similarity index 89% rename from test/screenshot/mdc-button/mixins/corner-radius.html rename to test/screenshot/spec/mdc-button/mixins/corner-radius.html index 1e452c5c39e..3773867ecde 100644 --- a/test/screenshot/mdc-button/mixins/corner-radius.html +++ b/test/screenshot/spec/mdc-button/mixins/corner-radius.html @@ -19,14 +19,13 @@ corner-radius Button Mixin - MDC Web Screenshot Test - - - - + + + -
    +
    @@ -59,6 +58,6 @@ - + diff --git a/test/screenshot/mdc-button/mixins/filled-accessible.html b/test/screenshot/spec/mdc-button/mixins/filled-accessible.html similarity index 91% rename from test/screenshot/mdc-button/mixins/filled-accessible.html rename to test/screenshot/spec/mdc-button/mixins/filled-accessible.html index 9678215e18f..fd953216b22 100644 --- a/test/screenshot/mdc-button/mixins/filled-accessible.html +++ b/test/screenshot/spec/mdc-button/mixins/filled-accessible.html @@ -19,14 +19,13 @@ filled-accessible Button Mixin - MDC Web Screenshot Test - - - - + + + -
    +
    @@ -71,6 +70,6 @@ - + diff --git a/test/screenshot/mdc-button/mixins/horizontal-padding-baseline.html b/test/screenshot/spec/mdc-button/mixins/horizontal-padding-baseline.html similarity index 93% rename from test/screenshot/mdc-button/mixins/horizontal-padding-baseline.html rename to test/screenshot/spec/mdc-button/mixins/horizontal-padding-baseline.html index 91d7f8ff55b..3ddf5e79bf4 100644 --- a/test/screenshot/mdc-button/mixins/horizontal-padding-baseline.html +++ b/test/screenshot/spec/mdc-button/mixins/horizontal-padding-baseline.html @@ -19,14 +19,13 @@ horizontal-padding Baseline Button Mixin - MDC Web Screenshot Test - - - - + + + -
    +
    @@ -86,6 +85,6 @@ - + diff --git a/test/screenshot/mdc-button/mixins/horizontal-padding-dense.html b/test/screenshot/spec/mdc-button/mixins/horizontal-padding-dense.html similarity index 93% rename from test/screenshot/mdc-button/mixins/horizontal-padding-dense.html rename to test/screenshot/spec/mdc-button/mixins/horizontal-padding-dense.html index f69d20271e8..7625528a577 100644 --- a/test/screenshot/mdc-button/mixins/horizontal-padding-dense.html +++ b/test/screenshot/spec/mdc-button/mixins/horizontal-padding-dense.html @@ -19,14 +19,13 @@ horizontal-padding Dense Button Mixin - MDC Web Screenshot Test - - - - + + + -
    +
    @@ -86,6 +85,6 @@ - + diff --git a/test/screenshot/mdc-button/mixins/icon-color.html b/test/screenshot/spec/mdc-button/mixins/icon-color.html similarity index 94% rename from test/screenshot/mdc-button/mixins/icon-color.html rename to test/screenshot/spec/mdc-button/mixins/icon-color.html index 2d175244ac3..11bca0fccc2 100644 --- a/test/screenshot/mdc-button/mixins/icon-color.html +++ b/test/screenshot/spec/mdc-button/mixins/icon-color.html @@ -19,14 +19,13 @@ icon-color Button Mixin - MDC Web Screenshot Test - - - - + + + -
    +
    @@ -107,6 +106,6 @@ - + diff --git a/test/screenshot/mdc-button/mixins/stroke-color.html b/test/screenshot/spec/mdc-button/mixins/stroke-color.html similarity index 84% rename from test/screenshot/mdc-button/mixins/stroke-color.html rename to test/screenshot/spec/mdc-button/mixins/stroke-color.html index 4ed48e26ade..b82c581a1d8 100644 --- a/test/screenshot/mdc-button/mixins/stroke-color.html +++ b/test/screenshot/spec/mdc-button/mixins/stroke-color.html @@ -19,14 +19,13 @@ outline-color Button Mixin - MDC Web Screenshot Test - - - - + + + -
    +
    @@ -44,6 +43,6 @@ - + diff --git a/test/screenshot/mdc-button/mixins/stroke-width.html b/test/screenshot/spec/mdc-button/mixins/stroke-width.html similarity index 94% rename from test/screenshot/mdc-button/mixins/stroke-width.html rename to test/screenshot/spec/mdc-button/mixins/stroke-width.html index c0cea1ce16e..5fed1c11dd7 100644 --- a/test/screenshot/mdc-button/mixins/stroke-width.html +++ b/test/screenshot/spec/mdc-button/mixins/stroke-width.html @@ -19,14 +19,13 @@ outline-width Button Mixin - MDC Web Screenshot Test - - - - + + + -
    +
    @@ -107,6 +106,6 @@ - + diff --git a/test/screenshot/spec/mdc-checkbox/classes/baseline.html b/test/screenshot/spec/mdc-checkbox/classes/baseline.html new file mode 100644 index 00000000000..786ffc361e5 --- /dev/null +++ b/test/screenshot/spec/mdc-checkbox/classes/baseline.html @@ -0,0 +1,147 @@ + + + + + + Baseline Checkbox - MDC Web Screenshot Test + + + + + + + +
    +
    +
    +
    + +
    + + + +
    +
    +
    +
    + +
    +
    + +
    + + + +
    +
    +
    +
    + +
    +
    + +
    + + + +
    +
    +
    +
    + +
    +
    + +
    + + + +
    +
    +
    +
    + +
    +
    + +
    + + + +
    +
    +
    +
    + +
    +
    + +
    + + + +
    +
    +
    +
    +
    +
    + + + + + + + + + diff --git a/test/screenshot/spec/mdc-checkbox/fixture.js b/test/screenshot/spec/mdc-checkbox/fixture.js new file mode 100644 index 00000000000..2d10e4025c9 --- /dev/null +++ b/test/screenshot/spec/mdc-checkbox/fixture.js @@ -0,0 +1,2 @@ +document.getElementById('checkbox-indeterminate').indeterminate = true; +document.getElementById('checkbox-indeterminate-disabled').indeterminate = true; diff --git a/test/screenshot/mdc-icon-button/fixture.scss b/test/screenshot/spec/mdc-checkbox/fixture.scss similarity index 96% rename from test/screenshot/mdc-icon-button/fixture.scss rename to test/screenshot/spec/mdc-checkbox/fixture.scss index f81a618f3a0..12509de7371 100644 --- a/test/screenshot/mdc-icon-button/fixture.scss +++ b/test/screenshot/spec/mdc-checkbox/fixture.scss @@ -14,7 +14,7 @@ // limitations under the License. // -.test-cell--icon-button { +.test-cell--checkbox { width: 91px; height: 91px; } diff --git a/test/screenshot/spec/mdc-drawer/classes/permanent.html b/test/screenshot/spec/mdc-drawer/classes/permanent.html new file mode 100644 index 00000000000..18802a568b9 --- /dev/null +++ b/test/screenshot/spec/mdc-drawer/classes/permanent.html @@ -0,0 +1,132 @@ + + + + + + Permanent Drawer - MDC Web Screenshot Test + + + + + + + + + +
    + + +
    +
    +
    +
    + Permanent Drawer +
    +
    +
    + +
    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +
    +
    +
    + + + + + + + + diff --git a/test/screenshot/spec/mdc-drawer/classes/persistent.html b/test/screenshot/spec/mdc-drawer/classes/persistent.html new file mode 100644 index 00000000000..489c46611be --- /dev/null +++ b/test/screenshot/spec/mdc-drawer/classes/persistent.html @@ -0,0 +1,135 @@ + + + + + + Persistent Drawer - MDC Web Screenshot Test + + + + + + + + + +
    + + +
    +
    +
    +
    + + Persistent Drawer +
    +
    +
    + +
    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +
    +
    +
    + + + + + + + + + + diff --git a/test/screenshot/spec/mdc-drawer/classes/temporary.html b/test/screenshot/spec/mdc-drawer/classes/temporary.html new file mode 100644 index 00000000000..f202c83fff4 --- /dev/null +++ b/test/screenshot/spec/mdc-drawer/classes/temporary.html @@ -0,0 +1,135 @@ + + + + + + Temporary Drawer - MDC Web Screenshot Test + + + + + + + + + +
    + + +
    +
    +
    +
    + + Temporary Drawer +
    +
    +
    + +
    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +
    +
    +
    + + + + + + + + + + diff --git a/test/screenshot/spec/mdc-drawer/fixture.js b/test/screenshot/spec/mdc-drawer/fixture.js new file mode 100644 index 00000000000..6954f2eca62 --- /dev/null +++ b/test/screenshot/spec/mdc-drawer/fixture.js @@ -0,0 +1,36 @@ +/* + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const temporaryDrawerEl = document.querySelector('.mdc-drawer--temporary'); +const persistentDrawerEl = document.querySelector('.mdc-drawer--persistent'); + +if (temporaryDrawerEl) { + const MDCTemporaryDrawer = mdc.drawer.MDCTemporaryDrawer; + const temporaryDrawer = new MDCTemporaryDrawer(temporaryDrawerEl); + + document.querySelector('#test-drawer-menu-button').addEventListener('click', () => { + temporaryDrawer.open = !temporaryDrawer.open; + }); +} + +if (persistentDrawerEl) { + const MDCPersistentDrawer = mdc.drawer.MDCPersistentDrawer; + const persistentDrawer = new MDCPersistentDrawer(persistentDrawerEl); + + document.querySelector('#test-drawer-menu-button').addEventListener('click', () => { + persistentDrawer.open = !persistentDrawer.open; + }); +} diff --git a/test/screenshot/spec/mdc-drawer/fixture.scss b/test/screenshot/spec/mdc-drawer/fixture.scss new file mode 100644 index 00000000000..2d3c98a23d6 --- /dev/null +++ b/test/screenshot/spec/mdc-drawer/fixture.scss @@ -0,0 +1,48 @@ +// +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@import "../../../../packages/mdc-drawer/mixins"; +@import "../../../../packages/mdc-list/mixins"; +@import "../../../../packages/mdc-theme/color-palette"; + +$custom-drawer-color: $material-color-orange-900; + +.test-main--drawer { + display: flex; + flex-direction: row; +} + +.test-drawer-column { + display: flex; + flex-direction: column; +} + +.test-drawer-paragraph { + margin: 1em 0 0 0; + line-height: 1; +} + +.custom-drawer--fill-color { + @include mdc-drawer-fill-color($custom-drawer-color); +} + +.custom-drawer--fill-color-accessible { + @include mdc-drawer-fill-color-accessible($custom-drawer-color); +} + +.custom-drawer--ink-color { + @include mdc-drawer-ink-color($custom-drawer-color); +} diff --git a/test/screenshot/spec/mdc-drawer/mixins/fill-color-accessible.html b/test/screenshot/spec/mdc-drawer/mixins/fill-color-accessible.html new file mode 100644 index 00000000000..f4881967fa1 --- /dev/null +++ b/test/screenshot/spec/mdc-drawer/mixins/fill-color-accessible.html @@ -0,0 +1,132 @@ + + + + + + fill-color-accessible Drawer Mixin - MDC Web Screenshot Test + + + + + + + + + +
    + + +
    +
    +
    +
    + Permanent Drawer - fill-color-accessible Mixin +
    +
    +
    + +
    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +
    +
    +
    + + + + + + + + diff --git a/test/screenshot/spec/mdc-drawer/mixins/fill-color.html b/test/screenshot/spec/mdc-drawer/mixins/fill-color.html new file mode 100644 index 00000000000..f76d51e3332 --- /dev/null +++ b/test/screenshot/spec/mdc-drawer/mixins/fill-color.html @@ -0,0 +1,132 @@ + + + + + + fill-color Drawer Mixin - MDC Web Screenshot Test + + + + + + + + + +
    + + +
    +
    +
    +
    + Permanent Drawer - fill-color Mixin +
    +
    +
    + +
    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +
    +
    +
    + + + + + + + + diff --git a/test/screenshot/spec/mdc-drawer/mixins/ink-color.html b/test/screenshot/spec/mdc-drawer/mixins/ink-color.html new file mode 100644 index 00000000000..79cd1651e8f --- /dev/null +++ b/test/screenshot/spec/mdc-drawer/mixins/ink-color.html @@ -0,0 +1,132 @@ + + + + + + ink-color Drawer Mixin - MDC Web Screenshot Test + + + + + + + + + +
    + + +
    +
    +
    +
    + Permanent Drawer - ink-color Mixin +
    +
    +
    + +
    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit,
    + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
    + enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
    + aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
    + voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
    + occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
    + anim id est laborum. +

    +
    +
    +
    + + + + + + + + diff --git a/test/screenshot/mdc-fab/classes/baseline.html b/test/screenshot/spec/mdc-fab/classes/baseline.html similarity index 87% rename from test/screenshot/mdc-fab/classes/baseline.html rename to test/screenshot/spec/mdc-fab/classes/baseline.html index c46341cfdf8..aa7685a312b 100644 --- a/test/screenshot/mdc-fab/classes/baseline.html +++ b/test/screenshot/spec/mdc-fab/classes/baseline.html @@ -19,13 +19,13 @@ Baseline FAB (Floating Action Button) - MDC Web Screenshot Test - - - + + + -
    +
    @@ -77,6 +77,6 @@ - + diff --git a/test/screenshot/mdc-icon-button/custom.scss b/test/screenshot/spec/mdc-icon-button/fixture.scss similarity index 84% rename from test/screenshot/mdc-icon-button/custom.scss rename to test/screenshot/spec/mdc-icon-button/fixture.scss index 0d20454c37d..22570208188 100644 --- a/test/screenshot/mdc-icon-button/custom.scss +++ b/test/screenshot/spec/mdc-icon-button/fixture.scss @@ -14,12 +14,17 @@ // limitations under the License. // -@import "../../../packages/mdc-icon-button/mixins"; -@import "../../../packages/mdc-theme/color-palette"; +@import "../../../../packages/mdc-icon-button/mixins"; +@import "../../../../packages/mdc-theme/color-palette"; $custom-icon-button-icon-ink-color: $material-color-red-500; $custom-icon-button-size: 36px; +.test-cell--icon-button { + width: 91px; + height: 91px; +} + .custom-icon-button--ink-color { @include mdc-icon-button-ink-color($custom-icon-button-icon-ink-color); } diff --git a/test/screenshot/mdc-icon-button/mixins/icon-size.html b/test/screenshot/spec/mdc-icon-button/mixins/icon-size.html similarity index 92% rename from test/screenshot/mdc-icon-button/mixins/icon-size.html rename to test/screenshot/spec/mdc-icon-button/mixins/icon-size.html index 6bd583873a8..04bb079ba24 100644 --- a/test/screenshot/mdc-icon-button/mixins/icon-size.html +++ b/test/screenshot/spec/mdc-icon-button/mixins/icon-size.html @@ -20,13 +20,12 @@ icon-size Icon Button - MDC Web Screenshot Test - - - - + + + -
    +
    @@ -78,6 +77,6 @@ - + diff --git a/test/screenshot/mdc-icon-button/mixins/ink-color.html b/test/screenshot/spec/mdc-icon-button/mixins/ink-color.html similarity index 92% rename from test/screenshot/mdc-icon-button/mixins/ink-color.html rename to test/screenshot/spec/mdc-icon-button/mixins/ink-color.html index a687db5b2c6..fd40e429653 100644 --- a/test/screenshot/mdc-icon-button/mixins/ink-color.html +++ b/test/screenshot/spec/mdc-icon-button/mixins/ink-color.html @@ -20,13 +20,12 @@ ink-color Icon Button - MDC Web Screenshot Test - - - - + + + -
    +
    @@ -78,6 +77,6 @@ - + diff --git a/test/screenshot/spec/mdc-textfield/classes/baseline-textfield.html b/test/screenshot/spec/mdc-textfield/classes/baseline-textfield.html new file mode 100644 index 00000000000..1be4e61a6ac --- /dev/null +++ b/test/screenshot/spec/mdc-textfield/classes/baseline-textfield.html @@ -0,0 +1,61 @@ + + + + + + Baseline Text Field Element - MDC Web Screenshot Test + + + + + + + +
    +
    +
    +
    + + +
    +
    +
    + +
    +
    + + +
    + + + +
    +
    +
    +
    +
    +
    + + + + + + + + + + diff --git a/test/screenshot/spec/mdc-textfield/fixture.js b/test/screenshot/spec/mdc-textfield/fixture.js new file mode 100644 index 00000000000..1f072ea4821 --- /dev/null +++ b/test/screenshot/spec/mdc-textfield/fixture.js @@ -0,0 +1,3 @@ +[].forEach.call(document.querySelectorAll('.mdc-text-field'), function(el) { + mdc.textField.MDCTextField.attachTo(el); +}); diff --git a/test/screenshot/mdc-fab/fixture.scss b/test/screenshot/spec/mdc-textfield/fixture.scss similarity index 78% rename from test/screenshot/mdc-fab/fixture.scss rename to test/screenshot/spec/mdc-textfield/fixture.scss index 75aaf9a1093..4f9ed82738b 100644 --- a/test/screenshot/mdc-fab/fixture.scss +++ b/test/screenshot/spec/mdc-textfield/fixture.scss @@ -14,12 +14,10 @@ // limitations under the License. // -.test-cell--fab { - width: 81px; - height: 81px; -} +@import "../../../../packages/mdc-textfield/mixins"; +@import "../../../../packages/mdc-theme/color-palette"; -.test-cell--fab-extended { - width: 191px; - height: 71px; +.test-cell--textfield { + width: 301px; + height: 101px; } diff --git a/test/screenshot/webpack.config.js b/test/screenshot/webpack.config.js index a688fa2d3f6..bb040f6fa13 100644 --- a/test/screenshot/webpack.config.js +++ b/test/screenshot/webpack.config.js @@ -19,6 +19,7 @@ const CssBundleFactory = require('../../scripts/webpack/css-bundle-factory'); const Environment = require('../../scripts/build/environment'); const Globber = require('../../scripts/webpack/globber'); +const JsBundleFactory = require('../../scripts/webpack/js-bundle-factory'); const PathResolver = require('../../scripts/build/path-resolver'); const PluginFactory = require('../../scripts/webpack/plugin-factory'); @@ -30,28 +31,93 @@ const globber = new Globber({pathResolver}); const pluginFactory = new PluginFactory({globber}); const copyrightBannerPlugin = pluginFactory.createCopyrightBannerPlugin(); const cssBundleFactory = new CssBundleFactory({env, pathResolver, globber, pluginFactory}); - -const OUTPUT = { - fsDirAbsolutePath: pathResolver.getAbsolutePath('/test/screenshot/out'), - httpDirAbsolutePath: '/out/', -}; +const jsBundleFactory = new JsBundleFactory({env, pathResolver, globber, pluginFactory}); module.exports = [ mainCssALaCarte(), - testCss(), + mainJsCombined(), + specCss(), + specJs(), + reportCss(), + reportJs(), ]; function mainCssALaCarte() { - return cssBundleFactory.createMainCssALaCarte({output: OUTPUT}); + return cssBundleFactory.createMainCssALaCarte({ + output: { + fsDirAbsolutePath: pathResolver.getAbsolutePath('/test/screenshot/out'), + httpDirAbsolutePath: '/out/', + }, + }); } -function testCss() { +function mainJsCombined() { + return jsBundleFactory.createMainJsCombined({ + output: { + fsDirAbsolutePath: pathResolver.getAbsolutePath('/test/screenshot/out'), + httpDirAbsolutePath: '/out/', + }, + }); +} + +function specCss() { return cssBundleFactory.createCustomCss({ bundleName: 'screenshot-test-css', chunkGlobConfig: { - inputDirectory: '/test/screenshot', + inputDirectory: '/test/screenshot/spec', + }, + output: { + fsDirAbsolutePath: pathResolver.getAbsolutePath('/test/screenshot/out/spec'), + httpDirAbsolutePath: '/out/spec/', + }, + plugins: [ + copyrightBannerPlugin, + ], + }); +} + +function specJs() { + return jsBundleFactory.createCustomJs({ + bundleName: 'screenshot-test-js', + chunkGlobConfig: { + inputDirectory: '/test/screenshot/spec', + }, + output: { + fsDirAbsolutePath: pathResolver.getAbsolutePath('/test/screenshot/out/spec'), + httpDirAbsolutePath: '/out/spec/', + }, + plugins: [ + copyrightBannerPlugin, + ], + }); +} + +function reportCss() { + return cssBundleFactory.createCustomCss({ + bundleName: 'screenshot-report-css', + chunkGlobConfig: { + inputDirectory: '/test/screenshot/report', + }, + output: { + fsDirAbsolutePath: pathResolver.getAbsolutePath('/test/screenshot/out/report'), + httpDirAbsolutePath: '/out/report/', + }, + plugins: [ + copyrightBannerPlugin, + ], + }); +} + +function reportJs() { + return jsBundleFactory.createCustomJs({ + bundleName: 'screenshot-report-js', + chunkGlobConfig: { + inputDirectory: '/test/screenshot/report', + }, + output: { + fsDirAbsolutePath: pathResolver.getAbsolutePath('/test/screenshot/out/report'), + httpDirAbsolutePath: '/out/report/', }, - output: OUTPUT, plugins: [ copyrightBannerPlugin, ], diff --git a/test/unit/mdc-auto-init/mdc-auto-init.test.js b/test/unit/mdc-auto-init/mdc-auto-init.test.js index 860b84448b6..6e0739488f9 100644 --- a/test/unit/mdc-auto-init/mdc-auto-init.test.js +++ b/test/unit/mdc-auto-init/mdc-auto-init.test.js @@ -138,3 +138,11 @@ test('#dispatches a MDCAutoInit:End event when all components are initialized - assert.isOk(evt !== null); assert.equal(evt.type, type); }); + +test('#returns the initialized components', () => { + const root = setupTest(); + const components = mdcAutoInit(root); + + assert.equal(components.length, 1); + assert.isOk(components[0] instanceof FakeComponent); +}); diff --git a/test/unit/mdc-chips/mdc-chip-set.foundation.test.js b/test/unit/mdc-chips/mdc-chip-set.foundation.test.js index 4ef7e085317..5e9ec9d3361 100644 --- a/test/unit/mdc-chips/mdc-chip-set.foundation.test.js +++ b/test/unit/mdc-chips/mdc-chip-set.foundation.test.js @@ -34,7 +34,7 @@ test('exports cssClasses', () => { test('defaultAdapter returns a complete adapter implementation', () => { verifyDefaultAdapter(MDCChipSetFoundation, [ - 'hasClass', 'registerInteractionHandler', 'deregisterInteractionHandler', 'removeChip', + 'hasClass', 'removeChip', ]); }); @@ -56,35 +56,13 @@ const setupTest = () => { return {foundation, mockAdapter, chipA, chipB}; }; -test('#init adds event listeners', () => { - const {foundation, mockAdapter} = setupTest(); - foundation.init(); - - td.verify(mockAdapter.registerInteractionHandler('MDCChip:interaction', td.matchers.isA(Function))); -}); - -test('#destroy removes event listeners', () => { - const {foundation, mockAdapter} = setupTest(); - foundation.destroy(); - - td.verify(mockAdapter.deregisterInteractionHandler('MDCChip:interaction', td.matchers.isA(Function))); -}); - -test('in choice chips, on custom MDCChip:interaction event selects chip if no chips are selected', () => { +test('in choice chips, #handleChipInteraction selects chip if no chips are selected', () => { const {foundation, mockAdapter, chipA} = setupTest(); - let chipInteractionHandler; - td.when(mockAdapter.registerInteractionHandler('MDCChip:interaction', td.matchers.isA(Function))) - .thenDo((evtType, handler) => { - chipInteractionHandler = handler; - }); td.when(mockAdapter.hasClass(cssClasses.CHOICE)).thenReturn(true); - td.when(chipA.foundation.isSelected()).thenReturn(false); assert.equal(foundation.selectedChips_.length, 0); - foundation.init(); - - chipInteractionHandler({ + foundation.handleChipInteraction({ detail: { chip: chipA, }, @@ -93,23 +71,15 @@ test('in choice chips, on custom MDCChip:interaction event selects chip if no ch assert.equal(foundation.selectedChips_.length, 1); }); -test('in choice chips, on custom MDCChip:interaction event deselects chip if another chip is selected', () => { +test('in choice chips, #handleChipInteraction deselects chip if another chip is selected', () => { const {foundation, mockAdapter, chipA, chipB} = setupTest(); - let chipInteractionHandler; - td.when(mockAdapter.registerInteractionHandler('MDCChip:interaction', td.matchers.isA(Function))) - .thenDo((evtType, handler) => { - chipInteractionHandler = handler; - }); td.when(mockAdapter.hasClass(cssClasses.CHOICE)).thenReturn(true); - foundation.select(chipB.foundation); td.when(chipA.foundation.isSelected()).thenReturn(false); td.when(chipB.foundation.isSelected()).thenReturn(true); assert.equal(foundation.selectedChips_.length, 1); - foundation.init(); - - chipInteractionHandler({ + foundation.handleChipInteraction({ detail: { chip: chipA, }, @@ -119,22 +89,14 @@ test('in choice chips, on custom MDCChip:interaction event deselects chip if ano assert.equal(foundation.selectedChips_.length, 1); }); -test('in filter chips, on custom MDCChip:interaction event selects multiple chips', () => { +test('in filter chips, #handleChipInteraction selects multiple chips', () => { const {foundation, mockAdapter, chipA, chipB} = setupTest(); - let chipInteractionHandler; - td.when(mockAdapter.registerInteractionHandler('MDCChip:interaction', td.matchers.isA(Function))) - .thenDo((evtType, handler) => { - chipInteractionHandler = handler; - }); td.when(mockAdapter.hasClass(cssClasses.FILTER)).thenReturn(true); - td.when(chipA.foundation.isSelected()).thenReturn(false); td.when(chipB.foundation.isSelected()).thenReturn(false); assert.equal(foundation.selectedChips_.length, 0); - foundation.init(); - - chipInteractionHandler({ + foundation.handleChipInteraction({ detail: { chip: chipA, }, @@ -142,7 +104,7 @@ test('in filter chips, on custom MDCChip:interaction event selects multiple chip td.verify(chipA.foundation.setSelected(true)); assert.equal(foundation.selectedChips_.length, 1); - chipInteractionHandler({ + foundation.handleChipInteraction({ detail: { chip: chipB, }, @@ -151,24 +113,16 @@ test('in filter chips, on custom MDCChip:interaction event selects multiple chip assert.equal(foundation.selectedChips_.length, 2); }); -test('in filter chips, on custom MDCChip:interaction event deselects selected chips', () => { +test('in filter chips, #handleChipInteraction event deselects selected chips', () => { const {foundation, mockAdapter, chipA, chipB} = setupTest(); - let chipInteractionHandler; - td.when(mockAdapter.registerInteractionHandler('MDCChip:interaction', td.matchers.isA(Function))) - .thenDo((evtType, handler) => { - chipInteractionHandler = handler; - }); td.when(mockAdapter.hasClass(cssClasses.FILTER)).thenReturn(true); - foundation.select(chipA.foundation); foundation.select(chipB.foundation); td.when(chipA.foundation.isSelected()).thenReturn(true); td.when(chipB.foundation.isSelected()).thenReturn(true); assert.equal(foundation.selectedChips_.length, 2); - foundation.init(); - - chipInteractionHandler({ + foundation.handleChipInteraction({ detail: { chip: chipB, }, @@ -176,7 +130,7 @@ test('in filter chips, on custom MDCChip:interaction event deselects selected ch td.verify(chipB.foundation.setSelected(false)); assert.equal(foundation.selectedChips_.length, 1); - chipInteractionHandler({ + foundation.handleChipInteraction({ detail: { chip: chipA, }, @@ -185,16 +139,10 @@ test('in filter chips, on custom MDCChip:interaction event deselects selected ch assert.equal(foundation.selectedChips_.length, 0); }); -test('on custom MDCChip:removal event removes chip', () => { +test('#handleChipRemoval removes chip', () => { const {foundation, mockAdapter, chipA} = setupTest(); - let chipRemovalHandler; - td.when(mockAdapter.registerInteractionHandler('MDCChip:removal', td.matchers.isA(Function))) - .thenDo((evtType, handler) => { - chipRemovalHandler = handler; - }); - - foundation.init(); - chipRemovalHandler({ + + foundation.handleChipRemoval({ detail: { chip: chipA, }, diff --git a/test/unit/mdc-chips/mdc-chip-set.test.js b/test/unit/mdc-chips/mdc-chip-set.test.js index 93d17c3b852..bb402f2607c 100644 --- a/test/unit/mdc-chips/mdc-chip-set.test.js +++ b/test/unit/mdc-chips/mdc-chip-set.test.js @@ -15,7 +15,6 @@ */ import bel from 'bel'; -import domEvents from 'dom-events'; import {assert} from 'chai'; import td from 'testdouble'; @@ -112,25 +111,6 @@ test('#adapter.hasClass returns true if class is set on chip set element', () => assert.isTrue(component.getDefaultFoundation().adapter_.hasClass('foo')); }); -test('#adapter.registerInteractionHandler adds a handler to the root element for a given event', () => { - const {root, component} = setupTest(); - const handler = td.func('eventHandler'); - - component.getDefaultFoundation().adapter_.registerInteractionHandler('click', handler); - domEvents.emit(root, 'click'); - td.verify(handler(td.matchers.anything())); -}); - -test('#adapter.deregisterInteractionHandler removes a handler from the root element for a given event', () => { - const {root, component} = setupTest(); - const handler = td.func('eventHandler'); - - root.addEventListener('click', handler); - component.getDefaultFoundation().adapter_.deregisterInteractionHandler('click', handler); - domEvents.emit(root, 'click'); - td.verify(handler(td.matchers.anything()), {times: 0}); -}); - test('#adapter.removeChip removes the chip object from the chip set', () => { const root = getFixture(); const component = new MDCChipSet(root, undefined, (el) => new FakeChip(el)); diff --git a/test/unit/mdc-chips/mdc-chip.foundation.test.js b/test/unit/mdc-chips/mdc-chip.foundation.test.js index a8fec52df7b..a31a2c627ce 100644 --- a/test/unit/mdc-chips/mdc-chip.foundation.test.js +++ b/test/unit/mdc-chips/mdc-chip.foundation.test.js @@ -17,7 +17,7 @@ import {assert} from 'chai'; import td from 'testdouble'; -import {verifyDefaultAdapter, captureHandlers} from '../helpers/foundation'; +import {verifyDefaultAdapter} from '../helpers/foundation'; import {createMockRaf} from '../helpers/raf'; import {setupFoundationTest} from '../helpers/setup'; import {MDCChipFoundation} from '../../../packages/mdc-chips/chip/foundation'; @@ -37,9 +37,7 @@ test('exports cssClasses', () => { test('defaultAdapter returns a complete adapter implementation', () => { verifyDefaultAdapter(MDCChipFoundation, [ 'addClass', 'removeClass', 'hasClass', 'addClassToLeadingIcon', - 'removeClassFromLeadingIcon', 'eventTargetHasClass', 'registerEventHandler', - 'deregisterEventHandler', 'registerTrailingIconInteractionHandler', - 'deregisterTrailingIconInteractionHandler', 'notifyInteraction', + 'removeClassFromLeadingIcon', 'eventTargetHasClass', 'notifyInteraction', 'notifyTrailingIconInteraction', 'notifyRemoval', 'getComputedStyleValue', 'setStyleProperty', ]); @@ -47,34 +45,6 @@ test('defaultAdapter returns a complete adapter implementation', () => { const setupTest = () => setupFoundationTest(MDCChipFoundation); -test('#init adds event listeners', () => { - const {foundation, mockAdapter} = setupTest(); - foundation.init(); - - td.verify(mockAdapter.registerEventHandler('click', td.matchers.isA(Function))); - td.verify(mockAdapter.registerEventHandler('keydown', td.matchers.isA(Function))); - td.verify(mockAdapter.registerEventHandler('transitionend', td.matchers.isA(Function))); - td.verify(mockAdapter.registerTrailingIconInteractionHandler('click', td.matchers.isA(Function))); - td.verify(mockAdapter.registerTrailingIconInteractionHandler('keydown', td.matchers.isA(Function))); - td.verify(mockAdapter.registerTrailingIconInteractionHandler('touchstart', td.matchers.isA(Function))); - td.verify(mockAdapter.registerTrailingIconInteractionHandler('pointerdown', td.matchers.isA(Function))); - td.verify(mockAdapter.registerTrailingIconInteractionHandler('mousedown', td.matchers.isA(Function))); -}); - -test('#destroy removes event listeners', () => { - const {foundation, mockAdapter} = setupTest(); - foundation.destroy(); - - td.verify(mockAdapter.deregisterEventHandler('click', td.matchers.isA(Function))); - td.verify(mockAdapter.deregisterEventHandler('keydown', td.matchers.isA(Function))); - td.verify(mockAdapter.deregisterEventHandler('transitionend', td.matchers.isA(Function))); - td.verify(mockAdapter.deregisterTrailingIconInteractionHandler('click', td.matchers.isA(Function))); - td.verify(mockAdapter.deregisterTrailingIconInteractionHandler('keydown', td.matchers.isA(Function))); - td.verify(mockAdapter.deregisterTrailingIconInteractionHandler('touchstart', td.matchers.isA(Function))); - td.verify(mockAdapter.deregisterTrailingIconInteractionHandler('pointerdown', td.matchers.isA(Function))); - td.verify(mockAdapter.deregisterTrailingIconInteractionHandler('mousedown', td.matchers.isA(Function))); -}); - test('#isSelected returns true if mdc-chip--selected class is present', () => { const {foundation, mockAdapter} = setupTest(); td.when(mockAdapter.hasClass(cssClasses.SELECTED)).thenReturn(true); @@ -105,22 +75,19 @@ test(`#beginExit adds ${cssClasses.CHIP_EXIT} class`, () => { td.verify(mockAdapter.addClass(cssClasses.CHIP_EXIT)); }); -test('on click, emit custom event', () => { +test('#handleInteraction emits custom event on click', () => { const {foundation, mockAdapter} = setupTest(); - const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); const mockEvt = { type: 'click', }; - foundation.init(); - handlers.click(mockEvt); + foundation.handleInteraction(mockEvt); td.verify(mockAdapter.notifyInteraction()); }); -test('on chip width transition end, notify removal of chip', () => { +test('#handleTransitionEnd notifies removal of chip on width transition end', () => { const {foundation, mockAdapter} = setupTest(); - const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); const mockEvt = { type: 'transitionend', target: {}, @@ -128,16 +95,14 @@ test('on chip width transition end, notify removal of chip', () => { }; td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.CHIP_EXIT)).thenReturn(true); - foundation.init(); - handlers.transitionend(mockEvt); + foundation.handleTransitionEnd(mockEvt); td.verify(mockAdapter.notifyRemoval()); }); -test('on chip opacity transition end, animate width if chip is exiting', () => { +test('#handleTransitionEnd animates width if chip is exiting on chip opacity transition end', () => { const {foundation, mockAdapter} = setupTest(); const raf = createMockRaf(); - const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); const mockEvt = { type: 'transitionend', target: {}, @@ -146,8 +111,7 @@ test('on chip opacity transition end, animate width if chip is exiting', () => { td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.CHIP_EXIT)).thenReturn(true); td.when(mockAdapter.getComputedStyleValue('width')).thenReturn('100px'); - foundation.init(); - handlers.transitionend(mockEvt); + foundation.handleTransitionEnd(mockEvt); raf.flush(); td.verify(mockAdapter.setStyleProperty('width', '100px')); @@ -158,10 +122,9 @@ test('on chip opacity transition end, animate width if chip is exiting', () => { td.verify(mockAdapter.setStyleProperty('width', '0')); }); -test(`on leading icon opacity transition end, add ${cssClasses.HIDDEN_LEADING_ICON}` + - 'class to leading icon if chip is selected', () => { +test(`#handleTransitionEnd adds ${cssClasses.HIDDEN_LEADING_ICON} class to leading icon ` + + 'on leading icon opacity transition end, if chip is selected', () => { const {foundation, mockAdapter} = setupTest(); - const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); const mockEvt = { type: 'transitionend', target: {}, @@ -170,15 +133,14 @@ test(`on leading icon opacity transition end, add ${cssClasses.HIDDEN_LEADING_IC td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.LEADING_ICON)).thenReturn(true); td.when(mockAdapter.hasClass(cssClasses.SELECTED)).thenReturn(true); - foundation.init(); - handlers.transitionend(mockEvt); + foundation.handleTransitionEnd(mockEvt); td.verify(mockAdapter.addClassToLeadingIcon(cssClasses.HIDDEN_LEADING_ICON)); }); -test('on leading icon opacity transition end, do nothing if chip is not selected', () => { +test('#handleTransitionEnd does nothing on leading icon opacity transition end,' + + 'if chip is not selected', () => { const {foundation, mockAdapter} = setupTest(); - const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); const mockEvt = { type: 'transitionend', target: {}, @@ -187,16 +149,14 @@ test('on leading icon opacity transition end, do nothing if chip is not selected td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.LEADING_ICON)).thenReturn(true); td.when(mockAdapter.hasClass(cssClasses.SELECTED)).thenReturn(false); - foundation.init(); - handlers.transitionend(mockEvt); + foundation.handleTransitionEnd(mockEvt); td.verify(mockAdapter.addClassToLeadingIcon(cssClasses.HIDDEN_LEADING_ICON), {times: 0}); }); -test(`on checkmark opacity transition end, remove ${cssClasses.HIDDEN_LEADING_ICON}` + - 'class from leading icon if chip is not selected', () => { +test(`#handleTransitionEnd removes ${cssClasses.HIDDEN_LEADING_ICON} class from leading icon ` + + 'on checkmark opacity transition end, if chip is not selected', () => { const {foundation, mockAdapter} = setupTest(); - const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); const mockEvt = { type: 'transitionend', target: {}, @@ -205,15 +165,13 @@ test(`on checkmark opacity transition end, remove ${cssClasses.HIDDEN_LEADING_IC td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.CHECKMARK)).thenReturn(true); td.when(mockAdapter.hasClass(cssClasses.SELECTED)).thenReturn(false); - foundation.init(); - handlers.transitionend(mockEvt); + foundation.handleTransitionEnd(mockEvt); td.verify(mockAdapter.removeClassFromLeadingIcon(cssClasses.HIDDEN_LEADING_ICON)); }); -test('on checkmark opacity transition end, do nothing if chip is selected', () => { +test('#handleTransitionEnd does nothing on checkmark opacity transition end, if chip is selected', () => { const {foundation, mockAdapter} = setupTest(); - const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); const mockEvt = { type: 'transitionend', target: {}, @@ -222,58 +180,49 @@ test('on checkmark opacity transition end, do nothing if chip is selected', () = td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.CHECKMARK)).thenReturn(true); td.when(mockAdapter.hasClass(cssClasses.SELECTED)).thenReturn(true); - foundation.init(); - handlers.transitionend(mockEvt); + foundation.handleTransitionEnd(mockEvt); td.verify(mockAdapter.removeClassFromLeadingIcon(cssClasses.HIDDEN_LEADING_ICON), {times: 0}); }); -test('on click in trailing icon, emit custom event', () => { +test('#handleTrailingIconInteraction emits custom event on click in trailing icon', () => { const {foundation, mockAdapter} = setupTest(); - const handlers = captureHandlers(mockAdapter, 'registerTrailingIconInteractionHandler'); const mockEvt = { type: 'click', stopPropagation: td.func('stopPropagation'), }; - foundation.init(); - handlers.click(mockEvt); - + foundation.handleTrailingIconInteraction(mockEvt); td.verify(mockAdapter.notifyTrailingIconInteraction()); td.verify(mockEvt.stopPropagation()); }); -test(`on click in trailing icon, add ${cssClasses.CHIP_EXIT} class by default`, () => { +test(`#handleTrailingIconInteraction adds ${cssClasses.CHIP_EXIT} class by default on click in trailing icon`, () => { const {foundation, mockAdapter} = setupTest(); - const handlers = captureHandlers(mockAdapter, 'registerTrailingIconInteractionHandler'); const mockEvt = { type: 'click', stopPropagation: td.func('stopPropagation'), }; - foundation.init(); - handlers.click(mockEvt); + foundation.handleTrailingIconInteraction(mockEvt); assert.isTrue(foundation.getShouldRemoveOnTrailingIconClick()); td.verify(mockAdapter.addClass(cssClasses.CHIP_EXIT)); td.verify(mockEvt.stopPropagation()); }); -test(`on click in trailing icon, do not add ${cssClasses.CHIP_EXIT} class if shouldRemoveOnTrailingIconClick_ is false`, - () => { - const {foundation, mockAdapter} = setupTest(); - const handlers = captureHandlers(mockAdapter, 'registerTrailingIconInteractionHandler'); - const mockEvt = { - type: 'click', - stopPropagation: td.func('stopPropagation'), - }; - - foundation.init(); - foundation.setShouldRemoveOnTrailingIconClick(false); - handlers.click(mockEvt); - - assert.isFalse(foundation.getShouldRemoveOnTrailingIconClick()); - td.verify(mockAdapter.addClass(cssClasses.CHIP_EXIT), {times: 0}); - td.verify(mockEvt.stopPropagation()); - } -); +test(`#handleTrailingIconInteraction does not add ${cssClasses.CHIP_EXIT} class on click in trailing icon ` + + 'if shouldRemoveOnTrailingIconClick_ is false', () => { + const {foundation, mockAdapter} = setupTest(); + const mockEvt = { + type: 'click', + stopPropagation: td.func('stopPropagation'), + }; + + foundation.setShouldRemoveOnTrailingIconClick(false); + foundation.handleTrailingIconInteraction(mockEvt); + + assert.isFalse(foundation.getShouldRemoveOnTrailingIconClick()); + td.verify(mockAdapter.addClass(cssClasses.CHIP_EXIT), {times: 0}); + td.verify(mockEvt.stopPropagation()); +}); diff --git a/test/unit/mdc-chips/mdc-chip.test.js b/test/unit/mdc-chips/mdc-chip.test.js index 1b5298609e1..9d3b0bce73d 100644 --- a/test/unit/mdc-chips/mdc-chip.test.js +++ b/test/unit/mdc-chips/mdc-chip.test.js @@ -17,7 +17,6 @@ import bel from 'bel'; import {assert} from 'chai'; import td from 'testdouble'; -import domEvents from 'dom-events'; import {MDCRipple} from '../../../packages/mdc-ripple'; import {MDCChip, MDCChipFoundation} from '../../../packages/mdc-chips/chip'; @@ -106,56 +105,6 @@ test('adapter#eventTargetHasClass returns true if given element has class', () = assert.isTrue(component.getDefaultFoundation().adapter_.eventTargetHasClass(mockEventTarget, 'foo')); }); -test('#adapter.registerEventHandler adds event listener for a given event to the root element', () => { - const {root, component} = setupTest(); - const handler = td.func('click handler'); - component.getDefaultFoundation().adapter_.registerEventHandler('click', handler); - domEvents.emit(root, 'click'); - - td.verify(handler(td.matchers.anything())); -}); - -test('#adapter.deregisterEventHandler removes event listener for a given event from the root element', () => { - const {root, component} = setupTest(); - const handler = td.func('click handler'); - - root.addEventListener('click', handler); - component.getDefaultFoundation().adapter_.deregisterEventHandler('click', handler); - domEvents.emit(root, 'click'); - - td.verify(handler(td.matchers.anything()), {times: 0}); -}); - -test('#adapter.registerTrailingIconInteractionHandler adds event listener for a given event to the trailing' + -'icon element', () => { - const {root, component} = setupTest(); - const icon = bel` - cancel - `; - root.appendChild(icon); - const handler = td.func('click handler'); - component.getDefaultFoundation().adapter_.registerTrailingIconInteractionHandler('click', handler); - domEvents.emit(icon, 'click'); - - td.verify(handler(td.matchers.anything())); -}); - -test('#adapter.deregisterTrailingIconInteractionHandler removes event listener for a given event from the trailing ' + -'icon element', () => { - const {root, component} = setupTest(); - const icon = bel` - cancel - `; - root.appendChild(icon); - const handler = td.func('click handler'); - - icon.addEventListener('click', handler); - component.getDefaultFoundation().adapter_.deregisterTrailingIconInteractionHandler('click', handler); - domEvents.emit(icon, 'click'); - - td.verify(handler(td.matchers.anything()), {times: 0}); -}); - test('#adapter.notifyInteraction emits ' + MDCChipFoundation.strings.INTERACTION_EVENT, () => { const {component} = setupTest(); const handler = td.func('interaction handler'); diff --git a/test/unit/mdc-list/foundation.test.js b/test/unit/mdc-list/foundation.test.js index 73de79c7453..454fff911c1 100644 --- a/test/unit/mdc-list/foundation.test.js +++ b/test/unit/mdc-list/foundation.test.js @@ -21,7 +21,7 @@ import td from 'testdouble'; import {verifyDefaultAdapter} from '../helpers/foundation'; import {setupFoundationTest} from '../helpers/setup'; -import {MDCListFoundation} from '../../../packages/mdc-list/foundation'; +import MDCListFoundation from '../../../packages/mdc-list/foundation'; import {strings, cssClasses} from '../../../packages/mdc-list/constants'; suite('MDCListFoundation'); @@ -36,8 +36,9 @@ test('exports cssClasses', () => { test('defaultAdapter returns a complete adapter implementation', () => { verifyDefaultAdapter(MDCListFoundation, [ - 'getListItemCount', 'getFocusedElementIndex', 'getListItemIndex', - 'focusItemAtIndex', 'setTabIndexForListItemChildren', + 'getListItemCount', 'getFocusedElementIndex', 'getListItemIndex', 'setAttributeForElementIndex', + 'removeAttributeForElementIndex', 'addClassForElementIndex', 'removeClassForElementIndex', + 'focusItemAtIndex', 'isElementFocusable', 'isListItem', 'setTabIndexForListItemChildren', ]); }); @@ -63,6 +64,7 @@ test('#handleFocusIn switches list item button/a elements to tabindex=0', () => const event = {target}; td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(1); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(true); foundation.handleFocusIn(event); td.verify(mockAdapter.setTabIndexForListItemChildren(1, 0)); @@ -74,6 +76,7 @@ test('#handleFocusOut switches list item button/a elements to tabindex=-1', () = const event = {target}; td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(1); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(true); foundation.handleFocusOut(event); td.verify(mockAdapter.setTabIndexForListItemChildren(1, -1)); @@ -86,6 +89,7 @@ test('#handleFocusIn switches list item button/a elements to tabindex=0 when tar const event = {target}; td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(1); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(true); foundation.handleFocusIn(event); td.verify(mockAdapter.setTabIndexForListItemChildren(1, 0)); @@ -98,6 +102,7 @@ test('#handleFocusOut switches list item button/a elements to tabindex=-1 when t const event = {target}; td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(1); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(true); foundation.handleFocusOut(event); td.verify(mockAdapter.setTabIndexForListItemChildren(1, -1)); @@ -125,6 +130,33 @@ test('#handleFocusOut does nothing if mdc-list-item is not on element or ancesto td.verify(mockAdapter.setTabIndexForListItemChildren(td.matchers.anything(), td.matchers.anything()), {times: 0}); }); +test('#handleFocusIn does nothing if list item is from nested list', () => { + const {foundation, mockAdapter} = setupTest(); + const parentElement = {classList: ['mdc-list-item']}; + const target = {classList: [], parentElement}; + const event = {target}; + + td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(-1); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(false, true); + foundation.handleFocusIn(event); + + td.verify(mockAdapter.setTabIndexForListItemChildren(td.matchers.anything(), td.matchers.anything()), {times: 0}); +}); + + +test('#handleFocusOut does nothing if list item is from nested list', () => { + const {foundation, mockAdapter} = setupTest(); + const parentElement = {classList: ['mdc-list-item']}; + const target = {classList: [], parentElement}; + const event = {target}; + + td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(-1); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(false, true); + foundation.handleFocusOut(event); + + td.verify(mockAdapter.setTabIndexForListItemChildren(td.matchers.anything(), td.matchers.anything()), {times: 0}); +}); + test('#handleKeydown does nothing if the key is not used for navigation', () => { const {foundation, mockAdapter} = setupTest(); const preventDefault = td.func('preventDefault'); @@ -162,6 +194,7 @@ test('#handleKeydown navigation key on an empty list does nothing', () => { td.when(mockAdapter.getFocusedElementIndex()).thenReturn(-1); td.when(mockAdapter.getListItemCount()).thenReturn(0); td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(-1); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(true); foundation.handleKeydown(event); td.verify(mockAdapter.focusItemAtIndex(td.matchers.anything()), {times: 0}); @@ -335,6 +368,7 @@ test('#handleKeydown End key on empty list does nothing', () => { td.when(mockAdapter.getFocusedElementIndex()).thenReturn(-1); td.when(mockAdapter.getListItemCount()).thenReturn(0); td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(-1); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(true); foundation.handleKeydown(event); td.verify(mockAdapter.focusItemAtIndex(td.matchers.anything()), {times: 0}); @@ -367,6 +401,8 @@ test('#handleKeydown finds the first ancestor with mdc-list-item', () => { td.when(mockAdapter.getFocusedElementIndex()).thenReturn(-1); td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(1); td.when(mockAdapter.getListItemCount()).thenReturn(3); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(false); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(true); foundation.handleKeydown(event); td.verify(mockAdapter.focusItemAtIndex(0), {times: 1}); @@ -383,7 +419,230 @@ test('#handleKeydown does not find ancestor with mdc-list-item so returns early' td.when(mockAdapter.getFocusedElementIndex()).thenReturn(-1); td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(-1); td.when(mockAdapter.getListItemCount()).thenReturn(3); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(false); foundation.handleKeydown(event); td.verify(preventDefault(), {times: 0}); }); + +test('#handleKeydown space key causes preventDefault to be called on the event when singleSelection=true', () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: ['mdc-list-item']}; + const event = {key: 'Space', target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(0); + td.when(mockAdapter.getListItemCount()).thenReturn(3); + foundation.setSingleSelection(true); + foundation.handleKeydown(event); + + td.verify(preventDefault(), {times: 1}); +}); + +test('#handleKeydown enter key causes preventDefault to be called on the event when singleSelection=true', () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: ['mdc-list-item']}; + const event = {key: 'Enter', target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(0); + td.when(mockAdapter.getListItemCount()).thenReturn(3); + foundation.setSingleSelection(true); + foundation.handleKeydown(event); + + td.verify(preventDefault(), {times: 1}); +}); + +test('#handleKeydown space key does not cause preventDefault to be called if singleSelection=false', () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: ['mdc-list-item']}; + const event = {key: 'Space', target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(0); + td.when(mockAdapter.getListItemCount()).thenReturn(3); + foundation.handleKeydown(event); + + td.verify(preventDefault(), {times: 0}); +}); + +test('#handleKeydown enter key does not cause preventDefault to be called if singleSelection=false', () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: ['mdc-list-item']}; + const event = {key: 'Enter', target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(0); + td.when(mockAdapter.getListItemCount()).thenReturn(3); + foundation.handleKeydown(event); + + td.verify(preventDefault(), {times: 0}); +}); + +test('#handleKeydown space key is triggered when singleSelection is true selects the list item', () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: ['mdc-list-item']}; + const event = {key: 'Space', target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(0); + td.when(mockAdapter.getListItemCount()).thenReturn(3); + td.when(mockAdapter.isListItem(target)).thenReturn(true); + foundation.setSingleSelection(true); + foundation.handleKeydown(event); + + td.verify(preventDefault(), {times: 1}); + td.verify(mockAdapter.setAttributeForElementIndex(0, strings.ARIA_SELECTED, true), {times: 1}); +}); + +test('#handleKeydown space key is triggered 2x when singleSelection is true un-selects the list item', () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: ['mdc-list-item']}; + const event = {key: 'Space', target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(0); + td.when(mockAdapter.getListItemCount()).thenReturn(3); + td.when(mockAdapter.isListItem(target)).thenReturn(true); + foundation.setSingleSelection(true); + foundation.handleKeydown(event); + foundation.handleKeydown(event); + + td.verify(preventDefault(), {times: 2}); + td.verify(mockAdapter.removeAttributeForElementIndex(0, strings.ARIA_SELECTED), {times: 1}); +}); + +test('#handleKeydown space key is triggered when singleSelection is true on second ' + + 'element updates first element tabindex', () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: ['mdc-list-item']}; + const event = {key: 'Space', target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(1); + td.when(mockAdapter.getListItemCount()).thenReturn(3); + td.when(mockAdapter.isListItem(target)).thenReturn(true); + foundation.setSingleSelection(true); + foundation.handleKeydown(event); + + td.verify(preventDefault(), {times: 1}); + td.verify(mockAdapter.setAttributeForElementIndex(1, 'tabindex', 0), {times: 1}); +}); + +test('#handleKeydown space key is triggered 2x when singleSelection is true on second ' + + 'element updates first element tabindex', () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: ['mdc-list-item']}; + const event = {key: 'Space', target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(1); + td.when(mockAdapter.getListItemCount()).thenReturn(3); + td.when(mockAdapter.isListItem(target)).thenReturn(true); + foundation.setSingleSelection(true); + foundation.handleKeydown(event); + foundation.handleKeydown(event); + + td.verify(preventDefault(), {times: 2}); + td.verify(mockAdapter.setAttributeForElementIndex(0, 'tabindex', 0), {times: 1}); + td.verify(mockAdapter.setAttributeForElementIndex(1, 'tabindex', -1), {times: 1}); +}); + +test('#handleKeydown space key is triggered and focused is moved to a different element', () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: ['mdc-list-item']}; + const event = {key: 'Space', target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(1); + td.when(mockAdapter.getListItemCount()).thenReturn(3); + td.when(mockAdapter.isListItem(target)).thenReturn(true); + foundation.setSingleSelection(true); + foundation.handleKeydown(event); + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(2); + foundation.handleKeydown(event); + + td.verify(mockAdapter.setAttributeForElementIndex(1, 'tabindex', -1), {times: 1}); + td.verify(mockAdapter.setAttributeForElementIndex(2, 'tabindex', 0), {times: 1}); +}); + +test('#handleClick when singleSelection=true on a list item should cause the list item to be selected', () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: ['mdc-list-item']}; + const event = {target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(1); + td.when(mockAdapter.getListItemCount()).thenReturn(3); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(true); + foundation.handleClick(event); + + td.verify(mockAdapter.setAttributeForElementIndex(1, 'tabindex', 0), {times: 1}); +}); + +test('#handleClick when singleSelection=true on a button subelement should not cause the list item to be selected', + () => { + const {foundation, mockAdapter} = setupTest(); + const parentElement = {classList: ['mdc-list-item']}; + const preventDefault = td.func('preventDefault'); + const target = {classList: [], parentElement}; + const event = {target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(-1); + td.when(mockAdapter.isElementFocusable(td.matchers.anything())).thenReturn(true); + td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(1); + td.when(mockAdapter.isListItem(td.matchers.anything())).thenReturn(false); + foundation.setSingleSelection(true); + foundation.handleClick(event); + + td.verify(mockAdapter.setAttributeForElementIndex(1, 'tabindex', 0), {times: 0}); + }); + +test('#handleClick when singleSelection=true on an element not in a list item should be ignored', + () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: []}; + const event = {target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(-1); + td.when(mockAdapter.isElementFocusable(td.matchers.anything())).thenReturn(true); + td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(-1); + foundation.setSingleSelection(true); + foundation.handleClick(event); + + td.verify(mockAdapter.setAttributeForElementIndex(1, 'tabindex', 0), {times: 0}); + }); + +test('#handleClick when singleSelection=true on the first element when already selected', + () => { + const {foundation, mockAdapter} = setupTest(); + const preventDefault = td.func('preventDefault'); + const target = {classList: []}; + const event = {target, preventDefault}; + + td.when(mockAdapter.getFocusedElementIndex()).thenReturn(0); + td.when(mockAdapter.isElementFocusable(td.matchers.anything())).thenReturn(true); + td.when(mockAdapter.getListItemIndex(td.matchers.anything())).thenReturn(0); + foundation.setSingleSelection(true); + foundation.handleClick(event); + foundation.handleClick(event); + + td.verify(mockAdapter.setAttributeForElementIndex(0, 'tabindex', 0), {times: 0}); + }); + +test('#focusFirstElement is called when the list is empty does not focus an element', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.getListItemCount()).thenReturn(-1); + foundation.focusFirstElement(); + + td.verify(mockAdapter.focusItemAtIndex(td.matchers.anything()), {times: 0}); +}); + +test('#focusLastElement is called when the list is empty does not focus an element', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.getListItemCount()).thenReturn(-1); + foundation.focusLastElement(); + + td.verify(mockAdapter.focusItemAtIndex(td.matchers.anything()), {times: 0}); +}); diff --git a/test/unit/mdc-list/mdc-list.test.js b/test/unit/mdc-list/mdc-list.test.js index 58f72298fc2..4ede0dda176 100644 --- a/test/unit/mdc-list/mdc-list.test.js +++ b/test/unit/mdc-list/mdc-list.test.js @@ -19,17 +19,23 @@ import {assert} from 'chai'; import td from 'testdouble'; import bel from 'bel'; -import {MDCList} from '../../../packages/mdc-list'; -import {MDCListFoundation} from '../../../packages/mdc-list/foundation'; +import {MDCList, MDCListFoundation} from '../../../packages/mdc-list'; +import domEvents from 'dom-events'; function getFixture() { return bel`
      -
    • Fruit -
    • -
    • Pasta -
    • -
    • Pizza
    • +
    • + Fruit + +
    • +
    • + Pasta + +
    • +
    • + Pizza +
    `; } @@ -72,6 +78,94 @@ test('#adapter.getListItemIndex returns the index of the element specified', () document.body.removeChild(root); }); +test('#adapter.setAttributeForElementIndex does nothing if the element at index does not exist', () => { + const {root, component} = setupTest(); + document.body.appendChild(root); + const func = () => { + component.getDefaultFoundation().adapter_.setAttributeForElementIndex(5, 'foo', 'bar'); + }; + assert.doesNotThrow(func); + document.body.removeChild(root); +}); + +test('#adapter.setAttributeForElementIndex sets the attribute for the list element at index specified', () => { + const {root, component} = setupTest(); + document.body.appendChild(root); + const selectedNode = root.querySelectorAll('.mdc-list-item')[1]; + component.getDefaultFoundation().adapter_.setAttributeForElementIndex(1, 'foo', 'bar'); + assert.equal('bar', selectedNode.getAttribute('foo')); + document.body.removeChild(root); +}); + +test('#adapter.removeAttributeForElementIndex does nothing if the element at index does not exist', () => { + const {root, component} = setupTest(); + document.body.appendChild(root); + const func = () => { + component.getDefaultFoundation().adapter_.removeAttributeForElementIndex(5, 'foo'); + }; + assert.doesNotThrow(func); + document.body.removeChild(root); +}); + +test('#adapter.removeAttributeForElementIndex sets the attribute for the list element at index specified', () => { + const {root, component} = setupTest(); + document.body.appendChild(root); + const selectedNode = root.querySelectorAll('.mdc-list-item')[1]; + component.getDefaultFoundation().adapter_.setAttributeForElementIndex(1, 'foo', 'bar'); + component.getDefaultFoundation().adapter_.removeAttributeForElementIndex(1, 'foo'); + assert.isFalse(selectedNode.hasAttribute('foo')); + document.body.removeChild(root); +}); + +test('#adapter.addClassForElementIndex does nothing if the element at index does not exist', () => { + const {root, component} = setupTest(); + document.body.appendChild(root); + const func = () => { + component.getDefaultFoundation().adapter_.addClassForElementIndex(5, 'foo'); + }; + assert.doesNotThrow(func); + document.body.removeChild(root); +}); + +test('#adapter.addClassForElementIndex adds the class to the list element at index specified', () => { + const {root, component} = setupTest(); + document.body.appendChild(root); + const selectedNode = root.querySelectorAll('.mdc-list-item')[1]; + component.getDefaultFoundation().adapter_.addClassForElementIndex(1, 'foo'); + assert.isTrue(selectedNode.classList.contains('foo')); + document.body.removeChild(root); +}); + +test('#adapter.removeClassForElementIndex does nothing if the element at index does not exist', () => { + const {root, component} = setupTest(); + document.body.appendChild(root); + const func = () => { + component.getDefaultFoundation().adapter_.removeClassForElementIndex(5, 'foo'); + }; + assert.doesNotThrow(func); + document.body.removeChild(root); +}); + +test('#adapter.removeClassForElementIndex removes the class from the list element at index specified', () => { + const {root, component} = setupTest(); + document.body.appendChild(root); + const selectedNode = root.querySelectorAll('.mdc-list-item')[1]; + selectedNode.classList.add('foo'); + component.getDefaultFoundation().adapter_.removeClassForElementIndex(1, 'foo'); + assert.isFalse(selectedNode.classList.contains('foo')); + document.body.removeChild(root); +}); + +test('#adapter.focusItemAtIndex does not throw an error if element at index is undefined/null', () => { + const {root, component} = setupTest(); + document.body.appendChild(root); + const func = () => { + component.getDefaultFoundation().adapter_.focusItemAtIndex(5); + }; + assert.doesNotThrow(func); + document.body.removeChild(root); +}); + test('#adapter.focusItemAtIndex focuses the list item at the index specified', () => { const {root, component} = setupTest(); document.body.appendChild(root); @@ -82,14 +176,46 @@ test('#adapter.focusItemAtIndex focuses the list item at the index specified', ( document.body.removeChild(root); }); +test('adapter#isListItem returns true if the element is a list item', () => { + const {root, component} = setupTest(true); + const item1 = root.querySelectorAll('.mdc-list-item')[0]; + assert.isTrue(component.getDefaultFoundation().adapter_.isListItem(item1)); +}); + +test('adapter#isListItem returns false if the element is a not a list item', () => { + const {root, component} = setupTest(true); + const item1 = root.querySelectorAll('.mdc-list-item button')[0]; + assert.isFalse(component.getDefaultFoundation().adapter_.isListItem(item1)); +}); + +test('adapter#isElementFocusable returns true if the element is a focusable list item sub-element', () => { + const {root, component} = setupTest(true); + const item1 = root.querySelectorAll('.mdc-list-item button')[0]; + assert.isTrue(component.getDefaultFoundation().adapter_.isElementFocusable(item1)); +}); + +test('adapter#isElementFocusable returns false if the element is not a focusable list item sub-element', + () => { + const {root, component} = setupTest(true); + const item1 = root.querySelectorAll('.mdc-list-item')[2]; + assert.isFalse(component.getDefaultFoundation().adapter_.isElementFocusable(item1)); + }); + +test('adapter#isElementFocusable returns false if the element is null/undefined', + () => { + const {component} = setupTest(true); + assert.isFalse(component.getDefaultFoundation().adapter_.isElementFocusable()); + }); + test('#adapter.setTabIndexForListItemChildren sets the child button/a elements of index', () => { const {root, component} = setupTest(); document.body.appendChild(root); const listItemIndex = 1; - const listItem = root.querySelectorAll('.mdclist-item')[listItemIndex]; + const listItem = root.querySelectorAll('.mdc-list-item')[listItemIndex]; component.getDefaultFoundation().adapter_.setTabIndexForListItemChildren(listItemIndex, 0); - assert.equal(1, root.querySelectorAll('button[tabIndex="0"]').length); - assert.equal(listItem, root.querySelectorAll('button[tabIndex="0"]').parentElement); + + assert.equal(1, root.querySelectorAll('button[tabindex="0"]').length); + assert.equal(listItem, root.querySelectorAll('button[tabindex="0"]')[0].parentElement); document.body.removeChild(root); }); @@ -115,6 +241,40 @@ test('wrapFocus calls setWrapFocus on foundation', () => { td.verify(mockFoundation.setWrapFocus(true), {times: 1}); }); +test('singleSelection true sets the selectedIndex if a list item has the --selected class', () => { + const {root, component, mockFoundation} = setupTest(); + root.querySelector('.mdc-list-item').classList.add(MDCListFoundation.cssClasses.LIST_ITEM_SELECTED_CLASS); + component.singleSelection = true; + td.verify(mockFoundation.setSelectedIndex(0), {times: 1}); +}); + +test('singleSelection true sets the click handler from the root element', () => { + const {root, component, mockFoundation} = setupTest(); + component.singleSelection = true; + domEvents.emit(root, 'click'); + td.verify(mockFoundation.handleClick(td.matchers.anything()), {times: 1}); +}); + +test('singleSelection false removes the click handler from the root element', () => { + const {root, component, mockFoundation} = setupTest(); + component.singleSelection = true; + component.singleSelection = false; + domEvents.emit(root, 'click'); + td.verify(mockFoundation.handleClick(td.matchers.anything()), {times: 0}); +}); + +test('singleSelection calls foundation setSingleSelection with the provided value', () => { + const {component, mockFoundation} = setupTest(); + component.singleSelection = true; + td.verify(mockFoundation.setSingleSelection(true), {times: 1}); +}); + +test('selectedIndex calls setSelectedIndex on foundation', () => { + const {component, mockFoundation} = setupTest(); + component.selectedIndex = 1; + td.verify(mockFoundation.setSelectedIndex(1), {times: 1}); +}); + test('keydown handler is added to root element', () => { const {root, mockFoundation} = setupTest(); const event = document.createEvent('KeyboardEvent');