Merge pull request #3339 from LLK/release/2019-09-11

[Master] Release for 2019-09-11
This commit is contained in:
Ray Schamp 2019-09-12 15:19:12 -04:00 committed by GitHub
commit 665b9566ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1474 additions and 721 deletions

View file

@ -33,6 +33,11 @@ env:
- CLOUDDATA_HOST_VAR=CLOUDDATA_HOST_$TRAVIS_BRANCH
- CLOUDDATA_HOST=${!CLOUDDATA_HOST_VAR}
- CLOUDDATA_HOST=${CLOUDDATA_HOST:-$CLOUDDATA_HOST_STAGING}
- RECAPTCHA_SITE_KEY_master=6LeRbUwUAAAAAFYhKgk3G9OKWqE_OJ7Z-7VTUCbl
- RECAPTCHA_SITE_KEY_STAGING=6LfukK4UAAAAAFR44yoZMhv8fj6xh-PMiIxwryG3
- RECAPTCHA_SITE=RECAPTCHA_SITE_KEY_$TRAVIS_BRANCH
- RECAPTCHA_SITE_KEY=${!RECAPTCHA_SITE_KEY_VAR}
- RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY:-$RECAPTCHA_SITE_KEY_STAGING}
- ROOT_URL_master=https://scratch.mit.edu
- ROOT_URL_STAGING=https://scratch.ly
- ROOT_URL_VAR=ROOT_URL_$TRAVIS_BRANCH

1138
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,8 @@
"test:lint": "eslint . --ext .js,.jsx,.json",
"test:integration": "npm run test:integration:jest && npm run test:smoke",
"test:integration:jest": "jest ./test/integration/*.test.js",
"test:integration:remote": "npm run test:integration:jest && npm run test:smoke:sauce",
"test:integration:remote": "npm run test:integration:jest:remote && npm run test:smoke:sauce",
"test:integration:jest:remote": "SMOKE_REMOTE=true jest ./test/integration/*.test.js",
"test:smoke": "tap ./test/integration-legacy/smoke-testing/*.js --timeout=3600 --no-coverage -R classic",
"test:smoke:verbose": "tap ./test/integration-legacy/smoke-testing/*.js --timeout=3600 --no-coverage -R spec",
"test:smoke:sauce": "SMOKE_REMOTE=true tap ./test/integration-legacy/smoke-testing/*.js --timeout=60000 --no-coverage -R classic",
@ -66,7 +67,7 @@
"babel-preset-react": "6.22.0",
"bowser": "1.9.4",
"cheerio": "1.0.0-rc.2",
"chromedriver": "75.1.0",
"chromedriver": "76.0.0",
"classnames": "2.2.5",
"cookie": "0.2.2",
"copy-webpack-plugin": "0.2.0",
@ -122,9 +123,10 @@
"react-string-replace": "0.4.1",
"react-telephone-input": "4.3.4",
"redux": "3.5.2",
"redux-mock-store": "^1.2.3",
"redux-thunk": "2.0.1",
"sass-loader": "6.0.6",
"scratch-gui": "0.1.0-prerelease.20190828224521",
"scratch-gui": "0.1.0-prerelease.20190912180550",
"scratch-l10n": "latest",
"selenium-webdriver": "3.6.0",
"slick-carousel": "1.6.0",

View file

@ -8,6 +8,7 @@ $ui-orange: hsla(38, 100, 55, 1); // #FFAB19 Control Primary
$ui-orange-high-contrast: hsla(30, 100, 55, 1); // #FFAB19 Control Primary
$ui-orange-10percent: hsla(35, 90, 55, .1);
$ui-orange-25percent: hsla(35, 90, 55, .25);
$ui-orange-90percent: hsla(38, 100, 55, .9);
$ui-dark-orange: hsla(30, 100, 55, 1); // ##FF8C1A Variables Primary

View file

@ -12,9 +12,10 @@ const FormikCheckboxSubComponent = ({
id,
label,
labelClassName,
outerClassName,
...props
}) => (
<div className="checkbox">
<div className={classNames('checkbox', outerClassName)}>
<input
checked={field.value}
className="formik-checkbox"
@ -50,7 +51,8 @@ FormikCheckboxSubComponent.propTypes = {
}),
id: PropTypes.string,
label: PropTypes.string,
labelClassName: PropTypes.string
labelClassName: PropTypes.string,
outerClassName: PropTypes.string
};
@ -59,6 +61,7 @@ const FormikCheckbox = ({
label,
labelClassName,
name,
outerClassName,
...props
}) => (
<Field
@ -67,6 +70,7 @@ const FormikCheckbox = ({
label={label}
labelClassName={labelClassName}
name={name}
outerClassName={outerClassName}
{...props}
/>
);
@ -75,7 +79,8 @@ FormikCheckbox.propTypes = {
id: PropTypes.string,
label: PropTypes.string,
labelClassName: PropTypes.string,
name: PropTypes.string
name: PropTypes.string,
outerClassName: PropTypes.string
};
module.exports = FormikCheckbox;

View file

@ -8,6 +8,7 @@ const {injectIntl, intlShape} = require('react-intl');
const countryData = require('../../lib/country-data');
const FormikSelect = require('../../components/formik-forms/formik-select.jsx');
const JoinFlowStep = require('./join-flow-step.jsx');
const FormikCheckbox = require('../../components/formik-forms/formik-checkbox.jsx');
require('./join-flow-steps.scss');
@ -94,6 +95,13 @@ class CountryStep extends React.Component {
validate={this.validateSelect}
validationClassName="validation-full-width-input"
/>
{/* note that this is a hidden checkbox the user will never see */}
<FormikCheckbox
id="yesno"
label={this.props.intl.formatMessage({id: 'registration.receiveEmails'})}
name="yesno"
outerClassName="yesNoCheckbox"
/>
</div>
</JoinFlowStep>
);

View file

@ -4,9 +4,9 @@ const React = require('react');
const PropTypes = require('prop-types');
import {Formik} from 'formik';
const {injectIntl, intlShape} = require('react-intl');
const emailValidator = require('email-validator');
const FormattedMessage = require('react-intl').FormattedMessage;
const validate = require('../../lib/validate');
const JoinFlowStep = require('./join-flow-step.jsx');
const FormikInput = require('../../components/formik-forms/formik-input.jsx');
const FormikCheckbox = require('../../components/formik-forms/formik-checkbox.jsx');
@ -20,30 +20,91 @@ class EmailStep extends React.Component {
'handleSetEmailRef',
'handleValidSubmit',
'validateEmail',
'validateForm'
'validateForm',
'setCaptchaRef',
'captchaSolved',
'onCaptchaLoad',
'onCaptchaError'
]);
this.state = {
captchaIsLoading: true
};
}
componentDidMount () {
// automatically start with focus on username field
if (this.emailInput) this.emailInput.focus();
// If grecaptcha doesn't exist on window, we havent loaded the captcha js yet. Load it.
if (!window.grecaptcha) {
// ReCaptcha calls a callback when the grecatpcha object is usable. That callback
// needs to be global so set it on the window.
window.grecaptchaOnLoad = this.onCaptchaLoad;
// Load Google ReCaptcha script.
const script = document.createElement('script');
script.async = true;
script.onerror = this.onCaptchaError;
script.src = `https://www.recaptcha.net/recaptcha/api.js?onload=grecaptchaOnLoad&render=explicit&hl=${window._locale}`;
document.body.appendChild(script);
}
}
componentWillUnmount () {
window.grecaptchaOnLoad = null;
}
handleSetEmailRef (emailInputRef) {
this.emailInput = emailInputRef;
}
onCaptchaError () {
// TODO send user to error step once we have one.
}
onCaptchaLoad () {
this.setState({captchaIsLoading: false});
this.grecaptcha = window.grecaptcha;
if (!this.grecaptcha) {
// According to the reCaptcha documentation, this callback shouldn't get
// called unless window.grecaptcha exists. This is just here to be extra defensive.
// TODO: Put up the error screen when we have one.
}
// TODO: Add in error callback for render once we have an error screen.
this.widgetId = this.grecaptcha.render(this.captchaRef,
{
callback: this.captchaSolved,
sitekey: process.env.RECAPTCHA_SITE_KEY
},
true);
}
validateEmail (email) {
if (!email) return this.props.intl.formatMessage({id: 'general.required'});
const isValidLocally = emailValidator.validate(email);
if (isValidLocally) {
return null; // TODO: validate email address remotely
}
return this.props.intl.formatMessage({id: 'registration.validationEmailInvalid'});
const localResult = validate.validateEmailLocally(email);
if (!localResult.valid) return this.props.intl.formatMessage({id: localResult.errMsgId});
return validate.validateEmailRemotely(email).then(
remoteResult => {
if (remoteResult.valid === true) {
return null;
}
return this.props.intl.formatMessage({id: remoteResult.errMsgId});
}
);
}
validateForm () {
return {};
}
handleValidSubmit (formData, formikBag) {
formikBag.setSubmitting(false);
this.props.onNextStep(formData);
this.formData = formData;
this.formikBag = formikBag;
// Change set submitting to false so that if the user clicks out of
// the captcha, the button is clickable again (instead of a disabled button with a spinner).
this.formikBag.setSubmitting(false);
this.grecaptcha.execute(this.widgetId);
}
captchaSolved (token) {
// Now thatcaptcha is done, we can tell Formik we're submitting.
this.formikBag.setSubmitting(true);
this.formData['g-recaptcha-response'] = token;
this.props.onNextStep(this.formData);
}
setCaptchaRef (ref) {
this.captchaRef = ref;
}
render () {
return (
@ -63,6 +124,8 @@ class EmailStep extends React.Component {
handleSubmit,
isSubmitting,
setFieldError,
setFieldTouched,
setFieldValue,
validateField
} = props;
return (
@ -88,7 +151,7 @@ class EmailStep extends React.Component {
innerClassName="join-flow-inner-email-step"
nextButton={this.props.intl.formatMessage({id: 'registration.createAccount'})}
title={this.props.intl.formatMessage({id: 'registration.emailStepTitle'})}
waiting={isSubmitting}
waiting={this.props.waiting || isSubmitting || this.state.captchaIsLoading}
onSubmit={handleSubmit}
>
<FormikInput
@ -105,7 +168,11 @@ class EmailStep extends React.Component {
validationClassName="validation-full-width-input"
/* eslint-disable react/jsx-no-bind */
onBlur={() => validateField('email')}
onFocus={() => setFieldError('email', null)}
onChange={e => {
setFieldValue('email', e.target.value);
setFieldTouched('email');
setFieldError('email', null);
}}
/* eslint-enable react/jsx-no-bind */
onSetRef={this.handleSetEmailRef}
/>
@ -116,6 +183,13 @@ class EmailStep extends React.Component {
name="subscribe"
/>
</div>
<div
className="g-recaptcha"
data-badge="bottomright"
data-sitekey={process.env.RECAPTCHA_SITE_KEY}
data-size="invisible"
ref={this.setCaptchaRef}
/>
</JoinFlowStep>
);
}}
@ -126,7 +200,8 @@ class EmailStep extends React.Component {
EmailStep.propTypes = {
intl: intlShape,
onNextStep: PropTypes.func
onNextStep: PropTypes.func,
waiting: PropTypes.bool
};

View file

@ -132,6 +132,10 @@
margin-left: -.5rem;
}
.join-flow-registration-error {
padding-top: 5.5rem;
}
.join-flow-gender-description {
margin-top: .625rem;
margin-bottom: 1.25rem;
@ -180,3 +184,7 @@
a.join-flow-link:link, a.join-flow-link:visited, a.join-flow-link:active {
text-decoration: underline;
}
.yesNoCheckbox {
display: none;
}

View file

@ -1,10 +1,13 @@
const bindAll = require('lodash.bindall');
const connect = require('react-redux').connect;
const defaults = require('lodash.defaultsdeep');
const PropTypes = require('prop-types');
const React = require('react');
const api = require('../../lib/api');
const injectIntl = require('../../lib/intl.jsx').injectIntl;
const intlShape = require('../../lib/intl.jsx').intlShape;
const sessionActions = require('../../redux/session.js');
const Progression = require('../progression/progression.jsx');
const UsernameStep = require('./username-step.jsx');
@ -13,44 +16,141 @@ const GenderStep = require('./gender-step.jsx');
const CountryStep = require('./country-step.jsx');
const EmailStep = require('./email-step.jsx');
const WelcomeStep = require('./welcome-step.jsx');
const RegistrationErrorStep = require('./registration-error-step.jsx');
/*
eslint-disable react/prefer-stateless-function, react/no-unused-prop-types, no-useless-constructor
*/
class JoinFlow extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleAdvanceStep'
'handleAdvanceStep',
'handlePrepareToRegister',
'handleRegistrationResponse',
'handleSubmitRegistration'
]);
this.state = {
formData: {},
registrationError: null,
step: 0
step: 0,
waiting: false
};
}
handleAdvanceStep (formData) {
formData = formData || {};
handlePrepareToRegister (newFormData) {
newFormData = newFormData || {};
const newState = {
formData: defaults({}, newFormData, this.state.formData)
};
this.setState(newState, () => {
this.handleSubmitRegistration(this.state.formData);
});
}
handleRegistrationResponse (err, body, res) {
// example of failing response:
// [
// {
// "msg": "This field is required.",
// "errors": {
// "username": ["This field is required."],
// "recaptcha": ["Incorrect, please try again."]
// },
// "success": false
// }
// ]
this.setState({waiting: false}, () => {
let errStr = '';
if (!err && res.statusCode === 200) {
if (body && body[0]) {
if (body[0].success) {
this.props.refreshSession();
this.setState({
step: this.state.step + 1
});
return;
}
if (body[0].errors) {
// body can include zero or more error objects, each
// with its own key and description. Here we assemble
// all of them into a single string, errStr.
const errorKeys = Object.keys(body[0].errors);
errorKeys.forEach(key => {
const val = body[0].errors[key];
if (val && val[0]) {
if (errStr.length) errStr += '; ';
errStr += `${key}: ${val[0]}`;
}
});
}
if (!errStr.length && body[0].msg) errStr = body[0].msg;
}
}
this.setState({
registrationError: errStr ||
`${this.props.intl.formatMessage({
id: 'registration.generalError'
})} (${res.statusCode})`
});
});
}
handleSubmitRegistration (formData) {
this.setState({waiting: true}, () => {
api({
host: '',
uri: '/accounts/register_new_user/',
method: 'post',
useCsrf: true,
formData: {
'username': formData.username,
'email': formData.email,
'password': formData.password,
'birth_month': formData.birth_month,
'birth_year': formData.birth_year,
'g-recaptcha-response': formData['g-recaptcha-response'],
'gender': formData.gender,
'country': formData.country,
'subscribe': true,
'is_robot': formData.yesno
// no need to include csrfmiddlewaretoken; will be provided in
// X-CSRFToken header, which scratchr2 looks for in
// scratchr2/middleware/csrf.py line 237.
}
}, (err, body, res) => {
this.handleRegistrationResponse(err, body, res);
});
});
}
handleAdvanceStep (newFormData) {
newFormData = newFormData || {};
this.setState({
step: this.state.step + 1,
formData: defaults({}, formData, this.state.formData)
formData: defaults({}, newFormData, this.state.formData),
step: this.state.step + 1
});
}
render () {
return (
<React.Fragment>
<Progression step={this.state.step}>
<UsernameStep onNextStep={this.handleAdvanceStep} />
<BirthDateStep onNextStep={this.handleAdvanceStep} />
<GenderStep onNextStep={this.handleAdvanceStep} />
<CountryStep onNextStep={this.handleAdvanceStep} />
<EmailStep onNextStep={this.handleAdvanceStep} />
<WelcomeStep
email={this.state.formData.email}
username={this.state.formData.username}
onNextStep={this.handleAdvanceStep}
{this.state.registrationError ? (
<RegistrationErrorStep
errorMsg={this.state.registrationError}
/* eslint-disable react/jsx-no-bind */
onTryAgain={() => this.handleSubmitRegistration(this.state.formData)}
/* eslint-enable react/jsx-no-bind */
/>
</Progression>
) : (
<Progression step={this.state.step}>
<UsernameStep onNextStep={this.handleAdvanceStep} />
<BirthDateStep onNextStep={this.handleAdvanceStep} />
<GenderStep onNextStep={this.handleAdvanceStep} />
<CountryStep onNextStep={this.handleAdvanceStep} />
<EmailStep
waiting={this.state.waiting}
onNextStep={this.handlePrepareToRegister}
/>
<WelcomeStep
email={this.state.formData.email}
username={this.state.formData.username}
onNextStep={this.props.onCompleteRegistration}
/>
</Progression>
)}
</React.Fragment>
);
}
@ -58,11 +158,27 @@ class JoinFlow extends React.Component {
JoinFlow.propTypes = {
intl: intlShape,
onCompleteRegistration: PropTypes.func
onCompleteRegistration: PropTypes.func,
refreshSession: PropTypes.func
};
module.exports = injectIntl(JoinFlow);
const IntlJoinFlow = injectIntl(JoinFlow);
/*
eslint-enable
*/
const mapDispatchToProps = dispatch => ({
refreshSession: () => {
dispatch(sessionActions.refreshSession());
}
});
// Allow incoming props to override redux-provided props. Used to mock in tests.
const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign(
{}, stateProps, dispatchProps, ownProps
);
const ConnectedJoinFlow = connect(
() => ({}),
mapDispatchToProps,
mergeProps
)(IntlJoinFlow);
module.exports = ConnectedJoinFlow;

View file

@ -4,10 +4,16 @@
.modal-flush-bottom-button {
margin: 0;
width: 100%;
border: none;
border-bottom-left-radius: 1rem;
border-bottom-right-radius: 1rem;
height: 5.1875rem;
background-color: $ui-orange;
&:hover {
transition: background-color .25s ease;
background-color: $ui-orange-90percent;
}
}
.next-step-title {

View file

@ -0,0 +1,45 @@
const bindAll = require('lodash.bindall');
const React = require('react');
const PropTypes = require('prop-types');
const {injectIntl, intlShape} = require('react-intl');
const JoinFlowStep = require('./join-flow-step.jsx');
require('./join-flow-steps.scss');
class RegistrationErrorStep extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleSubmit'
]);
}
handleSubmit (e) {
// JoinFlowStep includes a <form> that handles a submit action.
// But here, we're not really submitting, so we need to prevent
// the form from navigating away from the current page.
e.preventDefault();
this.props.onTryAgain();
}
render () {
return (
<JoinFlowStep
description={this.props.errorMsg}
innerClassName="join-flow-registration-error"
nextButton={this.props.intl.formatMessage({id: 'general.tryAgain'})}
title={this.props.intl.formatMessage({id: 'registration.generalError'})}
onSubmit={this.handleSubmit}
/>
);
}
}
RegistrationErrorStep.propTypes = {
errorMsg: PropTypes.string,
intl: intlShape,
onTryAgain: PropTypes.func
};
const IntlRegistrationErrorStep = injectIntl(RegistrationErrorStep);
module.exports = IntlRegistrationErrorStep;

View file

@ -217,6 +217,7 @@ class Navigation extends React.Component {
{this.props.registrationOpen && (
this.props.useScratch3Registration ? (
<Scratch3Registration
createProjectOnComplete
isOpen
key="scratch3registration"
/>

View file

@ -23,17 +23,19 @@ const Registration = ({
);
Registration.propTypes = {
// used in mapDispatchToProps; eslint doesn't understand that this prop is used
createProjectOnComplete: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types
handleCloseRegistration: PropTypes.func,
handleCompleteRegistration: PropTypes.func,
isOpen: PropTypes.bool
};
const mapDispatchToProps = dispatch => ({
const mapDispatchToProps = (dispatch, ownProps) => ({
handleCloseRegistration: () => {
dispatch(navigationActions.setRegistrationOpen(false));
},
handleCompleteRegistration: () => {
dispatch(navigationActions.handleCompleteRegistration());
dispatch(navigationActions.handleCompleteRegistration(ownProps.createProjectOnComplete));
}
});

View file

@ -89,6 +89,7 @@
"general.ideas": "Ideas",
"general.tipsWindow": "Tips Window",
"general.termsOfUse": "Terms of Use",
"general.tryAgain": "Try again",
"general.unhandledError": "We are so sorry, but it looks like Scratch has crashed. This bug has been automatically reported to the Scratch Team.",
"general.username": "Username",
"general.validationEmail": "Please enter a valid email address",

View file

@ -1,5 +1,6 @@
module.exports = {};
const api = require('./api');
const emailValidator = require('email-validator');
module.exports.validateUsernameLocally = username => {
if (!username || username === '') {
@ -67,3 +68,36 @@ module.exports.validatePasswordConfirm = (password, passwordConfirm) => {
}
return {valid: true};
};
module.exports.validateEmailLocally = email => {
if (!email || email === '') {
return {valid: false, errMsgId: 'general.required'};
} else if (emailValidator.validate(email)) {
return {valid: true};
}
return ({valid: false, errMsgId: 'registration.validationEmailInvalid'});
};
module.exports.validateEmailRemotely = email => (
new Promise(resolve => {
api({
host: '', // not handled by API; use existing infrastructure
params: {email: email},
uri: '/accounts/check_email/'
}, (err, body, res) => {
if (err || res.statusCode !== 200 || !body || body.length < 1 || !body[0].msg) {
resolve({valid: false, errMsgId: 'general.apiError'});
}
switch (body[0].msg) {
case 'valid email':
resolve({valid: true});
break;
case 'Scratch is not allowed to send email to this address.': // e.g., bad TLD or block-listed
case 'Enter a valid email address.':
default:
resolve({valid: false, errMsgId: 'registration.validationEmailInvalid'});
break;
}
});
})
);

View file

@ -92,9 +92,13 @@ module.exports.setSearchTerm = searchTerm => ({
searchTerm: searchTerm
});
module.exports.handleCompleteRegistration = () => (dispatch => {
dispatch(sessionActions.refreshSession());
dispatch(module.exports.setRegistrationOpen(false));
module.exports.handleCompleteRegistration = createProject => (dispatch => {
if (createProject) {
window.location = '/projects/editor/?tutorial=getStarted';
} else {
dispatch(sessionActions.refreshSession());
dispatch(module.exports.setRegistrationOpen(false));
}
});
module.exports.handleLogIn = (formData, callback) => (dispatch => {

View file

@ -161,6 +161,13 @@
"view": "jobs/moderator/moderator",
"title": "Community Moderator"
},
{
"name": "join",
"pattern": "^/join/?$",
"routeAlias": "/join/?$",
"view": "join/join",
"title": "Join Scratch"
},
{
"name": "messages",
"pattern": "^/messages/?$",

View file

@ -114,8 +114,8 @@ class Download extends React.Component {
className="download-button"
href={
this.state.OS === OS_ENUM.WINDOWS ?
'https://downloads.scratch.mit.edu/desktop/Scratch%20Desktop%20Setup%203.5.0.exe' :
'https://downloads.scratch.mit.edu/desktop/Scratch%20Desktop-3.5.0.dmg'
'https://downloads.scratch.mit.edu/desktop/Scratch%20Desktop%20Setup%203.6.0.exe' :
'https://downloads.scratch.mit.edu/desktop/Scratch%20Desktop-3.6.0.dmg'
}
>
<FormattedMessage id="download.downloadButton" />

17
src/views/join/join.jsx Normal file
View file

@ -0,0 +1,17 @@
const React = require('react');
const render = require('../../lib/render.jsx');
const JoinModal = require('../../components/modal/join/modal.jsx');
const ErrorBoundary = require('../../components/errorboundary/errorboundary.jsx');
// Require this even though we don't use it because, without it, webpack runs out of memory...
const Page = require('../../components/page/www/page.jsx'); // eslint-disable-line no-unused-vars
const openModal = true;
const Register = () => (
<ErrorBoundary>
<JoinModal
isOpen={openModal}
key="scratch3registration"
/>
</ErrorBoundary>
);
render(<Register />, document.getElementById('app'));

View file

@ -68,7 +68,7 @@ class SeleniumHelper {
let driverConfig = {
browserName: 'chrome',
platform: 'macOS 10.14',
version: '75.0'
version: '76.0'
};
var driver = new webdriver.Builder()
.withCapabilities({

View file

@ -1,62 +0,0 @@
/*
* Tests from:
*
* https://github.com/LLK/scratchr2/wiki/Smoke-Testing-Test-Cases
*
*/
const SeleniumHelper = require('../selenium-helpers.js');
const helper = new SeleniumHelper();
var tap = require('tap');
const test = tap.test;
const driver = helper.buildDriver('www-smoke test_sign_in_out_discuss');
const {
clickText,
findByXpath,
findText,
clickXpath,
clickButton
} = helper;
var username = process.env.SMOKE_USERNAME;
var password = process.env.SMOKE_PASSWORD;
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
var url = rootUrl + '/discuss';
tap.plan(2);
tap.tearDown(function () {
driver.quit();
});
tap.beforeEach(function () {
return driver.get(url);
});
test('Sign in to Scratch using scratchr2 navbar', t => {
clickText('Sign in')
.then(() => findByXpath('//input[@id="login_dropdown_username"]'))
.then((element) => element.sendKeys(username))
.then(() => findByXpath('//input[@name="password"]'))
.then((element) => element.sendKeys(password))
.then(() => clickButton('Sign in'))
.then(() => findByXpath('//li[contains(@class, "logged-in-user")' +
'and contains(@class, "dropdown")]/span'))
.then((element) => element.getText('span'))
.then((text) => t.match(text.toLowerCase(), username.substring(0, 10).toLowerCase(),
'first part of username should be displayed in navbar'))
.then(() => t.end());
});
test('Sign out of Scratch using scratchr2 navbar', t => {
clickXpath('//span[contains(@class, "user-name")' +
' and contains(@class, "dropdown-toggle")]/img[contains(@class, "user-icon")]')
.then(() => clickXpath('//input[@value="Sign out"]'))
.then(() => findText('Sign in'))
.then((element) => t.ok(element, 'Sign in reappeared on the page after signing out'))
.then(() => t.end());
});

View file

@ -1,59 +0,0 @@
/*
* Tests from:
*
* https://github.com/LLK/scratchr2/wiki/Smoke-Testing-Test-Cases
*
*/
const SeleniumHelper = require('../selenium-helpers.js');
const helper = new SeleniumHelper();
var tap = require('tap');
const test = tap.test;
const driver = helper.buildDriver('www-smoke test_sign_in_out_homepage');
const {
clickText,
findText,
findByXpath,
clickXpath
} = helper;
var username = process.env.SMOKE_USERNAME;
var password = process.env.SMOKE_PASSWORD;
var rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
tap.plan(2);
tap.tearDown(function () {
driver.quit();
});
tap.beforeEach(function () {
return driver.get(rootUrl);
});
test('Sign in to Scratch using scratch-www navbar', {skip: true}, t => {
clickText('Sign in')
.then(() => findByXpath('//input[@id="frc-username-1088"]'))
.then((element) => element.sendKeys(username))
.then(() => findByXpath('//input[@id="frc-password-1088"]'))
.then((element) => element.sendKeys(password))
.then(() => clickXpath('//button[contains(@class, "button") and ' +
'contains(@class, "submit-button") and contains(@class, "white")]'))
.then(() => findByXpath('//span[contains(@class, "profile-name")]'))
.then((element) => element.getText())
.then((text) => t.match(text.toLowerCase(), username.substring(0, 10).toLowerCase(),
'first part of username should be displayed in navbar'))
.then(() => t.end());
});
test('Sign out of Scratch using scratch-www navbar', {skip: true}, t => {
clickXpath('//a[contains(@class, "user-info")]')
.then(() => clickText('Sign out'))
.then(() => findText('Sign in'))
.then((element) => t.ok(element, 'Sign in reappeared on the page after signing out'))
.then(() => t.end());
});

View file

@ -67,8 +67,8 @@ class SeleniumHelper {
// https://wiki.saucelabs.com/display/DOCS/Platform+Configurator
let driverConfig = {
browserName: 'chrome',
platform: 'macOS 10.13',
version: '70.0'
platform: 'macOS 10.14',
version: '76.0'
};
var driver = new webdriver.Builder()
.withCapabilities({

View file

@ -0,0 +1,100 @@
const SeleniumHelper = require('./selenium-helpers.js');
const {
clickText,
findByXpath,
clickXpath,
clickButton,
buildDriver
} = new SeleniumHelper();
let username = process.env.SMOKE_USERNAME;
let password = process.env.SMOKE_PASSWORD;
let remote = process.env.SMOKE_REMOTE || false;
let rootUrl = process.env.ROOT_URL || 'https://scratch.ly';
let scratchr2url = rootUrl + '/users/' + username;
let wwwURL = rootUrl;
if (remote){
jest.setTimeout(60000);
}
let driver;
describe('www-integration sign-in-and-out', () => {
beforeAll(async () => {
driver = await buildDriver('www-integration sign-in-out');
});
describe('sign in', () => {
afterEach(async () => {
await driver.get(wwwURL);
await clickXpath('//div[@class="account-nav"]');
await clickText('Sign out');
});
test('sign in on www', async () => {
await driver.get(wwwURL);
await driver.sleep(1000);
await clickXpath('//li[@class="link right login-item"]/a');
let name = await findByXpath('//input[@id="frc-username-1088"]');
await name.sendKeys(username);
let word = await findByXpath('//input[@id="frc-password-1088"]');
await word.sendKeys(password);
await clickXpath('//button[contains(@class, "button") and ' +
'contains(@class, "submit-button") and contains(@class, "white")]');
let element = await findByXpath('//span[contains(@class, "profile-name")]');
let text = await element.getText();
await expect(text.toLowerCase()).toEqual(username.toLowerCase());
});
test('sign in on scratchr2', async () => {
await driver.get(scratchr2url);
await clickXpath('//li[@class="sign-in dropdown"]/span');
let name = await findByXpath('//input[@id="login_dropdown_username"]');
await name.sendKeys(username);
let word = await findByXpath('//input[@name="password"]');
await word.sendKeys(password);
await clickButton('Sign in');
let element = await findByXpath('//span[@class="user-name dropdown-toggle"]');
let text = await element.getText();
await expect(text.toLowerCase()).toEqual(username.toLowerCase());
});
});
describe('sign out', () => {
beforeEach(async () => {
await driver.get(wwwURL);
await clickXpath('//li[@class="link right login-item"]');
let name = await findByXpath('//input[@id="frc-username-1088"]');
await name.sendKeys(username);
let word = await findByXpath('//input[@id="frc-password-1088"]');
await word.sendKeys(password);
await clickXpath('//button[contains(@class, "button") and ' +
'contains(@class, "submit-button") and contains(@class, "white")]');
});
test('sign out on www', async () => {
await clickXpath('//a[contains(@class, "user-info")]');
await clickText('Sign out');
let element = await findByXpath('//li[@class="link right login-item"]/a/span');
let text = await element.getText();
await expect(text.toLowerCase()).toEqual('Sign In'.toLowerCase());
});
test('sign out on scratchr2', async () => {
await driver.get(scratchr2url);
await clickXpath('//span[@class="user-name dropdown-toggle"]');
await clickXpath('//li[@id="logout"]');
let element = await findByXpath('//li[@class="link right login-item"]/a/span');
let text = await element.getText();
await expect(text.toLowerCase()).toEqual('Sign In'.toLowerCase());
});
});
afterAll(async () => {
await driver.quit();
});
});

View file

@ -1,5 +0,0 @@
describe('test jest integration', () => {
test('testing test', () => {
expect('integration').toEqual('integration');
});
});

View file

@ -0,0 +1,140 @@
const React = require('react');
const {shallowWithIntl} = require('../../helpers/intl-helpers.jsx');
const EmailStep = require('../../../src/components/join-flow/email-step.jsx');
const JoinFlowStep = require('../../../src/components/join-flow/join-flow-step.jsx');
const FormikInput = require('../../../src/components/formik-forms/formik-input.jsx');
const FormikCheckbox = require('../../../src/components/formik-forms/formik-checkbox.jsx');
describe('EmailStep test', () => {
test('send correct props to formik', () => {
const wrapper = shallowWithIntl(<EmailStep />);
const formikWrapper = wrapper.dive();
expect(formikWrapper.props().initialValues.subscribe).toBe(false);
expect(formikWrapper.props().initialValues.email).toBe('');
expect(formikWrapper.props().validateOnBlur).toBe(false);
expect(formikWrapper.props().validateOnChange).toBe(false);
expect(formikWrapper.props().validate).toBe(formikWrapper.instance().validateForm);
expect(formikWrapper.props().onSubmit).toBe(formikWrapper.instance().handleValidSubmit);
});
test('props sent to JoinFlowStep', () => {
const wrapper = shallowWithIntl(<EmailStep />);
// Dive to get past the intl wrapper
const formikWrapper = wrapper.dive();
// Dive to get past the anonymous component.
const joinFlowWrapper = formikWrapper.dive().find(JoinFlowStep);
expect(joinFlowWrapper).toHaveLength(1);
expect(joinFlowWrapper.props().description).toBe('registration.emailStepDescription');
expect(joinFlowWrapper.props().footerContent.props.id).toBe('registration.acceptTermsOfUse');
expect(joinFlowWrapper.props().headerImgSrc).toBe('/images/join-flow/email-header.png');
expect(joinFlowWrapper.props().innerClassName).toBe('join-flow-inner-email-step');
expect(joinFlowWrapper.props().nextButton).toBe('registration.createAccount');
expect(joinFlowWrapper.props().title).toBe('registration.emailStepTitle');
expect(joinFlowWrapper.props().waiting).toBe(true);
});
test('props sent to FormikInput for email', () => {
const wrapper = shallowWithIntl(<EmailStep />);
// Dive to get past the intl wrapper
const formikWrapper = wrapper.dive();
// Dive to get past the anonymous component.
const joinFlowWrapper = formikWrapper.dive().find(JoinFlowStep);
expect(joinFlowWrapper).toHaveLength(1);
const emailInputWrapper = joinFlowWrapper.find(FormikInput).first();
expect(emailInputWrapper.props().id).toEqual('email');
expect(emailInputWrapper.props().error).toBeUndefined();
expect(emailInputWrapper.props().name).toEqual('email');
expect(emailInputWrapper.props().placeholder).toEqual('general.emailAddress');
expect(emailInputWrapper.props().validationClassName).toEqual('validation-full-width-input');
expect(emailInputWrapper.props().onSetRef).toEqual(formikWrapper.instance().handleSetEmailRef);
expect(emailInputWrapper.props().validate).toEqual(formikWrapper.instance().validateEmail);
});
test('props sent to FormikCheckbox for subscribe', () => {
const wrapper = shallowWithIntl(<EmailStep />);
// Dive to get past the intl wrapper
const formikWrapper = wrapper.dive();
// Dive to get past the anonymous component.
const joinFlowWrapper = formikWrapper.dive().find(JoinFlowStep);
expect(joinFlowWrapper).toHaveLength(1);
const checkboxWrapper = joinFlowWrapper.find(FormikCheckbox).first();
expect(checkboxWrapper).toHaveLength(1);
expect(checkboxWrapper.first().props().id).toEqual('subscribeCheckbox');
expect(checkboxWrapper.first().props().label).toEqual('registration.receiveEmails');
expect(checkboxWrapper.first().props().name).toEqual('subscribe');
});
test('handleValidSubmit passes formData to next step', () => {
const formikBag = {
setSubmitting: jest.fn()
};
global.grecaptcha = {
execute: jest.fn(),
render: jest.fn()
};
const formData = {item1: 'thing', item2: 'otherthing'};
const wrapper = shallowWithIntl(
<EmailStep />);
const formikWrapper = wrapper.dive();
formikWrapper.instance().onCaptchaLoad(); // to setup catpcha state
formikWrapper.instance().handleValidSubmit(formData, formikBag);
expect(formikBag.setSubmitting).toHaveBeenCalledWith(false);
expect(global.grecaptcha.execute).toHaveBeenCalled();
});
test('captchaSolved sets token and goes to next step', () => {
const props = {
onNextStep: jest.fn()
};
const formikBag = {
setSubmitting: jest.fn()
};
global.grecaptcha = {
execute: jest.fn(),
render: jest.fn()
};
const formData = {item1: 'thing', item2: 'otherthing'};
const wrapper = shallowWithIntl(
<EmailStep
{...props}
/>);
const formikWrapper = wrapper.dive();
// Call these to setup captcha.
formikWrapper.instance().onCaptchaLoad(); // to setup catpcha state
formikWrapper.instance().handleValidSubmit(formData, formikBag);
const captchaToken = 'abcd';
formikWrapper.instance().captchaSolved(captchaToken);
// Make sure captchaSolved calls onNextStep with formData that has
// a captcha token and left everything else in the object in place.
expect(props.onNextStep).toHaveBeenCalledWith(
expect.objectContaining({
'item1': formData.item1,
'item2': formData.item2,
'g-recaptcha-response': captchaToken
}));
expect(formikBag.setSubmitting).toHaveBeenCalledWith(true);
});
test('validateEmail test email empty', () => {
const wrapper = shallowWithIntl(
<EmailStep />);
const formikWrapper = wrapper.dive();
const val = formikWrapper.instance().validateEmail('');
expect(val).toBe('general.required');
});
test('validateEmail test email null', () => {
const wrapper = shallowWithIntl(
<EmailStep />);
const formikWrapper = wrapper.dive();
const val = formikWrapper.instance().validateEmail(null);
expect(val).toBe('general.required');
});
test('validateEmail test email undefined', () => {
const wrapper = shallowWithIntl(
<EmailStep />);
const formikWrapper = wrapper.dive();
const val = formikWrapper.instance().validateEmail();
expect(val).toBe('general.required');
});
});

View file

@ -0,0 +1,165 @@
import React from 'react';
const {shallowWithIntl} = require('../../helpers/intl-helpers.jsx');
import configureStore from 'redux-mock-store';
import JoinFlow from '../../../src/components/join-flow/join-flow';
import Progression from '../../../src/components/progression/progression.jsx';
import RegistrationErrorStep from '../../../src/components/join-flow/registration-error-step';
describe('JoinFlow', () => {
const mockStore = configureStore();
let store;
beforeEach(() => {
store = mockStore({sessionActions: {
refreshSession: jest.fn()
}});
});
const getJoinFlowWrapper = props => {
const wrapper = shallowWithIntl(
<JoinFlow
{...props}
/>
, {context: {store}}
);
return wrapper
.dive() // unwrap redux connect(injectIntl(JoinFlow))
.dive(); // unwrap injectIntl(JoinFlow)
};
test('handleRegistrationResponse with successful response', () => {
const props = {
refreshSession: jest.fn()
};
const joinFlowInstance = getJoinFlowWrapper(props).instance();
const responseErr = null;
const responseBody = [
{
success: true
}
];
const responseObj = {
statusCode: 200
};
joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj);
expect(joinFlowInstance.props.refreshSession).toHaveBeenCalled();
expect(joinFlowInstance.state.registrationError).toBe(null);
});
test('handleRegistrationResponse with healthy response, indicating failure', () => {
const props = {
refreshSession: jest.fn()
};
const joinFlowInstance = getJoinFlowWrapper(props).instance();
const responseErr = null;
const responseBody = [
{
msg: 'This field is required.',
errors: {
username: ['This field is required.']
},
success: false
}
];
const responseObj = {
statusCode: 200
};
joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj);
expect(joinFlowInstance.props.refreshSession).not.toHaveBeenCalled();
expect(joinFlowInstance.state.registrationError).toBe('username: This field is required.');
});
test('handleRegistrationResponse with failure response, with error fields missing', () => {
const props = {
refreshSession: jest.fn()
};
const joinFlowInstance = getJoinFlowWrapper(props).instance();
const responseErr = null;
const responseBody = [
{
msg: 'This field is required.',
success: false
}
];
const responseObj = {
statusCode: 200
};
joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj);
expect(joinFlowInstance.props.refreshSession).not.toHaveBeenCalled();
expect(joinFlowInstance.state.registrationError).toBe('This field is required.');
});
test('handleRegistrationResponse with failure response, with no text explanation', () => {
const props = {
refreshSession: jest.fn()
};
const joinFlowInstance = getJoinFlowWrapper(props).instance();
const responseErr = null;
const responseBody = [
{
success: false
}
];
const responseObj = {
statusCode: 200
};
joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj);
expect(joinFlowInstance.props.refreshSession).not.toHaveBeenCalled();
expect(joinFlowInstance.state.registrationError).toBe('registration.generalError (200)');
});
test('handleRegistrationResponse with failure status code', () => {
const props = {
refreshSession: jest.fn()
};
const joinFlowInstance = getJoinFlowWrapper(props).instance();
const responseErr = null;
const responseBody = [
{
success: false
}
];
const responseObj = {
statusCode: 400
};
joinFlowInstance.handleRegistrationResponse(responseErr, responseBody, responseObj);
expect(joinFlowInstance.props.refreshSession).not.toHaveBeenCalled();
expect(joinFlowInstance.state.registrationError).toBe('registration.generalError (400)');
});
test('handleAdvanceStep', () => {
const joinFlowInstance = getJoinFlowWrapper().instance();
joinFlowInstance.setState({formData: {username: 'ScratchCat123'}, step: 2});
joinFlowInstance.handleAdvanceStep({email: 'scratchcat123@scratch.mit.edu'});
expect(joinFlowInstance.state.formData.username).toBe('ScratchCat123');
expect(joinFlowInstance.state.formData.email).toBe('scratchcat123@scratch.mit.edu');
expect(joinFlowInstance.state.step).toBe(3);
});
test('when state.registrationError has error message, we show RegistrationErrorStep', () => {
const joinFlowWrapper = getJoinFlowWrapper();
joinFlowWrapper.instance().setState({registrationError: 'halp there is a errors!!'});
const registrationErrorWrapper = joinFlowWrapper.find(RegistrationErrorStep);
const progressionWrapper = joinFlowWrapper.find(Progression);
expect(registrationErrorWrapper).toHaveLength(1);
expect(progressionWrapper).toHaveLength(0);
});
test('when state.registrationError has null error message, we show Progression', () => {
const joinFlowWrapper = getJoinFlowWrapper();
joinFlowWrapper.instance().setState({registrationError: null});
const registrationErrorWrapper = joinFlowWrapper.find(RegistrationErrorStep);
const progressionWrapper = joinFlowWrapper.find(Progression);
expect(registrationErrorWrapper).toHaveLength(0);
expect(progressionWrapper).toHaveLength(1);
});
test('when state.registrationError has empty error message, we show Progression', () => {
const joinFlowWrapper = getJoinFlowWrapper();
joinFlowWrapper.instance().setState({registrationError: ''});
const registrationErrorWrapper = joinFlowWrapper.find(RegistrationErrorStep);
const progressionWrapper = joinFlowWrapper.find(Progression);
expect(registrationErrorWrapper).toHaveLength(0);
expect(progressionWrapper).toHaveLength(1);
});
});

View file

@ -0,0 +1,33 @@
import React from 'react';
import {shallowWithIntl} from '../../helpers/intl-helpers.jsx';
import JoinFlowStep from '../../../src/components/join-flow/join-flow-step';
import RegistrationErrorStep from '../../../src/components/join-flow/registration-error-step';
describe('RegistrationErrorStep', () => {
const onTryAgain = jest.fn();
let wrapper;
beforeEach(() => {
wrapper = shallowWithIntl(
<RegistrationErrorStep
errorMsg={'error message'}
onTryAgain={onTryAgain}
/>
);
});
test('shows JoinFlowStep with props', () => {
// Dive to get past the anonymous component.
const joinFlowStepWrapper = wrapper.dive().find(JoinFlowStep);
expect(joinFlowStepWrapper).toHaveLength(1);
expect(joinFlowStepWrapper.props().description).toBe('error message');
expect(joinFlowStepWrapper.props().nextButton).toBe('general.tryAgain');
});
test('when submitted, onTryAgain is called', () => {
// Dive to get past the anonymous component.
const joinFlowStepWrapper = wrapper.dive().find(JoinFlowStep);
joinFlowStepWrapper.props().onSubmit(new Event('event')); // eslint-disable-line no-undef
expect(onTryAgain).toHaveBeenCalled();
});
});

View file

@ -63,4 +63,52 @@ describe('unit test lib/validate.js', () => {
response = validate.validatePasswordConfirm('', 'abcdefg');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationPasswordConfirmNotEquals'});
});
test('validate email address locally', () => {
let response;
expect(typeof validate.validateEmailLocally).toBe('function');
// permitted addresses:
response = validate.validateEmailLocally('abc@def.com');
expect(response).toEqual({valid: true});
response = validate.validateEmailLocally('abcdefghijklmnopqrst@abcdefghijklmnopqrst.info');
expect(response).toEqual({valid: true});
response = validate.validateEmailLocally('abc-def-ghi@jkl-mno.org');
expect(response).toEqual({valid: true});
response = validate.validateEmailLocally('_______@example.com');
expect(response).toEqual({valid: true});
response = validate.validateEmailLocally('email@example.museum');
expect(response).toEqual({valid: true});
response = validate.validateEmailLocally('email@example.co.jp');
expect(response).toEqual({valid: true});
// non-permitted addresses:
response = validate.validateEmailLocally('');
expect(response).toEqual({valid: false, errMsgId: 'general.required'});
response = validate.validateEmailLocally('a');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'});
response = validate.validateEmailLocally('abc@def');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'});
response = validate.validateEmailLocally('abc@def.c');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'});
response = validate.validateEmailLocally('abc😄def@emoji.pizza');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'});
response = validate.validateEmailLocally('あいうえお@example.com');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'});
response = validate.validateEmailLocally('Abc..123@example.com');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'});
response = validate.validateEmailLocally('Joe Smith <email@example.com>');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'});
response = validate.validateEmailLocally('email@example@example.com');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'});
response = validate.validateEmailLocally('email@example..com');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'});
// edge cases:
// these are strictly legal according to email addres spec, but rejected by library we use:
response = validate.validateEmailLocally('email@123.123.123.123');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'});
response = validate.validateEmailLocally('much."more unusual"@example.com');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationEmailInvalid'});
});
});

View file

@ -173,6 +173,8 @@ module.exports = {
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"' + (process.env.NODE_ENV || 'development') + '"',
'process.env.API_HOST': '"' + (process.env.API_HOST || 'https://api.scratch.mit.edu') + '"',
'process.env.RECAPTCHA_SITE_KEY': '"' +
(process.env.RECAPTCHA_SITE_KEY || '6Lf6kK4UAAAAABKTyvdSqgcSVASEnMrCquiAkjVW') + '"',
'process.env.ASSET_HOST': '"' + (process.env.ASSET_HOST || 'https://assets.scratch.mit.edu') + '"',
'process.env.BACKPACK_HOST': '"' + (process.env.BACKPACK_HOST || 'https://backpack.scratch.mit.edu') + '"',
'process.env.CLOUDDATA_HOST': '"' + (process.env.CLOUDDATA_HOST || 'clouddata.scratch.mit.edu') + '"',