@@ -30,17 +30,14 @@ define(['layoutManager', 'cardBuilder', 'apphost', 'imageLoader', 'loading', 'sc
             recordingItems.classList.add('vertical-wrap');
         }

-        var supportsImageAnalysis = appHost.supports('imageanalysis');
-        var cardLayout = appHost.preferVisualCards || supportsImageAnalysis;
-        cardLayout = false;
         recordingItems.innerHTML = cardBuilder.getCardsHtml(Object.assign({
             items: recordings,
             shape: enableScrollX() ? 'autooverflow' : 'auto',
             showTitle: true,
             showParentTitle: true,
             coverImage: true,
-            cardLayout: cardLayout,
-            centerText: !cardLayout,
+            cardLayout: appHost.preferVisualCards,
+            centerText: !appHost.preferVisualCards,
             allowBottomPadding: !enableScrollX(),
             preferThumb: 'auto'
         }, cardOptions || {}));

define(['layoutManager', 'cardBuilder', 'apphost', 'imageLoader', 'loading', 'scripts/livetvcomponents', 'emby-button', 'emby-itemscontainer'], function (layoutManager, cardBuilder, appHost, imageLoader, loading) {
    'use strict';

    function enableScrollX() {
        return !layoutManager.desktop;
    }

    function renderRecordings(elem, recordings, cardOptions) {
        if (recordings.length) {
            elem.classList.remove('hide');
        } else {
            elem.classList.add('hide');
        }

        var recordingItems = elem.querySelector('.recordingItems');

        if (enableScrollX()) {
            recordingItems.classList.add('scrollX');

            if (layoutManager.tv) {
                recordingItems.classList.add('smoothScrollX');
            }

            recordingItems.classList.add('hiddenScrollX');
            recordingItems.classList.remove('vertical-wrap');
        } else {
            recordingItems.classList.remove('scrollX');
            recordingItems.classList.remove('smoothScrollX');
            recordingItems.classList.remove('hiddenScrollX');
            recordingItems.classList.add('vertical-wrap');
        }

        var supportsImageAnalysis = appHost.supports('imageanalysis');
        var cardLayout = appHost.preferVisualCards || supportsImageAnalysis;
        cardLayout = false;
        recordingItems.innerHTML = cardBuilder.getCardsHtml(Object.assign({
            items: recordings,
            shape: enableScrollX() ? 'autooverflow' : 'auto',
            showTitle: true,
            showParentTitle: true,
            coverImage: true,
            cardLayout: cardLayout,
            centerText: !cardLayout,
            allowBottomPadding: !enableScrollX(),
            preferThumb: 'auto'
        }, cardOptions || {}));
        imageLoader.lazyChildren(recordingItems);
    }

    function getBackdropShape() {
        return enableScrollX() ? 'overflowBackdrop' : 'backdrop';
    }

    function renderActiveRecordings(context, promise) {
        promise.then(function (result) {
            renderRecordings(context.querySelector('#activeRecordings'), result.Items, {
                shape: enableScrollX() ? 'autooverflow' : 'auto',
                defaultShape: getBackdropShape(),
                showParentTitle: false,
                showParentTitleOrTitle: true,
                showTitle: false,
                showAirTime: true,
                showAirEndTime: true,
                showChannelName: true,
                coverImage: true,
                overlayText: false,
                overlayMoreButton: true
            });
        });
    }

    function renderTimers(context, timers, options) {
        LiveTvHelpers.getTimersHtml(timers, options).then(function (html) {
            var elem = context;

            if (html) {
                elem.classList.remove('hide');
            } else {
                elem.classList.add('hide');
            }

            elem.querySelector('.recordingItems').innerHTML = html;
            imageLoader.lazyChildren(elem);
        });
    }

    function renderUpcomingRecordings(context, promise) {
        promise.then(function (result) {
            renderTimers(context.querySelector('#upcomingRecordings'), result.Items);
            loading.hide();
        });
    }

    return function (view, params, tabContent) {
        var activeRecordingsPromise;
        var upcomingRecordingsPromise;
        var self = this;
        tabContent.querySelector('#upcomingRecordings .recordingItems').addEventListener('timercancelled', function () {
            self.preRender();
            self.renderTab();
        });

        self.preRender = function () {
            activeRecordingsPromise = ApiClient.getLiveTvRecordings({
                UserId: Dashboard.getCurrentUserId(),
                IsInProgress: true,
                Fields: 'CanDelete,PrimaryImageAspectRatio,BasicSyncInfo',
                EnableTotalRecordCount: false,
                EnableImageTypes: 'Primary,Thumb,Backdrop'
            });
            upcomingRecordingsPromise = ApiClient.getLiveTvTimers({
                IsActive: false,
                IsScheduled: true
            });
        };

        self.renderTab = function () {
            loading.show();
            renderActiveRecordings(tabContent, activeRecordingsPromise);
            renderUpcomingRecordings(tabContent, upcomingRecordingsPromise);
        };
    };
});

Logic changed: `cardLayout = false;` overrides expression with `appHost.preferVisualCards`.


@@ -43,7 +43,8 @@ module.exports = function(config, auth, storage) {
     const base = Utils.combineBaseUrl(Utils.getWebProtocol(req), req.get('host'), config.url_prefix);
     let webPage = template
       .replace(/ToReplaceByVerdaccio/g, base)
-      .replace(/ToReplaceByTitle/g, _.get(config, 'web.title') ? config.web.title : WEB_TITLE);
+      .replace(/ToReplaceByTitle/g, _.get(config, 'web.title') ? config.web.title : WEB_TITLE)
+      .replace(/ToReplaceByScope/g, _.get(config, 'web.scope') ? config.web.scope : '');

     res.setHeader('Content-Type', 'text/html');

import express from 'express';
import _ from 'lodash';
import fs from 'fs';
import Search from '../../lib/search';
import * as Utils from '../../lib/utils';
import {WEB_TITLE} from '../../lib/constants';

const {securityIframe} = require('../middleware');
/* eslint new-cap:off */
const router = express.Router();
const env = require('../../config/env');
const template = fs.readFileSync(`${env.DIST_PATH}/index.html`).toString();
const spliceURL = require('../../utils/string').spliceURL;

module.exports = function(config, auth, storage) {
  Search.configureStorage(storage);

  router.use(auth.webUIJWTmiddleware());
  router.use(securityIframe);

  // Static
  router.get('/-/static/:filename', function(req, res, next) {
    const file = `${env.APP_ROOT}/static/${req.params.filename}`;
    res.sendFile(file, function(err) {
      if (!err) {
        return;
      }
      if (err.status === 404) {
        next();
      } else {
        next(err);
      }
    });
  });

  router.get('/-/verdaccio/logo', function(req, res) {
    const installPath = _.get(config, 'url_prefix', '');

    res.send(_.get(config, 'web.logo') || spliceURL(installPath, '/-/static/logo.png'));
  });

  router.get('/', function(req, res) {
    const base = Utils.combineBaseUrl(Utils.getWebProtocol(req), req.get('host'), config.url_prefix);
    let webPage = template
      .replace(/ToReplaceByVerdaccio/g, base)
      .replace(/ToReplaceByTitle/g, _.get(config, 'web.title') ? config.web.title : WEB_TITLE);

    res.setHeader('Content-Type', 'text/html');

    res.send(webPage);
  });

  return router;
};

I think here force to use either scope or title is not good. A user might want both.


@@ -3,11 +3,12 @@
 const Aspect = require('./operation').Aspect;
 const OperationBase = require('./operation').OperationBase;
 const resolveReadPreference = require('../utils').resolveReadPreference;
+const serverLacksFeature = require('../utils').serverLacksFeature;
 const ReadConcern = require('../read_concern');
 const WriteConcern = require('../write_concern');
 const maxWireVersion = require('../core/utils').maxWireVersion;
 const commandSupportsReadConcern = require('../core/sessions').commandSupportsReadConcern;
-const MongoError = require('../error').MongoError;
+const MongoError = require('../core').MongoError;

 const SUPPORTS_WRITE_CONCERN_AND_COLLATION = 5;

'use strict';

const Aspect = require('./operation').Aspect;
const OperationBase = require('./operation').OperationBase;
const resolveReadPreference = require('../utils').resolveReadPreference;
const ReadConcern = require('../read_concern');
const WriteConcern = require('../write_concern');
const maxWireVersion = require('../core/utils').maxWireVersion;
const commandSupportsReadConcern = require('../core/sessions').commandSupportsReadConcern;
const MongoError = require('../error').MongoError;

const SUPPORTS_WRITE_CONCERN_AND_COLLATION = 5;

class CommandOperationV2 extends OperationBase {
  constructor(parent, options, operationOptions) {
    super(options);

    this.ns = parent.s.namespace.withCollection('$cmd');
    this.readPreference = resolveReadPreference(parent, this.options);
    this.readConcern = resolveReadConcern(parent, this.options);
    this.writeConcern = resolveWriteConcern(parent, this.options);
    this.explain = false;

    if (operationOptions && typeof operationOptions.fullResponse === 'boolean') {
      this.fullResponse = true;
    }

    // TODO: A lot of our code depends on having the read preference in the options. This should
    //       go away, but also requires massive test rewrites.
    this.options.readPreference = this.readPreference;

    // TODO(NODE-2056): make logger another "inheritable" property
    if (parent.s.logger) {
      this.logger = parent.s.logger;
    } else if (parent.s.db && parent.s.db.logger) {
      this.logger = parent.s.db.logger;
    }
  }

  executeCommand(server, cmd, callback) {
    // TODO: consider making this a non-enumerable property
    this.server = server;

    const options = this.options;
    const serverWireVersion = maxWireVersion(server);
    const inTransaction = this.session && this.session.inTransaction();

    if (this.readConcern && commandSupportsReadConcern(cmd) && !inTransaction) {
      Object.assign(cmd, { readConcern: this.readConcern });
    }

    if (options.collation && serverWireVersion < SUPPORTS_WRITE_CONCERN_AND_COLLATION) {
      callback(
        new MongoError(
          `Server ${server.name}, which reports wire version ${serverWireVersion}, does not support collation`
        )
      );
      return;
    }

    if (serverWireVersion >= SUPPORTS_WRITE_CONCERN_AND_COLLATION) {
      if (this.writeConcern && this.hasAspect(Aspect.WRITE_OPERATION)) {
        Object.assign(cmd, { writeConcern: this.writeConcern });
      }

      if (options.collation && typeof options.collation === 'object') {
        Object.assign(cmd, { collation: options.collation });
      }
    }

    if (typeof options.maxTimeMS === 'number') {
      cmd.maxTimeMS = options.maxTimeMS;
    }

    if (typeof options.comment === 'string') {
      cmd.comment = options.comment;
    }

    if (this.logger && this.logger.isDebug()) {
      this.logger.debug(`executing command ${JSON.stringify(cmd)} against ${this.ns}`);
    }

    server.command(this.ns.toString(), cmd, this.options, (err, result) => {
      if (err) {
        callback(err, null);
        return;
      }

      if (this.fullResponse) {
        callback(null, result);
        return;
      }

      callback(null, result.result);
    });
  }
}

function resolveWriteConcern(parent, options) {
  return WriteConcern.fromOptions(options) || parent.writeConcern;
}

function resolveReadConcern(parent, options) {
  return ReadConcern.fromOptions(options) || parent.readConcern;
}

module.exports = CommandOperationV2;

let's actually use the direct include: `require('../core/error').MongoError;`


@@ -7,20 +7,32 @@
  * @private
  */
 function getIncompleteReason(checkData, messages) {
+	function getDefaultMsg(messages) {
+		if (messages.incomplete && messages.incomplete.default) {
+			// fall back to the default message if no reason specified
+			return messages.incomplete.default;
+		} else {
+			// TODO: localize it
+			return 'aXe couldn\'t tell the reason. Time to break out the element inspector!';
+		}
+	}
 	if (checkData && checkData.missingData) {
-		var missingReason;
 		try {
-			missingReason = checkData.missingData[0].reason;
-		} finally {
+			var msg = messages.incomplete[checkData.missingData[0].reason];
+			if (!msg) {
+				throw new Error();
+			}
+			return msg;
+		} catch (e) {
 			if (typeof checkData.missingData === 'string')  {
-				missingReason = checkData.missingData;
+				// return a string with the appropriate reason
+				return messages.incomplete[checkData.missingData];
+			} else {
+				return getDefaultMsg(messages);
 			}
 		}
-		// return a function with the appropriate message
-		return messages.incomplete[missingReason];
 	} else {
-		// fall back to the default message if no reason specified
-		return messages.incomplete.default;
+		return getDefaultMsg(messages);
 	}
 }
 /**

/**
 * Construct incomplete message from check.data
 * @param  {Object} checkData Check result with reason specified
 * @param  {Object} messages Source data object with message options
 * @return  {String}
 * @private
 */
function getIncompleteReason(checkData, messages) {
	if (checkData && checkData.missingData) {
		var missingReason;
		try {
			missingReason = checkData.missingData[0].reason;
		} finally {
			if (typeof checkData.missingData === 'string')  {
				missingReason = checkData.missingData;
			}
		}
		// return a function with the appropriate message
		return messages.incomplete[missingReason];
	} else {
		// fall back to the default message if no reason specified
		return messages.incomplete.default;
	}
}
/**
 * Extend checksData with the correct result message
 * @param  {Object} checksData The check result data
 * @param  {Boolean} shouldBeTrue Result of pass/fail check run
 * @return {Function}
 * @private
 */
function extender(checksData, shouldBeTrue) {
	'use strict';
	return function (check) {
		var sourceData = checksData[check.id] || {};
		var messages = sourceData.messages || {};
		var data = Object.assign({}, sourceData);
		delete data.messages;
		if (check.result === undefined) {
			if (typeof messages.incomplete === 'object') {
				data.message = function() { return getIncompleteReason(check.data, messages); };
			} else {
				// fall back to string message
				data.message = messages.incomplete;
			}
		} else {
			data.message = check.result === shouldBeTrue ? messages.pass : messages.fail;
		}
		axe.utils.extendMetaData(check, data);
	};
}

/**
 * Publish metadata from axe._audit.data
 * @param  {RuleResult} result Result to publish to
 * @private
 */
axe.utils.publishMetaData = function (ruleResult) {
	'use strict';

	var checksData = axe._audit.data.checks || {};
	var rulesData = axe._audit.data.rules || {};
	var rule = axe.utils.findBy(axe._audit.rules, 'id', ruleResult.id) || {};

	ruleResult.tags = axe.utils.clone(rule.tags || []);

	var shouldBeTrue = extender(checksData, true);
	var shouldBeFalse = extender(checksData, false);
	ruleResult.nodes.forEach(function (detail) {
		detail.any.forEach(shouldBeTrue);
		detail.all.forEach(shouldBeTrue);
		detail.none.forEach(shouldBeFalse);
	});
	axe.utils.extendMetaData(ruleResult, axe.utils.clone(rulesData[ruleResult.id] || {}));
};

This string could be anything. Feel free to suggest alternatives if it's not helpful enough / too cheeky.


@@ -82,9 +82,9 @@ class Button extends Component {
 				aria-controls={ ariaControls }
 				role={ 'a' === SemanticButton ? 'button' : undefined }
 			>
-				{ icon && icon }
+				{ icon }
 				{ children }
-				{ trailingIcon && trailingIcon }
+				{ trailingIcon }
 			</SemanticButton>
 		);
 	}

/**
 * Button component.
 *
 * Site Kit by Google, Copyright 2019 Google LLC
 *
 * 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.
 */

/**
 * External dependencies
 */
import PropTypes from 'prop-types';
import classnames from 'classnames';
/**
 * WordPress dependencies
 */
import { Component, createRef } from '@wordpress/element';
/**
 * Internal dependencies
 */
import { MDCRipple } from '../material-components';

class Button extends Component {
	constructor( props ) {
		super( props );
		this.buttonRef = createRef();
	}

	componentDidMount() {
		new MDCRipple( this.buttonRef.current );
	}

	render() {
		const {
			onClick,
			children,
			href,
			text,
			className,
			danger,
			disabled,
			target,
			id,
			icon,
			trailingIcon,
			ariaHaspopup,
			ariaExpanded,
			ariaControls,
		} = this.props;

		// Use a button if disabled, even if a href is provided to ensure expected behavior.
		const SemanticButton = ( href && ! disabled ) ? 'a' : 'button';

		return (
			<SemanticButton
				className={ classnames(
					'mdc-button',
					className,
					{
						'mdc-button--raised': ! text,
						'mdc-button--danger': danger,
					}
				) }
				onClick={ onClick }
				href={ disabled ? undefined : href }
				ref={ this.buttonRef }
				disabled={ !! disabled }
				target={ target || '_self' }
				id={ id }
				aria-haspopup={ ariaHaspopup }
				aria-expanded={ ariaExpanded }
				aria-controls={ ariaControls }
				role={ 'a' === SemanticButton ? 'button' : undefined }
			>
				{ icon && icon }
				{ children }
				{ trailingIcon && trailingIcon }
			</SemanticButton>
		);
	}
}

Button.propTypes = {
	onClick: PropTypes.func,
	children: PropTypes.string.isRequired,
	href: PropTypes.string,
	text: PropTypes.bool,
	className: PropTypes.string,
	danger: PropTypes.bool,
	disabled: PropTypes.bool,
	icon: PropTypes.element,
	trailingIcon: PropTypes.element,
	ariaHaspopup: PropTypes.string,
	ariaExpanded: PropTypes.bool,
	ariaControls: PropTypes.string,
};

Button.defaultProps = {
	onClick: null,
	href: null,
	text: false,
	className: '',
	danger: false,
	disabled: false,
	icon: null,
	trailingIcon: null,
	ariaHaspopup: '',
	ariaExpanded: false,
	ariaControls: '',
};

export default Button;

Why were these changed? I'm not saying this is necessarily a problem, but as far as I remember JSX behaves slightly different from regular JS logic for those types of comparisons - there were cases like this one before which resulted in unexpected behavior. Are we 100% sure removing these checks won't render something like `undefined` or `0` or `false` out as a string in the HTML?


@@ -1,11 +1,11 @@
 module.exports = {
-	hash: '964177e31d959b03bc63f11216c61e3e', files: {
-		'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
-		'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
-		'katex/fonts/KaTeX_Main-Regular.woff2': { data: require('./katex/fonts/KaTeX_Main-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
-		'katex/fonts/KaTeX_Math-Italic.woff2': { data: require('./katex/fonts/KaTeX_Math-Italic.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
-		'katex/fonts/KaTeX_Size1-Regular.woff2': { data: require('./katex/fonts/KaTeX_Size1-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
-		'katex/fonts/KaTeX_Size2-Regular.woff2': { data: require('./katex/fonts/KaTeX_Size2-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
-		'katex/katex.css': { data: require('./katex/katex.css.base64.js'), mime: 'text/css', encoding: 'base64' },
-	},
-};
+hash:"964177e31d959b03bc63f11216c61e3e", files: {
+'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
+'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
+'katex/fonts/KaTeX_Main-Regular.woff2': { data: require('./katex/fonts/KaTeX_Main-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
+'katex/fonts/KaTeX_Math-Italic.woff2': { data: require('./katex/fonts/KaTeX_Math-Italic.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
+'katex/fonts/KaTeX_Size1-Regular.woff2': { data: require('./katex/fonts/KaTeX_Size1-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
+'katex/fonts/KaTeX_Size2-Regular.woff2': { data: require('./katex/fonts/KaTeX_Size2-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
+'katex/katex.css': { data: require('./katex/katex.css.base64.js'), mime: 'text/css', encoding: 'base64' },
+}
+};

module.exports = {
	hash: '964177e31d959b03bc63f11216c61e3e', files: {
		'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
		'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
		'katex/fonts/KaTeX_Main-Regular.woff2': { data: require('./katex/fonts/KaTeX_Main-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
		'katex/fonts/KaTeX_Math-Italic.woff2': { data: require('./katex/fonts/KaTeX_Math-Italic.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
		'katex/fonts/KaTeX_Size1-Regular.woff2': { data: require('./katex/fonts/KaTeX_Size1-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
		'katex/fonts/KaTeX_Size2-Regular.woff2': { data: require('./katex/fonts/KaTeX_Size2-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
		'katex/katex.css': { data: require('./katex/katex.css.base64.js'), mime: 'text/css', encoding: 'base64' },
	},
};

Note to reviewer: I didn't touch this file; these lines were added automatically when I installed `react-native-quick-actions` via npm and linked the project in xcode (as described in the OP of this PR)


@@ -114,7 +114,10 @@ const baseResolvers = {
 			return;
 		}

-		yield fetchGetURLChannelsStore.actions.fetchGetURLChannels( accountID, clientID );
+		const { error } = yield fetchGetURLChannelsStore.actions.fetchGetURLChannels( accountID, clientID );
+		if ( error ) {
+			yield errorStoreActions.receiveError( error, 'getURLChannels', [ accountID, clientID ] );
+		}
 	},
 };

/**
 * `modules/adsense` data store: URL channels.
 *
 * Site Kit by Google, Copyright 2021 Google LLC
 *
 * 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.
 */

/**
 * External dependencies
 */
import invariant from 'invariant';

/**
 * Internal dependencies
 */
import API from 'googlesitekit-api';
import Data from 'googlesitekit-data';
import { STORE_NAME } from './constants';
import { createFetchStore } from '../../../googlesitekit/data/create-fetch-store';
import { actions as errorStoreActions } from '../../../googlesitekit/data/create-error-store';

const fetchGetURLChannelsStore = createFetchStore( {
	baseName: 'getURLChannels',
	controlCallback: ( { accountID, clientID } ) => {
		return API.get( 'modules', 'adsense', 'urlchannels', { accountID, clientID }, {
			useCache: false,
		} );
	},
	reducerCallback: ( state, urlchannels, { accountID, clientID } ) => {
		return {
			...state,
			urlchannels: {
				...state.urlchannels,
				[ `${ accountID }::${ clientID }` ]: [ ...urlchannels ],
			},
		};
	},
	argsToParams: ( accountID, clientID ) => {
		return { accountID, clientID };
	},
	validateParams: ( { accountID, clientID } = {} ) => {
		invariant( accountID, 'accountID is required.' );
		invariant( clientID, 'clientID is required.' );
	},
} );

// Actions
const RESET_URLCHANNELS = 'RESET_URLCHANNELS';

const baseInitialState = {
	urlchannels: {},
};

const baseActions = {
	*resetURLChannels() {
		const { dispatch } = yield Data.commonActions.getRegistry();

		yield {
			payload: {},
			type: RESET_URLCHANNELS,
		};

		yield errorStoreActions.clearErrors( 'getURLChannels' );

		return dispatch( STORE_NAME )
			.invalidateResolutionForStoreSelector( 'getURLChannels' );
	},
};

const baseReducer = ( state, { type } ) => {
	switch ( type ) {
		case RESET_URLCHANNELS: {
			const {
				siteStatus,
				siteSetupComplete,
			} = state.savedSettings || {};
			return {
				...state,
				urlchannels: initialState.urlchannels,
				settings: {
					...( state.settings || {} ),
					siteStatus,
					siteSetupComplete,
				},
			};
		}

		default: {
			return state;
		}
	}
};

const baseResolvers = {
	*getURLChannels( accountID, clientID ) {
		if ( undefined === accountID || undefined === clientID ) {
			return;
		}

		const registry = yield Data.commonActions.getRegistry();
		const existingURLChannels = registry.select( STORE_NAME ).getURLChannels( accountID, clientID );
		if ( existingURLChannels ) {
			return;
		}

		yield fetchGetURLChannelsStore.actions.fetchGetURLChannels( accountID, clientID );
	},
};

const baseSelectors = {
	/**
	 * Gets all Google AdSense URL channels for this account and client.
	 *
	 * @since 1.9.0
	 *
	 * @param {Object} state     Data store's state.
	 * @param {string} accountID The AdSense Account ID to fetch URL channels for.
	 * @param {string} clientID  The AdSense Client ID to fetch URL channels for.
	 * @return {(Array.<Object>|undefined)} An array of AdSense URL channels; `undefined` if not loaded.
	 */
	getURLChannels( state, accountID, clientID ) {
		if ( undefined === accountID || undefined === clientID ) {
			return undefined;
		}

		return state.urlchannels[ `${ accountID }::${ clientID }` ];
	},
};

const store = Data.combineStores(
	fetchGetURLChannelsStore,
	{
		initialState: baseInitialState,
		actions: baseActions,
		reducer: baseReducer,
		resolvers: baseResolvers,
		selectors: baseSelectors,
	}
);

export const initialState = store.initialState;
export const actions = store.actions;
export const controls = store.controls;
export const reducer = store.reducer;
export const resolvers = store.resolvers;
export const selectors = store.selectors;

export default store;

@eugene-manuilov Isn't this already taken care of by `fetchGetURLChannels` via `createFetchStore`? Why is the extra `receiveError` call needed here? (This was already in the IB, but just struck me while reviewing here.)


@@ -1,13 +1,24 @@
 /* global aria, axe, dom */
-function findDomNode(node, functor) {
-	if (functor(node)) {
-		return node;
+const idRefsRegex = /^idrefs?$/;
+
+function cacheIdRefs(node, refAttrs) {
+	if (node.hasAttribute) {
+		if (node.nodeName.toUpperCase() === 'LABEL' && node.hasAttribute('for')) {
+			axe._cache.idRefs[node.getAttribute('for')] = 1;
+		}
+
+		refAttrs
+			.filter(attr => node.hasAttribute(attr))
+			.forEach(attr => {
+				const attrValue = node.getAttribute(attr);
+				axe.utils.tokenList(attrValue).forEach(id => {
+					axe._cache.idRefs[id] = 1;
+				});
+			});
 	}
+
 	for (let i = 0; i < node.children.length; i++) {
-		const out = findDomNode(node.children[i], functor);
-		if (out) {
-			return out;
-		}
+		cacheIdRefs(node.children[i], refAttrs);
 	}
 }

/* global aria, axe, dom */
function findDomNode(node, functor) {
	if (functor(node)) {
		return node;
	}
	for (let i = 0; i < node.children.length; i++) {
		const out = findDomNode(node.children[i], functor);
		if (out) {
			return out;
		}
	}
}

/**
 * Check that a DOM node is a reference in the accessibility tree
 * @param {Element} node
 * @returns {Boolean}
 */
aria.isAccessibleRef = function isAccessibleRef(node) {
	node = node.actualNode || node;
	let root = dom.getRootNode(node);
	root = root.documentElement || root; // account for shadow roots
	const id = node.id;

	// Get all idref(s) attributes on the lookup table
	const refAttrs = Object.keys(aria.lookupTable.attributes).filter(attr => {
		const { type } = aria.lookupTable.attributes[attr];
		return /^idrefs?$/.test(type);
	});

	// Find the first element that IDREF(S) the node
	let refElm = findDomNode(root, elm => {
		if (elm.nodeType !== 1) {
			// Elements only
			return;
		}
		if (
			elm.nodeName.toUpperCase() === 'LABEL' &&
			elm.getAttribute('for') === id
		) {
			return true;
		}
		// See if there are any aria attributes that reference the node
		return refAttrs
			.filter(attr => elm.hasAttribute(attr))
			.some(attr => {
				const attrValue = elm.getAttribute(attr);
				if (aria.lookupTable.attributes[attr].type === 'idref') {
					return attrValue === id;
				}
				return axe.utils.tokenList(attrValue).includes(id);
			});
	});
	return typeof refElm !== 'undefined';
};

what happens if my `for` value is an `empty` space? ;) `<label for=' '>Temp</label>`


@@ -17,6 +17,13 @@ const getHasListItem = (hasListItem, tagName, isListItemRole) => {
 	return hasListItem || (tagName === 'LI' && isListItemRole) || isListItemRole;
 };

+const getIsHidden = actualNode => {
+	return (
+		window.getComputedStyle(actualNode, null).getPropertyValue('display') ===
+		'none'
+	);
+};
+
 let base = {
 	badNodes: [],
 	isEmpty: true,

const ALLOWED_TAGS = [
	'STYLE',
	'META',
	'LINK',
	'MAP',
	'AREA',
	'SCRIPT',
	'DATALIST',
	'TEMPLATE'
];

const getIsListItemRole = (role, tagName) => {
	return role === 'listitem' || (tagName === 'LI' && !role);
};

const getHasListItem = (hasListItem, tagName, isListItemRole) => {
	return hasListItem || (tagName === 'LI' && isListItemRole) || isListItemRole;
};

let base = {
	badNodes: [],
	isEmpty: true,
	hasNonEmptyTextNode: false,
	hasListItem: false,
	liItemsWithRole: 0
};

let out = virtualNode.children.reduce((out, { actualNode }) => {
	/*eslint
		max-statements: ["error", 20]
		complexity: ["error", 11]
		*/
	const tagName = actualNode.nodeName.toUpperCase();

	if (actualNode.nodeType === 1) {
		if (!ALLOWED_TAGS.includes(tagName)) {
			const role = (actualNode.getAttribute('role') || '').toLowerCase();
			const isListItemRole = getIsListItemRole(role, tagName);

			out.hasListItem = getHasListItem(
				out.hasListItem,
				tagName,
				isListItemRole
			);

			if (isListItemRole) {
				out.isEmpty = false;
			}
			if (tagName === 'LI' && !isListItemRole) {
				out.liItemsWithRole++;
			}
			if (tagName !== 'LI' && !isListItemRole) {
				out.badNodes.push(actualNode);
			}
		}
	}
	if (actualNode.nodeType === 3) {
		if (actualNode.nodeValue.trim() !== '') {
			out.hasNonEmptyTextNode = true;
		}
	}

	return out;
}, base);

const virtualNodeChildrenOfTypeLi = virtualNode.children.filter(
	({ actualNode }) => {
		return actualNode.nodeName.toUpperCase() === 'LI';
	}
);

const allLiItemsHaveRole =
	out.liItemsWithRole > 0 &&
	virtualNodeChildrenOfTypeLi.length === out.liItemsWithRole;

if (out.badNodes.length) {
	this.relatedNodes(out.badNodes);
}

const isInvalidListItem = !(
	out.hasListItem ||
	(out.isEmpty && !allLiItemsHaveRole)
);
return isInvalidListItem || !!out.badNodes.length || out.hasNonEmptyTextNode;

DRY, worth extracting this method to axe.utils


@@ -10,6 +10,8 @@ export function assign(obj, props) {
 	return /** @type {O & P} */ (obj);
 }

+export const IS_NON_DIMENSIONAL = /^(-|f[lo].*[^se]$|g.{5,}[^ps]$|z|o[pr]|(W.{5})?[lL]i.*(t|mp)$|an|(bo|s).{4}Im|sca|m.{6}[ds]|ta|c.*[st]$|wido|ini)/;
+
 /**
  * Check if two objects have a different shape
  * @param {object} a

/**
 * Assign properties from `props` to `obj`
 * @template O, P The obj and props types
 * @param {O} obj The object to copy properties to
 * @param {P} props The object to copy properties from
 * @returns {O & P}
 */
export function assign(obj, props) {
	for (let i in props) obj[i] = props[i];
	return /** @type {O & P} */ (obj);
}

/**
 * Check if two objects have a different shape
 * @param {object} a
 * @param {object} b
 * @returns {boolean}
 */
export function shallowDiffers(a, b) {
	for (let i in a) if (i !== '__source' && !(i in b)) return true;
	for (let i in b) if (i !== '__source' && a[i] !== b[i]) return true;
	return false;
}

Is this the regex that was suggested a while back? I am wondering if the increased repetition will impact performance.


@@ -5,7 +5,7 @@ setResolver(resolver);

 /* jshint ignore:start */
 mocha.setup({
-    timeout: 15000,
+    timeout: 25000,
     slow: 500
 });
 /* jshint ignore:end */

import resolver from './helpers/resolver';
import {setResolver} from 'ember-mocha';

setResolver(resolver);

/* jshint ignore:start */
mocha.setup({
    timeout: 15000,
    slow: 500
});
/* jshint ignore:end */

Were you having trouble with timeouts in general acceptance tests or only the editor test? It's possible to set timeouts on a per-test basis by using `this.timeout(25000)` within the `it()` function. I'd like to drop the global timeout in the future if possible rather than increase it - in some circumstances a failing test can have a knock-on effect which makes all later tests timeout so Travis can get hung up for a long time if no one notices and cancels the build.


@@ -1,4 +1,21 @@
+/**
+ * Utilities for the Tag Manager module.
+ *
+ * Site Kit by Google, Copyright 2020 Google LLC
+ *
+ * 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.
+ */
+
+export * from './validation';
 export { default as getContainers } from './get-containers';
-export { default as isValidAccountID } from './is-valid-account-id';
-export { default as isValidContainerID } from './is-valid-container-id';
 export { default as tagMatchers } from './tag-matchers';

export { default as getContainers } from './get-containers';
export { default as isValidAccountID } from './is-valid-account-id';
export { default as isValidContainerID } from './is-valid-container-id';
export { default as tagMatchers } from './tag-matchers';

Thanks! Not sure why these were left around


@@ -27,7 +27,7 @@ const main = async url => {
 		// Inject and run axe-core
 		const handle = await page.evaluateHandle(`
 			// Inject axe source code
-			${axeCore.source}
+			var axe = ${axeCore.source}
 			// Run axe
 			axe.run()
 		`);

const puppeteer = require('puppeteer');
const axeCore = require('axe-core');
const { parse: parseURL } = require('url');
const assert = require('assert');

// Cheap URL validation
const isValidURL = input => {
	const u = parseURL(input);
	return u.protocol && u.host;
};

// node axe-puppeteer.js <url>
const url = process.argv[2];
assert(isValidURL(url), 'Invalid URL');

const main = async url => {
	let browser;
	let results;
	try {
		// Setup Puppeteer
		browser = await puppeteer.launch();

		// Get new page
		const page = await browser.newPage();
		await page.goto(url);

		// Inject and run axe-core
		const handle = await page.evaluateHandle(`
			// Inject axe source code
			${axeCore.source}
			// Run axe
			axe.run()
		`);

		// Get the results from `axe.run()`.
		results = await handle.jsonValue();
		// Destroy the handle & return axe results.
		await handle.dispose();
	} catch (err) {
		// Ensure we close the puppeteer connection when possible
		if (browser) {
			await browser.close();
		}

		// Re-throw
		throw err;
	}

	await browser.close();
	return results;
};

main(url)
	.then(results => {
		console.log(results);
	})
	.catch(err => {
		console.error('Error running axe-core:', err.message);
		process.exit(1);
	});

Why is this necessary? I don't think I understand what's happening here.


@@ -34,7 +34,9 @@ import { isValidStringsOrObjects } from '../../../util/report-validation';
 export function isValidMetrics( metrics ) {
 	return isValidStringsOrObjects( metrics, ( metric ) => {
 		const validExpression = metric.hasOwnProperty( 'expression' ) && typeof metric.expression === 'string';
-		const validAlias = metric.hasOwnProperty( 'alias' ) && typeof metric.alias === 'string';
+
+		// 'alias' is optional; if provided, it must be a string.
+		const validAlias = ! metric.hasOwnProperty( 'alias' ) || typeof metric.alias === 'string';
 		return validExpression && validAlias;
 	} );
 }

/**
 * Reporting API validation utilities.
 *
 * Site Kit by Google, Copyright 2020 Google LLC
 *
 * 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.
 */

/**
 * Internal dependencies
 */
import { isValidStringsOrObjects } from '../../../util/report-validation';

/**
 * Verifies that provided metrics match allowed values. Metrics can be a string,
 * an array of string, an array of objects or mix of strings and objects. Objects
 * must have "expression" and "alias" properties in order to be considered as valid.
 *
 * @since 1.13.0
 *
 * @param {string|string[]|Object|Object[]} metrics The metrics to check.
 * @return {boolean} TRUE if metrics are valid, otherwise FALSE.
 */
export function isValidMetrics( metrics ) {
	return isValidStringsOrObjects( metrics, ( metric ) => {
		const validExpression = metric.hasOwnProperty( 'expression' ) && typeof metric.expression === 'string';
		const validAlias = metric.hasOwnProperty( 'alias' ) && typeof metric.alias === 'string';
		return validExpression && validAlias;
	} );
}

/**
 * Verifies provided dimensions to make sure it matches allowed values. It can be a string,
 * array of strings, an object with "name" field, array of such objects or an array of strings
 * and objects.
 *
 * @since 1.13.0
 *
 * @param {string|string[]|Object|Object[]} dimensions The dimensions to check.
 * @return {boolean} TRUE if dimensions are valid, otherwise FALSE.
 */
export function isValidDimensions( dimensions ) {
	return isValidStringsOrObjects( dimensions, ( dimension ) => {
		return dimension.hasOwnProperty( 'name' ) && typeof dimension.name === 'string';
	} );
}

although the doc block should be updated to reflect this as it currently contradicts this: > Objects must have "expression" and "alias" properties in order to be considered as valid.


@@ -16,9 +16,16 @@ module.exports = function(environment) {
     APP: {
       // Here you can pass flags/options to your application instance
       // when it is created
+    },
+
+    'simple-auth': {
+
+    },
+    'ember-cli-mirage': {
+      enabled: false
     }
   };
-
+
 ENV.APP.manifest = {
   enabled: true,
   appcacheFile: "/manifest.appcache",

/* jshint node: true */

module.exports = function(environment) {
  var ENV = {
    modulePrefix: 'hospitalrun',
    environment: environment,
    baseURL: '/',
    locationType: 'hash', //Auto incompatible with google login right now
    EmberENV: {
      FEATURES: {
        // Here you can enable experimental features on an ember canary build
        // e.g. 'with-controller': true
      }
    },

    APP: {
      // Here you can pass flags/options to your application instance
      // when it is created
    }
  };

ENV.APP.manifest = {
  enabled: true,
  appcacheFile: "/manifest.appcache",
  excludePaths: ['index.html', 'someother.html'],
  includePaths: ['/'],
  network: ['api/'],
  showCreateDate: true
};

  if (environment === 'development') {
    // ENV.APP.LOG_RESOLVER = true;
    // ENV.APP.LOG_ACTIVE_GENERATION = true;
    // ENV.APP.LOG_TRANSITIONS = true;
    // ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
    // ENV.APP.LOG_VIEW_LOOKUPS = true;
  }

  if (environment === 'test') {
    // Testem prefers this...
    ENV.baseURL = '/';
    ENV.locationType = 'none';

    // keep test console output quieter
    ENV.APP.LOG_ACTIVE_GENERATION = false;
    ENV.APP.LOG_VIEW_LOOKUPS = false;

    ENV.APP.rootElement = '#ember-testing';
  }

  if (environment === 'production') {

  }

ENV.manifest = {
  enabled: true,
  appcacheFile: "/manifest.appcache",
  excludePaths: ['index.html', 'tests', 'dymo','robots.txt','testem.js'],
  //includePaths: ['/'],
  showCreateDate: true
};

  return ENV;
};


@@ -9,8 +9,8 @@ const expect = require('chai').expect,
   MongoError = require('../../../lib/core/error').MongoError,
   ReadPreference = require('../../../lib/core/topologies/read_preference');

-const rsWithPrimaryPath = f('%s/../spec/max-staleness/ReplicaSetWithPrimary', __dirname);
-const rsWithoutPrimaryPath = f('%s/../spec/max-staleness/ReplicaSetNoPrimary', __dirname);
+const rsWithPrimaryPath = f('%s/../../spec/max-staleness/ReplicaSetWithPrimary', __dirname);
+const rsWithoutPrimaryPath = f('%s/../../spec/max-staleness/ReplicaSetNoPrimary', __dirname);

 describe('Max Staleness', function() {
   describe('ReplicaSet without primary', function() {

'use strict';

const expect = require('chai').expect,
  p = require('path'),
  f = require('util').format,
  fs = require('fs'),
  Server = require('../../../lib/core/topologies/server'),
  ReplSetState = require('../../../lib/core/topologies/replset_state'),
  MongoError = require('../../../lib/core/error').MongoError,
  ReadPreference = require('../../../lib/core/topologies/read_preference');

const rsWithPrimaryPath = f('%s/../spec/max-staleness/ReplicaSetWithPrimary', __dirname);
const rsWithoutPrimaryPath = f('%s/../spec/max-staleness/ReplicaSetNoPrimary', __dirname);

describe('Max Staleness', function() {
  describe('ReplicaSet without primary', function() {
    fs
      .readdirSync(rsWithoutPrimaryPath)
      .filter(x => x.indexOf('.json') !== -1)
      .forEach(x => {
        it(p.basename(x, '.json'), function(done) {
          executeEntry(f('%s/%s', rsWithoutPrimaryPath, x), done);
        });
      });
  });

  describe('ReplicaSet with primary', function() {
    fs
      .readdirSync(rsWithPrimaryPath)
      .filter(x => x.indexOf('.json') !== -1)
      .filter(x => x.indexOf('LongHeartbeat2.jwson') === -1)
      .forEach(x => {
        it(p.basename(x, '.json'), function(done) {
          executeEntry(f('%s/%s', rsWithPrimaryPath, x), done);
        });
      });
  });
});

function convert(mode) {
  if (mode === undefined) return 'primary';
  if (mode.toLowerCase() === 'primarypreferred') return 'primaryPreferred';
  if (mode.toLowerCase() === 'secondarypreferred') return 'secondaryPreferred';
  return mode.toLowerCase();
}

function executeEntry(path, callback) {
  // Read and parse the json file
  var file = require(path);

  // Let's pick out the parts of the selection specification
  var error = file.error;
  var heartbeatFrequencyMS = file.heartbeatFrequencyMS || 10000;
  var inLatencyWindow = file.in_latency_window;
  var readPreference = file.read_preference;
  var topologyDescription = file.topology_description;

  try {
    // Create a Replset and populate it with dummy topology servers
    var replset = new ReplSetState({
      heartbeatFrequencyMS: heartbeatFrequencyMS
    });

    replset.topologyType = topologyDescription.type;
    // For each server add them to the state
    topologyDescription.servers.forEach(function(s) {
      var server = new Server({
        host: s.address.split(':')[0],
        port: parseInt(s.address.split(':')[1], 10)
      });

      // Add additional information
      if (s.avg_rtt_ms) server.lastIsMasterMS = s.avg_rtt_ms;
      if (s.lastUpdateTime) server.lastUpdateTime = s.lastUpdateTime;
      // Set the last write
      if (s.lastWrite) {
        server.lastWriteDate = s.lastWrite.lastWriteDate.$numberLong;
      }

      server.ismaster = {};
      if (s.tags) server.ismaster.tags = s.tags;
      if (s.maxWireVersion) server.ismaster.maxWireVersion = s.maxWireVersion;
      // Ensure the server looks connected
      server.isConnected = function() {
        return true;
      };

      if (s.type === 'RSSecondary') {
        server.ismaster.secondary = true;
        replset.secondaries.push(server);
      } else if (s.type === 'RSPrimary') {
        server.ismaster.ismaster = true;
        replset.primary = server;
      } else if (s.type === 'RSArbiter') {
        server.ismaster.arbiterOnly = true;
        replset.arbiters.push(server);
      }
    });

    // Calculate staleness
    replset.updateSecondariesMaxStaleness(heartbeatFrequencyMS);

    // Create read preference
    var rp = new ReadPreference(convert(readPreference.mode), readPreference.tag_sets, {
      maxStalenessSeconds: readPreference.maxStalenessSeconds
    });

    // Perform a pickServer
    var server = replset.pickServer(rp);
    var foundWindow = null;

    // We expect an error
    if (error) {
      expect(server).to.be.an.instanceof(MongoError);
      return callback(null, null);
    }

    // server should be in the latency window
    for (var i = 0; i < inLatencyWindow.length; i++) {
      var w = inLatencyWindow[i];

      if (server.name === w.address) {
        foundWindow = w;
        break;
      }
    }

    if (
      ['ReplicaSetNoPrimary', 'Primary', 'ReplicaSetWithPrimary'].indexOf(
        topologyDescription.type
      ) !== -1 &&
      inLatencyWindow.length === 0
    ) {
      if (server instanceof MongoError) {
        expect(server.message).to.equal('maxStalenessSeconds must be set to at least 90 seconds');
      } else {
        expect(server).to.be.null;
      }
    } else {
      expect(foundWindow).to.not.be.null;
    }
  } catch (err) {
    if (file.error) return callback(null, null);
    return callback(err, null);
  }

  callback(null, null);
}

Since we're here, can we use a template?


@@ -18,6 +18,7 @@ bitcore.encoding.BufferWriter = require('./lib/encoding/bufferwriter');
 bitcore.encoding.Varint = require('./lib/encoding/varint');

 // main bitcoin library
+bitcore.Unit = require('./lib/unit');
 bitcore.Address = require('./lib/address');
 bitcore.BIP32 = require('./lib/bip32');
 bitcore.Block = require('./lib/block');

var bitcore = module.exports;

// crypto
bitcore.crypto = {};
bitcore.crypto.BN = require('./lib/crypto/bn');
bitcore.crypto.ECDSA = require('./lib/crypto/ecdsa');
bitcore.crypto.Hash = require('./lib/crypto/hash');
bitcore.crypto.Random = require('./lib/crypto/random');
bitcore.crypto.Point = require('./lib/crypto/point');

// encoding
bitcore.encoding = {};
bitcore.encoding.Base58 = require('./lib/encoding/base58');
bitcore.encoding.Base58Check = require('./lib/encoding/base58check');
bitcore.encoding.BufferReader = require('./lib/encoding/bufferreader');
bitcore.encoding.BufferWriter = require('./lib/encoding/bufferwriter');
bitcore.encoding.Varint = require('./lib/encoding/varint');

// main bitcoin library
bitcore.Address = require('./lib/address');
bitcore.BIP32 = require('./lib/bip32');
bitcore.Block = require('./lib/block');
bitcore.Blockheader = require('./lib/blockheader');
bitcore.Networks = require('./lib/networks');
bitcore.Opcode = require('./lib/opcode');
bitcore.PrivateKey = require('./lib/privatekey');
bitcore.PublicKey = require('./lib/publickey');
bitcore.Script = require('./lib/script');
bitcore.Signature = require('./lib/signature');
bitcore.Transaction = require('./lib/transaction');
bitcore.Txin = require('./lib/txin');
bitcore.Txout = require('./lib/txout');

//dependencies, subject to change
bitcore.deps = {};
bitcore.deps.bnjs = require('bn.js');
bitcore.deps.bs58 = require('bs58');
bitcore.deps.Buffer = Buffer;
bitcore.deps.elliptic = require('elliptic');

//bitcore.scriptexec = require('lib/scriptexec');
//bitcore.tx = require('lib/tx');
//bitcore.txpartial = require('lib/txpartial');

//bitcore.bip70 = require('lib/bip70');


@@ -41,14 +41,48 @@ describe( 'SettingsApp', () => {
 		registry.dispatch( CORE_USER ).receiveGetAuthentication( { needsReauthentication: false } );
 		registry.dispatch( CORE_USER ).receiveConnectURL( 'test-url' );

-		provideModules( registry, [ {
-			slug: 'analytics',
-			name: 'Analytics',
-			active: true,
-			connected: true,
-			setupComplete: true,
-			SettingsEditComponent: () =>
edit
,
-		} ] );
+		provideModules( registry, [
+			{
+				slug: 'analytics',
+				name: 'Analytics',
+				active: true,
+				connected: true,
+				setupComplete: true,
+				SettingsEditComponent: () =>
edit
,
+			},
+			{
+				slug: 'optimize',
+				name: 'Optimize',
+				active: true,
+				connected: true,
+				setupComplete: true,
+				SettingsEditComponent: () =>
edit
,
+			},
+			{
+				slug: 'tagmanager',
+				name: 'Tag Manager',
+				active: true,
+				connected: true,
+				setupComplete: true,
+				SettingsEditComponent: () =>
edit
,
+			},
+			{
+				slug: 'adsense',
+				name: 'AdSense',
+				active: true,
+				connected: true,
+				setupComplete: true,
+				SettingsEditComponent: () =>
edit
,
+			},
+			{
+				slug: 'pagespeed-insights',
+				name: 'PageSpeed Insights',
+				active: true,
+				connected: true,
+				setupComplete: true,
+				SettingsEditComponent: () =>
edit
,
+			},
+		] );

 		global._googlesitekitLegacyData.modules.analytics = {
 			...global._googlesitekitLegacyData.modules.analytics,

/**
 * SettingsApp component tests.
 *
 * Site Kit by Google, Copyright 2021 Google LLC
 *
 * 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.
 */

/**
 * Internal dependencies
 */
import SettingsApp from './SettingsApp';
import { render, fireEvent, createTestRegistry, provideModules, waitFor } from '../../../../tests/js/test-utils';
import { CORE_USER } from '../../googlesitekit/datastore/user/constants';
import * as fixtures from '../../modules/analytics/datastore/__fixtures__';

describe( 'SettingsApp', () => {
	let registry;

	const features = [ 'storeErrorNotifications' ];
	const validResponse = {
		accountID: 'pub-12345678',
		clientID: 'ca-pub-12345678',
		useSnippet: true,
	};

	beforeEach( () => {
		global.location.hash = '';

		registry = createTestRegistry();
		registry.dispatch( CORE_USER ).receiveGetAuthentication( { needsReauthentication: false } );
		registry.dispatch( CORE_USER ).receiveConnectURL( 'test-url' );

		provideModules( registry, [ {
			slug: 'analytics',
			name: 'Analytics',
			active: true,
			connected: true,
			setupComplete: true,
			SettingsEditComponent: () =>
edit
,
		} ] );

		global._googlesitekitLegacyData.modules.analytics = {
			...global._googlesitekitLegacyData.modules.analytics,
			active: true,
			setupComplete: true,
		};
	} );

	it( 'should change location hash & DOM correctly when module accordion clicked and opened', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/settings/,
			{ body: validResponse, status: 200 }
		);

		const { getByRole, findByRole } = render( <SettingsApp />, { features, registry } );

		fireEvent.click( getByRole( 'tab', { name: /analytics/i } ) );
		expect( global.location.hash ).toEqual( '#settings/analytics/view' );

		const analyticsPanel = await findByRole( 'tabpanel', { hidden: false } );
		expect( analyticsPanel ).toHaveAttribute( 'id', 'googlesitekit-settings-module__content--analytics' );
	} );

	it( 'should change location hash & DOM correctly when module accordion clicked and closed', async () => {
		const { getByRole, findByRole, queryByRole } = render( <SettingsApp />, { features, registry } );

		fireEvent.click( getByRole( 'tab', { name: /analytics/i } ) );

		const analyticsPanel = await findByRole( 'tabpanel' );
		expect( analyticsPanel ).toBeInTheDocument();

		expect( global.location.hash ).toEqual( '#settings/analytics/view' );

		fireEvent.click( getByRole( 'tab', { name: /analytics/i } ) );
		// A closed state should not be reflected in the hash as it is implied as default.
		expect( global.location.hash ).toEqual( '#settings' );

		await waitFor( () => {
			expect( queryByRole( 'tabpanel' ) ).toBeNull();
		} );
	} );

	it( 'should change location hash & DOM correctly when module is being edited', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/accounts-properties-profiles/,
			{ body: fixtures.accountsPropertiesProfiles, status: 200 }
		);

		const { getByRole, findByRole, queryByTestID } = render( <SettingsApp />, { features, registry } );

		fireEvent.click( getByRole( 'tab', { name: /analytics/i } ) );

		const analyticsPanel = await findByRole( 'tabpanel' );
		expect( analyticsPanel ).toBeInTheDocument();

		fireEvent.click( getByRole( 'button', { name: /edit/i } ) );

		expect( global.location.hash ).toEqual( '#settings/analytics/edit' );

		await waitFor( () => {
			expect( queryByTestID( 'edit-component' ) ).toBeInTheDocument();
		} );
	} );

	it( 'should change location hash & DOM correctly when module is no longer being edited', async () => {
		fetchMock.getOnce(
			/^\/google-site-kit\/v1\/modules\/analytics\/data\/accounts-properties-profiles/,
			{ body: fixtures.accountsPropertiesProfiles, status: 200 }
		);

		const { getByRole, findByRole } = render( <SettingsApp />, { features, registry } );

		fireEvent.click( getByRole( 'tab', { name: /analytics/i } ) );

		const analyticsPanel = await findByRole( 'tabpanel' );
		expect( analyticsPanel ).toBeInTheDocument();

		fireEvent.click( getByRole( 'button', { name: /edit/i } ) );
		expect( global.location.hash ).toEqual( '#settings/analytics/edit' );

		fireEvent.click( getByRole( 'button', { name: /cancel/i } ) );
		expect( global.location.hash ).toEqual( '#settings/analytics/view' );

		const finalAnalyticsPanel = await findByRole( 'tabpanel' );
		expect( finalAnalyticsPanel ).toBeInTheDocument();
	} );

	it( 'should change location hash & DOM correctly when tab is clicked and changed', async () => {
		const { findByText, getAllByRole } = render( <SettingsApp />, { features, registry } );

		fireEvent.click( getAllByRole( 'tab' )[ 1 ] );
		expect( global.location.hash ).toEqual( '#connect' );

		const allConnected = await findByText( /congrats, you’ve connected all services/i );
		expect( allConnected ).toBeInTheDocument();

		fireEvent.click( getAllByRole( 'tab' )[ 2 ] );
		expect( global.location.hash ).toEqual( '#admin' );

		const pluginStatus = await findByText( /plugin status/i );
		expect( pluginStatus ).toBeInTheDocument();
	} );
} );

`setupComplete` is an invalid key as this structure uses the new schema.


@@ -2,10 +2,36 @@

 const expect = require('chai').expect;
 const { getSymbolFrom } = require('../tools/utils');
-const MongoNetworkError = require('../../src/error').MongoNetworkError;
+const { MongoNetworkError } = require('../../src/index');
+const {
+  PoolClosedError: MongoPoolClosedError,
+  WaitQueueTimeoutError: MongoWaitQueueTimeoutError
+} = require('../../src/cmap/errors');

-describe('MongoErrors', function () {
-  describe('MongoNetworkError', function () {
+describe('MongoErrors', () => {
+  // import errors as object
+  let errorClasses = Object.fromEntries(
+    Object.entries(require('../../src/index')).filter(([key]) => key.endsWith('Error'))
+  );
+  errorClasses = { ...errorClasses, MongoPoolClosedError, MongoWaitQueueTimeoutError };
+
+  for (const errorName in errorClasses) {
+    describe(errorName, () => {
+      it(`name should be read-only`, () => {
+        // Dynamically create error class with message
+        let error = new errorClasses[errorName]('generated by test');
+        // expect name property to be class name
+        expect(error).to.have.property('name', errorName);
+
+        try {
+          error.name = 'renamed by test';
+          expect(error).to.have.property('name', 'generated by test');
+        } catch (err) {}
+      });
+    });
+  }
+
+  describe('when MongoNetworkError is constructed', () => {
     it('should only define beforeHandshake symbol if boolean option passed in', function () {
       const errorWithOptionTrue = new MongoNetworkError('', { beforeHandshake: true });
       expect(getSymbolFrom(errorWithOptionTrue, 'beforeHandshake', false)).to.be.a('symbol');

'use strict';

const expect = require('chai').expect;
const { getSymbolFrom } = require('../tools/utils');
const MongoNetworkError = require('../../src/error').MongoNetworkError;

describe('MongoErrors', function () {
  describe('MongoNetworkError', function () {
    it('should only define beforeHandshake symbol if boolean option passed in', function () {
      const errorWithOptionTrue = new MongoNetworkError('', { beforeHandshake: true });
      expect(getSymbolFrom(errorWithOptionTrue, 'beforeHandshake', false)).to.be.a('symbol');

      const errorWithOptionFalse = new MongoNetworkError('', { beforeHandshake: false });
      expect(getSymbolFrom(errorWithOptionFalse, 'beforeHandshake', false)).to.be.a('symbol');

      const errorWithBadOption = new MongoNetworkError('', { beforeHandshake: 'not boolean' });
      expect(getSymbolFrom(errorWithBadOption, 'beforeHandshake', false)).to.be.an('undefined');

      const errorWithoutOption = new MongoNetworkError('');
      expect(getSymbolFrom(errorWithoutOption, 'beforeHandshake', false)).to.be.an('undefined');
    });
  });
});

this expect should be outside the try/catch block since you don't want to swallow the error thrown by the expect in the case of failure, otherwise looks good :)


@@ -15,6 +15,7 @@ export default AbstractModel.extend(DOBDays, PatientName, {
   bloodType: DS.attr('string'),
   clinic: DS.attr('string'),
   country: DS.attr('string'),
+  createOutpatientVisit: DS.attr('boolean'),
   dateOfBirth: DS.attr('date'),
   economicClassification: DS.attr('string'),
   email: DS.attr('string'),

import AbstractModel from 'hospitalrun/models/abstract';
import DOBDays from 'hospitalrun/mixins/dob-days';
import EmailValidation from 'hospitalrun/utils/email-validation';
import Ember from 'ember';
import DS from 'ember-data';
import PatientName from 'hospitalrun/mixins/patient-name';

export default AbstractModel.extend(DOBDays, PatientName, {
  admitted: DS.attr('boolean', { defaultValue: false }),
  additionalContacts: DS.attr(),
  address: DS.attr('string'),
  address2: DS.attr('string'),
  address3: DS.attr('string'),
  address4: DS.attr('string'),
  bloodType: DS.attr('string'),
  clinic: DS.attr('string'),
  country: DS.attr('string'),
  dateOfBirth: DS.attr('date'),
  economicClassification: DS.attr('string'),
  email: DS.attr('string'),
  expenses: DS.attr(),
  externalPatientId: DS.attr('string'),
  familySupport1: DS.attr('string'),
  familySupport2: DS.attr('string'),
  familySupport3: DS.attr('string'),
  familySupport4: DS.attr('string'),
  familySupport5: DS.attr('string'),
  friendlyId: DS.attr('string'),
  familyInfo: DS.attr(),
  firstName: DS.attr('string'),
  sex: DS.attr('string'),
  occupation: DS.attr('string'),
  history: DS.attr('string'),
  insurance: DS.attr('string'),
  lastName: DS.attr('string'),
  livingArrangement: DS.attr('string'),
  middleName: DS.attr('string'),
  notes: DS.attr('string'),
  otherIncome: DS.attr('string'),
  payments: DS.hasMany('payment', {
    async: true
  }),
  patientType: DS.attr('string'),
  parent: DS.attr('string'),
  paymentProfile: DS.belongsTo('price-profile', {
    async: false
  }),
  phone: DS.attr('string'),
  placeOfBirth: DS.attr('string'),
  referredDate: DS.attr('date'),
  referredBy: DS.attr('string'),
  religion: DS.attr('string'),
  socialActionTaken: DS.attr('string'),
  socialRecommendation: DS.attr('string'),
  status: DS.attr('string'),

  age: function() {
    var dob = this.get('dateOfBirth');
    return this.convertDOBToText(dob);
  }.property('dateOfBirth'),

  displayAddress: function() {
    var addressFields = this.getProperties('address', 'address2', 'address3', 'address4'),
      displayAddress = '';
    for (var prop in addressFields) {
      if (!Ember.isEmpty(addressFields[prop])) {
        if (!Ember.isEmpty(displayAddress)) {
          displayAddress += ', ';
        }
        displayAddress += addressFields[prop];
      }
    }
    return displayAddress;
  }.property('address', 'address2', 'address3', 'address4'),

  displayName: function() {
    return this.getPatientDisplayName(this);
  }.property('firstName', 'lastName', 'middleName'),

  displayPatientId: function() {
    return this.getPatientDisplayId(this);
  }.property('id', 'externalPatientId', 'friendlyId'),

  validations: {
    email: {
      format: {
        with: EmailValidation.emailRegex,
        allowBlank: true,
        message: 'please enter a valid email address'
      }
    },
    friendlyId: {
      presence: true
    },
    firstName: {
      presence: true
    },
    lastName: {
      presence: true
    }
  }

});

This attribute should not be here because we do not want to (or need to) save this value to the database.


@@ -6,6 +6,17 @@ let null_dict = null_dictionary || {}
 // eslint-disable-next-line no-undef
 assert_true(dict.X !== undefined && dict.doSomething !== undefined);  // Testing successful object creation.

+
+/*
+    It seems that Object.values(dict) is not supported on JSC for non-static fields, it would be interesting to
+    review this in the future.
+    verify_object_fields(Object.values(dict).length)
+ */
+
+// eslint-disable-next-line no-undef
+verify_object_fields(Object.keys(dict).length)
+
+
 null_dict.hello(true);  // Testing method call from object.
 null_dict.alo(true); // Testing method call from object <again>.

// eslint-disable-next-line no-undef,strict
let dict = dictionary || {}
// eslint-disable-next-line no-undef
let null_dict = null_dictionary || {}

// eslint-disable-next-line no-undef
assert_true(dict.X !== undefined && dict.doSomething !== undefined);  // Testing successful object creation.

null_dict.hello(true);  // Testing method call from object.
null_dict.alo(true); // Testing method call from object <again>.

dict.doSomething(28850);

/* Testing accessors. */
dict.X=666;

// eslint-disable-next-line no-undef
test_accessor(dict, 'X', 666);

assert_enumerate(JSON.stringify(dict))

/*
    Testing exception mechanism.
*/
try {
    dict.X = -1  // testing wrong value.
}catch(error){
    assert_exception(error.message)
}

You can create an issue for it and put it on the backlog so we don't forget it.


@@ -9,6 +9,10 @@ const databaseNamespace = require('./shared').databaseNamespace;
 const isTransactionCommand = require('../transactions').isTransactionCommand;
 const applySession = require('../sessions').applySession;

+function isClientEncryptionEnabled(server) {
+  return server && server.s && server.s.options && server.s.options.autoEncrypter;
+}
+
 function command(server, ns, cmd, options, callback) {
   if (typeof options === 'function') (callback = options), (options = {});
   options = options || {};

'use strict';

const Query = require('../connection/commands').Query;
const Msg = require('../connection/msg').Msg;
const MongoError = require('../error').MongoError;
const getReadPreference = require('./shared').getReadPreference;
const isSharded = require('./shared').isSharded;
const databaseNamespace = require('./shared').databaseNamespace;
const isTransactionCommand = require('../transactions').isTransactionCommand;
const applySession = require('../sessions').applySession;

function command(server, ns, cmd, options, callback) {
  if (typeof options === 'function') (callback = options), (options = {});
  options = options || {};

  if (cmd == null) {
    return callback(new MongoError(`command ${JSON.stringify(cmd)} does not return a cursor`));
  }

  const bson = server.s.bson;
  const pool = server.s.pool;
  const readPreference = getReadPreference(cmd, options);
  const shouldUseOpMsg = supportsOpMsg(server);
  const session = options.session;

  let clusterTime = server.clusterTime;
  let finalCmd = Object.assign({}, cmd);
  if (hasSessionSupport(server) && session) {
    if (
      session.clusterTime &&
      session.clusterTime.clusterTime.greaterThan(clusterTime.clusterTime)
    ) {
      clusterTime = session.clusterTime;
    }

    const err = applySession(session, finalCmd, options);
    if (err) {
      return callback(err);
    }
  }

  // if we have a known cluster time, gossip it
  if (clusterTime) {
    finalCmd.$clusterTime = clusterTime;
  }

  if (
    isSharded(server) &&
    !shouldUseOpMsg &&
    readPreference &&
    readPreference.preference !== 'primary'
  ) {
    finalCmd = {
      $query: finalCmd,
      $readPreference: readPreference.toJSON()
    };
  }

  const commandOptions = Object.assign(
    {
      command: true,
      numberToSkip: 0,
      numberToReturn: -1,
      checkKeys: false
    },
    options
  );

  // This value is not overridable
  commandOptions.slaveOk = readPreference.slaveOk();

  const cmdNs = `${databaseNamespace(ns)}.$cmd`;
  const message = shouldUseOpMsg
    ? new Msg(bson, cmdNs, finalCmd, commandOptions)
    : new Query(bson, cmdNs, finalCmd, commandOptions);

  const inTransaction = session && (session.inTransaction() || isTransactionCommand(finalCmd));
  const commandResponseHandler = inTransaction
    ? function(err) {
        if (
          !cmd.commitTransaction &&
          err &&
          err instanceof MongoError &&
          err.hasErrorLabel('TransientTransactionError')
        ) {
          session.transaction.unpinServer();
        }

        return callback.apply(null, arguments);
      }
    : callback;

  try {
    pool.write(message, commandOptions, commandResponseHandler);
  } catch (err) {
    commandResponseHandler(err);
  }
}

function hasSessionSupport(topology) {
  if (topology == null) return false;
  if (topology.description) {
    return topology.description.maxWireVersion >= 6;
  }

  return topology.ismaster == null ? false : topology.ismaster.maxWireVersion >= 6;
}

function supportsOpMsg(topologyOrServer) {
  const description = topologyOrServer.ismaster
    ? topologyOrServer.ismaster
    : topologyOrServer.description;

  if (description == null) {
    return false;
  }

  return description.maxWireVersion >= 6 && description.__nodejs_mock_server__ == null;
}

module.exports = command;

This could be follow-on work, but it would be nice to have `server.autoEncrypter` similar to `collection.readConcern` so we don't have to do this long check here.


@@ -1,5 +1,6 @@
 const allowedE2EModules = [
   'window',
+  'hyperformula*',
   'jasmine-co',
   'jest-matcher-utils',
   'html-parse-stringify',

const allowedE2EModules = [
  'window',
  'jasmine-co',
  'jest-matcher-utils',
  'html-parse-stringify',
  'core-js/*',
  'regenerator-runtime/runtime*',
  '@babel/runtime/*',
  './htmlNormalize',
  './common',
  './mouseEvents',
  './../bootstrap',
  './helpers/custom-matchers',
  './asciiTable',
  './MemoryLeakTest',
  '../MemoryLeakTest',
];

const babelPresetConfig = () => ({
  targets: {
    chrome: '41',
    firefox: '34',
    ie: '9',
    safari: '9'
  },
  modules: false,
  debug: false,
  useBuiltIns: 'usage',
  corejs: 3,
});

module.exports = {
  presets: [
    ['@babel/preset-env', babelPresetConfig()]
  ],
  plugins: [
    ['@babel/plugin-proposal-object-rest-spread', { useBuiltIns: true }],
    ['transform-inline-environment-variables'],
    ['@babel/plugin-proposal-class-properties']
  ],
  env: {
    // Environment for unit testing, source code and languages building via webpack (UMD).
    commonjs: {
      plugins: [
        ['@babel/plugin-transform-runtime', {
          corejs: false,
          helpers: true,
          regenerator: true,
          useESModules: false,
        }],
        ['@babel/plugin-transform-modules-commonjs', { loose: true }]
      ]
    },
    // Environment for transpiling files to be compatible with CommonJS.
    commonjs_dist: {
      plugins: [
        ['@babel/plugin-transform-modules-commonjs', { loose: true }],
        ['babel-plugin-transform-require-ignore', { extensions: ['.css'] }]
      ],
      ignore: [
        '**/__tests__/**',
        '**/test/**',
        '**/dist/**',
      ]
    },
    // Environment for transpiling files to be compatible with ES Modules.
    es: {
      plugins: [
        ['babel-plugin-transform-require-ignore', { extensions: ['.css'] }],
        ['./.config/plugin/babel/add-import-extension.js', { extension: 'mjs' }]
      ],
      ignore: [
        '**/__tests__/**',
        '**/test/**',
        '**/dist/**',
      ],
    },
    // Environment for transpiling only legacy language files (e.q. import `languages/pl-PL`)
    // which need to be compatible with ES Modules. That format, by default, automatically
    // registers the language pack. It's not suitable to use with the modularized version of
    // the Handsontable.
    es_languages: {
      plugins: [
        ['babel-plugin-transform-require-ignore', { extensions: ['.css'] }],
        ['./.config/plugin/babel/add-import-extension.js', { extension: 'mjs' }],
        ['./.config/plugin/babel/add-language-registration.js'],
      ],
    },
    // Environment for building E2E tests (UMD).
    commonjs_e2e: {
      plugins: [
        ['@babel/plugin-transform-runtime', {
          corejs: false,
          helpers: true,
          regenerator: true,
          useESModules: false,
        }],
        ['@babel/plugin-transform-modules-commonjs', { loose: true }],
        ['babel-plugin-forbidden-imports', {
          allowedModules: allowedE2EModules
        }]
      ],
    },
  },
  ignore: [
    'src/3rdparty/walkontable/dist/',
    'src/3rdparty/walkontable/test/dist/',
  ],
};

Is `*` necessary after change to the hf npm module?


@@ -45,12 +45,18 @@ class SearchConsoleDashboardWidgetSiteStats extends Component {

 	setOptions() {
 		const { selectedStats, series, vAxes } = this.props;
+		const { pageTitle } = global.googlesitekit;

-		const pageTitle = global.googlesitekit.pageTitle && global.googlesitekit.pageTitle.length ? sprintf( __( 'Search Traffic Summary for %s', 'google-site-kit' ), decodeHtmlEntity( global.googlesitekit.pageTitle ) ) : __( 'Search Traffic Summary', 'google-site-kit' );
+		let title = __( 'Search Traffic Summary', 'google-site-kit' );
+
+		if ( pageTitle && pageTitle.length ) {
+			/* translators: %s: page title */
+			title = sprintf( __( 'Search Traffic Summary for %s', 'google-site-kit' ), decodeHtmlEntity( pageTitle ) );
+		}

 		const options = {
 			chart: {
-				title: pageTitle,
+				title,
 			},
 			curveType: 'line',
 			height: 270,

/**
 * SearchConsoleDashboardWidgetSiteStats component.
 *
 * Site Kit by Google, Copyright 2019 Google LLC
 *
 * 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.
 */

/**
 * External dependencies
 */
import withData from 'GoogleComponents/higherorder/withdata';
import { TYPE_MODULES } from 'GoogleComponents/data';
import GoogleChart from 'GoogleComponents/google-chart.js';
import PreviewBlock from 'GoogleComponents/preview-block';
import { decodeHtmlEntity, getTimeInSeconds } from 'GoogleUtil';

/**
 * Internal dependencies
 */
import { extractSearchConsoleDashboardData } from './util';

/**
 * WordPress dependencies
 */
import { __, sprintf } from '@wordpress/i18n';
import { Component } from '@wordpress/element';

class SearchConsoleDashboardWidgetSiteStats extends Component {
	constructor( props ) {
		super( props );

		this.setOptions = this.setOptions.bind( this );
	}

	setOptions() {
		const { selectedStats, series, vAxes } = this.props;

		const pageTitle = global.googlesitekit.pageTitle && global.googlesitekit.pageTitle.length ? sprintf( __( 'Search Traffic Summary for %s', 'google-site-kit' ), decodeHtmlEntity( global.googlesitekit.pageTitle ) ) : __( 'Search Traffic Summary', 'google-site-kit' );

		const options = {
			chart: {
				title: pageTitle,
			},
			curveType: 'line',
			height: 270,
			width: '100%',
			chartArea: {
				height: '80%',
				width: '87%',
			},
			legend: {
				position: 'top',
				textStyle: {
					color: '#616161',
					fontSize: 12,
				},
			},
			hAxis: {
				format: 'M/d/yy',
				gridlines: {
					color: '#fff',
				},
				textStyle: {
					color: '#616161',
					fontSize: 12,
				},
			},
			vAxis: {
				gridlines: {
					color: '#eee',
				},
				minorGridlines: {
					color: '#eee',
				},
				textStyle: {
					color: '#616161',
					fontSize: 12,
				},
				titleTextStyle: {
					color: '#616161',
					fontSize: 12,
					italic: false,
				},
			},
		};

		options.series = series;
		options.vAxes = vAxes;

		// Clean up chart if more than three stats are selected.
		if ( 3 <= selectedStats.length ) {
			options.vAxis.textPosition = 'none';
			options.vAxis.gridlines.color = '#fff';
			options.vAxis.minorGridlines.color = '#fff';
			options.chartArea.width = '98%';
		}

		return options;
	}

	render() {
		const { data, selectedStats } = this.props;

		if ( ! data || ! data.length ) {
			return null;
		}

		const options = this.setOptions();
		const processedData = extractSearchConsoleDashboardData( data );

		return (

						<GoogleChart
							selectedStats={ selectedStats }
							data={ processedData.dataMap }
							options={ options }
							singleStat={ false }
						/>

		);
	}
}

export default withData(
	SearchConsoleDashboardWidgetSiteStats,
	[
		{
			type: TYPE_MODULES,
			identifier: 'search-console',
			datapoint: 'searchanalytics',
			data: {
				dimensions: 'date',
				compareDateRanges: true,
			},
			priority: 1,
			maxAge: getTimeInSeconds( 'day' ),
			context: 'Single',
		},
	],
	<PreviewBlock width="100%" height="270px" padding />,
	{ createGrid: true }
);

The ESLint rules weren't properly detecting the translators string in a multiline ternary so I split it up this way, which is probably cleaner anyway


@@ -19,28 +19,23 @@
 /**
  * WordPress dependencies
  */
-import { Component, createPortal } from '@wordpress/element';
+import { createPortal, useEffect, useState } from '@wordpress/element';

-export default class Modal extends Component {
-	constructor( props ) {
-		super( props );
-		this.el = document.createElement( 'div' );
-		// This class is the outermost wrapper which is present on all Site Kit pages.
-		this.root = document.querySelector( '.googlesitekit-plugin' ) || document.body;
-	}
+function Modal( { children } ) {
+	// Using state as we need `el` to not change when the component re-renders
+	const [ el ] = useState( document.createElement( 'div' ) );

-	componentDidMount() {
-		this.root.appendChild( this.el );
-	}
+	useEffect( () => {
+		const root = document.querySelector( '.googlesitekit-plugin' ) || document.body;
+		root.appendChild( el );

-	componentWillUnmount() {
-		this.root.removeChild( this.el );
-	}
+		return () => root.removeChild( el );
+	}, [] );

-	render() {
-		return createPortal(
-			this.props.children,
-			this.el,
-		);
-	}
+	return createPortal(
+		children,
+		el,
+	);
 }
+
+export default Modal;

/**
 * Modal component.
 *
 * Site Kit by Google, Copyright 2019 Google LLC
 *
 * 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.
 */

/**
 * WordPress dependencies
 */
import { Component, createPortal } from '@wordpress/element';

export default class Modal extends Component {
	constructor( props ) {
		super( props );
		this.el = document.createElement( 'div' );
		// This class is the outermost wrapper which is present on all Site Kit pages.
		this.root = document.querySelector( '.googlesitekit-plugin' ) || document.body;
	}

	componentDidMount() {
		this.root.appendChild( this.el );
	}

	componentWillUnmount() {
		this.root.removeChild( this.el );
	}

	render() {
		return createPortal(
			this.props.children,
			this.el,
		);
	}
}

I think this file was accidentally kept. It should be removed in favor of `Modal.js`.


@@ -1,5 +1,5 @@
 const moment = require('moment');
-const packageBody = require('./package.json');
+const packageBody = require('./handsontable/package.json');

 module.exports = {
   HOT_FILENAME: 'handsontable',

const moment = require('moment');
const packageBody = require('./package.json');

module.exports = {
  HOT_FILENAME: 'handsontable',
  HOT_VERSION: packageBody.version,
  HOT_PACKAGE_NAME: packageBody.name,
  HOT_BUILD_DATE: moment().format('DD/MM/YYYY HH:mm:ss'),
  HOT_RELEASE_DATE: '29/09/2021',
};

Ok, now I see what you meant when asking about hot.config   And those are probably used by `licenseBanner.js` to insert package name and file name. Maybe we should rethink how and when we alter the monorepo version. Maybe `handsontable/package.json` should have it injected as wrappers do and this file will become the source of truth?


@@ -1,4 +1,11 @@
 var parent = axe.commons.dom.getComposedParent(node);
-return (['UL', 'OL'].includes(parent.nodeName.toUpperCase()) ||
-    (parent.getAttribute('role') || '').toLowerCase() === 'list');
-
+
+var parentRole = (parent.getAttribute('role') || '').toLowerCase();
+
+var isListRole = parentRole === 'list';
+
+return (
+	(['UL', 'OL'].includes(parent.nodeName.toUpperCase()) &&
+		(!parentRole || isListRole)) ||
+	isListRole
+);

var parent = axe.commons.dom.getComposedParent(node);
return (['UL', 'OL'].includes(parent.nodeName.toUpperCase()) ||
    (parent.getAttribute('role') || '').toLowerCase() === 'list');

This doesn't follow our spacing convention. It's also a little hard to read. Can you reformat?


@@ -1,12 +1,27 @@
-/**
- *
- * sends auth token to uppy client
- */
 const { URL } = require('url')
 const tokenService = require('../helpers/jwt')
 const { hasMatch, sanitizeHtml } = require('../helpers/utils')
 const oAuthState = require('../helpers/oauth-state')
-const versionCmp = require('../helpers/version')
+
+/**
+ *
+ * @param {string} token uppy auth token
+ * @param {string} origin url string
+ */
+const htmlContent = (token, origin) => {
+  return `
+    <!DOCTYPE html>
+    <html>
+    <head>
+        <meta charset="utf-8" />
+        <script>
+          window.opener.postMessage(JSON.stringify({token: "${token}"}), "${sanitizeHtml(origin)}")
+          window.close()
+        </script>
+    </head>
+    <body></body>
+    </html>`
+}

 /**
  *

/**
 *
 * sends auth token to uppy client
 */
const { URL } = require('url')
const tokenService = require('../helpers/jwt')
const { hasMatch, sanitizeHtml } = require('../helpers/utils')
const oAuthState = require('../helpers/oauth-state')
const versionCmp = require('../helpers/version')

/**
 *
 * @param {object} req
 * @param {object} res
 * @param {Function} next
 */
module.exports = function sendToken (req, res, next) {
  const uppyAuthToken = req.companion.authToken
  // some providers need the token in cookies for thumbnail/image requests
  if (req.companion.provider.needsCookieAuth) {
    tokenService.addToCookies(res, uppyAuthToken, req.companion.options, req.companion.provider.authProvider)
  }

  const dynamic = (req.session.grant || {}).dynamic || {}
  const { state } = dynamic
  if (state) {
    const origin = oAuthState.getFromState(state, 'origin', req.companion.options.secret)
    const clientVersion = oAuthState.getFromState(
      state,
      'clientVersion',
      req.companion.options.secret
    )
    const allowedClients = req.companion.options.clients
    // if no preset clients then allow any client
    if (!allowedClients || hasMatch(origin, allowedClients) || hasMatch((new URL(origin)).host, allowedClients)) {
      const allowsStringMessage = versionCmp.gte(clientVersion, '1.0.2')
      return res.send(allowsStringMessage ? htmlContent(uppyAuthToken, origin) : oldHtmlContent(uppyAuthToken, origin))
    }
  }
  next()
}

/**
 *
 * @param {string} token uppy auth token
 * @param {string} origin url string
 */
const htmlContent = (token, origin) => {
  return `
    <!DOCTYPE html>

        <meta charset="utf-8" />

    `
}

/**
 * @todo remove this function in next major release
 * @param {string} token uppy auth token
 * @param {string} origin url string
 */
const oldHtmlContent = (token, origin) => {
  return `
    <!DOCTYPE html>

        <meta charset="utf-8" />

    `
}

I know you didn't write this code, but can we 100% trust the value of token and origin never being manipulated by the user? If not, then I think we should consider some more robust html serialization, maybe use react-dom renderToString or similar. JSON.stringify I believe is very unsafe


@@ -25,9 +25,11 @@ import {
 	subscribeUntil,
 	unsubscribeFromAll,
 } from 'tests/js/utils';
-import { INITIAL_STATE } from './index';
+import store from './index';
 import { STORE_NAME } from './constants';

+const { INITIAL_STATE } = store;
+
 describe( 'core/site site info', () => {
 	const baseInfoVar = '_googlesitekitBaseData';
 	const baseInfo = {

/**
 * core/site data store: site info tests.
 *
 * Site Kit by Google, Copyright 2020 Google LLC
 *
 * 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.
 */

/**
 * Internal dependencies
 */
import {
	createTestRegistry,
	muteConsole,
	subscribeUntil,
	unsubscribeFromAll,
} from 'tests/js/utils';
import { INITIAL_STATE } from './index';
import { STORE_NAME } from './constants';

describe( 'core/site site info', () => {
	const baseInfoVar = '_googlesitekitBaseData';
	const baseInfo = {
		adminURL: 'http://something.test/wp-admin',
		ampMode: 'reader',
		homeURL: 'http://something.test/homepage',
		referenceSiteURL: 'http://something.test',
	};
	const entityInfoVar = '_googlesitekitEntityData';
	const entityInfo = {
		currentEntityURL: 'http://something.test',
		currentEntityType: 'post',
		currentEntityTitle: 'Something Witty',
		currentEntityID: '4',
	};
	let registry;

	beforeEach( () => {
		registry = createTestRegistry();
	} );

	afterEach( () => {
		delete global[ baseInfoVar ];
		delete global[ entityInfoVar ];
		unsubscribeFromAll( registry );
	} );

	describe( 'actions', () => {
		describe( 'receiveSiteInfo', () => {
			it( 'requires the siteInfo param', () => {
				expect( () => {
					registry.dispatch( STORE_NAME ).receiveSiteInfo();
				} ).toThrow( 'siteInfo is required.' );
			} );

			it( 'receives and sets site info ', async () => {
				await registry.dispatch( STORE_NAME ).receiveSiteInfo( { ...baseInfo, ...entityInfo } );

				expect(
					registry.select( STORE_NAME ).getSiteInfo()
				).toMatchObject( { ...baseInfo, ...entityInfo, currentEntityID: 4 } );
			} );
		} );
	} );

	describe( 'selectors', () => {
		describe( 'getSiteInfo', () => {
			it( 'uses a resolver to load site info from a global variable by default, then deletes that global variable after consumption', async () => {
				global[ baseInfoVar ] = baseInfo;
				global[ entityInfoVar ] = entityInfo;

				expect( global[ baseInfoVar ] ).not.toEqual( undefined );
				expect( global[ entityInfoVar ] ).not.toEqual( undefined );

				registry.select( STORE_NAME ).getSiteInfo();
				await subscribeUntil( registry,
					() => (
						registry.select( STORE_NAME ).getSiteInfo() !== INITIAL_STATE
					),
				);

				const info = registry.select( STORE_NAME ).getSiteInfo();

				expect( info ).toEqual( { ...baseInfo, ...entityInfo, currentEntityID: 4 } );

				// Data must not be wiped after retrieving, as it could be used by other dependants.
				expect( global[ baseInfoVar ] ).not.toEqual( undefined );
				expect( global[ entityInfoVar ] ).not.toEqual( undefined );
			} );

			it( 'will return initial state (undefined) when no data is available', async () => {
				expect( global[ baseInfoVar ] ).toEqual( undefined );
				expect( global[ entityInfoVar ] ).toEqual( undefined );

				muteConsole( 'error' );
				const info = registry.select( STORE_NAME ).getSiteInfo();

				expect( info ).toBe( INITIAL_STATE.siteInfo );
			} );
		} );

		describe.each( [
			[ 'getAdminURL' ],
			[ 'getAMPMode' ],
			[ 'getCurrentEntityID' ],
			[ 'getCurrentEntityTitle' ],
			[ 'getCurrentEntityType' ],
			[ 'getCurrentEntityURL' ],
			[ 'getHomeURL' ],
			[ 'getReferenceSiteURL' ],
		] )( `%i()`, ( selector ) => {
			it( 'uses a resolver to load site info then returns the info when this specific selector is used', async () => {
				global[ baseInfoVar ] = baseInfo;
				global[ entityInfoVar ] = entityInfo;

				registry.select( STORE_NAME )[ selector ]();
				await subscribeUntil( registry,
					() => (
						registry.select( STORE_NAME )[ selector ]() !== undefined
					),
				);

				const info = registry.select( STORE_NAME ).getSiteInfo();

				expect( info ).toEqual( { ...baseInfo, ...entityInfo, currentEntityID: 4 } );
			} );

			it( 'will return initial state (undefined) when no data is available', async () => {
				expect( global[ baseInfoVar ] ).toEqual( undefined );
				expect( global[ entityInfoVar ] ).toEqual( undefined );

				muteConsole( 'error' );
				const result = registry.select( STORE_NAME )[ selector ]();

				expect( result ).toEqual( undefined );
			} );
		} );

		describe( 'isAmp', () => {
			it( 'uses a resolver to load site info, then returns true if AMP mode is set', async () => {
				global[ baseInfoVar ] = baseInfo;
				global[ entityInfoVar ] = entityInfo;

				registry.select( STORE_NAME ).isAmp();
				await subscribeUntil( registry,
					() => (
						registry.select( STORE_NAME ).isAmp() !== undefined
					),
				);

				const isAmp = registry.select( STORE_NAME ).isAmp();

				expect( isAmp ).toEqual( true );
			} );

			it( 'uses a resolver to load site info, then returns false if AMP mode is not set', async () => {
				global[ baseInfoVar ] = {
					...baseInfo,
					ampMode: null,
				};
				global[ entityInfoVar ] = entityInfo;

				registry.select( STORE_NAME ).isAmp();
				await subscribeUntil( registry,
					() => (
						registry.select( STORE_NAME ).isAmp() !== undefined
					),
				);

				const isAmp = registry.select( STORE_NAME ).isAmp();

				expect( isAmp ).toEqual( false );
			} );

			it( 'will return initial state (undefined) when no data is available', async () => {
				expect( global[ baseInfoVar ] ).toEqual( undefined );
				expect( global[ entityInfoVar ] ).toEqual( undefined );

				muteConsole( 'error' );
				const result = registry.select( STORE_NAME ).isAmp();

				expect( result ).toEqual( undefined );
			} );
		} );
	} );
} );

Please restore to the previous usage now that the exports are available.


@@ -32,19 +32,19 @@ const getConfigFromEnv = () => {
     providerOptions: {
       google: {
         key: process.env.COMPANION_GOOGLE_KEY,
-        secret: process.env.COMPANION_GOOGLE_SECRET
+        secret: typeof process.env.COMPANION_GOOGLE_SECRET_FILE === 'string' ? fs.readFileSync(process.env.COMPANION_GOOGLE_SECRET_FILE).toString() : process.env.COMPANION_GOOGLE_SECRET
       },
       dropbox: {
         key: process.env.COMPANION_DROPBOX_KEY,
-        secret: process.env.COMPANION_DROPBOX_SECRET
+        secret: typeof process.env.COMPANION_DROPBOX_SECRET_FILE === 'string' ? fs.readFileSync(process.env.COMPANION_DROPBOX_SECRET_FILE).toString() : process.env.COMPANION_DROPBOX_SECRET
       },
       instagram: {
         key: process.env.COMPANION_INSTAGRAM_KEY,
-        secret: process.env.COMPANION_INSTAGRAM_SECRET
+        secret: typeof process.env.COMPANION_INSTAGRAM_SECRET_FILE === 'string' ? fs.readFileSync(process.env.COMPANION_INSTAGRAM_SECRET_FILE).toString() : process.env.COMPANION_INSTAGRAM_SECRET
       },
       s3: {
         key: process.env.COMPANION_AWS_KEY,
-        secret: process.env.COMPANION_AWS_SECRET,
+        secret: typeof process.env.COMPANION_AWS_SECRET_FILE === 'string' ? fs.readFileSync(process.env.COMPANION_AWS_SECRET_FILE).toString() : process.env.COMPANION_AWS_SECRET,
         bucket: process.env.COMPANION_AWS_BUCKET,
         endpoint: process.env.COMPANION_AWS_ENDPOINT,
         region: process.env.COMPANION_AWS_REGION

const fs = require('fs')
const merge = require('lodash.merge')
const stripIndent = require('common-tags/lib/stripIndent')
const utils = require('../server/helpers/utils')
const logger = require('../server/logger')
const crypto = require('crypto')
// @ts-ignore
const { version } = require('../../package.json')

/**
 * Reads all companion configuration set via environment variables
 * and via the config file path
 *
 * @returns {object}
 */
exports.getUppyOptions = () => {
  return merge({}, getConfigFromEnv(), getConfigFromFile())
}

/**
 * Loads the config from environment variables
 *
 * @returns {object}
 */
const getConfigFromEnv = () => {
  const uploadUrls = process.env.COMPANION_UPLOAD_URLS
  const domains = process.env.COMPANION_DOMAINS || process.env.COMPANION_DOMAIN || null
  const validHosts = domains ? domains.split(',') : []

  return {
    // TODO: Rename providerOptions to providers.
    providerOptions: {
      google: {
        key: process.env.COMPANION_GOOGLE_KEY,
        secret: process.env.COMPANION_GOOGLE_SECRET
      },
      dropbox: {
        key: process.env.COMPANION_DROPBOX_KEY,
        secret: process.env.COMPANION_DROPBOX_SECRET
      },
      instagram: {
        key: process.env.COMPANION_INSTAGRAM_KEY,
        secret: process.env.COMPANION_INSTAGRAM_SECRET
      },
      s3: {
        key: process.env.COMPANION_AWS_KEY,
        secret: process.env.COMPANION_AWS_SECRET,
        bucket: process.env.COMPANION_AWS_BUCKET,
        endpoint: process.env.COMPANION_AWS_ENDPOINT,
        region: process.env.COMPANION_AWS_REGION
      }
    },
    server: {
      host: process.env.COMPANION_DOMAIN,
      protocol: process.env.COMPANION_PROTOCOL,
      path: process.env.COMPANION_PATH,
      implicitPath: process.env.COMPANION_IMPLICIT_PATH,
      oauthDomain: process.env.COMPANION_OAUTH_DOMAIN,
      validHosts: validHosts
    },
    filePath: process.env.COMPANION_DATADIR,
    redisUrl: process.env.COMPANION_REDIS_URL,
    sendSelfEndpoint: process.env.COMPANION_SELF_ENDPOINT,
    uploadUrls: uploadUrls ? uploadUrls.split(',') : null,
    secret: process.env.COMPANION_SECRET || generateSecret(),
    debug: process.env.NODE_ENV !== 'production',
    // TODO: this is a temporary hack to support distributed systems.
    // it is not documented, because it should be changed soon.
    cookieDomain: process.env.COMPANION_COOKIE_DOMAIN,
    multipleInstances: true
  }
}

/**
 * Auto-generates server secret
 *
 * @returns {string}
 */
const generateSecret = () => {
  logger.warn('auto-generating server secret because none was specified', 'startup.secret')
  return crypto.randomBytes(64).toString('hex')
}

/**
 * Loads the config from a file and returns it as an object
 *
 * @returns {object}
 */
const getConfigFromFile = () => {
  const path = getConfigPath()
  if (!path) return {}

  const rawdata = fs.readFileSync(getConfigPath())
  // TODO validate the json object fields to match the uppy config schema
  // @ts-ignore
  return JSON.parse(rawdata)
}

/**
 * Returns the config path specified via cli arguments
 *
 * @returns {string}
 */
const getConfigPath = () => {
  let configPath

  for (let i = process.argv.length - 1; i >= 0; i--) {
    const isConfigFlag = process.argv[i] === '-c' || process.argv[i] === '--config'
    const flagHasValue = i + 1 <= process.argv.length
    if (isConfigFlag && flagHasValue) {
      configPath = process.argv[i + 1]
      break
    }
  }

  return configPath
}

/**
 * validates that the mandatory companion options are set.
 * If it is invalid, it will console an error of unset options and exits the process.
 * If it is valid, nothing happens.
 *
 * @param {object} config
 */
exports.validateConfig = (config) => {
  const mandatoryOptions = ['secret', 'filePath', 'server.host']
  /** @type {string[]} */
  const unspecified = []

  mandatoryOptions.forEach((i) => {
    const value = i.split('.').reduce((prev, curr) => prev[curr], config)

    if (!value) unspecified.push(`"${i}"`)
  })

  // vaidate that all required config is specified
  if (unspecified.length) {
    console.error('\x1b[31m', 'Please specify the following options',
      'to run companion as Standalone:\n', unspecified.join(',\n'), '\x1b[0m')
    process.exit(1)
  }

  // validate that specified filePath is writeable/readable.
  // TODO: consider moving this into the uppy module itself.
  try {
    // @ts-ignore
    fs.accessSync(`${config.filePath}`, fs.R_OK | fs.W_OK)
  } catch (err) {
    console.error('\x1b[31m', `No access to "${config.filePath}".`,
      'Please ensure the directory exists and with read/write permissions.', '\x1b[0m')
    process.exit(1)
  }
}

/**
 *
 * @param {string} url
 */
exports.hasProtocol = (url) => {
  return url.startsWith('http://') || url.startsWith('https://')
}

exports.buildHelpfulStartupMessage = (uppyOptions) => {
  const buildURL = utils.getURLBuilder(uppyOptions)
  const callbackURLs = []
  Object.keys(uppyOptions.providerOptions).forEach((providerName) => {
    // s3 does not need redirect_uris
    if (providerName === 's3') {
      return
    }

    if (providerName === 'google') {
      providerName = 'drive'
    }

    callbackURLs.push(buildURL(`/connect/${providerName}/callback`, true))
  })

  return stripIndent`
    Welcome to Companion v${version}
    ===================================

    Congratulations on setting up Companion! Thanks for joining our cause, you have taken
    the first step towards the future of file uploading! We
    hope you are as excited about this as we are!

    While you did an awesome job on getting Companion running, this is just the welcome
    message, so let's talk about the places that really matter:

    - Be sure to add ${callbackURLs.join(', ')} as your Oauth redirect uris on their corresponding developer interfaces.
    - The URL ${buildURL('/metrics', true)} is available for  statistics to keep Companion running smoothly
    - https://github.com/transloadit/uppy/issues - report your bugs here

    So quit lollygagging, start uploading and experience the future!
  `
}

can we pull this into a function instead? And reuse it for others too?