diff --git a/.babelrc b/.babelrc
index 97242d46ff06d0c89e2652bd3bd629e722def056..0cb0bd16047bb69cd9786166ba7709906320358e 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,6 +1,7 @@
 {
     "plugins": [
         "syntax-dynamic-import",
+        "transform-async-to-generator",
         "transform-object-rest-spread",
         ["react-intl", {
             "messagesDir": "./translations/messages/"
diff --git a/.travis.yml b/.travis.yml
index cc8a5822a40f0588d27d8e81a5c613b940fcde2d..ec6ccee6c4196c66b195391b41637c38633ca256 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,6 +1,11 @@
 language: node_js
 sudo: false
 dist: trusty
+addons:
+    chrome: stable
+before_script:
+  - "export DISPLAY=:99.0"
+  - "sh -e /etc/init.d/xvfb start"
 node_js:
 - 6
 cache:
diff --git a/README.md b/README.md
index b478b1e47504962bd77f2cc115da6e57c2dabea1..c036a170930285319aff1fe3f5b348883df9ec65 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ Then go to [http://localhost:8601/](http://localhost:8601/) - the playground out
 ## Testing
 NOTE: If you're a windows user, please run these scripts in Windows `cmd.exe`  instead of Git Bash/MINGW64.
 
-Run linter, unit tests, and build.
+Run linter, unit tests, build, and integration tests.
 ```bash
 npm test
 ```
@@ -46,6 +46,11 @@ Run unit tests in watch mode (watches for code changes and continuously runs tes
 npm run unit-test -- --watch
 ```
 
+Run integration tests in isolation.
+```bash
+npm run integration-test
+```
+
 You may want to review the documentation for [Jest](https://facebook.github.io/jest/docs/en/api.html) and [Enzyme](http://airbnb.io/enzyme/docs/api/) as you write your tests.
 
 ## Publishing to GitHub Pages
diff --git a/package.json b/package.json
index 1c766e8b49309cde678121dfd18bb76add111d64..9f7b08cf6fd298fc3bd5900da8bcd44c92a78187 100644
--- a/package.json
+++ b/package.json
@@ -11,8 +11,9 @@
     "i18n:src": "babel src > tmp.js && rimraf tmp.js && ./scripts/build-i18n-source.js ./translations/messages/ ./translations/",
     "lint": "eslint . --ext .js,.jsx",
     "start": "npm run i18n:msgs && webpack-dev-server",
-    "unit-test": "jest",
-    "test": "npm run lint && npm run unit-test && npm run build",
+    "unit-test": "jest test/unit",
+    "integration-test": "npm run build && jest test/integration",
+    "test": "npm run lint && npm run unit-test && npm run integration-test",
     "watch": "webpack --progress --colors --watch"
   },
   "author": "Massachusetts Institute of Technology",
@@ -34,9 +35,11 @@
     "babel-loader": "^7.0.0",
     "babel-plugin-react-intl": "2.3.1",
     "babel-plugin-syntax-dynamic-import": "6.18.0",
+    "babel-plugin-transform-async-to-generator": "^6.24.1",
     "babel-plugin-transform-object-rest-spread": "^6.22.0",
     "babel-preset-es2015": "^6.22.0",
     "babel-preset-react": "^6.22.0",
+    "chromedriver": "^2.31.0",
     "classnames": "2.2.5",
     "copy-webpack-plugin": "4.0.1",
     "css-loader": "0.28.3",
@@ -46,6 +49,7 @@
     "eslint-config-scratch": "^3.0.0",
     "eslint-plugin-import": "^2.7.0",
     "eslint-plugin-react": "^7.0.1",
+    "get-float-time-domain-data": "0.1.0",
     "gh-pages": "github:rschamp/gh-pages#publish-branch-to-subfolder",
     "html-webpack-plugin": "2.30.0",
     "immutable": "3.8.1",
@@ -67,8 +71,9 @@
     "react-draggable": "2.2.6",
     "react-intl": "2.3.0",
     "react-intl-redux": "0.6.0",
-    "react-modal": "2.2.2",
-    "react-redux": "5.0.5",
+    "react-modal": "2.2.3",
+    "react-redux": "5.0.6",
+    "react-responsive": "1.3.4",
     "react-style-proptype": "3.0.0",
     "react-tabs": "1.1.0",
     "react-test-renderer": "^15.5.4",
@@ -81,10 +86,11 @@
     "scratch-render": "latest",
     "scratch-storage": "^0.2.0",
     "scratch-vm": "latest",
+    "selenium-webdriver": "^3.5.0",
     "style-loader": "^0.18.0",
     "svg-to-image": "1.1.3",
     "svg-url-loader": "2.1.0",
-    "wav-encoder": "1.1.0",
+    "wav-encoder": "1.2.0",
     "web-audio-test-api": "^0.5.2",
     "webpack": "^2.4.1",
     "webpack-dev-server": "^2.4.1",
diff --git a/src/components/forms/input.css b/src/components/forms/input.css
index 6747e2c215a38356104d977b29b9e6008f8b42f8..91b42d4cc59e69dc505975a90d22db22891b5253 100644
--- a/src/components/forms/input.css
+++ b/src/components/forms/input.css
@@ -35,6 +35,6 @@
 }
 
 .input-small {
-    width: 3.5rem; 
+    width: 3rem; 
     text-align: center;
 }
diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css
index de9c7f9ec3918a67cfdb2b65661f9277b5dd5676..5146c84f16e5340ab0be74a4340f40c3533ebbd6 100644
--- a/src/components/gui/gui.css
+++ b/src/components/gui/gui.css
@@ -112,13 +112,6 @@
     */
     display: flex;
     flex-direction: column;
-
-    /*
-        Calc the minimum width for this pane, accounting for left + right spacing.
-        If and when the width doesn't need to be fixed, we can move the spacing out
-        of this calc, and into padding instead
-    */
-    flex-basis: calc(480px + ($space * 2));
 }
 
 .stage-wrapper {
diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx
index f15a5c9a933f1d1814bd54ef8537b6589cdb6645..1e7fe027e54d69c1caead489e3bb5f9f6dde6fdf 100644
--- a/src/components/gui/gui.jsx
+++ b/src/components/gui/gui.jsx
@@ -2,6 +2,7 @@ import classNames from 'classnames';
 import PropTypes from 'prop-types';
 import React from 'react';
 import {Tab, Tabs, TabList, TabPanel} from 'react-tabs';
+import MediaQuery from 'react-responsive';
 import tabStyles from 'react-tabs/style/react-tabs.css';
 import VM from 'scratch-vm';
 
@@ -16,9 +17,9 @@ import StopAll from '../../containers/stop-all.jsx';
 import Box from '../box/box.jsx';
 import MenuBar from '../menu-bar/menu-bar.jsx';
 
+import layout from '../../lib/layout-constants.js';
 import styles from './gui.css';
 
-
 const GUIComponent = props => {
     const {
         basePath,
@@ -87,20 +88,22 @@ const GUIComponent = props => {
                         </Tabs>
                     </Box>
 
-                    <Box className={styles.stageAndTargetWrapper} >
-                        <Box className={styles.stageMenuWrapper} >
+                    <Box className={styles.stageAndTargetWrapper}>
+                        <Box className={styles.stageMenuWrapper}>
                             <GreenFlag vm={vm} />
                             <StopAll vm={vm} />
                         </Box>
-
-                        <Box className={styles.stageWrapper} >
-                            <Stage
-                                shrink={0}
-                                vm={vm}
-                            />
+                        <Box className={styles.stageWrapper}>
+                            <MediaQuery minWidth={layout.fullSizeMinWidth}>{isFullSize => (
+                                <Stage
+                                    height={isFullSize ? layout.fullStageHeight : layout.smallerStageHeight}
+                                    shrink={0}
+                                    vm={vm}
+                                    width={isFullSize ? layout.fullStageWidth : layout.smallerStageWidth}
+                                />
+                            )}</MediaQuery>
                         </Box>
-
-                        <Box className={styles.targetWrapper} >
+                        <Box className={styles.targetWrapper}>
                             <TargetPane
                                 vm={vm}
                             />
diff --git a/src/components/sound-editor/icon--redo.svg b/src/components/sound-editor/icon--redo.svg
new file mode 100644
index 0000000000000000000000000000000000000000..960785c7b6707bda17c4b2365f3b08abda42cd7c
Binary files /dev/null and b/src/components/sound-editor/icon--redo.svg differ
diff --git a/src/components/sound-editor/icon--undo.svg b/src/components/sound-editor/icon--undo.svg
new file mode 100644
index 0000000000000000000000000000000000000000..af3d7ba4239d630cdc6c5a21344887285354b8bc
Binary files /dev/null and b/src/components/sound-editor/icon--undo.svg differ
diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css
index c209d507a64955fd60733301458a008af5609f00..88fb5773f14dc0589be7a2796f5a3d9b6a900fe5 100644
--- a/src/components/sound-editor/sound-editor.css
+++ b/src/components/sound-editor/sound-editor.css
@@ -35,12 +35,14 @@
     padding: 3px;
 }
 
+$border-radius: 0.25rem;
+
 .button {
     height: 2rem;
     padding: 0.25rem;
     outline: none;
     background: white;
-    border-radius: 0.25rem;
+    border-radius: $border-radius;
     border: 1px solid #ddd;
     cursor: pointer;
     font-size: 0.85rem;
@@ -83,3 +85,27 @@
     width: 60px;
     height: 60px;
 }
+
+.button-group {
+    margin: 0 1rem;
+}
+
+.button-group .button {
+    border-radius: 0;
+    border-left: none;
+}
+
+.button-group .button:last-of-type {
+    border-top-right-radius: $border-radius;
+    border-bottom-right-radius: $border-radius;
+}
+
+.button-group .button:first-of-type {
+    border-left: 1px solid #ddd;
+    border-top-left-radius: $border-radius;
+    border-bottom-left-radius: $border-radius;
+}
+
+.button:disabled > img {
+    opacity: 0.25;
+}
diff --git a/src/components/sound-editor/sound-editor.jsx b/src/components/sound-editor/sound-editor.jsx
index 240e427194aba238e7eddd5f5c254307046ff860..21c2613bfa21511fc4e0aa0f3f1e616c0efe41e5 100644
--- a/src/components/sound-editor/sound-editor.jsx
+++ b/src/components/sound-editor/sound-editor.jsx
@@ -16,6 +16,8 @@ import styles from './sound-editor.css';
 import playIcon from '../record-modal/icon--play.svg';
 import stopIcon from '../record-modal/icon--stop-playback.svg';
 import trimIcon from './icon--trim.svg';
+import redoIcon from './icon--redo.svg';
+import undoIcon from './icon--undo.svg';
 import echoIcon from './icon--echo.svg';
 import higherIcon from './icon--higher.svg';
 import lowerIcon from './icon--lower.svg';
@@ -52,6 +54,16 @@ const messages = defineMessages({
         description: 'Title of the button to save trimmed sound',
         defaultMessage: 'Save'
     },
+    undo: {
+        id: 'soundEditor.undo',
+        description: 'Title of the button to undo',
+        defaultMessage: 'Undo'
+    },
+    redo: {
+        id: 'soundEditor.redo',
+        description: 'Title of the button to redo',
+        defaultMessage: 'Redo'
+    },
     faster: {
         id: 'soundEditor.faster',
         description: 'Title of the button to apply the faster effect',
@@ -140,6 +152,24 @@ const SoundEditor = props => (
                         <FormattedMessage {...messages.save} />
                     )}
                 </button>
+                <div className={styles.buttonGroup}>
+                    <button
+                        className={styles.button}
+                        disabled={!props.canUndo}
+                        title={props.intl.formatMessage(messages.undo)}
+                        onClick={props.onUndo}
+                    >
+                        <img src={undoIcon} />
+                    </button>
+                    <button
+                        className={styles.button}
+                        disabled={!props.canRedo}
+                        title={props.intl.formatMessage(messages.redo)}
+                        onClick={props.onRedo}
+                    >
+                        <img src={redoIcon} />
+                    </button>
+                </div>
             </div>
         </div>
         <div className={styles.row}>
@@ -206,6 +236,8 @@ const SoundEditor = props => (
 );
 
 SoundEditor.propTypes = {
+    canRedo: PropTypes.bool.isRequired,
+    canUndo: PropTypes.bool.isRequired,
     chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired,
     intl: intlShape,
     name: PropTypes.string.isRequired,
@@ -215,6 +247,7 @@ SoundEditor.propTypes = {
     onFaster: PropTypes.func.isRequired,
     onLouder: PropTypes.func.isRequired,
     onPlay: PropTypes.func.isRequired,
+    onRedo: PropTypes.func.isRequired,
     onReverse: PropTypes.func.isRequired,
     onRobot: PropTypes.func.isRequired,
     onSetTrimEnd: PropTypes.func,
@@ -222,6 +255,7 @@ SoundEditor.propTypes = {
     onSlower: PropTypes.func.isRequired,
     onSofter: PropTypes.func.isRequired,
     onStop: PropTypes.func.isRequired,
+    onUndo: PropTypes.func.isRequired,
     playhead: PropTypes.number,
     trimEnd: PropTypes.number,
     trimStart: PropTypes.number
diff --git a/src/components/sprite-info/sprite-info.jsx b/src/components/sprite-info/sprite-info.jsx
index fc68c9121a44f6d587b46a15d1654e372b4688f4..8b3f212d99dfa870db88c6338f4efd09cfe0a643 100644
--- a/src/components/sprite-info/sprite-info.jsx
+++ b/src/components/sprite-info/sprite-info.jsx
@@ -1,12 +1,14 @@
 import classNames from 'classnames';
 import PropTypes from 'prop-types';
 import React from 'react';
+import MediaQuery from 'react-responsive';
 
 import Box from '../box/box.jsx';
 import Label from '../forms/label.jsx';
 import Input from '../forms/input.jsx';
 import BufferedInputHOC from '../forms/buffered-input-hoc.jsx';
 
+import layout from '../../lib/layout-constants.js';
 import styles from './sprite-info.css';
 
 import xIcon from './icon--x.svg';
@@ -49,12 +51,14 @@ class SpriteInfo extends React.Component {
                     </div>
 
                     <div className={styles.group}>
-                        <div className={styles.iconWrapper}>
-                            <img
-                                className={classNames(styles.xIcon, styles.icon)}
-                                src={xIcon}
-                            />
-                        </div>
+                        <MediaQuery minWidth={layout.fullSizeMinWidth}>
+                            <div className={styles.iconWrapper}>
+                                <img
+                                    className={classNames(styles.xIcon, styles.icon)}
+                                    src={xIcon}
+                                />
+                            </div>
+                        </MediaQuery>
                         <Label text="x">
                             <BufferedInput
                                 small
@@ -69,12 +73,14 @@ class SpriteInfo extends React.Component {
                     </div>
 
                     <div className={styles.group}>
-                        <div className={styles.iconWrapper}>
-                            <img
-                                className={classNames(styles.yIcon, styles.icon)}
-                                src={yIcon}
-                            />
-                        </div>
+                        <MediaQuery minWidth={layout.fullSizeMinWidth}>
+                            <div className={styles.iconWrapper}>
+                                <img
+                                    className={classNames(styles.yIcon, styles.icon)}
+                                    src={yIcon}
+                                />
+                            </div>
+                        </MediaQuery>
                         <Label text="y">
                             <BufferedInput
                                 small
diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx
index 1328e88fc8c28a31e7af3cb2566c1aafe0a04030..67c4b1b5fdab808d0920decf7a2becd240a8d43f 100644
--- a/src/containers/sound-editor.jsx
+++ b/src/containers/sound-editor.jsx
@@ -21,7 +21,10 @@ class SoundEditor extends React.Component {
             'handleActivateTrim',
             'handleUpdateTrimEnd',
             'handleUpdateTrimStart',
-            'handleEffect'
+            'handleEffect',
+            'handleUndo',
+            'handleRedo',
+            'submitNewSamples'
         ]);
         this.state = {
             chunkLevels: computeChunkedRMS(this.props.samples),
@@ -29,12 +32,17 @@ class SoundEditor extends React.Component {
             trimStart: null,
             trimEnd: null
         };
+
+        this.redoStack = [];
+        this.undoStack = [];
     }
     componentDidMount () {
         this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate);
     }
     componentWillReceiveProps (newProps) {
         if (newProps.soundId !== this.props.soundId) { // A different sound has been selected
+            this.redoStack = [];
+            this.undoStack = [];
             this.resetState(newProps.samples, newProps.sampleRate);
         }
     }
@@ -51,7 +59,11 @@ class SoundEditor extends React.Component {
             trimEnd: null
         });
     }
-    submitNewSamples (samples, sampleRate) {
+    submitNewSamples (samples, sampleRate, skipUndo) {
+        if (!skipUndo) {
+            this.redoStack = [];
+            this.undoStack.push(this.props.samples.slice(0));
+        }
         this.resetState(samples, sampleRate);
         this.props.onUpdateSoundBuffer(
             this.props.soundIndex,
@@ -107,10 +119,26 @@ class SoundEditor extends React.Component {
             this.handlePlay();
         });
     }
+    handleUndo () {
+        this.redoStack.push(this.props.samples.slice(0));
+        const samples = this.undoStack.pop();
+        if (samples) {
+            this.submitNewSamples(samples, this.props.sampleRate, true);
+        }
+    }
+    handleRedo () {
+        const samples = this.redoStack.pop();
+        if (samples) {
+            this.undoStack.push(this.props.samples.slice(0));
+            this.submitNewSamples(samples, this.props.sampleRate, true);
+        }
+    }
     render () {
         const {effectTypes} = AudioEffects;
         return (
             <SoundEditorComponent
+                canRedo={this.redoStack.length > 0}
+                canUndo={this.undoStack.length > 0}
                 chunkLevels={this.state.chunkLevels}
                 name={this.props.name}
                 playhead={this.state.playhead}
@@ -122,6 +150,7 @@ class SoundEditor extends React.Component {
                 onFaster={this.effectFactory(effectTypes.FASTER)}
                 onLouder={this.effectFactory(effectTypes.LOUDER)}
                 onPlay={this.handlePlay}
+                onRedo={this.handleRedo}
                 onReverse={this.effectFactory(effectTypes.REVERSE)}
                 onRobot={this.effectFactory(effectTypes.ROBOT)}
                 onSetTrimEnd={this.handleUpdateTrimEnd}
@@ -129,6 +158,7 @@ class SoundEditor extends React.Component {
                 onSlower={this.effectFactory(effectTypes.SLOWER)}
                 onSofter={this.effectFactory(effectTypes.SOFTER)}
                 onStop={this.handleStopPlaying}
+                onUndo={this.handleUndo}
             />
         );
     }
diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx
index f0545c088dc422b39b61ea7aa8ee17eb342c0853..d9ab05f12966d701d898142b9f7a5a4855af9d15 100644
--- a/src/containers/stage.jsx
+++ b/src/containers/stage.jsx
@@ -40,8 +40,8 @@ class Stage extends React.Component {
         this.audioEngine = new AudioEngine();
         this.props.vm.attachAudioEngine(this.audioEngine);
     }
-    shouldComponentUpdate () {
-        return false;
+    shouldComponentUpdate (nextProps) {
+        return this.props.width !== nextProps.width || this.props.height !== nextProps.height;
     }
     componentWillUnmount () {
         this.detachMouseEvents(this.canvas);
@@ -69,9 +69,10 @@ class Stage extends React.Component {
         this.rect = this.canvas.getBoundingClientRect();
     }
     getScratchCoords (x, y) {
+        const nativeSize = this.renderer.getNativeSize();
         return [
-            x - (this.rect.width / 2),
-            y - (this.rect.height / 2)
+            (nativeSize[0] / this.rect.width) * (x - (this.rect.width / 2)),
+            (nativeSize[1] / this.rect.height) * (y - (this.rect.height / 2))
         ];
     }
     handleDoubleClick (e) {
@@ -127,6 +128,7 @@ class Stage extends React.Component {
         }
     }
     onMouseDown (e) {
+        this.updateRect();
         const mousePosition = [e.clientX - this.rect.left, e.clientY - this.rect.top];
         this.setState({
             mouseDown: true,
@@ -192,7 +194,9 @@ class Stage extends React.Component {
 }
 
 Stage.propTypes = {
-    vm: PropTypes.instanceOf(VM).isRequired
+    height: PropTypes.number,
+    vm: PropTypes.instanceOf(VM).isRequired,
+    width: PropTypes.number
 };
 
 export default Stage;
diff --git a/src/lib/audio/audio-recorder.js b/src/lib/audio/audio-recorder.js
index 576021a2ef01ae606c17dfe329b0d800e978d099..528bb1eec28e9a020d37e7caa2ebb1cf11c97c58 100644
--- a/src/lib/audio/audio-recorder.js
+++ b/src/lib/audio/audio-recorder.js
@@ -1,3 +1,4 @@
+import 'get-float-time-domain-data';
 import SharedAudioContext from './shared-audio-context.js';
 import {computeRMS} from './audio-util.js';
 
diff --git a/src/lib/layout-constants.js b/src/lib/layout-constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..672c8013f58d477ae767a159a3d2af28784d7139
--- /dev/null
+++ b/src/lib/layout-constants.js
@@ -0,0 +1,7 @@
+export default {
+    fullStageWidth: 480,
+    fullStageHeight: 360,
+    smallerStageWidth: 480 * 0.85,
+    smallerStageHeight: 360 * 0.85,
+    fullSizeMinWidth: 1096
+};
diff --git a/src/lib/libraries/costumes.json b/src/lib/libraries/costumes.json
index 370f2d8b95b60b31f319982dd5bf547f6e1b4595..f359a3f16670dd17f324c32b6e4585d2d6395678 100644
--- a/src/lib/libraries/costumes.json
+++ b/src/lib/libraries/costumes.json
@@ -7717,7 +7717,7 @@
             109,
             32
         ],
-        "md5": "4cddf35440090d30d4de188625d609c9.svg",
+        "md5": "0a54c19714962c197532f68c56fde123.svg",
         "type": "costume",
         "name": "text awesome",
         "tags": [
@@ -7731,7 +7731,7 @@
             135,
             24
         ],
-        "md5": "6f54c5de1985d7f23a24341e1688c4b0.svg",
+        "md5": "97c9e6ef78d4f1987fd8c6e5042963cb.svg",
         "type": "costume",
         "name": "text keep scratching",
         "tags": [
@@ -7745,7 +7745,7 @@
             172,
             30
         ],
-        "md5": "e634628e4b948573710f7e4a4f50659b.svg",
+        "md5": "9fde2e190a9d01c08737dff291bdd4a8.svg",
         "type": "costume",
         "name": "text valentine's day",
         "tags": [
@@ -7759,7 +7759,7 @@
             165,
             34
         ],
-        "md5": "7ebe62c6f2b7c8b644d2abe036b8f69e.svg",
+        "md5": "0176a402a133f7b833da61b890e0d73e.svg",
         "type": "costume",
         "name": "text Halloween",
         "tags": [
@@ -10629,4 +10629,4 @@
             "vector"
         ]
     }
-]
\ No newline at end of file
+]
diff --git a/test/helpers/selenium-helpers.js b/test/helpers/selenium-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..5ee0af01e65a379c5dee5f92f26536e3aa98dab4
--- /dev/null
+++ b/test/helpers/selenium-helpers.js
@@ -0,0 +1,58 @@
+/* eslint-env jest */
+jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; // eslint-disable-line no-undef
+
+import webdriver from 'selenium-webdriver';
+
+const {By, until} = webdriver;
+
+const driver = new webdriver.Builder()
+    .forBrowser('chrome')
+    .build();
+
+const findByXpath = (xpath) => {
+    return driver.wait(until.elementLocated(By.xpath(xpath), 5 * 1000));
+};
+
+const clickXpath = (xpath) => {
+    return findByXpath(xpath).then(el => el.click());
+};
+
+const clickText = (text) => {
+    return clickXpath(`//*[contains(text(), '${text}')]`);
+};
+
+const clickButton = (text) => {
+    return clickXpath(`//button[contains(text(), '${text}')]`);
+};
+
+const getLogs = (whitelist) => {
+    return 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;
+                }
+            });
+        });
+};
+
+export {
+    clickText,
+    clickButton,
+    clickXpath,
+    driver,
+    findByXpath,
+    getLogs
+};
diff --git a/test/integration/test.js b/test/integration/test.js
new file mode 100644
index 0000000000000000000000000000000000000000..7e3e60a651a6e7d64f06ad56c561058449432f20
--- /dev/null
+++ b/test/integration/test.js
@@ -0,0 +1,89 @@
+/* eslint-env jest */
+/* globals Promise */
+
+import path from 'path';
+import {
+    clickText,
+    clickButton,
+    clickXpath,
+    driver,
+    findByXpath,
+    getLogs
+} from '../helpers/selenium-helpers';
+
+const uri = path.resolve(__dirname, '../../build/index.html');
+
+const errorWhitelist = [
+    'The play() request was interrupted by a call to pause()'
+];
+
+describe('costumes, sounds and variables', () => {
+    afterAll(async () => {
+        await driver.quit();
+    });
+
+    test('Adding a costume', async () => {
+        await driver.get('file://' + uri);
+        await clickText('Costumes');
+        await clickText('Add Costume');
+        const el = await findByXpath("//input[@placeholder='what are you looking for?']");
+        await el.sendKeys('abb');
+        await clickText('abby-a'); // Should close the modal, then click the costumes in the selector
+        await clickText('costume1');
+        await clickText('abby-a');
+        const logs = await getLogs(errorWhitelist);
+        await expect(logs).toEqual([]);
+    });
+
+    test('Adding a sound', async () => {
+        await driver.get('file://' + uri);
+        await clickText('Sounds');
+        await clickText('Add Sound');
+        const el = await findByXpath("//input[@placeholder='what are you looking for?']");
+        await el.sendKeys('chom');
+        await clickText('chomp'); // Should close the modal, then click the sounds in the selector
+        await clickText('meow');
+        await clickText('chomp');
+        await clickXpath('//button[@title="Play"]');
+        await clickText('meow');
+        await clickXpath('//button[@title="Play"]');
+
+        await clickText('Louder');
+        await clickText('Softer');
+        await clickText('Faster');
+        await clickText('Slower');
+        await clickText('Robot');
+        await clickText('Echo');
+        await clickText('Reverse');
+
+        const logs = await getLogs(errorWhitelist);
+        await expect(logs).toEqual([]);
+    });
+
+    test('Load a project by ID', async () => {
+        const projectId = '96708228';
+        await driver.get('file://' + uri + '#' + projectId);
+        await new Promise(resolve => setTimeout(resolve, 2000));
+        await clickXpath('//img[@title="Go"]');
+        await new Promise(resolve => setTimeout(resolve, 2000));
+        await clickXpath('//img[@title="Stop"]');
+        const logs = await getLogs(errorWhitelist);
+        await expect(logs).toEqual([]);
+    });
+
+    test('Creating variables', async () => {
+        await driver.get('file://' + uri);
+        await clickText('Blocks');
+        await clickText('Data');
+        await clickText('Create variable...');
+        let el = await findByXpath("//input[@placeholder='']");
+        await el.sendKeys('score');
+        await clickButton('OK');
+        await clickText('Create variable...');
+        el = await findByXpath("//input[@placeholder='']");
+        await el.sendKeys('second variable');
+        await clickButton('OK');
+        const logs = await getLogs(errorWhitelist);
+        await expect(logs).toEqual([]);
+    });
+});
diff --git a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap
index 076dd860b6b9a08d9b5536f93739a274947526a2..644d330d0e5a099f59cfe4c527d93b5aa5913535 100644
--- a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap
+++ b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap
@@ -58,6 +58,30 @@ exports[`Sound Editor Component matches snapshot 1`] = `
           Save
         </span>
       </button>
+      <div
+        className={undefined}
+      >
+        <button
+          className={undefined}
+          disabled={false}
+          onClick={[Function]}
+          title="Undo"
+        >
+          <img
+            src="test-file-stub"
+          />
+        </button>
+        <button
+          className={undefined}
+          disabled={true}
+          onClick={[Function]}
+          title="Redo"
+        >
+          <img
+            src="test-file-stub"
+          />
+        </button>
+      </div>
     </div>
   </div>
   <div
diff --git a/test/unit/components/sound-editor.test.jsx b/test/unit/components/sound-editor.test.jsx
index b3b744235e827ff35654dbe4150b6647e4888189..6c12f473060ed5b7167fe2b353563345b349c92d 100644
--- a/test/unit/components/sound-editor.test.jsx
+++ b/test/unit/components/sound-editor.test.jsx
@@ -7,6 +7,8 @@ describe('Sound Editor Component', () => {
     let props;
     beforeEach(() => {
         props = {
+            canUndo: true,
+            canRedo: false,
             chunkLevels: [1, 2, 3],
             name: 'sound name',
             playhead: 0.5,
@@ -15,6 +17,7 @@ describe('Sound Editor Component', () => {
             onActivateTrim: jest.fn(),
             onChangeName: jest.fn(),
             onPlay: jest.fn(),
+            onRedo: jest.fn(),
             onReverse: jest.fn(),
             onSofter: jest.fn(),
             onLouder: jest.fn(),
@@ -24,7 +27,8 @@ describe('Sound Editor Component', () => {
             onSlower: jest.fn(),
             onSetTrimEnd: jest.fn(),
             onSetTrimStart: jest.fn(),
-            onStop: jest.fn()
+            onStop: jest.fn(),
+            onUndo: jest.fn()
         };
     });
 
@@ -64,6 +68,7 @@ describe('Sound Editor Component', () => {
             .simulate('blur');
         expect(props.onChangeName).toHaveBeenCalled();
     });
+
     test('effect buttons call the correct callbacks', () => {
         const wrapper = mountWithIntl(<SoundEditor {...props} />);
 
@@ -88,4 +93,23 @@ describe('Sound Editor Component', () => {
         wrapper.find('[children="Softer"]').simulate('click');
         expect(props.onSofter).toHaveBeenCalled();
     });
+
+    test('undo and redo buttons can be disabled by canUndo/canRedo', () => {
+        let wrapper = mountWithIntl(<SoundEditor {...props} canUndo={true} canRedo={false} />);
+        expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(false);
+        expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(true);
+
+        wrapper = mountWithIntl(<SoundEditor {...props} canUndo={false} canRedo={true} />);
+        expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(true);
+        expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(false);
+    });
+
+    test.skip('undo/redo buttons call the correct callback', () => {
+        let wrapper = mountWithIntl(<SoundEditor {...props} canUndo={true} canRedo={true} />);
+        wrapper.find('button[title="Undo"]').simulate('click');
+        expect(props.onUndo).toHaveBeenCalled();
+
+        wrapper.find('button[title="Redo"]').simulate('click');
+        expect(props.onRedo).toHaveBeenCalled();
+    });
 });
diff --git a/test/unit/containers/sound-editor.test.jsx b/test/unit/containers/sound-editor.test.jsx
index b84a1233ff753d30baac63bb25fc4c543b178201..5a5618ea96cd88491b5a7de40cc0d321632ae1b8 100644
--- a/test/unit/containers/sound-editor.test.jsx
+++ b/test/unit/containers/sound-editor.test.jsx
@@ -160,4 +160,39 @@ describe('Sound Editor Container', () => {
         expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.ROBOT);
         expect(mockAudioEffects.instance.process).toHaveBeenCalled();
     });
+
+    test('undo/redo functionality', () => {
+        const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />);
+        const component = wrapper.find(SoundEditorComponent);
+        // Undo and redo should be disabled initially
+        expect(component.prop('canUndo')).toEqual(false);
+        expect(component.prop('canRedo')).toEqual(false);
+
+        // Submitting new samples should make it possible to undo
+        component.props().onActivateTrim(); // Activate trimming
+        component.props().onActivateTrim(); // Submit new samples by calling again
+        wrapper.update();
+        expect(component.prop('canUndo')).toEqual(true);
+        expect(component.prop('canRedo')).toEqual(false);
+
+        // Undoing should make it possible to redo and not possible to undo again
+        component.props().onUndo();
+        wrapper.update();
+        expect(component.prop('canUndo')).toEqual(false);
+        expect(component.prop('canRedo')).toEqual(true);
+
+        // Redoing should make it possible to undo and not possible to redo again
+        component.props().onRedo();
+        wrapper.update();
+        expect(component.prop('canUndo')).toEqual(true);
+        expect(component.prop('canRedo')).toEqual(false);
+
+        // New submission should clear the redo stack
+        component.props().onUndo(); // Undo to go back to a state where redo is enabled
+        wrapper.update();
+        expect(component.prop('canRedo')).toEqual(true);
+        component.props().onActivateTrim(); // Activate trimming
+        component.props().onActivateTrim(); // Submit new samples by calling again
+        expect(component.prop('canRedo')).toEqual(false);
+    });
 });