Merge pull request #3170 from LLK/develop

Merge develop to release branch
This commit is contained in:
Ray Schamp 2019-07-24 15:52:16 -04:00 committed by GitHub
commit ea3243b8a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 3738 additions and 487 deletions

View file

@ -129,11 +129,7 @@ jobs:
branch: branch:
- master - master
- stage: smoke - stage: smoke
install: script: npm run test:integration:remote
- cd test/integration
- npm install
- cd -
script: npm run test:smoke:sauce
stages: stages:
- test - test
- name: smoke - name: smoke

View file

@ -95,10 +95,43 @@ To stop the process that is making the site available to your web browser (creat
**NOTE:** Because by default `API_HOST=https://api.scratch.mit.edu`, please be aware that, by default, you will be seeing and interacting with real data on the Scratch website. **NOTE:** Because by default `API_HOST=https://api.scratch.mit.edu`, please be aware that, by default, you will be seeing and interacting with real data on the Scratch website.
### To Test ### Unit Tests
To run:
```bash ```bash
npm test npm test
``` ```
This will build the application and run the unit and localization tests. Some of the tests are run using the TAP framework and others run using Jest.
### Integration tests
We are transitioning from using TAP to using Jest as our testing framework so for the time being our tests run using both.
#### Running the tests
* By default, tests run against our Staging instance, but you can pass in a different location with the ROOT_URL environment variable (see below) if you want to run the tests against e.g. your local build
#### Running the tests
* Run all tests from the command-line: `$ SMOKE_USERNAME=username SMOKE_PASSWORD=password ROOT_URL=https://scratch.mit.edu npm run test:integration`
* To run a single file from the command-line using TAP: `$ SMOKE_USERNAME=username SMOKE_PASSWORD=password ROOT_URL=https://scratch.mit.edu node_modules/.bin/tap ./test/integration-legacy/smoke-testing/filename.js --timeout=3600`
* The timeout var is for the length of the entire tap test-suite; if you are getting a timeout error, you may need to adjust this value (some of the Selenium tests take a while to run)
* To run a single file from the command-line using Jest: `$ SMOKE_USERNAME=username SMOKE_PASSWORD=password ROOT_URL=https://scratch.mit.edu node_modules/.bin/jest ./test/integration/filename.test.js`
#### Running Remote tests
* TAP tests can be run using Saucelabs, an online service that can test browser/os combinations remotely. Currently all tests are written for use for chrome on mac.
* You will need a Saucelabs account in order to use it for testing. To find the Access Key, click your username and select User Settings from the dropdown menu. Near the bottom of the page is your access key that you can copy and use in the command line.
* Currently Jest tests will not run with Saucelabs.
* To run tests using saucelabs run this command `$ SMOKE_USERNAME=username SMOKE_PASSWORD=password SAUCE_USERNAME=saucelabsUsername SAUCE_ACCESS_KEY=saucelabsAccessKey ROOT_URL=https://scratch.mit.edu npm run test:integration:remote`
#### Configuration
| Variable | Default | Description |
| --------------------- | --------------------- | --------------------------------------------------------- |
| `ROOT_URL` | `scratch.ly` | Location you want to run the tests against |
| `SMOKE_USERNAME` | `None` | Username for Scratch user you're signing in with to test |
| `SMOKE_PASSWORD` | `None` | Password for Scratch user you're signing in with to test |
| `SMOKE_REMOTE` | `false` | Tests with Sauce Labs or not. True if running test:smoke:sauce |
| `SMOKE_HEADLESS` | `false` | Run browser in headless mode. Flaky at the moment |
| `SAUCE_USERNAME` | `None` | Username for your Sauce Labs account |
| `SAUCE_ACCESS_KEY` | `None` | Access Key for Sauce Labs found under User Settings |
### To Deploy ### To Deploy

3555
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,13 +4,18 @@
"description": "Standalone WWW client for Scratch", "description": "Standalone WWW client for Scratch",
"scripts": { "scripts": {
"start": "node ./dev-server/index.js", "start": "node ./dev-server/index.js",
"test": "npm run test:lint && npm run build && npm run test:tap", "test": "npm run test:lint && npm run build && npm run test:unit",
"test:lint": "eslint . --ext .js,.jsx,.json", "test:lint": "eslint . --ext .js,.jsx,.json",
"test:smoke": "tap ./test/integration/smoke-testing/*.js --timeout=3600 --no-coverage -R classic", "test:integration": "npm run test:integration:jest && npm run test:smoke",
"test:smoke:verbose": "tap ./test/integration/smoke-testing/*.js --timeout=3600 --no-coverage -R spec", "test:integration:jest": "jest ./test/integration/*.test.js",
"test:smoke:sauce": "SMOKE_REMOTE=true tap ./test/integration/smoke-testing/*.js --timeout=60000 --no-coverage -R classic", "test:integration:remote": "npm run test:integration:jest && npm run test:smoke:sauce",
"test:tap": "tap ./test/{unit,localization}/*.js --no-coverage -R classic", "test:smoke": "tap ./test/integration-legacy/smoke-testing/*.js --timeout=3600 --no-coverage -R classic",
"test:coverage": "tap ./test/{unit,localization}/*.js --coverage --coverage-report=lcov", "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",
"test:unit": "npm run test:unit:jest && npm run test:unit:tap",
"test:unit:jest": "jest ./test/unit/ && jest ./test/localization/*.test.js",
"test:unit:tap": "tap ./test/{unit-legacy,localization-legacy}/*.js --no-coverage -R classic",
"test:coverage": "tap ./test/{unit-legacy,localization-legacy}/*.js --coverage --coverage-report=lcov",
"build": "npm run clean && npm run translate && webpack --bail", "build": "npm run clean && npm run translate && webpack --bail",
"clean": "rm -rf ./build && rm -rf ./intl && mkdir -p build && mkdir -p intl", "clean": "rm -rf ./build && rm -rf ./intl && mkdir -p build && mkdir -p intl",
"deploy": "npm run deploy:s3 && npm run deploy:fastly", "deploy": "npm run deploy:s3 && npm run deploy:fastly",
@ -53,23 +58,24 @@
"autoprefixer": "6.3.6", "autoprefixer": "6.3.6",
"babel-cli": "6.26.0", "babel-cli": "6.26.0",
"babel-core": "6.23.1", "babel-core": "6.23.1",
"babel-eslint": "8.0.2", "babel-eslint": "10.0.2",
"babel-loader": "7.1.0", "babel-loader": "7.1.0",
"babel-plugin-transform-object-rest-spread": "6.26.0", "babel-plugin-transform-object-rest-spread": "6.26.0",
"babel-preset-es2015": "6.22.0", "babel-preset-es2015": "6.22.0",
"babel-preset-react": "6.22.0", "babel-preset-react": "6.22.0",
"bowser": "1.9.4", "bowser": "1.9.4",
"cheerio": "1.0.0-rc.2", "cheerio": "1.0.0-rc.2",
"chromedriver": "75.1.0",
"classnames": "2.2.5", "classnames": "2.2.5",
"cookie": "0.2.2", "cookie": "0.2.2",
"copy-webpack-plugin": "0.2.0", "copy-webpack-plugin": "0.2.0",
"create-react-class": "15.6.2", "create-react-class": "15.6.2",
"css-loader": "0.23.1", "css-loader": "0.23.1",
"eslint": "4.7.1", "eslint": "5.16.0",
"eslint-config-scratch": "5.0.0", "eslint-config-scratch": "5.0.0",
"eslint-plugin-cypress": "^2.0.1", "eslint-plugin-cypress": "^2.0.1",
"eslint-plugin-json": "1.2.0", "eslint-plugin-json": "1.4.0",
"eslint-plugin-react": "7.4.0", "eslint-plugin-react": "7.14.2",
"exenv": "1.2.0", "exenv": "1.2.0",
"fastly": "1.2.1", "fastly": "1.2.1",
"file-loader": "4.0.0", "file-loader": "4.0.0",
@ -81,6 +87,7 @@
"google-libphonenumber": "3.2.3", "google-libphonenumber": "3.2.3",
"html-webpack-plugin": "2.22.0", "html-webpack-plugin": "2.22.0",
"iso-3166-2": "0.4.0", "iso-3166-2": "0.4.0",
"jest": "^23.6.0",
"json-loader": "0.5.2", "json-loader": "0.5.2",
"json2po-stream": "1.0.3", "json2po-stream": "1.0.3",
"keymirror": "0.1.1", "keymirror": "0.1.1",
@ -113,8 +120,9 @@
"redux": "3.5.2", "redux": "3.5.2",
"redux-thunk": "2.0.1", "redux-thunk": "2.0.1",
"sass-loader": "6.0.6", "sass-loader": "6.0.6",
"scratch-gui": "0.1.0-prerelease.20190722181739", "scratch-gui": "0.1.0-prerelease.20190724175453",
"scratch-l10n": "latest", "scratch-l10n": "latest",
"selenium-webdriver": "3.6.0",
"slick-carousel": "1.6.0", "slick-carousel": "1.6.0",
"source-map-support": "0.3.2", "source-map-support": "0.3.2",
"style-loader": "0.12.3", "style-loader": "0.12.3",

View file

@ -7,5 +7,10 @@ module.exports = {
globals: { globals: {
process: true process: true
}, },
plugins: ['json'] plugins: ['json'],
settings: {
react: {
version: '16.2' // Prevent 16.3 lifecycle method errors
}
}
}; };

View file

@ -31,6 +31,7 @@ const InstallScratchLink = ({
'https://www.microsoft.com/store/productId/9N48XLLCZH0X' : 'https://www.microsoft.com/store/productId/9N48XLLCZH0X' :
'https://itunes.apple.com/us/app/scratch-link/id1408863490' 'https://itunes.apple.com/us/app/scratch-link/id1408863490'
} }
rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<img <img

View file

@ -5,6 +5,7 @@ const ProjectCard = props => (
<a <a
className="project-card" className="project-card"
href={props.cardUrl} href={props.cardUrl}
rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<div className="project-card-image"> <div className="project-card-image">

View file

@ -11,9 +11,10 @@ require('../forms/row.scss');
const FormikInput = ({ const FormikInput = ({
className, className,
error, error,
validationClassName,
...props ...props
}) => ( }) => (
<div className="col-sm-9 row"> <div className="col-sm-9 row row-with-tooltip">
<Field <Field
className={classNames( className={classNames(
'input', 'input',
@ -22,7 +23,10 @@ const FormikInput = ({
{...props} {...props}
/> />
{error && ( {error && (
<ValidationMessage message={error} /> <ValidationMessage
className={validationClassName}
message={error}
/>
)} )}
</div> </div>
); );
@ -31,7 +35,8 @@ const FormikInput = ({
FormikInput.propTypes = { FormikInput.propTypes = {
className: PropTypes.string, className: PropTypes.string,
error: PropTypes.string, error: PropTypes.string,
type: PropTypes.string type: PropTypes.string,
validationClassName: PropTypes.string
}; };
module.exports = FormikInput; module.exports = FormikInput;

View file

@ -24,10 +24,9 @@ const FormikSelect = ({
</option> </option>
)); ));
return ( return (
<div className="col-sm-9 row"> <div className="select">
<Field <Field
className={classNames( className={classNames(
'select',
className className
)} )}
component="select" component="select"

View file

@ -21,6 +21,11 @@
} }
} }
/* allow elements such as validation errors to position relative to this row */
.row-with-tooltip {
position: relative;
}
.row-label { .row-label {
margin-bottom: .75rem; margin-bottom: .75rem;
line-height: 1.7rem; line-height: 1.7rem;

View file

@ -53,7 +53,12 @@ const Grid = props => (
Grid.propTypes = { Grid.propTypes = {
className: PropTypes.string, className: PropTypes.string,
itemType: PropTypes.string, itemType: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.object) items: PropTypes.arrayOf(PropTypes.object),
showAvatar: PropTypes.bool,
showFavorites: PropTypes.bool,
showLoves: PropTypes.bool,
showRemixes: PropTypes.bool,
showViews: PropTypes.bool
}; };
Grid.defaultProps = { Grid.defaultProps = {

View file

@ -0,0 +1,143 @@
const bindAll = require('lodash.bindall');
const classNames = require('classnames');
const React = require('react');
const PropTypes = require('prop-types');
import {Formik} from 'formik';
const {injectIntl, intlShape} = require('react-intl');
const FormikSelect = require('../../components/formik-forms/formik-select.jsx');
const JoinFlowStep = require('./join-flow-step.jsx');
require('./join-flow-steps.scss');
const getBirthMonthOptions = intl => ([
{value: 'null', label: intl.formatMessage({id: 'general.month'})},
{value: '1', label: intl.formatMessage({id: 'general.monthJanuary'})},
{value: '2', label: intl.formatMessage({id: 'general.monthFebruary'})},
{value: '3', label: intl.formatMessage({id: 'general.monthMarch'})},
{value: '4', label: intl.formatMessage({id: 'general.monthApril'})},
{value: '5', label: intl.formatMessage({id: 'general.monthMay'})},
{value: '6', label: intl.formatMessage({id: 'general.monthJune'})},
{value: '7', label: intl.formatMessage({id: 'general.monthJuly'})},
{value: '8', label: intl.formatMessage({id: 'general.monthAugust'})},
{value: '9', label: intl.formatMessage({id: 'general.monthSeptember'})},
{value: '10', label: intl.formatMessage({id: 'general.monthOctober'})},
{value: '11', label: intl.formatMessage({id: 'general.monthNovember'})},
{value: '12', label: intl.formatMessage({id: 'general.monthDecember'})}
]);
const getBirthYearOptions = intl => {
const curYearRaw = (new Date()).getYear();
const curYear = curYearRaw + 1900;
// including both 1900 and current year, there are (curYearRaw + 1) options.
const numYearOptions = curYearRaw + 1;
const birthYearOptions = Array(numYearOptions).fill()
.map((defaultVal, i) => (
{value: String(curYear - i), label: String(curYear - i)}
));
birthYearOptions.unshift({
value: 'null',
label: intl.formatMessage({id: 'general.year'})
});
return birthYearOptions;
};
class BirthDateStep extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleValidSubmit',
'validateForm',
'validateSelect'
]);
}
validateSelect (selection) {
if (selection === 'null') {
return this.props.intl.formatMessage({id: 'form.validationRequired'});
}
return null;
}
validateForm () {
return {};
}
handleValidSubmit (formData, formikBag) {
formikBag.setSubmitting(false);
this.props.onNextStep(formData);
}
render () {
const birthMonthOptions = getBirthMonthOptions(this.props.intl);
const birthYearOptions = getBirthYearOptions(this.props.intl);
return (
<Formik
initialValues={{
birth_month: 'null',
birth_year: 'null'
}}
validate={this.validateForm}
validateOnBlur={false}
validateOnChange={false}
onSubmit={this.handleValidSubmit}
>
{props => {
const {
errors,
handleSubmit,
isSubmitting
} = props;
return (
<JoinFlowStep
description={this.props.intl.formatMessage({id: 'registration.birthDateStepDescription'})}
headerImgSrc="/images/hoc/getting-started.jpg"
title={this.props.intl.formatMessage({id: 'general.joinScratch'})}
waiting={isSubmitting}
onSubmit={handleSubmit}
>
<div
className={classNames(
'col-sm-9',
'row',
'birthdate-select-row'
)}
>
<FormikSelect
className={classNames(
'join-flow-select',
'join-flow-select-month',
{fail: errors.birth_month}
)}
error={errors.birth_month}
id="birth_month"
name="birth_month"
options={birthMonthOptions}
validate={this.validateSelect}
validationClassName="validation-full-width-input"
/>
<FormikSelect
className={classNames(
'join-flow-select',
{fail: errors.birth_year}
)}
error={errors.birth_year}
id="birth_year"
name="birth_year"
options={birthYearOptions}
validate={this.validateSelect}
validationClassName="validation-full-width-input"
/>
</div>
</JoinFlowStep>
);
}}
</Formik>
);
}
}
BirthDateStep.propTypes = {
intl: intlShape,
onNextStep: PropTypes.func
};
const IntlBirthDateStep = injectIntl(BirthDateStep);
module.exports = IntlBirthDateStep;

View file

@ -10,11 +10,17 @@ require('./join-flow-step.scss');
const JoinFlowStep = ({ const JoinFlowStep = ({
children, children,
description, description,
headerImgSrc,
onSubmit, onSubmit,
title, title,
waiting waiting
}) => ( }) => (
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
{headerImgSrc && (
<div className="join-flow-header-image">
<img src={headerImgSrc} />
</div>
)}
<div> <div>
<ModalInnerContent className="join-flow-inner-content"> <ModalInnerContent className="join-flow-inner-content">
{title && ( {title && (
@ -38,6 +44,7 @@ const JoinFlowStep = ({
JoinFlowStep.propTypes = { JoinFlowStep.propTypes = {
children: PropTypes.node, children: PropTypes.node,
description: PropTypes.string, description: PropTypes.string,
headerImgSrc: PropTypes.string,
onSubmit: PropTypes.func, onSubmit: PropTypes.func,
title: PropTypes.string, title: PropTypes.string,
waiting: PropTypes.bool waiting: PropTypes.bool

View file

@ -21,3 +21,13 @@
padding: 2.3125rem 0 2.5rem; padding: 2.3125rem 0 2.5rem;
font-size: .875rem; font-size: .875rem;
} }
/* overflow will only work if this class is set on parent of img, not img itself */
.join-flow-header-image {
width: 100%;
height: 7.5rem;
overflow: hidden;
margin: 0;
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
}

View file

@ -22,6 +22,19 @@
transform: translate(21.5625rem, 0); transform: translate(21.5625rem, 0);
} }
select.join-flow-select {
width: 9.125rem;
}
.join-flow-select-month {
margin-right: .5rem;
}
.join-flow-password-section { .join-flow-password-section {
margin-top: 1.125rem; margin-top: 1.125rem;
} }
.birthdate-select-row {
display: flex;
margin: 0 auto;
}

View file

@ -7,7 +7,8 @@ const injectIntl = require('../../lib/intl.jsx').injectIntl;
const intlShape = require('../../lib/intl.jsx').intlShape; const intlShape = require('../../lib/intl.jsx').intlShape;
const Progression = require('../progression/progression.jsx'); const Progression = require('../progression/progression.jsx');
const JoinFlowSteps = require('./join-flow-steps.jsx'); const UsernameStep = require('./username-step.jsx');
const BirthDateStep = require('./birthdate-step.jsx');
/* /*
eslint-disable react/prefer-stateless-function, react/no-unused-prop-types, no-useless-constructor eslint-disable react/prefer-stateless-function, react/no-unused-prop-types, no-useless-constructor
@ -35,9 +36,8 @@ class JoinFlow extends React.Component {
return ( return (
<React.Fragment> <React.Fragment>
<Progression step={this.state.step}> <Progression step={this.state.step}>
<JoinFlowSteps.UsernameStep <UsernameStep onNextStep={this.handleAdvanceStep} />
onNextStep={this.handleAdvanceStep} <BirthDateStep onNextStep={this.handleAdvanceStep} />
/>
</Progression> </Progression>
</React.Fragment> </React.Fragment>
); );

View file

@ -1,4 +1,3 @@
/* eslint-disable react/no-multi-comp */
const bindAll = require('lodash.bindall'); const bindAll = require('lodash.bindall');
const classNames = require('classnames'); const classNames = require('classnames');
const React = require('react'); const React = require('react');
@ -10,6 +9,8 @@ const validate = require('../../lib/validate');
const FormikInput = require('../../components/formik-forms/formik-input.jsx'); const FormikInput = require('../../components/formik-forms/formik-input.jsx');
const JoinFlowStep = require('./join-flow-step.jsx'); const JoinFlowStep = require('./join-flow-step.jsx');
require('./join-flow-steps.scss');
/* /*
* Username step * Username step
*/ */
@ -127,6 +128,7 @@ class UsernameStep extends React.Component {
id="username" id="username"
name="username" name="username"
validate={this.validateUsernameIfPresent} validate={this.validateUsernameIfPresent}
validationClassName="validation-full-width-input"
onBlur={() => validateField('username')} // eslint-disable-line react/jsx-no-bind onBlur={() => validateField('username')} // eslint-disable-line react/jsx-no-bind
/> />
<div className="join-flow-password-section"> <div className="join-flow-password-section">
@ -187,11 +189,12 @@ class UsernameStep extends React.Component {
); );
} }
} }
/* eslint-enable */
UsernameStep.propTypes = { UsernameStep.propTypes = {
intl: intlShape, intl: intlShape,
onNextStep: PropTypes.func onNextStep: PropTypes.func
}; };
module.exports.UsernameStep = injectIntl(UsernameStep); const IntlUsernameStep = injectIntl(UsernameStep);
module.exports = IntlUsernameStep;

View file

@ -3,15 +3,17 @@
.mod-addToStudio { .mod-addToStudio {
min-height: 15rem; min-height: 15rem;
max-height: calc(100% - 8rem); max-height: calc(100% - 5rem);
/* Some value for height must be set for scrolling to work */ /* Some value for height must be set for scrolling to work */
height: 100%; height: 28rem;
overflow: hidden; overflow: hidden;
@media #{$small}, #{$small-height} { @media #{$small}, #{$small-height} {
overflow: hidden; overflow: hidden;
height: 100%;
max-height: 100%;
} }
} }
@ -71,6 +73,12 @@
min-height: 30rem; min-height: 30rem;
*/ */
/* provides buffer at bottom of list, so that bottommost items
are not obscured by gradient overlay */
.studio-list-footer-spacer {
height: 1.5rem;
}
.studio-list-bottom-gradient { .studio-list-bottom-gradient {
position: absolute; position: absolute;
right: 1rem; right: 1rem;

View file

@ -56,6 +56,7 @@ const AddToStudioModalPresentation = ({
<div className="studio-list-container"> <div className="studio-list-container">
{studioButtons} {studioButtons}
</div> </div>
<div className="studio-list-footer-spacer" />
<div className="studio-list-bottom-gradient" /> <div className="studio-list-bottom-gradient" />
</div> </div>
</div> </div>

View file

@ -56,6 +56,7 @@ const TTTModal = props => (
<div className="ttt-item"> <div className="ttt-item">
<a <a
href={props.cardsUrl} href={props.cardsUrl}
rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<FormattedMessage id="ideas.downloadPDF" /> <FormattedMessage id="ideas.downloadPDF" />
@ -72,6 +73,7 @@ const TTTModal = props => (
<div className="ttt-item"> <div className="ttt-item">
<a <a
href={props.guideUrl} href={props.guideUrl}
rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<FormattedMessage id="ideas.downloadPDF" /> <FormattedMessage id="ideas.downloadPDF" />

View file

@ -51,7 +51,12 @@ const ThumbnailColumn = props => (
ThumbnailColumn.propTypes = { ThumbnailColumn.propTypes = {
className: PropTypes.string, className: PropTypes.string,
itemType: PropTypes.string, itemType: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.object) items: PropTypes.arrayOf(PropTypes.object),
showAvatar: PropTypes.bool,
showFavorites: PropTypes.bool,
showLoves: PropTypes.bool,
showRemixes: PropTypes.bool,
showViews: PropTypes.bool
}; };
ThumbnailColumn.defaultProps = { ThumbnailColumn.defaultProps = {

View file

@ -158,6 +158,7 @@ class Ideas extends React.Component {
href={this.props.intl.formatMessage({ href={this.props.intl.formatMessage({
id: 'cards.scratch-cards-allLink' id: 'cards.scratch-cards-allLink'
})} })}
rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<Button className="ideas-button"> <Button className="ideas-button">

5
test/.eslintrc.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
env: {
jest: true
}
};

View file

@ -12,10 +12,10 @@
* Tests can be run using Saucelabs, an online service that can test browser/os combinations remotely. Currently all tests are written for use for chrome on mac. * Tests can be run using Saucelabs, an online service that can test browser/os combinations remotely. Currently all tests are written for use for chrome on mac.
## Using tap ## Using tap
* Run all tests in the smoke-testing directory from the command-line: `$ SMOKE_USERNAME=username SMOKE_PASSWORD=password ROOT_URL=https://scratch.mit.edu npm run test:smoke` * Run all tests in the smoke-testing directory from the command-line: `$ SMOKE_USERNAME=username SMOKE_PASSWORD=password ROOT_URL=https://scratch.mit.edu npm run smoke`
* To run a single file from the command-line: `$ SMOKE_USERNAME=username SMOKE_PASSWORD=password ROOT_URL=https://scratch.mit.edu node_modules/.bin/tap ./test/integration/smoke-testing/filename.js --timeout=3600` * To run a single file from the command-line: `$ SMOKE_USERNAME=username SMOKE_PASSWORD=password ROOT_URL=https://scratch.mit.edu node_modules/.bin/tap ./test/integration/smoke-testing/filename.js --timeout=3600`
* The timeout var is for the length of the entire tap test-suite; if you are getting a timeout error, you may need to adjust this value (some of the Selenium tests take a while to run) * The timeout var is for the length of the entire tap test-suite; if you are getting a timeout error, you may need to adjust this value (some of the Selenium tests take a while to run)
* To run tests using saucelabs run this command `$ SMOKE_USERNAME=username SMOKE_PASSWORD=password SAUCE_USERNAME=saucelabsUsername SAUCE_ACCESS_KEY=saucelabsAccessKey ROOT_URL=https://scratch.mit.edu npm run test:smoke:sauce` * To run tests using saucelabs run this command `$ SMOKE_USERNAME=username SMOKE_PASSWORD=password SAUCE_USERNAME=saucelabsUsername SAUCE_ACCESS_KEY=saucelabsAccessKey ROOT_URL=https://scratch.mit.edu npm run smoke-sauce`
### Configuration ### Configuration
@ -25,7 +25,7 @@
| `ROOT_URL` | `scratch.ly` | Location you want to run the tests against | | `ROOT_URL` | `scratch.ly` | Location you want to run the tests against |
| `SMOKE_USERNAME` | `None` | Username for Scratch user you're signing in with to test | | `SMOKE_USERNAME` | `None` | Username for Scratch user you're signing in with to test |
| `SMOKE_PASSWORD` | `None` | Password for Scratch user you're signing in with to test | | `SMOKE_PASSWORD` | `None` | Password for Scratch user you're signing in with to test |
| `SMOKE_REMOTE` | `false` | Tests with Sauce Labs or not. True if running test:smoke:sauce | | `SMOKE_REMOTE` | `false` | Tests with Sauce Labs or not. True if running smoke-sauce |
| `SMOKE_HEADLESS` | `false` | Run browser in headless mode. Flaky at the moment | | `SMOKE_HEADLESS` | `false` | Run browser in headless mode. Flaky at the moment |
| `SAUCE_USERNAME` | `None` | Username for your Sauce Labs account | | `SAUCE_USERNAME` | `None` | Username for your Sauce Labs account |
| `SAUCE_ACCESS_KEY` | `None` | Access Key for Sauce Labs found under User Settings | | `SAUCE_ACCESS_KEY` | `None` | Access Key for Sauce Labs found under User Settings |

View file

@ -0,0 +1,168 @@
const webdriver = require('selenium-webdriver');
const bindAll = require('lodash.bindall');
require('chromedriver');
const headless = process.env.SMOKE_HEADLESS || false;
const remote = process.env.SMOKE_REMOTE || false;
const ci = process.env.CI || false;
const buildID = process.env.TRAVIS_BUILD_NUMBER;
const {SAUCE_USERNAME, SAUCE_ACCESS_KEY} = process.env;
const {By, Key, until} = webdriver;
const DEFAULT_TIMEOUT_MILLISECONDS = 20 * 1000;
class SeleniumHelper {
constructor () {
bindAll(this, [
'buildDriver',
'clickButton',
'clickCss',
'clickText',
'clickXpath',
'dragFromXpathToXpath',
'findByCss',
'findByXpath',
'findText',
'getKey',
'getDriver',
'getLogs',
'getSauceDriver',
'urlMatches',
'waitUntilGone'
]);
}
buildDriver (name) {
if (remote === 'true'){
let nameToUse;
if (ci === 'true'){
nameToUse = 'travis ' + buildID + ' : ' + name;
} else {
nameToUse = name;
}
this.driver = this.getSauceDriver(SAUCE_USERNAME, SAUCE_ACCESS_KEY, nameToUse);
} else {
this.driver = this.getDriver();
}
return this.driver;
}
getDriver () {
const chromeCapabilities = webdriver.Capabilities.chrome();
let args = [];
if (headless) {
args.push('--headless');
args.push('window-size=1024,1680');
args.push('--no-sandbox');
}
chromeCapabilities.set('chromeOptions', {args});
let driver = new webdriver.Builder()
.forBrowser('chrome')
.withCapabilities(chromeCapabilities)
.build();
return driver;
}
getSauceDriver (username, accessKey, name) {
// Driver configs can be generated with the Sauce Platform Configurator
// https://wiki.saucelabs.com/display/DOCS/Platform+Configurator
let driverConfig = {
browserName: 'chrome',
platform: 'macOS 10.14',
version: '75.0'
};
var driver = new webdriver.Builder()
.withCapabilities({
browserName: driverConfig.browserName,
platform: driverConfig.platform,
version: driverConfig.version,
username: username,
accessKey: accessKey,
name: name
})
.usingServer(`http://${username}:${accessKey
}@ondemand.saucelabs.com:80/wd/hub`)
.build();
return driver;
}
getKey (keyName) {
return Key[keyName];
}
findByXpath (xpath, timeoutMessage = `findByXpath timed out for path: ${xpath}`) {
return this.driver.wait(until.elementLocated(By.xpath(xpath)), DEFAULT_TIMEOUT_MILLISECONDS, timeoutMessage)
.then(el => (
this.driver.wait(el.isDisplayed(), DEFAULT_TIMEOUT_MILLISECONDS, `${xpath} is not visible`)
.then(() => el)
));
}
waitUntilGone (element) {
return this.driver.wait(until.stalenessOf(element));
}
clickXpath (xpath) {
return this.findByXpath(xpath).then(el => el.click());
}
clickText (text) {
return this.clickXpath(`//*[contains(text(), '${text}')]`);
}
findText (text) {
return this.driver.wait(until.elementLocated(By.xpath(`//*[contains(text(), '${text}')]`), 5 * 1000));
}
clickButton (text) {
return this.clickXpath(`//button[contains(text(), '${text}')]`);
}
findByCss (css) {
return this.driver.wait(until.elementLocated(By.css(css), 1000 * 5));
}
clickCss (css) {
return this.findByCss(css).then(el => el.click());
}
dragFromXpathToXpath (startXpath, endXpath) {
return this.findByXpath(startXpath).then(startEl => {
return this.findByXpath(endXpath).then(endEl => {
return this.driver.actions()
.dragAndDrop(startEl, endEl)
.perform();
});
});
}
urlMatches (regex) {
return this.driver.wait(until.urlMatches(regex), 1000 * 5);
}
getLogs (whitelist) {
return this.driver.manage()
.logs()
.get('browser')
.then((entries) => {
return entries.filter((entry) => {
const message = entry.message;
for (let i = 0; i < whitelist.length; i++) {
if (message.indexOf(whitelist[i]) !== -1) {
// eslint-disable-next-line no-console
// console.warn('Ignoring whitelisted error: ' + whitelist[i]);
return false;
} else if (entry.level !== 'SEVERE') {
// eslint-disable-next-line no-console
// console.warn('Ignoring non-SEVERE entry: ' + message);
return false;
}
return true;
}
return true;
});
});
}
}
module.exports = SeleniumHelper;

View file

@ -33,18 +33,24 @@ tap.tearDown(function () {
}); });
tap.beforeEach(function () { tap.beforeEach(function () {
return driver.get(url); return driver.get(url)
}); .then(() => clickText('Sign in'))
test('Sign in to Scratch using scratchr2 navbar', t => {
clickText('Sign in')
.then(() => findByXpath('//input[@id="login_dropdown_username"]')) .then(() => findByXpath('//input[@id="login_dropdown_username"]'))
.then((element) => element.sendKeys(username)) .then((element) => element.sendKeys(username))
.then(() => findByXpath('//input[@name="password"]')) .then(() => findByXpath('//input[@name="password"]'))
.then((element) => element.sendKeys(password)) .then((element) => element.sendKeys(password))
.then(() => clickButton('Sign in')) .then(() => clickButton('Sign in'));
.then(() => findByXpath('//li[contains(@class, "logged-in-user")' + });
'and contains(@class, "dropdown")]/span'))
tap.afterEach(function () {
return clickXpath('//span[@class="user-name dropdown-toggle"]')
.then(() => clickXpath('//li[@id="logout"] '))
.then(() => findByXpath('//div[@class="title-banner intro-banner"]'));
});
test('Sign in to Scratch using scratchr2 navbar', t => {
findByXpath('//li[contains(@class, "logged-in-user")' +
'and contains(@class, "dropdown")]/span')
.then((element) => element.getText('span')) .then((element) => element.getText('span'))
.then((text) => t.match(text.toLowerCase(), username.substring(0, 10).toLowerCase(), .then((text) => t.match(text.toLowerCase(), username.substring(0, 10).toLowerCase(),
'first part of username should be displayed in navbar')) 'first part of username should be displayed in navbar'))
@ -85,6 +91,7 @@ test('clicking See Inside should take you to the editor', t => {
var expectedUrl = '/editor'; var expectedUrl = '/editor';
t.equal(u.substr(-expectedUrl.length), expectedUrl, 'after clicking, the URL should end in #editor'); t.equal(u.substr(-expectedUrl.length), expectedUrl, 'after clicking, the URL should end in #editor');
}) })
.then(() => driver.get(url))
.then(() => t.end()); .then(() => t.end());
}); });
@ -96,6 +103,7 @@ test('clicking a project title should take you to the project page', t => {
var expectedUrlRegExp = new RegExp('/projects/.*[0-9].*/?'); var expectedUrlRegExp = new RegExp('/projects/.*[0-9].*/?');
t.match(u, expectedUrlRegExp, 'after clicking, the URL should end in projects/PROJECT_ID/'); t.match(u, expectedUrlRegExp, 'after clicking, the URL should end in projects/PROJECT_ID/');
}) })
.then(() => driver.get(url))
.then(() => t.end()); .then(() => t.end());
}); });

View file

@ -1,6 +0,0 @@
{
"dependencies": {
"selenium-webdriver": "3.6.0",
"chromedriver": "2.43.1"
}
}

View file

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

View file

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

View file

@ -1,66 +0,0 @@
const tap = require('tap');
const validate = require('../../../src/lib/validate');
tap.tearDown(() => process.nextTick(process.exit));
tap.test('validate username locally', t => {
let response;
t.type(validate.validateUsernameLocally, 'function');
response = validate.validateUsernameLocally('abc');
t.deepEqual(response, {valid: true});
response = validate.validateUsernameLocally('abcdefghijklmnopqrst');
t.deepEqual(response, {valid: true});
response = validate.validateUsernameLocally('abc-def-ghi');
t.deepEqual(response, {valid: true});
response = validate.validateUsernameLocally('');
t.deepEqual(response, {valid: false, errMsgId: 'form.validationRequired'});
response = validate.validateUsernameLocally('ab');
t.deepEqual(response, {valid: false, errMsgId: 'form.validationUsernameMinLength'});
response = validate.validateUsernameLocally('abcdefghijklmnopqrstu');
t.deepEqual(response, {valid: false, errMsgId: 'form.validationUsernameMaxLength'});
response = validate.validateUsernameLocally('abc def');
t.deepEqual(response, {valid: false, errMsgId: 'form.validationUsernameRegexp'});
response = validate.validateUsernameLocally('abc!def');
t.deepEqual(response, {valid: false, errMsgId: 'form.validationUsernameRegexp'});
response = validate.validateUsernameLocally('abc😄def');
t.deepEqual(response, {valid: false, errMsgId: 'form.validationUsernameRegexp'});
t.end();
});
tap.test('validate password', t => {
let response;
t.type(validate.validatePassword, 'function');
response = validate.validatePassword('abcdef');
t.deepEqual(response, {valid: true});
response = validate.validatePassword('abcdefghijklmnopqrst');
t.deepEqual(response, {valid: true});
response = validate.validatePassword('passwo');
t.deepEqual(response, {valid: true});
response = validate.validatePassword('');
t.deepEqual(response, {valid: false, errMsgId: 'form.validationRequired'});
response = validate.validatePassword('abcde');
t.deepEqual(response, {valid: false, errMsgId: 'registration.validationPasswordLength'});
response = validate.validatePassword('password');
t.deepEqual(response, {valid: false, errMsgId: 'registration.validationPasswordNotEquals'});
t.end();
});
tap.test('validate password confirm', t => {
let response;
t.type(validate.validatePasswordConfirm, 'function');
response = validate.validatePasswordConfirm('abcdef', 'abcdef');
t.deepEqual(response, {valid: true});
response = validate.validatePasswordConfirm('abcdefghijklmnopqrst', 'abcdefghijklmnopqrst');
t.deepEqual(response, {valid: true});
response = validate.validatePasswordConfirm('passwo', 'passwo');
t.deepEqual(response, {valid: true});
response = validate.validatePasswordConfirm('', '');
t.deepEqual(response, {valid: false, errMsgId: 'form.validationRequired'});
response = validate.validatePasswordConfirm('abcdef', 'abcdefg');
t.deepEqual(response, {valid: false, errMsgId: 'general.error'});
response = validate.validatePasswordConfirm('abcdef', '123456');
t.deepEqual(response, {valid: false, errMsgId: 'general.error'});
response = validate.validatePasswordConfirm('', 'abcdefg');
t.deepEqual(response, {valid: false, errMsgId: 'general.error'});
t.end();
});

View file

@ -0,0 +1,62 @@
const validate = require('../../../src/lib/validate');
describe('unit test lib/validate.js', () => {
test('validate username locally', () => {
let response;
expect(typeof validate.validateUsernameLocally).toBe('function');
response = validate.validateUsernameLocally('abc');
expect(response).toEqual({valid: true});
response = validate.validateUsernameLocally('abcdefghijklmnopqrst');
expect(response).toEqual({valid: true});
response = validate.validateUsernameLocally('abc-def-ghi');
expect(response).toEqual({valid: true});
response = validate.validateUsernameLocally('');
expect(response).toEqual({valid: false, errMsgId: 'form.validationRequired'});
response = validate.validateUsernameLocally('ab');
expect(response).toEqual({valid: false, errMsgId: 'form.validationUsernameMinLength'});
response = validate.validateUsernameLocally('abcdefghijklmnopqrstu');
expect(response).toEqual({valid: false, errMsgId: 'form.validationUsernameMaxLength'});
response = validate.validateUsernameLocally('abc def');
expect(response).toEqual({valid: false, errMsgId: 'form.validationUsernameRegexp'});
response = validate.validateUsernameLocally('abc!def');
expect(response).toEqual({valid: false, errMsgId: 'form.validationUsernameRegexp'});
response = validate.validateUsernameLocally('abc😄def');
expect(response).toEqual({valid: false, errMsgId: 'form.validationUsernameRegexp'});
});
test('validate password', () => {
let response;
expect(typeof validate.validatePassword).toBe('function');
response = validate.validatePassword('abcdef');
expect(response).toEqual({valid: true});
response = validate.validatePassword('abcdefghijklmnopqrst');
expect(response).toEqual({valid: true});
response = validate.validatePassword('passwo');
expect(response).toEqual({valid: true});
response = validate.validatePassword('');
expect(response).toEqual({valid: false, errMsgId: 'form.validationRequired'});
response = validate.validatePassword('abcde');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationPasswordLength'});
response = validate.validatePassword('password');
expect(response).toEqual({valid: false, errMsgId: 'registration.validationPasswordNotEquals'});
});
test('validate password confirm', () => {
let response;
expect(typeof validate.validatePasswordConfirm).toBe('function');
response = validate.validatePasswordConfirm('abcdef', 'abcdef');
expect(response).toEqual({valid: true});
response = validate.validatePasswordConfirm('abcdefghijklmnopqrst', 'abcdefghijklmnopqrst');
expect(response).toEqual({valid: true});
response = validate.validatePasswordConfirm('passwo', 'passwo');
expect(response).toEqual({valid: true});
response = validate.validatePasswordConfirm('', '');
expect(response).toEqual({valid: false, errMsgId: 'form.validationRequired'});
response = validate.validatePasswordConfirm('abcdef', 'abcdefg');
expect(response).toEqual({valid: false, errMsgId: 'general.error'});
response = validate.validatePasswordConfirm('abcdef', '123456');
expect(response).toEqual({valid: false, errMsgId: 'general.error'});
response = validate.validatePasswordConfirm('', 'abcdefg');
expect(response).toEqual({valid: false, errMsgId: 'general.error'});
});
});