Bug 1549397 - add progress indicator to accessibility panel audit. r=yulia,flod
authorYura Zenevich <yura.zenevich@gmail.com>
Mon, 13 May 2019 14:01:16 +0000
changeset 473601 36629188da45f0fd85746523f7986621839c6f33
parent 473600 b87cdb4550833374f77f1bc5163e554552fb35f9
child 473602 c4671c8a9f62e835109f6911d2fbd243af7dfbb7
push id36007
push userapavel@mozilla.com
push dateMon, 13 May 2019 21:45:52 +0000
treeherdermozilla-central@3c7f3988e704 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersyulia, flod
bugs1549397
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1549397 - add progress indicator to accessibility panel audit. r=yulia,flod Differential Revision: https://phabricator.services.mozilla.com/D30454
devtools/client/accessibility/accessibility.css
devtools/client/accessibility/actions/audit.js
devtools/client/accessibility/components/AuditProgressOverlay.js
devtools/client/accessibility/components/MainFrame.js
devtools/client/accessibility/components/moz.build
devtools/client/accessibility/constants.js
devtools/client/accessibility/reducers/audit.js
devtools/client/accessibility/test/jest/components/__snapshots__/audit-progress-overlay.test.js.snap
devtools/client/accessibility/test/jest/components/audit-progress-overlay.test.js
devtools/client/accessibility/test/jest/fixtures/plural-form.js
devtools/client/accessibility/test/jest/jest.config.js
devtools/client/locales/en-US/accessibility.properties
devtools/server/actors/accessibility/walker.js
devtools/server/tests/browser/browser_accessibility_walker_audit.js
devtools/shared/specs/accessibility.js
--- a/devtools/client/accessibility/accessibility.css
+++ b/devtools/client/accessibility/accessibility.css
@@ -171,16 +171,35 @@ body {
   display: flex;
   flex-wrap: nowrap;
   flex-direction: row;
   align-items: center;
   white-space: nowrap;
   margin-inline-end: 5px;
 }
 
+#audit-progress-container {
+  position: fixed;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+  z-index: 9999;
+  background: rgba(255,255,255,0.9);
+  padding-block-start: 30vh;
+  font: message-box;
+  font-size: 12px;
+  font-style: italic;
+}
+
+.audit-progress-progressbar {
+  width: 30vw;
+}
+
 /* Description */
 .description {
   color: var(--theme-toolbar-color);
   font: message-box;
   font-size: calc(var(--accessibility-font-size) + 1px);
   margin: auto;
   padding-top: 15vh;
   width: 50vw;
--- a/devtools/client/accessibility/actions/audit.js
+++ b/devtools/client/accessibility/actions/audit.js
@@ -1,23 +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";
 
-const { AUDIT, AUDITING, FILTER_TOGGLE } = require("../constants");
+const { AUDIT, AUDIT_PROGRESS, AUDITING, FILTER_TOGGLE } = require("../constants");
 
 exports.filterToggle = filter =>
   dispatch => dispatch({ filter, type: FILTER_TOGGLE });
 
 exports.auditing = filter =>
   dispatch => dispatch({ auditing: filter, type: AUDITING });
 
 exports.audit = (walker, filter) =>
-  dispatch => {
-    const onAuditEvent = walker.once("audit-event");
+  dispatch => new Promise(resolve => {
+    const auditEventHandler = ({ type, ancestries, progress }) => {
+      switch (type) {
+        case "error":
+          walker.off("audit-event", auditEventHandler);
+          dispatch({ type: AUDIT, error: true });
+          resolve();
+          break;
+        case "completed":
+          walker.off("audit-event", auditEventHandler);
+          dispatch({ type: AUDIT, response: ancestries });
+          resolve();
+          break;
+        case "progress":
+          dispatch({ type: AUDIT_PROGRESS, progress });
+          break;
+        default:
+          break;
+      }
+    };
+
+    walker.on("audit-event", auditEventHandler);
     walker.startAudit();
-    return onAuditEvent
-      .then(({ ancestries: response, error }) =>
-        dispatch({ type: AUDIT, error, response }))
-      .catch(error => dispatch({ type: AUDIT, error }));
-  };
+  });
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/components/AuditProgressOverlay.js
@@ -0,0 +1,79 @@
+/* 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 React = require("devtools/client/shared/vendor/react");
+const ReactDOM = require("devtools/client/shared/vendor/react-dom-factories");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const { connect } = require("devtools/client/shared/vendor/react-redux");
+const { PluralForm } = require("devtools/shared/plural-form");
+
+const { L10N } = require("../utils/l10n");
+
+/**
+ * Helper functional component to render an accessible text progressbar.
+ * @param {Object} props
+ *        - id for the progressbar element
+ *        - valuetext for the progressbar element
+ */
+function TextProgressBar({ id, textStringKey }) {
+  const text = L10N.getStr(textStringKey);
+  return ReactDOM.span({
+    id,
+    key: id,
+    role: "progressbar",
+    "aria-valuetext": text,
+  },
+    text);
+}
+
+class AuditProgressOverlay extends React.Component {
+  static get propTypes() {
+    return {
+      auditing: PropTypes.array,
+      total: PropTypes.number,
+      percentage: PropTypes.number,
+    };
+  }
+
+  render() {
+    const { auditing, percentage, total } = this.props;
+    if (!auditing) {
+      return null;
+    }
+
+    const id = "audit-progress-container";
+
+    if (total == null) {
+      return TextProgressBar({id, textStringKey: "accessibility.progress.initializing"});
+    }
+
+    if (percentage === 100) {
+      return TextProgressBar({id, textStringKey: "accessibility.progress.finishing"});
+    }
+
+    const progressbarString = PluralForm.get(total,
+      L10N.getStr("accessibility.progress.progressbar"));
+
+    return ReactDOM.span({
+      id,
+      key: id,
+    },
+      progressbarString.replace("#1", total),
+      ReactDOM.progress({
+        max: 100,
+        value: percentage,
+        className: "audit-progress-progressbar",
+        "aria-labelledby": id,
+      }));
+  }
+}
+
+const mapStateToProps = ({ audit: { auditing, progress }}) => {
+  const { total, percentage } = progress || {};
+  return { auditing, total, percentage };
+};
+
+module.exports = connect(mapStateToProps)(AuditProgressOverlay);
--- a/devtools/client/accessibility/components/MainFrame.js
+++ b/devtools/client/accessibility/components/MainFrame.js
@@ -2,42 +2,44 @@
  * 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";
 
 /* global gToolbox */
 
 // React & Redux
 const { Component, createFactory } = require("devtools/client/shared/vendor/react");
-const { div } = require("devtools/client/shared/vendor/react-dom-factories");
+const { span, div } = require("devtools/client/shared/vendor/react-dom-factories");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 const { connect } = require("devtools/client/shared/vendor/react-redux");
 const { reset } = require("../actions/ui");
 
 // Constants
 const { SIDEBAR_WIDTH, PORTRAIT_MODE_WIDTH } = require("../constants");
 
 // Accessibility Panel
 const AccessibilityTree = createFactory(require("./AccessibilityTree"));
+const AuditProgressOverlay = createFactory(require("./AuditProgressOverlay"));
 const Description = createFactory(require("./Description").Description);
 const RightSidebar = createFactory(require("./RightSidebar"));
 const Toolbar = createFactory(require("./Toolbar"));
 const SplitBox = createFactory(require("devtools/client/shared/components/splitter/SplitBox"));
 
 /**
  * Renders basic layout of the Accessibility panel. The Accessibility panel
  * content consists of two main parts: tree and sidebar.
  */
 class MainFrame extends Component {
   static get propTypes() {
     return {
       accessibility: PropTypes.object.isRequired,
       walker: PropTypes.object.isRequired,
       enabled: PropTypes.bool.isRequired,
       dispatch: PropTypes.func.isRequired,
+      auditing: PropTypes.string,
     };
   }
 
   constructor(props) {
     super(props);
 
     this.resetAccessibility = this.resetAccessibility.bind(this);
     this.onPanelWindowResize = this.onPanelWindowResize.bind(this);
@@ -84,41 +86,48 @@ class MainFrame extends Component {
       this.refs.splitBox.setState({ vert: this.useLandscapeMode });
     }
   }
 
   /**
    * Render Accessibility panel content
    */
   render() {
-    const { accessibility, walker, enabled } = this.props;
+    const { accessibility, walker, enabled, auditing } = this.props;
 
     if (!enabled) {
       return Description({ accessibility });
     }
 
     return (
       div({ className: "mainFrame", role: "presentation" },
         Toolbar({ accessibility, walker }),
-        SplitBox({
-          ref: "splitBox",
-          initialSize: SIDEBAR_WIDTH,
-          minSize: "10px",
-          maxSize: "80%",
-          splitterSize: 1,
-          endPanelControl: true,
-          startPanel: div({
-            className: "main-panel",
-            role: "presentation",
-          }, AccessibilityTree({ walker })),
-          endPanel: RightSidebar({ walker }),
-          vert: this.useLandscapeMode,
-        })
+        auditing && AuditProgressOverlay(),
+        span({
+          "aria-hidden": !!auditing,
+          role: "presentation",
+          style: { display: "contents" },
+        },
+          SplitBox({
+            ref: "splitBox",
+            initialSize: SIDEBAR_WIDTH,
+            minSize: "10px",
+            maxSize: "80%",
+            splitterSize: 1,
+            endPanelControl: true,
+            startPanel: div({
+              className: "main-panel",
+              role: "presentation",
+            }, AccessibilityTree({ walker })),
+            endPanel: RightSidebar({ walker }),
+            vert: this.useLandscapeMode,
+          })),
       ));
   }
 }
 
-const mapStateToProps = ({ ui }) => ({
+const mapStateToProps = ({ ui, audit: { auditing } }) => ({
   enabled: ui.enabled,
+  auditing,
 });
 
 // Exports from this module
 module.exports = connect(mapStateToProps)(MainFrame);
--- a/devtools/client/accessibility/components/moz.build
+++ b/devtools/client/accessibility/components/moz.build
@@ -5,16 +5,17 @@
 DevToolsModules(
     'AccessibilityRow.js',
     'AccessibilityRowValue.js',
     'AccessibilityTree.js',
     'AccessibilityTreeFilter.js',
     'Accessible.js',
     'AuditController.js',
     'AuditFilter.js',
+    'AuditProgressOverlay.js',
     'Badge.js',
     'Badges.js',
     'Button.js',
     'Checks.js',
     'ColorContrastAccessibility.js',
     'ContrastBadge.js',
     'Description.js',
     'LearnMoreLink.js',
--- a/devtools/client/accessibility/constants.js
+++ b/devtools/client/accessibility/constants.js
@@ -29,16 +29,17 @@ exports.HIGHLIGHT = "HIGHLIGHT";
 exports.UNHIGHLIGHT = "UNHIGHLIGHT";
 exports.ENABLE = "ENABLE";
 exports.DISABLE = "DISABLE";
 exports.UPDATE_CAN_BE_DISABLED = "UPDATE_CAN_BE_DISABLED";
 exports.UPDATE_CAN_BE_ENABLED = "UPDATE_CAN_BE_ENABLED";
 exports.FILTER_TOGGLE = "FILTER_TOGGLE";
 exports.AUDIT = "AUDIT";
 exports.AUDITING = "AUDITING";
+exports.AUDIT_PROGRESS = "AUDIT_PROGRESS";
 
 // List of filters for accessibility checks.
 exports.FILTERS = {
   [AUDIT_TYPE.CONTRAST]: "CONTRAST",
 };
 
 // Ordered accessible properties to be displayed by the accessible component.
 exports.ORDERED_PROPS = [
--- a/devtools/client/accessibility/reducers/audit.js
+++ b/devtools/client/accessibility/reducers/audit.js
@@ -1,31 +1,33 @@
 /* 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 {
   AUDIT,
   AUDITING,
+  AUDIT_PROGRESS,
   FILTER_TOGGLE,
   FILTERS,
   RESET,
   SELECT,
 } = require("../constants");
 
 /**
  * Initial state definition
  */
 function getInitialState() {
   return {
     filters: {
       [FILTERS.CONTRAST]: false,
     },
     auditing: null,
+    progress: null,
   };
 }
 
 function audit(state = getInitialState(), action) {
   switch (action.type) {
     case FILTER_TOGGLE:
       const { filter } = action;
       let { filters } = state;
@@ -45,16 +47,24 @@ function audit(state = getInitialState()
       return {
         ...state,
         auditing,
       };
     case AUDIT:
       return {
         ...state,
         auditing: null,
+        progress: null,
+      };
+    case AUDIT_PROGRESS:
+      const { progress } = action;
+
+      return {
+        ...state,
+        progress,
       };
     case SELECT:
     case RESET:
       return getInitialState();
     default:
       return state;
   }
 }
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/test/jest/components/__snapshots__/audit-progress-overlay.test.js.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AuditProgressOverlay component: render auditing finishing up 1`] = `"<span id=\\"audit-progress-container\\" role=\\"progressbar\\" aria-valuetext=\\"accessibility.progress.finishing\\">accessibility.progress.finishing</span>"`;
+
+exports[`AuditProgressOverlay component: render auditing initializing 1`] = `"<span id=\\"audit-progress-container\\" role=\\"progressbar\\" aria-valuetext=\\"accessibility.progress.initializing\\">accessibility.progress.initializing</span>"`;
+
+exports[`AuditProgressOverlay component: render auditing progress 1`] = `"<span id=\\"audit-progress-container\\">accessibility.progress.progressbar<progress max=\\"100\\" value=\\"0\\" class=\\"audit-progress-progressbar\\" aria-labelledby=\\"audit-progress-container\\"></progress></span>"`;
+
+exports[`AuditProgressOverlay component: render auditing progress 2`] = `"<span id=\\"audit-progress-container\\">accessibility.progress.progressbar<progress max=\\"100\\" value=\\"50\\" class=\\"audit-progress-progressbar\\" aria-labelledby=\\"audit-progress-container\\"></progress></span>"`;
+
+exports[`AuditProgressOverlay component: render auditing progress 3`] = `"<span id=\\"audit-progress-container\\">accessibility.progress.progressbar<progress max=\\"100\\" value=\\"75\\" class=\\"audit-progress-progressbar\\" aria-labelledby=\\"audit-progress-container\\"></progress></span>"`;
+
+exports[`AuditProgressOverlay component: render not auditing 1`] = `null`;
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/test/jest/components/audit-progress-overlay.test.js
@@ -0,0 +1,116 @@
+/* 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 { mount } = require("enzyme");
+
+const { createFactory } = require("devtools/client/shared/vendor/react");
+const Provider = createFactory(require("devtools/client/shared/vendor/react-redux").Provider);
+const { setupStore } = require("devtools/client/accessibility/test/jest/helpers");
+
+const { accessibility: { AUDIT_TYPE } } = require("devtools/shared/constants");
+const { AUDIT_PROGRESS } = require("devtools/client/accessibility/constants");
+
+const ConnectedAuditProgressOverlayClass =
+  require("devtools/client/accessibility/components/AuditProgressOverlay");
+const AuditProgressOverlayClass = ConnectedAuditProgressOverlayClass.WrappedComponent;
+const AuditProgressOverlay = createFactory(ConnectedAuditProgressOverlayClass);
+
+function testTextProgressBar(store, expectedText) {
+  const wrapper = mount(Provider({ store }, AuditProgressOverlay()));
+  expect(wrapper.html()).toMatchSnapshot();
+
+  const overlay = wrapper.find(AuditProgressOverlayClass);
+  expect(overlay.children().length).toBe(1);
+
+  const overlayText = overlay.childAt(0);
+  expect(overlayText.type()).toBe("span");
+  expect(overlayText.prop("id")).toBe("audit-progress-container");
+  expect(overlayText.prop("role")).toBe("progressbar");
+  expect(overlayText.prop("aria-valuetext")).toBe(expectedText);
+  expect(overlayText.text()).toBe(expectedText);
+}
+
+function testProgress(wrapper, percentage) {
+  const progress = wrapper.find("progress");
+  expect(progress.prop("max")).toBe(100);
+  expect(progress.prop("value")).toBe(percentage);
+  expect(progress.hasClass("audit-progress-progressbar")).toBe(true);
+  expect(progress.prop("aria-labelledby")).toBe("audit-progress-container");
+}
+
+describe("AuditProgressOverlay component:", () => {
+  it("render not auditing", () => {
+    const store = setupStore();
+    const wrapper = mount(Provider({ store }, AuditProgressOverlay()));
+    expect(wrapper.html()).toMatchSnapshot();
+    expect(wrapper.isEmptyRender()).toBe(true);
+  });
+
+  it("render auditing initializing", () => {
+    const store = setupStore({
+      preloadedState: { audit: { auditing: AUDIT_TYPE.CONTRAST } },
+    });
+
+    testTextProgressBar(store, "accessibility.progress.initializing");
+  });
+
+  it("render auditing progress", () => {
+    const store = setupStore({
+      preloadedState: {
+        audit: {
+          auditing: AUDIT_TYPE.CONTRAST,
+          progress: { total: 5, percentage: 0 },
+        },
+      },
+    });
+
+    const wrapper = mount(Provider({ store }, AuditProgressOverlay()));
+    expect(wrapper.html()).toMatchSnapshot();
+
+    const overlay = wrapper.find(AuditProgressOverlayClass);
+    expect(overlay.children().length).toBe(1);
+
+    const overlayContainer = overlay.childAt(0);
+    expect(overlayContainer.type()).toBe("span");
+    expect(overlayContainer.prop("id")).toBe("audit-progress-container");
+    expect(overlayContainer.children().length).toBe(1);
+
+    expect(overlayContainer.text()).toBe("accessibility.progress.progressbar");
+    expect(overlayContainer.childAt(0).type()).toBe("progress");
+
+    testProgress(wrapper, 0);
+
+    store.dispatch({
+      type: AUDIT_PROGRESS,
+      progress: { total: 5, percentage: 50 },
+    });
+    wrapper.update();
+
+    expect(wrapper.html()).toMatchSnapshot();
+    testProgress(wrapper, 50);
+
+    store.dispatch({
+      type: AUDIT_PROGRESS,
+      progress: { total: 5, percentage: 75 },
+    });
+    wrapper.update();
+
+    expect(wrapper.html()).toMatchSnapshot();
+    testProgress(wrapper, 75);
+  });
+
+  it("render auditing finishing up", () => {
+    const store = setupStore({
+      preloadedState: {
+        audit: {
+          auditing: AUDIT_TYPE.CONTRAST,
+          progress: { total: 5, percentage: 100 },
+        },
+      },
+    });
+
+    testTextProgressBar(store, "accessibility.progress.finishing");
+  });
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/accessibility/test/jest/fixtures/plural-form.js
@@ -0,0 +1,11 @@
+/* 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";
+
+module.exports.PluralForm = {
+  get(num, str) {
+    return str;
+  },
+};
--- a/devtools/client/accessibility/test/jest/jest.config.js
+++ b/devtools/client/accessibility/test/jest/jest.config.js
@@ -7,16 +7,17 @@
 /* global __dirname */
 
 module.exports = {
   verbose: true,
   moduleNameMapper: {
     // Custom name mappers for modules that require m-c specific API.
     "^../utils/l10n": `${__dirname}/fixtures/l10n`,
     "^devtools/client/shared/link": `${__dirname}/fixtures/stub`,
+    "^devtools/shared/plural-form": `${__dirname}/fixtures/plural-form`,
     "^devtools/client/shared/components/tree/TreeView": `${__dirname}/fixtures/stub`,
     "^Services": `${__dirname}/fixtures/Services`,
     // Map all require("devtools/...") to the real devtools root.
     "^devtools\\/(.*)": `${__dirname}/../../../../$1`,
   },
   setupFiles: [
     "<rootDir>setup.js",
   ],
--- a/devtools/client/locales/en-US/accessibility.properties
+++ b/devtools/client/locales/en-US/accessibility.properties
@@ -168,8 +168,22 @@ accessibility.badge.contrast=contrast
 # row in the accessibility tree for a given accessible object that does not
 # satisfy the WCAG guideline for colour contrast.
 accessibility.badge.contrast.tooltip=Does not meet WCAG standards for accessible text.
 
 # LOCALIZATION NOTE (accessibility.tree.filters): A title text for the toolbar
 # within the main accessibility panel that contains a list of filters to be for
 # accessibility audit.
 accessibility.tree.filters=Check for issues:
+
+# LOCALIZATION NOTE (accessibility.progress.initializing): A title text for the
+# accessibility panel overlay shown when accessibility audit is starting up.
+accessibility.progress.initializing=Initializing…
+
+# LOCALIZATION NOTE (accessibility.progress.initializing): A title text for the
+# accessibility panel overlay shown when accessibility audit is running showing
+# the number of nodes being audited. Semi-colon list of plural forms. See:
+# http://developer.mozilla.org/en/docs/Localization_and_Plurals
+accessibility.progress.progressbar=Checking #1 node;Checking #1 nodes
+
+# LOCALIZATION NOTE (accessibility.progress.finishing): A title text for the
+# accessibility panel overlay shown when accessibility audit is finishing up.
+accessibility.progress.finishing=Finishing up…
--- a/devtools/server/actors/accessibility/walker.js
+++ b/devtools/server/actors/accessibility/walker.js
@@ -125,27 +125,77 @@ function isStale(accessible) {
 
 /**
  * Get accessibility audit starting with the passed accessible object as a root.
  *
  * @param {Object} acc
  *        AccessibileActor to be used as the root for the audit.
  * @param {Map} report
  *        An accumulator map to be used to store audit information.
+ * @param {Object} progress
+ *        An audit project object that is used to track the progress of the
+ *        audit and send progress "audit-event" events to the client.
  */
-function getAudit(acc, report) {
+function getAudit(acc, report, progress) {
   if (acc.isDefunct) {
     return;
   }
 
   // Audit returns a promise, save the actual value in the report.
-  report.set(acc, acc.audit().then(result => report.set(acc, result)));
+  report.set(acc, acc.audit().then(result => {
+    report.set(acc, result);
+    progress.increment();
+  }));
 
   for (const child of acc.children()) {
-    getAudit(child, report);
+    getAudit(child, report, progress);
+  }
+}
+
+/**
+ * A helper class that is used to track audit progress and send progress events
+ * to the client.
+ */
+class AuditProgress {
+  constructor(walker) {
+    this.completed = 0;
+    this.percentage = 0;
+    this.walker = walker;
+  }
+
+  setTotal(size) {
+    this.size = size;
+  }
+
+  notify() {
+    this.walker.emit("audit-event", {
+      type: "progress",
+      progress: {
+        total: this.size,
+        percentage: this.percentage,
+      },
+    });
+  }
+
+  increment() {
+    this.completed++;
+    const { completed, size } = this;
+    if (!size) {
+      return;
+    }
+
+    const percentage = Math.round(completed / size * 100);
+    if (percentage > this.percentage) {
+      this.percentage = percentage;
+      this.notify();
+    }
+  }
+
+  destroy() {
+    this.walker = null;
   }
 }
 
 /**
  * The AccessibleWalkerActor stores a cache of AccessibleActors that represent
  * accessible objects in a given document.
  *
  * It is also responsible for implicitely initializing and shutting down
@@ -396,17 +446,19 @@ const AccessibleWalkerActor = ActorClass
    *
    * @return {Promise}
    *         A promise that resolves when the audit is complete and all relevant
    *         ancestries are calculated.
    */
   async audit() {
     const doc = await this.getDocument();
     const report = new Map();
-    getAudit(doc, report);
+    this._auditProgress = new AuditProgress(this);
+    getAudit(doc, report, this._auditProgress);
+    this._auditProgress.setTotal(report.size);
     await Promise.all(report.values());
 
     const ancestries = [];
     for (const [acc, audit] of report.entries()) {
       // Filter out audits that have no failing checks.
       if (audit &&
           Object.values(audit).some(check => check != null && !check.error &&
             check.score === accessibility.SCORES.FAIL)) {
@@ -426,20 +478,25 @@ const AccessibleWalkerActor = ActorClass
     // Audit is already running, wait for the "audit-event" event.
     if (this._auditing) {
       return;
     }
 
     this._auditing = this.audit()
       // We do not want to block on audit request, instead fire "audit-event"
       // event when internal audit is finished or failed.
-      .then(ancestries => this.emit("audit-event", { ancestries }))
-      .catch(() => this.emit("audit-event", { error: true }))
+      .then(ancestries => this.emit("audit-event", {
+        type: "completed",
+        ancestries,
+      }))
+      .catch(() => this.emit("audit-event", { type: "error" }))
       .finally(() => {
         this._auditing = null;
+        this._auditProgress.destroy();
+        this._auditProgress = null;
       });
   },
 
   onHighlighterEvent: function(data) {
     this.emit("highlighter-event", data);
   },
 
   /**
--- a/devtools/server/tests/browser/browser_accessibility_walker_audit.js
+++ b/devtools/server/tests/browser/browser_accessibility_walker_audit.js
@@ -54,30 +54,59 @@ add_task(async function() {
         "value": 4.00,
         "color": [255, 0, 0, 1],
         "backgroundColor": [255, 255, 255, 1],
         "isLargeText": false,
         "score": "fail",
       },
     },
   }];
+  const total = accessibles.length;
+  const expectedProgress = [
+    { total, percentage: 20 },
+    { total, percentage: 40 },
+    { total, percentage: 60 },
+    { total, percentage: 80 },
+    { total, percentage: 100},
+  ];
 
   function findAccessible(name, role) {
     return accessibles.find(accessible =>
       accessible.name === name && accessible.role === role);
   }
 
   const a11yWalker = await accessibility.getWalker();
   ok(a11yWalker, "The AccessibleWalkerFront was returned");
   await accessibility.enable();
 
   info("Checking AccessibleWalker audit functionality");
-  const auditEvent = a11yWalker.once("audit-event");
-  a11yWalker.startAudit();
-  const { ancestries } = await auditEvent;
+  const ancestries = await new Promise((resolve, reject) => {
+    const auditEventHandler = ({ type, ancestries: response, progress }) => {
+      switch (type) {
+        case "error":
+          a11yWalker.off("audit-event", auditEventHandler);
+          reject();
+          break;
+        case "completed":
+          a11yWalker.off("audit-event", auditEventHandler);
+          resolve(response);
+          is(expectedProgress.length, 0, "All progress events fired");
+          break;
+        case "progress":
+          SimpleTest.isDeeply(progress, expectedProgress.shift(),
+                              "Progress data is correct");
+          break;
+        default:
+          break;
+      }
+    };
+
+    a11yWalker.on("audit-event", auditEventHandler);
+    a11yWalker.startAudit();
+  });
 
   is(ancestries.length, 2, "The size of ancestries is correct");
   for (const ancestry of ancestries) {
     for (const { accessible, children } of ancestry) {
       checkA11yFront(accessible,
                      findAccessible(accessibles.name, accessibles.role));
       for (const child of children) {
         checkA11yFront(child,
--- a/devtools/shared/specs/accessibility.js
+++ b/devtools/shared/specs/accessibility.js
@@ -16,25 +16,26 @@ types.addActorType("accessible");
 types.addDictType("accessibleWithChildren", {
   // Accessible
   accessible: "accessible",
   // Accessible's children
   children: "array:accessible",
 });
 
 /**
- * Data passed via "audit-event" to the client. It may include a list of
+ * Data passed via "audit-event" to the client. It may include type, a list of
  * ancestries for accessible actors that have failing accessibility checks or
- * an error flag.
+ * a progress information.
  */
 types.addDictType("auditEventData", {
+  type: "string",
   // List of ancestries (array:accessibleWithChildren)
-  ancestries: "array:array:accessibleWithChildren",
-  // True if the audit failed.
-  error: "boolean",
+  ancestries: "nullable:array:array:accessibleWithChildren",
+  // Audit progress information
+  progress: "nullable:json",
 });
 
 /**
  * Accessible relation object described by its type that also includes relation targets.
  */
 types.addDictType("accessibleRelation", {
   // Accessible relation type
   type: "string",