Bug 1408124 - Add component tests for Perf component draft
authorGreg Tatum <gtatum@mozilla.com>
Wed, 01 Nov 2017 14:34:17 -0500
changeset 695084 d54258c6b2a666848b414b1ddf5253ebb949830f
parent 695083 b8348691e824e597c60f57b2195b7e19154ff1bc
child 695085 57a2cecb27f3fc0274776dd5b2e546cc9a8df71d
push id88335
push usergtatum@mozilla.com
push dateWed, 08 Nov 2017 18:51:46 +0000
bugs1408124
milestone58.0a1
Bug 1408124 - Add component tests for Perf component MozReview-Commit-ID: J4tli5VgOUz
devtools/client/performance/new/components/Perf.js
devtools/client/performance/new/components/moz.build
devtools/client/performance/new/initializer.js
devtools/client/performance/new/moz.build
devtools/client/performance/new/test/.eslintrc.js
devtools/client/performance/new/test/browser.ini
devtools/client/performance/new/test/browser_perf-state-01.js
devtools/client/performance/new/test/browser_perf-state-02.js
devtools/client/performance/new/test/browser_perf-state-03.js
devtools/client/performance/new/test/doc_mount_react.html
devtools/client/performance/new/test/head.js
new file mode 100644
--- /dev/null
+++ b/devtools/client/performance/new/components/Perf.js
@@ -0,0 +1,233 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { createClass, PropTypes, DOM } = require("devtools/client/shared/vendor/react");
+const { div, button } = DOM;
+
+/**
+ * The recordingState is one of the following:
+ **/
+
+// The initial state before we've queried the PerfActor
+const NOT_YET_KNOWN = "not-yet-known";
+// The profiler is available, we haven't started recording yet.
+const AVAILABLE_TO_RECORD = "available-to-record";
+// An async request has been sent to start the profiler.
+const REQUEST_TO_START_RECORDING = "request-to-start-recording";
+// An async request has been sent to get the profile and stop the profiler.
+const REQUEST_TO_GET_PROFILE_AND_STOP_PROFILER =
+  "request-to-get-profile-and-stop-profiler";
+// An async request has been sent to stop the profiler.
+const REQUEST_TO_STOP_PROFILER = "request-to-stop-profiler";
+// The profiler notified us that our request to start it actually started it.
+const RECORDING = "recording";
+// Some other code with access to the profiler started it.
+const OTHER_IS_RECORDING = "other-is-recording";
+
+const Perf = createClass({
+  displayName: "Perf",
+
+  propTypes: {
+    perfFront: PropTypes.object.isRequired,
+    receiveProfile: PropTypes.func.isRequired
+  },
+
+  getInitialState() {
+    return {
+      recordingState: NOT_YET_KNOWN,
+      recordingUnexpectedlyStopped: false,
+    };
+  },
+
+  getRecordingStateForTesting() {
+    return this.state.recordingState;
+  },
+
+  componentDidMount() {
+    // We don't yet know what state the profile is in, find out.
+    this.props.perfFront.isActive().then(isActive => {
+      if (this.state.recordingState === NOT_YET_KNOWN) {
+        this.setState({
+          recordingState: isActive ? OTHER_IS_RECORDING : AVAILABLE_TO_RECORD
+        });
+      }
+    });
+
+    // Handle when the profiler changes state. It might be us, it might be someone else.
+    this.props.perfFront.on("profiler-started", this.handleProfilerStarting);
+    this.props.perfFront.on("profiler-stopped", this.handleProfilerStopping);
+  },
+
+  handleProfilerStarting() {
+    switch (this.state.recordingState) {
+      case NOT_YET_KNOWN:
+        // We couldn't have started it yet, so it must have been someone
+        // else. (fallthrough)
+      case AVAILABLE_TO_RECORD:
+        // We aren't recording, someone else started it up. (fallthrough)
+      case REQUEST_TO_STOP_PROFILER:
+        // We aren't recording, someone else started it up. (fallthrough)
+      case REQUEST_TO_GET_PROFILE_AND_STOP_PROFILER:
+        // Someone re-started the profiler while we were asking for the completed
+        // profile.
+
+        this.setState({
+          recordingState: OTHER_IS_RECORDING,
+          recordingUnexpectedlyStopped: false
+        });
+        break;
+
+      case REQUEST_TO_START_RECORDING:
+        // Wait for the profiler to tell us that it has started.
+        this.setState({
+          recordingState: RECORDING,
+          recordingUnexpectedlyStopped: false
+        });
+        break;
+
+      case OTHER_IS_RECORDING:
+      case RECORDING:
+        // These state cases don't make sense to happen, and means we have a logical
+        // fallacy somewhere.
+        throw new Error(
+          "The profiler started recording, when it shouldn't have " +
+          `been able to. Current state: "${this.state.recordingState}"`);
+      default:
+        throw new Error("Unhandled recording state");
+    }
+  },
+
+  handleProfilerStopping() {
+    switch (this.state.recordingState) {
+      case NOT_YET_KNOWN:
+      case REQUEST_TO_GET_PROFILE_AND_STOP_PROFILER:
+      case REQUEST_TO_STOP_PROFILER:
+      case OTHER_IS_RECORDING:
+        this.setState({
+          recordingState: AVAILABLE_TO_RECORD,
+          recordingUnexpectedlyStopped: false
+        });
+        break;
+
+      case REQUEST_TO_START_RECORDING:
+        // Highly unlikely, but someone stopped the recorder, this is fine. Do nothing.
+        break;
+
+      case RECORDING:
+        this.setState({
+          recordingState: AVAILABLE_TO_RECORD,
+          recordingUnexpectedlyStopped: true
+        });
+        break;
+
+      case AVAILABLE_TO_RECORD:
+        throw new Error(
+          "The profiler stopped recording, when it shouldn't have been able to.");
+      default:
+        throw new Error("Unhandled recording state");
+    }
+  },
+
+  startRecording() {
+    this.setState({
+      recordingState: REQUEST_TO_START_RECORDING,
+      // Reset this error state since it's no longer valid.
+      recordingUnexpectedlyStopped: false,
+    });
+    this.props.perfFront.startProfiler();
+  },
+
+  getProfileAndStopProfiler: async function () {
+    this.setState({ recordingState: REQUEST_TO_GET_PROFILE_AND_STOP_PROFILER });
+    const profile = await this.props.perfFront.getProfileAndStopProfiler();
+    this.setState({ recordingState: AVAILABLE_TO_RECORD });
+    console.log("getProfileAndStopProfiler");
+    this.props.receiveProfile(profile);
+  },
+
+  stopProfilerAndDiscardProfile: async function () {
+    this.setState({ recordingState: REQUEST_TO_STOP_PROFILER });
+    this.props.perfFront.stopProfilerAndDiscardProfile();
+  },
+
+  render() {
+    const { recordingState } = this.state;
+    // TODO - L10N all of the messages.
+    switch (recordingState) {
+      case NOT_YET_KNOWN:
+        return null;
+
+      case AVAILABLE_TO_RECORD:
+        return renderButton({
+          recordingState,
+          onClick: this.startRecording,
+          label: "Start recording",
+          additionalMessage: this.state.recordingUnexpectedlyStopped
+            ? div({}, "The recording was stopped by another tool.")
+            : null
+        });
+
+      case REQUEST_TO_STOP_PROFILER:
+        return renderButton({
+          recordingState,
+          label: "Stopping the recording",
+          disabled: true
+        });
+
+      case REQUEST_TO_GET_PROFILE_AND_STOP_PROFILER:
+        return renderButton({
+          recordingState,
+          label: "Stopping the recording, and capturing the profile",
+          disabled: true
+        });
+
+      case REQUEST_TO_START_RECORDING:
+      case RECORDING:
+        return renderButton({
+          recordingState,
+          label: "Stop and grab the recording",
+          onClick: this.getProfileAndStopProfiler,
+          disabled: this.state.recordingState === REQUEST_TO_START_RECORDING
+        });
+
+      case OTHER_IS_RECORDING:
+        return renderButton({
+          recordingState,
+          label: "Stop and discard the other recording",
+          onClick: this.stopProfilerAndDiscardProfile,
+          disabled: this.state.recordingState === REQUEST_TO_START_RECORDING,
+          additionalMessage: "Another tool is currently recording."
+        });
+
+      default:
+        throw new Error("Unhandled recording state");
+    }
+  }
+});
+
+module.exports = Perf;
+
+function renderButton(props) {
+  const { disabled, label, onClick, additionalMessage, recordingState } = props;
+  const nbsp = "\u00A0";
+
+  return div(
+    { className: "perf" },
+    div({ className: "perf-additional-message" }, additionalMessage || nbsp),
+    div(
+      {},
+      button(
+        {
+          className: "devtools-button perf-button",
+          "data-standalone": true,
+          "data-state": recordingState,
+          disabled,
+          onClick
+        },
+        label
+      )
+    )
+  );
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/performance/new/components/moz.build
@@ -0,0 +1,8 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+    'Perf.js',
+)
--- a/devtools/client/performance/new/initializer.js
+++ b/devtools/client/performance/new/initializer.js
@@ -1,254 +1,39 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
+
 /* exported gInit */
 
 const { utils: Cu } = Components;
 const BrowserLoaderModule = {};
 Cu.import("resource://devtools/client/shared/browser-loader.js", BrowserLoaderModule);
 const { require } = BrowserLoaderModule.BrowserLoader({
   baseURI: "resource://devtools/client/memory/",
   window
 });
-const { createClass, createElement, PropTypes, DOM } = require("devtools/client/shared/vendor/react");
+const Perf = require("devtools/client/performance/new/components/Perf");
 const { render } = require("devtools/client/shared/vendor/react-dom");
-const { div, button } = DOM;
-
-/**
- * The recordingState is one of the following:
- **/
-
-// The initial state before we've queried the PerfActor
-const NOT_YET_KNOWN = "not-yet-known";
-// The profiler is available, we haven't started recording yet.
-const AVAILABLE_TO_RECORD = "available-to-record";
-// An async request has been sent to start the profiler.
-const REQUEST_TO_START_RECORDING = "request-to-start-recording";
-// An async request has been sent to get the profile and stop the profiler.
-const REQUEST_TO_GET_PROFILE_AND_STOP_PROFILER =
-  "request-to-get-profile-and-stop-profiler";
-// An async request has been sent to stop the profiler.
-const REQUEST_TO_STOP_PROFILER = "request-to-stop-profiler";
-// The profiler notified us that our request to start it actually started it.
-const RECORDING = "recording";
-// Some other code with access to the profiler started it.
-const OTHER_IS_RECORDING = "other-is-recording";
-
-const Perf = createClass({
-  displayName: "Perf",
-
-  propTypes: {
-    perfFront: PropTypes.object.isRequired,
-  },
-
-  getInitialState() {
-    return {
-      recordingState: NOT_YET_KNOWN,
-      recordingUnexpectedlyStopped: false,
-    };
-  },
-
-  componentDidUpdate() {
-    console.log("!!! recordingState", this.state.recordingState);
-  },
-
-  componentDidMount() {
-    // We don't yet know what state the profile is in, find out.
-    this.props.perfFront.isActive().then(isActive => {
-      if (this.state.recordingState === NOT_YET_KNOWN) {
-        this.setState({
-          recordingState: isActive ? OTHER_IS_RECORDING : AVAILABLE_TO_RECORD
-        });
-      }
-    });
-
-    // Handle when the profiler changes state. It might be us, it might be someone else.
-    this.props.perfFront.on("profiler-started", this.handleProfilerStarting);
-
-    // Handle when the profiler changes state. It might be us, it might be someone else.
-    this.props.perfFront.on("profiler-stopped", this.handleProfilerStopping);
-  },
-
-  handleProfilerStarting() {
-    switch (this.state.recordingState) {
-      // Eslint doesn't allow comments between case statements:
-      //
-      // case NOT_YET_KNOWN:
-      //   We couldn't have started it yet, so it must have been someone else.
-      // case AVAILABLE_TO_RECORD
-      //   We aren't recording, someone else started it up.
-      // case REQUEST_TO_STOP_PROFILER
-      //   Someone re-started the profiler while we were asking for the completed
-      //   profile.
-      // case REQUEST_TO_GET_PROFILE_AND_STOP_PROFILER
-      //   Someone re-started the profiler while we were asking for the completed
-      //   profile.
-      case NOT_YET_KNOWN:
-      case AVAILABLE_TO_RECORD:
-      case REQUEST_TO_STOP_PROFILER:
-      case REQUEST_TO_GET_PROFILE_AND_STOP_PROFILER:
-        this.setState({
-          recordingState: OTHER_IS_RECORDING,
-          recordingUnexpectedlyStopped: false
-        });
-        break;
-
-      // Wait for the profiler to tell us that it has started.
-      case REQUEST_TO_START_RECORDING:
-        this.setState({
-          recordingState: RECORDING,
-          recordingUnexpectedlyStopped: false
-        });
-        break;
-
-      // These don't make sense to happen, and means we have a logical fallacy somewhere.
-      case OTHER_IS_RECORDING:
-      case RECORDING:
-        throw new Error(
-          "The profiler started recording, when it shouldn't have been able to.");
-      default:
-        throw new Error("Unhandled recording state");
-    }
-  },
-
-  handleProfilerStopping() {
-    switch (this.state.recordingState) {
-      case NOT_YET_KNOWN:
-      case REQUEST_TO_GET_PROFILE_AND_STOP_PROFILER:
-      case REQUEST_TO_STOP_PROFILER:
-      case OTHER_IS_RECORDING:
-        this.setState({
-          recordingState: AVAILABLE_TO_RECORD,
-          recordingUnexpectedlyStopped: false
-        });
-        break;
-
-      // Highly unlikely, but someone stopped the recorder, this is fine.
-      case REQUEST_TO_START_RECORDING:
-        // Do nothing.
-        break;
-
-      case RECORDING:
-        this.setState({
-          recordingState: AVAILABLE_TO_RECORD,
-          recordingUnexpectedlyStopped: true
-        });
-        break;
-
-      case AVAILABLE_TO_RECORD:
-        throw new Error(
-          "The profiler stopped recording, when it shouldn't have been able to.");
-      default:
-        throw new Error("Unhandled recording state");
-    }
-  },
-
-  startRecording() {
-    this.setState({
-      recordingState: REQUEST_TO_START_RECORDING,
-      // Reset this error state since it's no longer valid.
-      recordingUnexpectedlyStopped: false,
-    });
-    this.props.perfFront.startProfiler();
-  },
-
-  getProfileAndStopProfiler: async function () {
-    this.setState({ recordingState: REQUEST_TO_GET_PROFILE_AND_STOP_PROFILER });
-    const profile = await this.props.perfFront.getProfileAndStopProfiler();
-    this.setState({ recordingState: AVAILABLE_TO_RECORD });
-
-    // Open up a new tab and send a message with the profile.
-    const browser = top.gBrowser;
-    const tab = browser.addTab("https://perf-html.io/from-addon");
-    browser.selectedTab = tab;
-    const mm = tab.linkedBrowser.messageManager;
-    mm.loadFrameScript("chrome://devtools/content/performance/new/frame-script.js",
-                       false);
-    mm.sendAsyncMessage("devtools:perf-html-transfer-profile", profile);
-  },
-
-  stopProfilerAndDiscardProfile: async function () {
-    this.props.perfFront.stopProfilerAndDiscardProfile();
-  },
-
-  render() {
-    // TODO - L10N all of the messages.
-    switch (this.state.recordingState) {
-      case NOT_YET_KNOWN:
-        return null;
-
-      case AVAILABLE_TO_RECORD:
-        return renderButton({
-          onClick: this.startRecording,
-          label: "Start recording",
-          additionalMessage: this.state.recordingUnexpectedlyStopped
-            ? div({}, "The recording was stopped by another tool.")
-            : null
-        });
-
-      case REQUEST_TO_STOP_PROFILER:
-        return renderButton({
-          label: "Stopping the recording",
-          disabled: true
-        });
-
-      case REQUEST_TO_GET_PROFILE_AND_STOP_PROFILER:
-        return renderButton({
-          label: "Stopping the recording, and capturing the profile",
-          disabled: true
-        });
-
-      case REQUEST_TO_START_RECORDING:
-      case RECORDING:
-        return renderButton({
-          label: "Stop and grab the recording",
-          onClick: this.getProfileAndStopProfiler,
-          disabled: this.state.recordingState === REQUEST_TO_START_RECORDING
-        });
-
-      case OTHER_IS_RECORDING:
-        return renderButton({
-          label: "Stop and discard the other recording",
-          onClick: this.stopProfilerAndDiscardProfile,
-          disabled: this.state.recordingState === REQUEST_TO_START_RECORDING,
-          additionalMessage: "Another tool is currently recording."
-        });
-
-      default:
-        throw new Error("Unhandled recording state");
-    }
-  }
-});
-
-function renderButton(props) {
-  const { disabled, label, onClick, additionalMessage } = props;
-  const nbsp = "\u00A0";
-
-  return div(
-    { className: "perf" },
-    div({ className: "perf-additional-message" }, additionalMessage || nbsp),
-    div(
-      {},
-      button(
-        {
-          className: "devtools-button perf-button",
-          "data-standalone": true,
-          disabled,
-          onClick
-        },
-        label
-      )
-    )
-  );
-}
+const { createElement } = require("devtools/client/shared/vendor/react");
 
 /**
  * Perform a simple initialization on the panel. Hook up event listeners.
  *
  * @param perfFront - The Perf actor's front. Used to start and stop recordings.
  */
 function gInit(perfFront) {
-  const props = { perfFront };
+  const props = {
+    perfFront,
+    receiveProfile: profile => {
+      // Open up a new tab and send a message with the profile.
+      const browser = top.gBrowser;
+      const tab = browser.addTab("https://perf-html.io/from-addon");
+      browser.selectedTab = tab;
+      const mm = tab.linkedBrowser.messageManager;
+      mm.loadFrameScript("chrome://devtools/content/performance/new/frame-script.js",
+                         false);
+      mm.sendAsyncMessage("devtools:perf-html-transfer-profile", profile);
+    }
+  };
   render(createElement(Perf, props), document.querySelector("#root"));
 }
--- a/devtools/client/performance/new/moz.build
+++ b/devtools/client/performance/new/moz.build
@@ -1,9 +1,18 @@
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
+DIRS += [
+    'components',
+]
+
 DevToolsModules(
     'initializer.js',
     'panel.js',
 )
+
+BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
+
+with Files('**'):
+    BUG_COMPONENT = ('Firefox', 'Developer Tools: Performance Tools (Profiler/Timeline)')
new file mode 100644
--- /dev/null
+++ b/devtools/client/performance/new/test/.eslintrc.js
@@ -0,0 +1,6 @@
+"use strict";
+
+module.exports = {
+  // Extend from the shared list of defined globals for mochitests.
+  "extends": "../../../../.eslintrc.mochitests.js"
+};
new file mode 100644
--- /dev/null
+++ b/devtools/client/performance/new/test/browser.ini
@@ -0,0 +1,10 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+  head.js
+  doc_mount_react.html
+
+[browser_perf-state-01.js]
+[browser_perf-state-02.js]
+[browser_perf-state-03.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/performance/new/test/browser_perf-state-01.js
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * Tests the normal workflow of starting and stopping the profiler through the Perf
+ * component.
+ */
+addPerfTest(async ({ document, browserRequire, mountElement, perfFront }) => {
+  const Perf = browserRequire("devtools/client/performance/new/components/Perf");
+  const React = browserRequire("devtools/client/shared/vendor/react");
+  const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom");
+
+  // Inject a function which will allow us to receive the profile.
+  let profile;
+  function receiveProfile(profileIn) {
+    profile = profileIn;
+  }
+
+  const element = React.createElement(Perf, { perfFront, receiveProfile });
+  const perfComponent = ReactDOM.render(element, mountElement);
+
+  is(perfComponent.state.recordingState, "not-yet-known",
+    "The component at first is in an unknown state.");
+
+  await perfFront.flushAsyncQueue();
+  is(perfComponent.state.recordingState, "available-to-record",
+    "After talking to the actor, we're ready to record.");
+
+  document.querySelector("button").click();
+  is(perfComponent.state.recordingState, "request-to-start-recording",
+    "Sent in a request to start recording.");
+
+  await perfFront.flushAsyncQueue();
+  is(perfComponent.state.recordingState, "recording",
+    "The actor has started its recording");
+
+  document.querySelector("button").click();
+  is(perfComponent.state.recordingState, "request-to-get-profile-and-stop-profiler",
+    "We have requested to stop the profiler.");
+
+  await perfFront.flushAsyncQueue();
+  is(perfComponent.state.recordingState, "available-to-record",
+    "The profiler is available to record again.");
+
+  await perfFront.flushAsyncQueue();
+  is(typeof profile, "object", "Got a profile");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/performance/new/test/browser_perf-state-02.js
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * Tests the perf component for when the profiler is already started.
+ */
+addPerfTest(async ({ document, browserRequire, mountElement, perfFront }) => {
+  const Perf = browserRequire("devtools/client/performance/new/components/Perf");
+  const React = browserRequire("devtools/client/shared/vendor/react");
+  const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom");
+
+  const element = React.createElement(Perf, { perfFront });
+
+  ok(true, "Start the profiler before initiliazing the component, to simulate" +
+           "the profiler being controlled by another tool.");
+
+  perfFront.startProfiler();
+  await perfFront.flushAsyncQueue();
+
+  const perfComponent = ReactDOM.render(element, mountElement);
+  is(perfComponent.state.recordingState, "not-yet-known",
+    "The component at first is in an unknown state.");
+
+  await perfFront.flushAsyncQueue();
+  is(perfComponent.state.recordingState, "other-is-recording",
+    "The profiler is not available to record.");
+
+  document.querySelector("button").click();
+  is(perfComponent.state.recordingState, "request-to-stop-profiler",
+    "We can request to stop the profiler.");
+
+  await perfFront.flushAsyncQueue();
+  is(perfComponent.state.recordingState, "available-to-record",
+    "The profiler is now available to record.");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/performance/new/test/browser_perf-state-03.js
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * Tests the workflow of what happens when a third party tool interrupts a recording.
+ */
+addPerfTest(async ({ document, browserRequire, mountElement, perfFront }) => {
+  const Perf = browserRequire("devtools/client/performance/new/components/Perf");
+  const React = browserRequire("devtools/client/shared/vendor/react");
+  const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom");
+
+  const element = React.createElement(Perf, { perfFront });
+
+  const perfComponent = ReactDOM.render(element, mountElement);
+  is(perfComponent.state.recordingState, "not-yet-known",
+    "The component at first is in an unknown state.");
+
+  await perfFront.flushAsyncQueue();
+  is(perfComponent.state.recordingState, "available-to-record",
+    "After talking to the actor, we're ready to record.");
+
+  document.querySelector("button").click();
+  is(perfComponent.state.recordingState, "request-to-start-recording",
+    "Sent in a request to start recording.");
+
+  await perfFront.flushAsyncQueue();
+  is(perfComponent.state.recordingState, "recording",
+    "The actor has started its recording");
+
+  ok(true, "Simulate a third party stopping the profiler.");
+  perfFront.stopProfilerAndDiscardProfile();
+  await perfFront.flushAsyncQueue();
+
+  ok(perfComponent.state.recordingUnexpectedlyStopped,
+    "The profiler unexpectedly stopped.");
+  is(perfComponent.state.recordingState, "available-to-record",
+    "However, the profiler is available to record again.");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/performance/new/test/doc_mount_react.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>Perf Test</title>
+  </head>
+  <body>
+    <div id="root"></div>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/devtools/client/performance/new/test/head.js
@@ -0,0 +1,140 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/* exported addPerfTest */
+/* globals URL_ROOT */
+
+// Load the shared test helpers into this compartment.
+Services.scriptloader.loadSubScript(
+  "chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js",
+  this);
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const { Hosts } = require("devtools/client/framework/toolbox-hosts");
+const { DOMHelpers } = Cu.import("resource://devtools/client/shared/DOMHelpers.jsm", {});
+const { BrowserLoader } = Cu.import("resource://devtools/client/shared/browser-loader.js", {});
+
+/**
+ * The perf tests require some common setup. This does that setup, and injects a few
+ * common requirements. It is a thin wrapper around add_task().
+ */
+function addPerfTest(asyncTestFn) {
+  add_task(async () => {
+    waitForExplicitFinish();
+
+    const [host, window, document] = await createHost(URL_ROOT + "doc_mount_react.html");
+    const mountElement = document.querySelector("#root");
+    const perfFront = new MockPerfFront();
+    const { require: browserRequire } = BrowserLoader({
+      baseURI: "res`ource://devtools/client/performance/new/test/",
+      window
+    });
+
+    // Run the test.
+    try {
+      await asyncTestFn({
+        browserRequire,
+        document,
+        mountElement,
+        perfFront
+      });
+    } catch (error) {
+      console.error(error);
+      ok(false, "The test threw an error.");
+    }
+
+    // Clean up.
+    host.destroy();
+    finish();
+  });
+}
+
+/**
+ * The Gecko Profiler is a rather heavy-handed component that uses a lot of resources.
+ * In order to get around that, and have quick component tests we provide a mock of
+ * the performance front. It also has a method called flushAsyncQueue() that will
+ * flush any queued async calls to deterministically run our tests.
+ */
+class MockPerfFront extends EventEmitter {
+  constructor() {
+    super();
+    this._isActive = false;
+    this._asyncQueue = [];
+
+    // Wrap all async methods in a flushable queue, so that tests can control
+    // when the responses come back.
+    this.isActive = this._wrapInAsyncQueue(this.isActive);
+    this.startProfiler = this._wrapInAsyncQueue(this.startProfiler);
+    this.stopProfilerAndDiscardProfile = this._wrapInAsyncQueue(
+      this.stopProfilerAndDiscardProfile);
+    this.getProfileAndStopProfiler = this._wrapInAsyncQueue(
+      this.getProfileAndStopProfiler);
+  }
+
+  /**
+   * Provide a flushable queue mechanism for all async work. The work piles up
+   * and then is evaluated at once when _flushPendingQueue is called.
+   */
+  _wrapInAsyncQueue(fn) {
+    if (typeof fn !== "function") {
+      throw new Error("_wrapInAsyncQueue requires a function");
+    }
+    return (...args) => {
+      return new Promise(resolve => {
+        this._asyncQueue.push(() => {
+          resolve(fn.apply(this, args));
+        });
+      });
+    };
+  }
+
+  flushAsyncQueue() {
+    const pending = this._asyncQueue;
+    this._asyncQueue = [];
+    pending.forEach(fn => fn());
+    return Promise.resolve();
+  }
+
+  isActive() {
+    return this._isActive;
+  }
+
+  startProfiler() {
+    this._isActive = true;
+    // Defer this so it doesn't happen immediately.
+    this.emit("profiler-started");
+  }
+
+  getProfileAndStopProfiler() {
+    this._isActive = false;
+    // Defer this so it doesn't happen immediately.
+    this.emit("profiler-stopped");
+    // Return a fake profile.
+    return {};
+  }
+
+  stopProfilerAndDiscardProfile() {
+    this._isActive = false;
+    // Defer this so it doesn't happen immediately.
+    this.emit("profiler-stopped");
+  }
+}
+
+/**
+ * Create a DevTools host on the bottom of the window with the given URL. This quickly
+ * gives us a document that the test can directly manipulate.
+ */
+async function createHost(url) {
+  const host = new Hosts.bottom(gBrowser.selectedTab); // eslint-disable-line new-cap
+  const iframe = await host.create();
+
+  await new Promise(resolve => {
+    const domHelper = new DOMHelpers(iframe.contentWindow);
+    iframe.setAttribute("src", url);
+    domHelper.onceDOMReady(resolve);
+  });
+
+  return [host, iframe.contentWindow, iframe.contentDocument];
+}