summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authordmazzoni <dmazzoni@chromium.org>2015-12-01 13:16:57 -0800
committerCommit bot <commit-bot@chromium.org>2015-12-01 21:17:57 +0000
commiteaa0c42071267f268cf77b5fc66fbfc9b12d2e41 (patch)
tree093e1c97a683a0d069aa0ff99668119d395b0bca
parent788eaa142aaf2187981ebaf954f8efe9fce77eb5 (diff)
downloadchromium_src-eaa0c42071267f268cf77b5fc66fbfc9b12d2e41.zip
chromium_src-eaa0c42071267f268cf77b5fc66fbfc9b12d2e41.tar.gz
chromium_src-eaa0c42071267f268cf77b5fc66fbfc9b12d2e41.tar.bz2
Complete live region support in ChromeVox Next.
Optimizes tree change notifications so that ChromeVox can only listen to those relevant to live regions, and skip the rest. Implements aria-atomic, and adds some code to avoid duplicate speaking of changes to a live region due to multiple tree changes. Improves speech queue mode support by always flushing following any key event, queueing otherwise, and doing a category flush when a new live region event happens 500 ms after the last one. The last one is a heuristic, but the idea is that multiple live region events at the same time should queue up, but a new live region some discrete time later should interrupt previous live region events. Adds some tests for live region output including flushing and queueing behavior. Manual tests include the live region test suite created for Google Docs testing. There are probably some bugs and corner cases left, but this gets us a lot closer. BUG=478217 Review URL: https://codereview.chromium.org/1457683009 Cr-Commit-Position: refs/heads/master@{#362500}
-rw-r--r--chrome/browser/resources/chromeos/chromevox/chromevox.gni2
-rw-r--r--chrome/browser/resources/chromeos/chromevox/chromevox_tests.gypi1
-rw-r--r--chrome/browser/resources/chromeos/chromevox/common/chrome_extension_externs.js54
-rw-r--r--chrome/browser/resources/chromeos/chromevox/cvox2/background/automation_util.js21
-rw-r--r--chrome/browser/resources/chromeos/chromevox/cvox2/background/automation_util_test.extjs73
-rw-r--r--chrome/browser/resources/chromeos/chromevox/cvox2/background/background.js248
-rw-r--r--chrome/browser/resources/chromeos/chromevox/cvox2/background/background_test.extjs43
-rw-r--r--chrome/browser/resources/chromeos/chromevox/cvox2/background/chromevox_state.js93
-rw-r--r--chrome/browser/resources/chromeos/chromevox/cvox2/background/desktop_automation_handler.js55
-rw-r--r--chrome/browser/resources/chromeos/chromevox/cvox2/background/live_regions.js159
-rw-r--r--chrome/browser/resources/chromeos/chromevox/cvox2/background/live_regions_test.extjs216
-rw-r--r--chrome/browser/resources/chromeos/chromevox/cvox2/background/output.js79
-rw-r--r--chrome/browser/resources/chromeos/chromevox/cvox2/background/tabs_automation_handler.js2
-rw-r--r--chrome/browser/resources/chromeos/chromevox/testing/chromevox_e2e_test_base.js8
-rw-r--r--chrome/browser/resources/chromeos/chromevox/testing/chromevox_next_e2e_test_base.js20
-rw-r--r--chrome/browser/resources/chromeos/chromevox/testing/mock_feedback.js50
-rw-r--r--chrome/common/extensions/api/automation.idl43
-rw-r--r--chrome/common/extensions/api/automation_internal.idl9
-rw-r--r--chrome/renderer/extensions/automation_internal_custom_bindings.cc141
-rw-r--r--chrome/renderer/extensions/automation_internal_custom_bindings.h19
-rw-r--r--chrome/renderer/resources/extensions/automation_custom_bindings.js127
-rw-r--r--chrome/test/data/extensions/api_test/automation/sites/tree_change.html20
-rw-r--r--chrome/test/data/extensions/api_test/automation/tests/tabs/tree_change.js22
23 files changed, 1172 insertions, 333 deletions
diff --git a/chrome/browser/resources/chromeos/chromevox/chromevox.gni b/chrome/browser/resources/chromeos/chromevox/chromevox.gni
index 2a4e8c8..caa35d0 100644
--- a/chrome/browser/resources/chromeos/chromevox/chromevox.gni
+++ b/chrome/browser/resources/chromeos/chromevox/chromevox.gni
@@ -126,9 +126,11 @@ chromevox_modules = [
"cvox2/background/automation_util.js",
"cvox2/background/background.js",
"cvox2/background/base_automation_handler.js",
+ "cvox2/background/chromevox_state.js",
"cvox2/background/cursors.js",
"cvox2/background/desktop_automation_handler.js",
"cvox2/background/earcon_engine.js",
+ "cvox2/background/live_regions.js",
"cvox2/background/next_earcons.js",
"cvox2/background/output.js",
"cvox2/background/panel.js",
diff --git a/chrome/browser/resources/chromeos/chromevox/chromevox_tests.gypi b/chrome/browser/resources/chromeos/chromevox/chromevox_tests.gypi
index 73a5ea7..28ed0bb 100644
--- a/chrome/browser/resources/chromeos/chromevox/chromevox_tests.gypi
+++ b/chrome/browser/resources/chromeos/chromevox/chromevox_tests.gypi
@@ -83,6 +83,7 @@
'cvox2/background/automation_util_test.extjs',
'cvox2/background/background_test.extjs',
'cvox2/background/cursors_test.extjs',
+ 'cvox2/background/live_regions_test.extjs',
'cvox2/background/output_test.extjs',
'host/chrome/tts_background_test.extjs',
],
diff --git a/chrome/browser/resources/chromeos/chromevox/common/chrome_extension_externs.js b/chrome/browser/resources/chromeos/chromevox/common/chrome_extension_externs.js
index 630c365..a087fbe 100644
--- a/chrome/browser/resources/chromeos/chromevox/common/chrome_extension_externs.js
+++ b/chrome/browser/resources/chromeos/chromevox/common/chrome_extension_externs.js
@@ -275,37 +275,23 @@ chrome.automation.AutomationNode = function() {};
/**
- * Get the automation tree for the tab with the given tabId, or the current tab
- * if no tabID is given, enabling automation if necessary. Returns a tree with a
- * placeholder root node; listen for the "loadComplete" event to get a
- * notification that the tree has fully loaded (the previous root node reference
- * will stop working at or before this point).
* @param {number} tabId
* @param {function(chrome.automation.AutomationNode):void} callback
- * Called when the <code>AutomationNode</code> for the page is available.
*/
chrome.automation.getTree = function(tabId, callback) {};
-/**
- * Get the automation tree for the whole desktop which consists of all on screen
- * views. Note this API is currently only supported on Chrome OS.
- * @param {function(!chrome.automation.AutomationNode):void} callback
- * Called when the <code>AutomationNode</code> for the page is available.
- */
+/** @param {function(!chrome.automation.AutomationNode):void} callback */
chrome.automation.getDesktop = function(callback) {};
/**
- * Add a tree change observer. Tree change observers are static/global,
- * they listen to tree changes across all trees.
- * @param {function(chrome.automation.TreeChange):void} observer
- * A listener for tree changes on the <code>AutomationNode</code> tree.
+ * @param {string} filter
+ * @param {function(chrome.automation.TreeChange) : void}
+ * observer
*/
-chrome.automation.addTreeChangeObserver = function(observer) {};
+chrome.automation.addTreeChangeObserver = function(filter, observer) {};
/**
- * Remove a tree change observer.
- * @param {function(chrome.automation.TreeChange):void} observer
- * A listener for tree changes on the <code>AutomationNode</code> tree.
+ * @param {function(chrome.automation.TreeChange) : void} observer
*/
chrome.automation.removeTreeChangeObserver = function(observer) {};
@@ -477,22 +463,6 @@ chrome.automation.TreeChange.prototype.target;
chrome.automation.TreeChange.prototype.type;
-/**
- * @param {function(chrome.automation.TreeChange) : void}
- * callback
- */
-chrome.automation.AutomationNode.prototype.addTreeChangeObserver =
- function(callback) {};
-
-
-/**
- * @param {function(chrome.automation.TreeChange) : void}
- * callback
- */
-chrome.automation.AutomationNode.prototype.removeTreeChangeObserver =
- function(callback) {};
-
-
chrome.automation.AutomationNode.prototype.doDefault = function() {};
@@ -522,6 +492,18 @@ chrome.automation.AutomationNode.prototype.containerLiveAtomic;
/** @type {boolean} */
chrome.automation.AutomationNode.prototype.containerLiveBusy;
+/** @type {string} */
+chrome.automation.AutomationNode.prototype.liveStatus;
+
+/** @type {string} */
+chrome.automation.AutomationNode.prototype.liveRelevant;
+
+/** @type {boolean} */
+chrome.automation.AutomationNode.prototype.liveAtomic;
+
+/** @type {boolean} */
+chrome.automation.AutomationNode.prototype.liveBusy;
+
/**
* @param {Object} findParams
diff --git a/chrome/browser/resources/chromeos/chromevox/cvox2/background/automation_util.js b/chrome/browser/resources/chromeos/chromevox/cvox2/background/automation_util.js
index c7c743a..c45aac8 100644
--- a/chrome/browser/resources/chromeos/chromevox/cvox2/background/automation_util.js
+++ b/chrome/browser/resources/chromeos/chromevox/cvox2/background/automation_util.js
@@ -276,4 +276,25 @@ AutomationUtil.isTraversalRoot = function(node) {
}
};
+/**
+ * Determines whether the two given nodes come from the same webpage.
+ * @param {AutomationNode} a
+ * @param {AutomationNode} b
+ * @return {boolean}
+ */
+AutomationUtil.isInSameWebpage = function(a, b) {
+ if (!a || !b)
+ return false;
+
+ a = a.root;
+ while (a && a.parent && AutomationUtil.isInSameTree(a.parent, a))
+ a = a.parent.root;
+
+ b = b.root;
+ while (b && b.parent && AutomationUtil.isInSameTree(b.parent, b))
+ b = b.parent.root;
+
+ return a == b;
+};
+
}); // goog.scope
diff --git a/chrome/browser/resources/chromeos/chromevox/cvox2/background/automation_util_test.extjs b/chrome/browser/resources/chromeos/chromevox/cvox2/background/automation_util_test.extjs
index 2c75b9f..86ff2bf 100644
--- a/chrome/browser/resources/chromeos/chromevox/cvox2/background/automation_util_test.extjs
+++ b/chrome/browser/resources/chromeos/chromevox/cvox2/background/automation_util_test.extjs
@@ -25,6 +25,22 @@ AutomationUtilE2ETest.prototype = {
basicDoc: function() {/*!
<p><a href='#'></a>hello</p>
<h1><ul><li>a</ul><button></h1>
+ */},
+
+ secondDoc: function() {/*!
+ <html>
+ <head><title>Second doc</title></head>
+ <body><div>Second</div></body>
+ </html>
+ */},
+
+ iframeDoc: function() {/*!
+ <html>
+ <head><title>Second doc</title></head>
+ <body>
+ <iframe src="data:text/html,<p>Inside</p>"></iframe>
+ </body>
+ </html>
*/}
};
@@ -97,6 +113,61 @@ TEST_F('AutomationUtilE2ETest', 'GetDirection', function() {
right = right.lastChild;
assertEquals(Dir.BACKWARD, AutomationUtil.getDirection(right, left));
assertEquals(Dir.FORWARD, AutomationUtil.getDirection(left, right));
-
});
});
+
+TEST_F('AutomationUtilE2ETest', 'IsInSameWebpage', function() {
+ this.runWithLoadedTree(this.basicDoc, function(root) {
+ this.runWithLoadedTree(this.secondDoc, function(root2) {
+ chrome.automation.getDesktop(this.newCallback(function(desktop) {
+ assertTrue(AutomationUtil.isInSameWebpage(root, root));
+ assertTrue(AutomationUtil.isInSameWebpage(root.firstChild, root));
+ assertTrue(AutomationUtil.isInSameWebpage(root, root.firstChild));
+
+ assertTrue(AutomationUtil.isInSameWebpage(root2, root2));
+ assertTrue(AutomationUtil.isInSameWebpage(root2.firstChild, root2));
+ assertTrue(AutomationUtil.isInSameWebpage(root2, root2.firstChild));
+
+ assertFalse(AutomationUtil.isInSameWebpage(root, root2));
+ assertFalse(AutomationUtil.isInSameWebpage(root.firstChild, root2));
+ assertFalse(AutomationUtil.isInSameWebpage(root2.firstChild));
+ assertFalse(AutomationUtil.isInSameWebpage(
+ root.firstChild, root2.firstChild));
+
+ assertFalse(AutomationUtil.isInSameWebpage(root, desktop));
+ assertFalse(AutomationUtil.isInSameWebpage(root2, desktop));
+ }.bind(this)));
+ }.bind(this));
+ }.bind(this));
+});
+
+TEST_F('AutomationUtilE2ETest', 'IsInSameWebpageIframe', function() {
+ // Wait for load complete on both outer frame and iframe.
+ var outerFrame;
+ var innerFrame;
+ var desktop;
+ var onSuccess = this.newCallback(function() {
+ assertTrue(AutomationUtil.isInSameWebpage(outerFrame, innerFrame));
+ assertFalse(AutomationUtil.isInSameWebpage(outerFrame, desktop));
+ assertFalse(AutomationUtil.isInSameWebpage(innerFrame, desktop));
+ assertFalse(AutomationUtil.isInSameWebpage(outerFrame.parent, innerFrame));
+ }.bind(this));
+
+ chrome.automation.getDesktop(function(r) {
+ desktop = r;
+ this.runWithTab(this.iframeDoc, function(newTabUrl) {
+ var listener = function(evt) {
+ if (evt.target.docUrl == newTabUrl)
+ outerFrame = evt.target;
+ if (evt.target.docUrl.indexOf('data:text/html') == 0)
+ innerFrame = evt.target;
+
+ if (outerFrame && innerFrame) {
+ desktop.removeEventListener('loadComplete', listener, true);
+ onSuccess();
+ }
+ }.bind(this);
+ desktop.addEventListener('loadComplete', listener, true);
+ }.bind(this));
+ }.bind(this));
+});
diff --git a/chrome/browser/resources/chromeos/chromevox/cvox2/background/background.js b/chrome/browser/resources/chromeos/chromevox/cvox2/background/background.js
index 523e200..b7ba66a 100644
--- a/chrome/browser/resources/chromeos/chromevox/cvox2/background/background.js
+++ b/chrome/browser/resources/chromeos/chromevox/cvox2/background/background.js
@@ -8,11 +8,12 @@
*/
goog.provide('Background');
-goog.provide('ChromeVoxMode');
goog.provide('global');
goog.require('AutomationPredicate');
goog.require('AutomationUtil');
+goog.require('ChromeVoxState');
+goog.require('LiveRegions');
goog.require('NextEarcons');
goog.require('Output');
goog.require('Output.EventType');
@@ -31,21 +32,13 @@ var EventType = chrome.automation.EventType;
var RoleType = chrome.automation.RoleType;
/**
- * All possible modes ChromeVox can run.
- * @enum {string}
- */
-ChromeVoxMode = {
- CLASSIC: 'classic',
- COMPAT: 'compat',
- NEXT: 'next',
- FORCE_NEXT: 'force_next'
-};
-
-/**
* ChromeVox2 background page.
* @constructor
+ * @extends {ChromeVoxState}
*/
Background = function() {
+ ChromeVoxState.call(this);
+
/**
* A list of site substring patterns to use with ChromeVox next. Keep these
* strings relatively specific.
@@ -127,35 +120,115 @@ Background = function() {
// Classic keymap.
cvox.ChromeVoxKbHandler.handlerKeyMap = cvox.KeyMap.fromDefaults();
- chrome.automation.addTreeChangeObserver(this.onTreeChange);
+ // Live region handler.
+ this.liveRegions_ = new LiveRegions(this);
};
Background.prototype = {
- /** Forces ChromeVox Next to be active for all tabs. */
- forceChromeVoxNextActive: function() {
- this.setChromeVoxMode(ChromeVoxMode.FORCE_NEXT);
- },
+ __proto__: ChromeVoxState.prototype,
- /** @type {ChromeVoxMode} */
- get mode() {
+ /**
+ * @override
+ */
+ getMode: function() {
return this.mode_;
},
- /** @type {cursors.Range} */
- get currentRange() {
+ /**
+ * @override
+ */
+ setMode: function(mode, opt_injectClassic) {
+ // Switching key maps potentially affects the key codes that involve
+ // sequencing. Without resetting this list, potentially stale key codes
+ // remain. The key codes themselves get pushed in
+ // cvox.KeySequence.deserialize which gets called by cvox.KeyMap.
+ cvox.ChromeVox.sequenceSwitchKeyCodes = [];
+ if (mode === ChromeVoxMode.CLASSIC || mode === ChromeVoxMode.COMPAT)
+ cvox.ChromeVoxKbHandler.handlerKeyMap = cvox.KeyMap.fromDefaults();
+ else
+ cvox.ChromeVoxKbHandler.handlerKeyMap = cvox.KeyMap.fromNext();
+
+ if (mode == ChromeVoxMode.CLASSIC) {
+ if (chrome.commands &&
+ chrome.commands.onCommand.hasListener(this.onGotCommand))
+ chrome.commands.onCommand.removeListener(this.onGotCommand);
+ } else {
+ if (chrome.commands &&
+ !chrome.commands.onCommand.hasListener(this.onGotCommand))
+ chrome.commands.onCommand.addListener(this.onGotCommand);
+ }
+
+ chrome.tabs.query({active: true}, function(tabs) {
+ if (mode === ChromeVoxMode.CLASSIC) {
+ // Generally, we don't want to inject classic content scripts as it is
+ // done by the extension system at document load. The exception is when
+ // we toggle classic on manually as part of a user command.
+ if (opt_injectClassic)
+ cvox.ChromeVox.injectChromeVoxIntoTabs(tabs);
+ } else {
+ // When in compat mode, if the focus is within the desktop tree proper,
+ // then do not disable content scripts.
+ if (this.currentRange_ &&
+ this.currentRange_.start.node.root.role == RoleType.desktop)
+ return;
+
+ this.disableClassicChromeVox_();
+ }
+ }.bind(this));
+
+ // If switching out of a ChromeVox Next mode, make sure we cancel
+ // the progress loading sound just in case.
+ if ((this.mode_ === ChromeVoxMode.NEXT ||
+ this.mode_ === ChromeVoxMode.FORCE_NEXT) &&
+ this.mode_ != mode) {
+ cvox.ChromeVox.earcons.cancelEarcon(cvox.Earcon.PAGE_START_LOADING);
+ }
+
+ this.mode_ = mode;
+ },
+
+ /**
+ * @override
+ */
+ refreshMode: function(url) {
+ var mode = this.mode_;
+ if (mode != ChromeVoxMode.FORCE_NEXT) {
+ if (this.isWhitelistedForNext_(url))
+ mode = ChromeVoxMode.NEXT;
+ else if (this.isBlacklistedForClassic_(url))
+ mode = ChromeVoxMode.COMPAT;
+ else
+ mode = ChromeVoxMode.CLASSIC;
+ }
+
+ this.setMode(mode);
+ },
+
+ /**
+ * @override
+ */
+ getCurrentRange: function() {
return this.currentRange_;
},
- set currentRange(value) {
- if (!value)
+ /**
+ * @override
+ */
+ setCurrentRange: function(newRange) {
+ if (!newRange)
return;
- this.currentRange_ = value;
+ this.currentRange_ = newRange;
if (this.currentRange_)
this.currentRange_.start.node.makeVisible();
},
+ /** Forces ChromeVox Next to be active for all tabs. */
+ forceChromeVoxNextActive: function() {
+ this.setMode(ChromeVoxMode.FORCE_NEXT);
+ },
+
/**
* Handles ChromeVox Next commands.
* @param {string} command
@@ -331,8 +404,8 @@ Background.prototype = {
.onSpeechEnd(function() { continueReading(prevRange); })
.go();
prevRange = this.currentRange_;
- this.currentRange =
- this.currentRange.move(cursors.Unit.NODE, Dir.FORWARD);
+ this.setCurrentRange(
+ this.currentRange_.move(cursors.Unit.NODE, Dir.FORWARD));
if (!this.currentRange_ || this.currentRange_.equals(prevRange))
global.isReadingContinuously = false;
@@ -372,7 +445,7 @@ Background.prototype = {
} else {
newMode = ChromeVoxMode.FORCE_NEXT;
}
- this.setChromeVoxMode(newMode, true);
+ this.setMode(newMode, true);
var isClassic =
newMode == ChromeVoxMode.CLASSIC || newMode == ChromeVoxMode.COMPAT;
@@ -408,7 +481,7 @@ Background.prototype = {
actionNode.focus();
var prevRange = this.currentRange_;
- this.currentRange = current;
+ this.setCurrentRange(current);
new Output().withSpeechAndBraille(
this.currentRange_, prevRange, Output.EventType.NAVIGATE)
@@ -429,6 +502,8 @@ Background.prototype = {
evt.preventDefault();
evt.stopPropagation();
}
+
+ Output.flushNextSpeechUtterance();
},
/**
@@ -481,67 +556,6 @@ Background.prototype = {
},
/**
- * Refreshes the current mode based on a url.
- * @param {string} url
- */
- refreshMode: function(url) {
- var mode = this.mode_;
- if (mode != ChromeVoxMode.FORCE_NEXT) {
- if (this.isWhitelistedForNext_(url))
- mode = ChromeVoxMode.NEXT;
- else if (this.isBlacklistedForClassic_(url))
- mode = ChromeVoxMode.COMPAT;
- else
- mode = ChromeVoxMode.CLASSIC;
- }
-
- this.setChromeVoxMode(mode);
- },
-
- /**
- * Called when the automation tree is changed.
- * @param {chrome.automation.TreeChange} treeChange
- */
- onTreeChange: function(treeChange) {
- if (this.mode_ === ChromeVoxMode.CLASSIC || !cvox.ChromeVox.isActive)
- return;
-
- var node = treeChange.target;
- if (!node.containerLiveStatus)
- return;
-
- if (node.containerLiveRelevant.indexOf('additions') >= 0 &&
- treeChange.type == 'nodeCreated')
- this.outputLiveRegionChange_(node, null);
- if (node.containerLiveRelevant.indexOf('text') >= 0 &&
- treeChange.type == 'nodeChanged')
- this.outputLiveRegionChange_(node, null);
- if (node.containerLiveRelevant.indexOf('removals') >= 0 &&
- treeChange.type == 'nodeRemoved')
- this.outputLiveRegionChange_(node, '@live_regions_removed');
- },
-
- /**
- * Given a node that needs to be spoken as part of a live region
- * change and an additional optional format string, output the
- * live region description.
- * @param {!chrome.automation.AutomationNode} node The changed node.
- * @param {?string} opt_prependFormatStr If set, a format string for
- * cvox2.Output to prepend to the output.
- * @private
- */
- outputLiveRegionChange_: function(node, opt_prependFormatStr) {
- var range = cursors.Range.fromNode(node);
- var output = new Output();
- if (opt_prependFormatStr) {
- output.format(opt_prependFormatStr);
- }
- output.withSpeech(range, null, Output.EventType.NAVIGATE);
- output.withSpeechCategory(cvox.TtsCategory.LIVE);
- output.go();
- },
-
- /**
* Returns true if the url should have Classic running.
* @return {boolean}
* @private
@@ -583,62 +597,6 @@ Background.prototype = {
},
/**
- * Sets the current ChromeVox mode.
- * @param {ChromeVoxMode} mode
- * @param {boolean=} opt_injectClassic Injects ChromeVox classic into tabs;
- * defaults to false.
- */
- setChromeVoxMode: function(mode, opt_injectClassic) {
- // Switching key maps potentially affects the key codes that involve
- // sequencing. Without resetting this list, potentially stale key codes
- // remain. The key codes themselves get pushed in
- // cvox.KeySequence.deserialize which gets called by cvox.KeyMap.
- cvox.ChromeVox.sequenceSwitchKeyCodes = [];
- if (mode === ChromeVoxMode.CLASSIC || mode === ChromeVoxMode.COMPAT)
- cvox.ChromeVoxKbHandler.handlerKeyMap = cvox.KeyMap.fromDefaults();
- else
- cvox.ChromeVoxKbHandler.handlerKeyMap = cvox.KeyMap.fromNext();
-
- if (mode == ChromeVoxMode.CLASSIC) {
- if (chrome.commands &&
- chrome.commands.onCommand.hasListener(this.onGotCommand))
- chrome.commands.onCommand.removeListener(this.onGotCommand);
- } else {
- if (chrome.commands &&
- !chrome.commands.onCommand.hasListener(this.onGotCommand))
- chrome.commands.onCommand.addListener(this.onGotCommand);
- }
-
- chrome.tabs.query({active: true}, function(tabs) {
- if (mode === ChromeVoxMode.CLASSIC) {
- // Generally, we don't want to inject classic content scripts as it is
- // done by the extension system at document load. The exception is when
- // we toggle classic on manually as part of a user command.
- if (opt_injectClassic)
- cvox.ChromeVox.injectChromeVoxIntoTabs(tabs);
- } else {
- // When in compat mode, if the focus is within the desktop tree proper,
- // then do not disable content scripts.
- if (this.currentRange_ &&
- this.currentRange_.start.node.root.role == RoleType.desktop)
- return;
-
- this.disableClassicChromeVox_();
- }
- }.bind(this));
-
- // If switching out of a ChromeVox Next mode, make sure we cancel
- // the progress loading sound just in case.
- if ((this.mode_ === ChromeVoxMode.NEXT ||
- this.mode_ === ChromeVoxMode.FORCE_NEXT) &&
- this.mode_ != mode) {
- cvox.ChromeVox.earcons.cancelEarcon(cvox.Earcon.PAGE_START_LOADING);
- }
-
- this.mode_ = mode;
- },
-
- /**
* @param {!Spannable} text
* @param {number} position
* @private
diff --git a/chrome/browser/resources/chromeos/chromevox/cvox2/background/background_test.extjs b/chrome/browser/resources/chromeos/chromevox/cvox2/background/background_test.extjs
index fbeb836..36191c4 100644
--- a/chrome/browser/resources/chromeos/chromevox/cvox2/background/background_test.extjs
+++ b/chrome/browser/resources/chromeos/chromevox/cvox2/background/background_test.extjs
@@ -216,49 +216,6 @@ TEST_F('BackgroundTest', 'ContinuousRead', function() {
});
});
-TEST_F('BackgroundTest', 'LiveRegionAddElement', function() {
- var mockFeedback = this.createMockFeedback();
- this.runWithLoadedTree(
- function() {/*!
- <h1>Document with live region</h1>
- <p id="live" aria-live="polite"></p>
- <button id="go">Go</button>
- <script>
- document.getElementById('go').addEventListener('click', function() {
- document.getElementById('live').innerHTML = 'Hello, world';
- }, false);
- </script>
- */},
- function(rootNode) {
- var go = rootNode.find({ role: RoleType.button });
- mockFeedback.call(go.doDefault.bind(go))
- .expectSpeech('Hello, world');
- mockFeedback.replay();
- });
-});
-
-TEST_F('BackgroundTest', 'LiveRegionRemoveElement', function() {
- var mockFeedback = this.createMockFeedback();
- this.runWithLoadedTree(
- function() {/*!
- <h1>Document with live region</h1>
- <p id="live" aria-live="polite" aria-relevant="removals">Hello, world</p>
- <button id="go">Go</button>
- <script>
- document.getElementById('go').addEventListener('click', function() {
- document.getElementById('live').innerHTML = '';
- }, false);
- </script>
- */},
- function(rootNode) {
- var go = rootNode.find({ role: RoleType.button });
- go.doDefault();
- mockFeedback.expectSpeech('removed:')
- .expectSpeech('Hello, world');
- mockFeedback.replay();
- });
-});
-
TEST_F('BackgroundTest', 'InitialFocus', function() {
var mockFeedback = this.createMockFeedback();
this.runWithLoadedTree('<a href="a">a</a>',
diff --git a/chrome/browser/resources/chromeos/chromevox/cvox2/background/chromevox_state.js b/chrome/browser/resources/chromeos/chromevox/cvox2/background/chromevox_state.js
new file mode 100644
index 0000000..25ba09e
--- /dev/null
+++ b/chrome/browser/resources/chromeos/chromevox/cvox2/background/chromevox_state.js
@@ -0,0 +1,93 @@
+// Copyright 2015 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview An interface for querying and modifying the global
+ * ChromeVox state, to avoid direct dependencies on the Background
+ * object and to facilitate mocking for tests.
+ */
+
+goog.provide('ChromeVoxMode');
+goog.provide('ChromeVoxState');
+
+goog.require('cursors.Cursor');
+
+/**
+ * All possible modes ChromeVox can run.
+ * @enum {string}
+ */
+ChromeVoxMode = {
+ CLASSIC: 'classic',
+ COMPAT: 'compat',
+ NEXT: 'next',
+ FORCE_NEXT: 'force_next'
+};
+
+/**
+ * ChromeVox2 state object.
+ * @constructor
+ */
+ChromeVoxState = function() {
+ if (ChromeVoxState.instance)
+ throw 'Trying to create two instances of singleton ChromeVoxState.';
+ ChromeVoxState.instance = this;
+};
+
+/**
+ * @type {ChromeVoxState}
+ */
+ChromeVoxState.instance;
+
+ChromeVoxState.prototype = {
+ /** @type {ChromeVoxMode} */
+ get mode() {
+ return this.getMode();
+ },
+
+ /**
+ * @return {ChromeVoxMode} The current mode.
+ * @protected
+ */
+ getMode: function() {
+ return ChromeVoxMode.NEXT;
+ },
+
+ /**
+ * Sets the current ChromeVox mode.
+ * @param {ChromeVoxMode} mode
+ * @param {boolean=} opt_injectClassic Injects ChromeVox classic into tabs;
+ * defaults to false.
+ */
+ setMode: function(mode, opt_injectClassic) {
+ throw new Error('setMode must be implemented by subclass.');
+ },
+
+ /**
+ * Refreshes the current mode based on a url.
+ * @param {string} url
+ */
+ refreshMode: function(url) {
+ throw new Error('refresthMode must be implemented by subclass.');
+ },
+
+ /** @type {cursors.Range} */
+ get currentRange() {
+ return this.getCurrentRange();
+ },
+
+ /**
+ * @return {cursors.Range} The current range.
+ * @protected
+ */
+ getCurrentRange: function() {
+ return null;
+ },
+
+ /**
+ * @param {cursors.Range} newRange The new range.
+ */
+ setCurrentRange: function(newRange) {
+ throw new Error('setCurrentRange must be implemented by subclass.');
+ },
+};
diff --git a/chrome/browser/resources/chromeos/chromevox/cvox2/background/desktop_automation_handler.js b/chrome/browser/resources/chromeos/chromevox/cvox2/background/desktop_automation_handler.js
index c164545..4b6df72 100644
--- a/chrome/browser/resources/chromeos/chromevox/cvox2/background/desktop_automation_handler.js
+++ b/chrome/browser/resources/chromeos/chromevox/cvox2/background/desktop_automation_handler.js
@@ -8,8 +8,8 @@
goog.provide('DesktopAutomationHandler');
-goog.require('Background');
goog.require('BaseAutomationHandler');
+goog.require('ChromeVoxState');
goog.scope(function() {
var AutomationEvent = chrome.automation.AutomationEvent;
@@ -61,20 +61,20 @@ DesktopAutomationHandler.prototype = {
if (!node)
return;
- var prevRange = global.backgroundObj.currentRange;
+ var prevRange = ChromeVoxState.instance.currentRange;
- global.backgroundObj.currentRange = cursors.Range.fromNode(node);
+ ChromeVoxState.instance.setCurrentRange(cursors.Range.fromNode(node));
// Check to see if we've crossed roots. Continue if we've crossed roots or
// are not within web content.
if (node.root.role == RoleType.desktop ||
!prevRange ||
prevRange.start.node.root != node.root)
- global.backgroundObj.refreshMode(node.root.docUrl || '');
+ ChromeVoxState.instance.refreshMode(node.root.docUrl || '');
// Don't process nodes inside of web content if ChromeVox Next is inactive.
if (node.root.role != RoleType.desktop &&
- global.backgroundObj.mode === ChromeVoxMode.CLASSIC) {
+ ChromeVoxState.instance.mode === ChromeVoxMode.CLASSIC) {
if (cvox.ChromeVox.isChromeOS)
chrome.accessibilityPrivate.setFocusRing([]);
return;
@@ -83,11 +83,11 @@ DesktopAutomationHandler.prototype = {
// Don't output if focused node hasn't changed.
if (prevRange &&
evt.type == 'focus' &&
- global.backgroundObj.currentRange.equals(prevRange))
+ ChromeVoxState.instance.currentRange.equals(prevRange))
return;
new Output().withSpeechAndBraille(
- global.backgroundObj.currentRange, prevRange, evt.type)
+ ChromeVoxState.instance.currentRange, prevRange, evt.type)
.go();
},
@@ -102,7 +102,7 @@ DesktopAutomationHandler.prototype = {
// Don't process nodes inside of web content if ChromeVox Next is inactive.
if (node.root.role != RoleType.desktop &&
- global.backgroundObj.mode === ChromeVoxMode.CLASSIC) {
+ ChromeVoxState.instance.mode === ChromeVoxMode.CLASSIC) {
return;
}
@@ -129,7 +129,7 @@ DesktopAutomationHandler.prototype = {
if (node.role == RoleType.rootWebArea) {
// Discard focus events for root web areas when focus was previously
// placed on a descendant.
- var currentRange = global.backgroundObj.currentRange;
+ var currentRange = ChromeVoxState.instance.currentRange;
if (currentRange && currentRange.start.node.root == node)
return;
@@ -152,20 +152,20 @@ DesktopAutomationHandler.prototype = {
* @param {Object} evt
*/
onLoadComplete: function(evt) {
- global.backgroundObj.refreshMode(evt.target.docUrl);
+ ChromeVoxState.instance.refreshMode(evt.target.docUrl);
// Don't process nodes inside of web content if ChromeVox Next is inactive.
if (evt.target.root.role != RoleType.desktop &&
- global.backgroundObj.mode === ChromeVoxMode.CLASSIC)
+ ChromeVoxState.instance.mode === ChromeVoxMode.CLASSIC)
return;
// If initial focus was already placed on this page (e.g. if a user starts
// tabbing before load complete), then don't move ChromeVox's position on
// the page.
- if (global.backgroundObj.currentRange &&
- global.backgroundObj.currentRange.start.node.role !=
+ if (ChromeVoxState.instance.currentRange &&
+ ChromeVoxState.instance.currentRange.start.node.role !=
RoleType.rootWebArea &&
- global.backgroundObj.currentRange.start.node.root.docUrl ==
+ ChromeVoxState.instance.currentRange.start.node.root.docUrl ==
evt.target.docUrl)
return;
@@ -182,11 +182,11 @@ DesktopAutomationHandler.prototype = {
AutomationPredicate.leaf);
if (node)
- global.backgroundObj.currentRange = cursors.Range.fromNode(node);
+ ChromeVoxState.instance.setCurrentRange(cursors.Range.fromNode(node));
- if (global.backgroundObj.currentRange)
+ if (ChromeVoxState.instance.currentRange)
new Output().withSpeechAndBraille(
- global.backgroundObj.currentRange, null, evt.type)
+ ChromeVoxState.instance.currentRange, null, evt.type)
.go();
},
@@ -200,15 +200,16 @@ DesktopAutomationHandler.prototype = {
// Don't process nodes inside of web content if ChromeVox Next is inactive.
if (evt.target.root.role != RoleType.desktop &&
- global.backgroundObj.mode === ChromeVoxMode.CLASSIC)
+ ChromeVoxState.instance.mode === ChromeVoxMode.CLASSIC)
return;
if (!evt.target.state.focused)
return;
- if (!global.backgroundObj.currentRange) {
+ if (!ChromeVoxState.instance.currentRange) {
this.onEventDefault(evt);
- global.backgroundObj.currentRange = cursors.Range.fromNode(evt.target);
+ ChromeVoxState.instance.setCurrentRange(
+ cursors.Range.fromNode(evt.target));
}
this.createEditableTextHandlerIfNeeded_(evt.target);
@@ -222,7 +223,7 @@ DesktopAutomationHandler.prototype = {
this.editableTextHandler_.changed(textChangeEvent);
new Output().withBraille(
- global.backgroundObj.currentRange, null, evt.type)
+ ChromeVoxState.instance.currentRange, null, evt.type)
.go();
},
@@ -233,16 +234,18 @@ DesktopAutomationHandler.prototype = {
onValueChanged: function(evt) {
// Don't process nodes inside of web content if ChromeVox Next is inactive.
if (evt.target.root.role != RoleType.desktop &&
- global.backgroundObj.mode === ChromeVoxMode.CLASSIC)
+ ChromeVoxState.instance.mode === ChromeVoxMode.CLASSIC)
return;
if (!evt.target.state.focused)
return;
// Value change events fire on web editables when typing. Suppress them.
- if (!global.backgroundObj.currentRange || !this.isEditable_(evt.target)) {
+ if (!ChromeVoxState.instance.currentRange ||
+ !this.isEditable_(evt.target)) {
this.onEventDefault(evt);
- global.backgroundObj.currentRange = cursors.Range.fromNode(evt.target);
+ ChromeVoxState.instance.setCurrentRange(
+ cursors.Range.fromNode(evt.target));
}
},
@@ -251,7 +254,7 @@ DesktopAutomationHandler.prototype = {
* @override
*/
onScrollPositionChanged: function(evt) {
- var currentRange = global.backgroundObj.currentRange;
+ var currentRange = ChromeVoxState.instance.currentRange;
if (currentRange)
new Output().withLocation(currentRange, null, evt.type).go();
},
@@ -262,7 +265,7 @@ DesktopAutomationHandler.prototype = {
*/
createEditableTextHandlerIfNeeded_: function(node) {
if (!this.editableTextHandler_ ||
- node != global.backgroundObj.currentRange.start.node) {
+ node != ChromeVoxState.instance.currentRange.start.node) {
var start = node.textSelStart;
var end = node.textSelEnd;
if (start > end) {
diff --git a/chrome/browser/resources/chromeos/chromevox/cvox2/background/live_regions.js b/chrome/browser/resources/chromeos/chromevox/cvox2/background/live_regions.js
new file mode 100644
index 0000000..42ed81c
--- /dev/null
+++ b/chrome/browser/resources/chromeos/chromevox/cvox2/background/live_regions.js
@@ -0,0 +1,159 @@
+// Copyright 2015 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Implements support for live regions in ChromeVox Next.
+ */
+
+goog.provide('LiveRegions');
+
+goog.require('ChromeVoxState');
+
+goog.scope(function() {
+var AutomationNode = chrome.automation.AutomationNode;
+var TreeChange = chrome.automation.TreeChange;
+
+/**
+ * ChromeVox2 live region handler.
+ * @param {!ChromeVoxState} chromeVoxState The ChromeVox state object,
+ * keeping track of the current mode and current range.
+ * @constructor
+ */
+LiveRegions = function(chromeVoxState) {
+ /**
+ * @type {!ChromeVoxState}
+ * @private
+ */
+ this.chromeVoxState_ = chromeVoxState;
+
+ /**
+ * The time the last live region event was output.
+ * @type {!Date}
+ * @private
+ */
+ this.lastLiveRegionTime_ = new Date(0);
+
+ /**
+ * Set of nodes that have been announced as part of a live region since
+ * |this.lastLiveRegionTime_|, to prevent duplicate announcements.
+ * @type {!WeakSet<AutomationNode>}
+ * @private
+ */
+ this.liveRegionNodeSet_ = new WeakSet();
+
+ chrome.automation.addTreeChangeObserver(
+ 'liveRegionTreeChanges', this.onTreeChange.bind(this));
+};
+
+/**
+ * Live region events received in fewer than this many milliseconds will
+ * queue, otherwise they'll be output with a category flush.
+ * @type {number}
+ * @const
+ */
+LiveRegions.LIVE_REGION_QUEUE_TIME_MS = 500;
+
+/**
+ * Whether live regions from background tabs should be announced or not.
+ * @type {boolean}
+ * @private
+ */
+LiveRegions.announceLiveRegionsFromBackgroundTabs_ = true;
+
+LiveRegions.prototype = {
+ /**
+ * Called when the automation tree is changed.
+ * @param {TreeChange} treeChange
+ */
+ onTreeChange: function(treeChange) {
+ var node = treeChange.target;
+ if (!node.containerLiveStatus)
+ return;
+
+ var mode = this.chromeVoxState_.mode;
+ var currentRange = this.chromeVoxState_.currentRange;
+
+ if (mode === ChromeVoxMode.CLASSIC || !cvox.ChromeVox.isActive)
+ return;
+
+ if (!currentRange)
+ return;
+
+ if (!LiveRegions.announceLiveRegionsFromBackgroundTabs_ &&
+ !AutomationUtil.isInSameWebpage(node, currentRange.start.node)) {
+ return;
+ }
+
+ var type = treeChange.type;
+ var relevant = node.containerLiveRelevant;
+ if (relevant.indexOf('additions') >= 0 &&
+ (type == 'nodeCreated' || type == 'subtreeCreated')) {
+ this.outputLiveRegionChange_(node, null);
+ }
+
+ if (relevant.indexOf('text') >= 0 && type == 'nodeChanged')
+ this.outputLiveRegionChange_(node, null);
+
+ if (relevant.indexOf('removals') >= 0 && type == 'nodeRemoved')
+ this.outputLiveRegionChange_(node, '@live_regions_removed');
+ },
+
+ /**
+ * Given a node that needs to be spoken as part of a live region
+ * change and an additional optional format string, output the
+ * live region description.
+ * @param {!AutomationNode} node The changed node.
+ * @param {?string=} opt_prependFormatStr If set, a format string for
+ * cvox2.Output to prepend to the output.
+ * @private
+ */
+ outputLiveRegionChange_: function(node, opt_prependFormatStr) {
+ if (node.containerLiveBusy)
+ return;
+
+ if (node.containerLiveAtomic && !node.liveAtomic) {
+ if (node.parent)
+ this.outputLiveRegionChange_(node.parent, opt_prependFormatStr);
+ return;
+ }
+
+ var range = cursors.Range.fromNode(node);
+ var output = new Output();
+ if (opt_prependFormatStr)
+ output.format(opt_prependFormatStr);
+ output.withSpeech(range, range, Output.EventType.NAVIGATE);
+
+ if (!output.hasSpeech && node.liveAtomic)
+ output.format('$descendants', node);
+
+ output.withSpeechCategory(cvox.TtsCategory.LIVE);
+
+ if (!output.hasSpeech)
+ return;
+
+ // Enqueue live region updates that were received at approximately
+ // the same time, otherwise flush previous live region updates.
+ var currentTime = new Date();
+ var queueTime = LiveRegions.LIVE_REGION_QUEUE_TIME_MS;
+ if (currentTime - this.lastLiveRegionTime_ > queueTime) {
+ this.liveRegionNodeSet_ = new WeakSet();
+ output.withQueueMode(cvox.QueueMode.CATEGORY_FLUSH);
+ this.lastLiveRegionTime_ = currentTime;
+ } else {
+ output.withQueueMode(cvox.QueueMode.QUEUE);
+ }
+
+ var parent = node;
+ while (parent) {
+ if (this.liveRegionNodeSet_.has(parent))
+ return;
+ parent = parent.parent;
+ }
+
+ this.liveRegionNodeSet_.add(node);
+ output.go();
+ },
+};
+
+}); // goog.scope
diff --git a/chrome/browser/resources/chromeos/chromevox/cvox2/background/live_regions_test.extjs b/chrome/browser/resources/chromeos/chromevox/cvox2/background/live_regions_test.extjs
new file mode 100644
index 0000000..3a06754
--- /dev/null
+++ b/chrome/browser/resources/chromeos/chromevox/cvox2/background/live_regions_test.extjs
@@ -0,0 +1,216 @@
+// Copyright 2015 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Include test fixture.
+GEN_INCLUDE(['../../testing/chromevox_next_e2e_test_base.js',
+ '../../testing/assert_additions.js']);
+
+GEN_INCLUDE(['../../testing/mock_feedback.js']);
+
+/**
+ * Test fixture for Live Regions.
+ * @constructor
+ * @extends {ChromeVoxNextE2ETest}
+ */
+function LiveRegionsTest() {
+ ChromeVoxNextE2ETest.call(this);
+}
+
+LiveRegionsTest.prototype = {
+ __proto__: ChromeVoxNextE2ETest.prototype,
+
+ /** @override */
+ setUp: function() {
+ global.backgroundObj.forceChromeVoxNextActive();
+ window.RoleType = chrome.automation.RoleType;
+ },
+
+ /**
+ * @return {!MockFeedback}
+ */
+ createMockFeedback: function() {
+ var mockFeedback = new MockFeedback(this.newCallback(),
+ this.newCallback.bind(this));
+ mockFeedback.install();
+ return mockFeedback;
+ },
+
+ /**
+ * Create a function which performs the command |cmd|.
+ * @param {string} cmd
+ * @return {function() : void}
+ */
+ doCmd: function(cmd) {
+ return function() {
+ global.backgroundObj.onGotCommand(cmd);
+ };
+ },
+};
+
+TEST_F('LiveRegionsTest', 'LiveRegionAddElement', function() {
+ var mockFeedback = this.createMockFeedback();
+ this.runWithLoadedTree(
+ function() {/*!
+ <h1>Document with live region</h1>
+ <p id="live" aria-live="polite"></p>
+ <button id="go">Go</button>
+ <script>
+ document.getElementById('go').addEventListener('click', function() {
+ document.getElementById('live').innerHTML = 'Hello, world';
+ }, false);
+ </script>
+ */},
+ function(rootNode) {
+ var go = rootNode.find({ role: RoleType.button });
+ mockFeedback.call(go.doDefault.bind(go))
+ .expectCategoryFlushSpeech('Hello, world');
+ mockFeedback.replay();
+ });
+});
+
+TEST_F('LiveRegionsTest', 'LiveRegionRemoveElement', function() {
+ var mockFeedback = this.createMockFeedback();
+ this.runWithLoadedTree(
+ function() {/*!
+ <h1>Document with live region</h1>
+ <p id="live" aria-live="polite" aria-relevant="removals">Hello, world</p>
+ <button id="go">Go</button>
+ <script>
+ document.getElementById('go').addEventListener('click', function() {
+ document.getElementById('live').innerHTML = '';
+ }, false);
+ </script>
+ */},
+ function(rootNode) {
+ var go = rootNode.find({ role: RoleType.button });
+ go.doDefault();
+ mockFeedback.expectCategoryFlushSpeech('removed:')
+ .expectQueuedSpeech('Hello, world');
+ mockFeedback.replay();
+ });
+});
+
+TEST_F('LiveRegionsTest', 'LiveRegionChangeAtomic', function() {
+ var mockFeedback = this.createMockFeedback();
+ this.runWithLoadedTree(
+ function() {/*!
+ <div id="live" aria-live="polite" aria-atomic="true">
+ <div id="a"></div><div id="b">Bravo</div><div id="c"></div>
+ </div>
+ <button id="go">Go</button>
+ <script>
+ document.getElementById('go').addEventListener('click', function() {
+ document.getElementById('c').textContent = 'Charlie';
+ document.getElementById('a').textContent = 'Alpha';
+ }, false);
+ </script>
+ */},
+ function(rootNode) {
+ var go = rootNode.find({ role: RoleType.button });
+ mockFeedback.call(go.doDefault.bind(go))
+ .expectQueuedSpeech('Alpha')
+ .expectQueuedSpeech('Bravo')
+ .expectQueuedSpeech('Charlie');
+ mockFeedback.replay();
+ });
+});
+
+TEST_F('LiveRegionsTest', 'LiveRegionChangeImageAlt', function() {
+ var mockFeedback = this.createMockFeedback();
+ this.runWithLoadedTree(
+ function() {/*!
+ <div id="live" aria-live="polite">
+ <img id="img" src="#" alt="Before">
+ </div>
+ <button id="go">Go</button>
+ <script>
+ document.getElementById('go').addEventListener('click', function() {
+ document.getElementById('img').setAttribute('alt', 'After');
+ }, false);
+ </script>
+ */},
+ function(rootNode) {
+ var go = rootNode.find({ role: RoleType.button });
+ mockFeedback.call(go.doDefault.bind(go))
+ .expectCategoryFlushSpeech('After')
+ .expectQueuedSpeech('Image');
+ mockFeedback.replay();
+ });
+});
+
+TEST_F('LiveRegionsTest', 'LiveRegionThenFocus', function() {
+ var mockFeedback = this.createMockFeedback();
+ this.runWithLoadedTree(
+ function() {/*!
+ <div id="live" aria-live="polite"></div>
+ <button id="go">Go</button>
+ <button id="focus">Focus</button>
+ <script>
+ document.getElementById('go').addEventListener('click', function() {
+ document.getElementById('live').textContent = 'Live';
+ window.setTimeout(function() {
+ document.getElementById('focus').focus();
+ }, 50);
+ }, false);
+ </script>
+ */},
+ function(rootNode) {
+ var go = rootNode.find({ role: RoleType.button });
+ mockFeedback.call(go.doDefault.bind(go))
+ .expectCategoryFlushSpeech('Live')
+ .expectQueuedSpeech('Focus');
+ mockFeedback.replay();
+ });
+});
+
+TEST_F('LiveRegionsTest', 'FocusThenLiveRegion', function() {
+ var mockFeedback = this.createMockFeedback();
+ this.runWithLoadedTree(
+ function() {/*!
+ <div id="live" aria-live="polite"></div>
+ <button id="go">Go</button>
+ <button id="focus">Focus</button>
+ <script>
+ document.getElementById('go').addEventListener('click', function() {
+ document.getElementById('focus').focus();
+ window.setTimeout(function() {
+ document.getElementById('live').textContent = 'Live';
+ }, 50);
+ }, false);
+ </script>
+ */},
+ function(rootNode) {
+ var go = rootNode.find({ role: RoleType.button });
+ mockFeedback.call(go.doDefault.bind(go))
+ .expectQueuedSpeech('Focus')
+ .expectCategoryFlushSpeech('Live');
+ mockFeedback.replay();
+ });
+});
+
+TEST_F('LiveRegionsTest', 'LiveRegionCategoryFlush', function() {
+ var mockFeedback = this.createMockFeedback();
+ this.runWithLoadedTree(
+ function() {/*!
+ <div id="live1" aria-live="polite"></div>
+ <div id="live2" aria-live="polite"></div>
+ <button id="go">Go</button>
+ <button id="focus">Focus</button>
+ <script>
+ document.getElementById('go').addEventListener('click', function() {
+ document.getElementById('live1').textContent = 'Live1';
+ window.setTimeout(function() {
+ document.getElementById('live2').textContent = 'Live2';
+ }, 1000);
+ }, false);
+ </script>
+ */},
+ function(rootNode) {
+ var go = rootNode.find({ role: RoleType.button });
+ mockFeedback.call(go.doDefault.bind(go))
+ .expectCategoryFlushSpeech('Live1')
+ .expectCategoryFlushSpeech('Live2');
+ mockFeedback.replay();
+ });
+});
diff --git a/chrome/browser/resources/chromeos/chromevox/cvox2/background/output.js b/chrome/browser/resources/chromeos/chromevox/cvox2/background/output.js
index 1bea384..0b9313e 100644
--- a/chrome/browser/resources/chromeos/chromevox/cvox2/background/output.js
+++ b/chrome/browser/resources/chromeos/chromevox/cvox2/background/output.js
@@ -64,14 +64,30 @@ Output = function() {
/**
* Current global options.
* @type {{speech: boolean, braille: boolean}}
+ * @private
*/
this.formatOptions_ = {speech: true, braille: false};
/**
* Speech properties to apply to the entire output.
* @type {!Object<*>}
+ * @private
*/
this.speechProperties_ = {};
+
+ /**
+ * The speech category for the generated speech utterance.
+ * @type {cvox.TtsCategory}
+ * @private
+ */
+ this.speechCategory_ = cvox.TtsCategory.NAV;
+
+ /**
+ * The speech queue mode for the generated speech utterance.
+ * @type {cvox.QueueMode}
+ * @private
+ */
+ this.queueMode_ = cvox.QueueMode.QUEUE;
};
/**
@@ -587,6 +603,22 @@ Output.EventType = {
NAVIGATE: 'navigate'
};
+/**
+ * If true, the next speech utterance will flush instead of the normal
+ * queueing mode.
+ * @type {boolean}
+ * @private
+ */
+Output.flushNextSpeechUtterance_ = false;
+
+/**
+ * Calling this will make the next speech utterance flush even if it would
+ * normally queue or do a category flush.
+ */
+Output.flushNextSpeechUtterance = function() {
+ Output.flushNextSpeechUtterance_ = true;
+};
+
Output.prototype = {
/**
* Gets the spoken output with separator '|'.
@@ -611,6 +643,17 @@ Output.prototype = {
},
/**
+ * @return {boolean} True if there's any speech that will be output.
+ */
+ get hasSpeech() {
+ for (var i = 0; i < this.speechBuffer_.length; i++) {
+ if (this.speechBuffer_[i].trim().length)
+ return true;
+ }
+ return false;
+ },
+
+ /**
* Specify ranges for speech.
* @param {!cursors.Range} range
* @param {cursors.Range} prevRange
@@ -668,7 +711,17 @@ Output.prototype = {
* @return {!Output}
*/
withSpeechCategory: function(category) {
- this.speechProperties_['category'] = category;
+ this.speechCategory_ = category;
+ return this;
+ },
+
+ /**
+ * Applies the given speech queue mode to the output.
+ * @param {cvox.QueueMode} queueMode The queueMode for the speech.
+ * @return {!Output}
+ */
+ withQueueMode: function(queueMode) {
+ this.queueMode_ = queueMode;
return this;
},
@@ -676,14 +729,18 @@ Output.prototype = {
* Apply a format string directly to the output buffer. This lets you
* output a message directly to the buffer using the format syntax.
* @param {string} formatStr
+ * @param {!chrome.automation.AutomationNode=} opt_node An optional
+ * node to apply the formatting to.
* @return {!Output}
*/
- format: function(formatStr) {
+ format: function(formatStr, opt_node) {
+ var node = opt_node || null;
+
this.formatOptions_ = {speech: true, braille: false};
- this.format_(null, formatStr, this.speechBuffer_);
+ this.format_(node, formatStr, this.speechBuffer_);
this.formatOptions_ = {speech: false, braille: true};
- this.format_(null, formatStr, this.brailleBuffer_);
+ this.format_(node, formatStr, this.brailleBuffer_);
return this;
},
@@ -705,8 +762,14 @@ Output.prototype = {
*/
go: function() {
// Speech.
- var queueMode = this.speechProperties_['category'] ?
- cvox.QueueMode.CATEGORY_FLUSH : cvox.QueueMode.FLUSH;
+ var queueMode = this.queueMode_;
+ if (Output.flushNextSpeechUtterance_) {
+ queueMode = cvox.QueueMode.FLUSH;
+ Output.flushNextSpeechUtterance_ = false;
+ }
+
+ this.speechProperties_.category = this.speechCategory_;
+
this.speechBuffer_.forEach(function(buff, i, a) {
(function() {
var scopedBuff = buff;
@@ -754,8 +817,10 @@ Output.prototype = {
}
// Display.
- if (cvox.ChromeVox.isChromeOS)
+ if (cvox.ChromeVox.isChromeOS &&
+ this.speechCategory_ != cvox.TtsCategory.LIVE) {
chrome.accessibilityPrivate.setFocusRing(this.locations_);
+ }
},
/**
diff --git a/chrome/browser/resources/chromeos/chromevox/cvox2/background/tabs_automation_handler.js b/chrome/browser/resources/chromeos/chromevox/cvox2/background/tabs_automation_handler.js
index f507215..943e0cb 100644
--- a/chrome/browser/resources/chromeos/chromevox/cvox2/background/tabs_automation_handler.js
+++ b/chrome/browser/resources/chromeos/chromevox/cvox2/background/tabs_automation_handler.js
@@ -40,7 +40,7 @@ TabsAutomationHandler.prototype = {
/** @override */
onLoadComplete: function(evt) {
- global.backgroundObj.refreshMode(evt.target.docUrl);
+ ChromeVoxState.instance.refreshMode(evt.target.docUrl);
var focused = evt.target.find({state: {focused: true}}) || evt.target;
this.onFocus({target: focused, type: EventType.focus});
}
diff --git a/chrome/browser/resources/chromeos/chromevox/testing/chromevox_e2e_test_base.js b/chrome/browser/resources/chromeos/chromevox/testing/chromevox_e2e_test_base.js
index bc7744c..d9d5089 100644
--- a/chrome/browser/resources/chromeos/chromevox/testing/chromevox_e2e_test_base.js
+++ b/chrome/browser/resources/chromeos/chromevox/testing/chromevox_e2e_test_base.js
@@ -73,7 +73,8 @@ ChromeVoxE2ETest.prototype = {
/**
* Launches the given document in a new tab.
* @param {function() : void} doc Snippet wrapped inside of a function.
- * @param {function()} opt_callback Called once the document is created.
+ * @param {function(url: string)} opt_callback Called once the
+ * document is created.
*/
runWithTab: function(doc, opt_callback) {
var docString = TestUtils.extractHtmlFromCommentEncodedString(doc);
@@ -84,7 +85,10 @@ ChromeVoxE2ETest.prototype = {
active: true,
url: url
};
- chrome.tabs.create(createParams, opt_callback);
+ chrome.tabs.create(createParams, function(tab) {
+ if (opt_callback)
+ opt_callback(tab.url);
+ });
},
/**
diff --git a/chrome/browser/resources/chromeos/chromevox/testing/chromevox_next_e2e_test_base.js b/chrome/browser/resources/chromeos/chromevox/testing/chromevox_next_e2e_test_base.js
index bb9f1fb0..a346bf8 100644
--- a/chrome/browser/resources/chromeos/chromevox/testing/chromevox_next_e2e_test_base.js
+++ b/chrome/browser/resources/chromeos/chromevox/testing/chromevox_next_e2e_test_base.js
@@ -34,17 +34,17 @@ ChromeVoxNextE2ETest.prototype = {
runWithLoadedTree: function(doc, callback) {
callback = this.newCallback(callback);
chrome.automation.getDesktop(function(r) {
- var listener = function(evt) {
- if (!evt.target.docUrl ||
- evt.target.docUrl.indexOf('test') == -1)
- return;
+ this.runWithTab(doc, function(newTabUrl) {
+ var listener = function(evt) {
+ if (!evt.target.docUrl || evt.target.docUrl != newTabUrl)
+ return;
- r.removeEventListener('loadComplete', listener, true);
- callback && callback(evt.target);
- callback = null;
- };
- r.addEventListener('loadComplete', listener, true);
- this.runWithTab(doc);
+ r.removeEventListener('loadComplete', listener, true);
+ callback && callback(evt.target);
+ callback = null;
+ };
+ r.addEventListener('loadComplete', listener, true);
+ }.bind(this));
}.bind(this));
},
diff --git a/chrome/browser/resources/chromeos/chromevox/testing/mock_feedback.js b/chrome/browser/resources/chromeos/chromevox/testing/mock_feedback.js
index 4c846e8..ec90c10 100644
--- a/chrome/browser/resources/chromeos/chromevox/testing/mock_feedback.js
+++ b/chrome/browser/resources/chromeos/chromevox/testing/mock_feedback.js
@@ -160,6 +160,55 @@ MockFeedback.prototype = {
},
/**
+ * Adds an expectation for one spoken utterance that will be enqueued
+ * with a given queue mode.
+ * @param {string|RegExp} text One utterance expectation.
+ * @param {cvox.QueueMode} queueMode The expected queue mode.
+ * @return {MockFeedback} |this| for chaining
+ */
+ expectSpeechWithQueueMode: function(text, queueMode) {
+ assertFalse(this.replaying_);
+ this.pendingActions_.push({
+ perform: function() {
+ return !!MockFeedback.matchAndConsume_(
+ text, {queueMode: queueMode}, this.pendingUtterances_);
+ }.bind(this),
+ toString: function() {
+ return 'Speak \'' + text + '\' with mode ' + queueMode;
+ }
+ });
+ return this;
+ },
+
+ /**
+ * Adds an expectation for one spoken utterance that will be queued.
+ * @param {string|RegExp} text One utterance expectation.
+ * @return {MockFeedback} |this| for chaining
+ */
+ expectQueuedSpeech: function(text) {
+ return this.expectSpeechWithQueueMode(text, cvox.QueueMode.QUEUE);
+ },
+
+ /**
+ * Adds an expectation for one spoken utterance that will be flushed.
+ * @param {string|RegExp} text One utterance expectation.
+ * @return {MockFeedback} |this| for chaining
+ */
+ expectFlushingSpeech: function(text) {
+ return this.expectSpeechWithQueueMode(text, cvox.QueueMode.FLUSH);
+ },
+
+ /**
+ * Adds an expectation for one spoken utterance that will be queued
+ * with the category flush mode.
+ * @param {string|RegExp} text One utterance expectation.
+ * @return {MockFeedback} |this| for chaining
+ */
+ expectCategoryFlushSpeech: function(text) {
+ return this.expectSpeechWithQueueMode(text, cvox.QueueMode.CATEGORY_FLUSH);
+ },
+
+ /**
* Adds an expectation that the next spoken utterances do *not* match
* the given arguments.
*
@@ -299,6 +348,7 @@ MockFeedback.prototype = {
}
this.pendingUtterances_.push(
{text: textString,
+ queueMode: queueMode,
callback: callback});
this.process_();
},
diff --git a/chrome/common/extensions/api/automation.idl b/chrome/common/extensions/api/automation.idl
index c6f02b9..62266d5 100644
--- a/chrome/common/extensions/api/automation.idl
+++ b/chrome/common/extensions/api/automation.idl
@@ -317,6 +317,14 @@
TreeChangeType type;
};
+ // Possible tree changes to listen to using addTreeChangeObserver.
+ // Note that listening to all tree changes can be expensive.
+ enum TreeChangeObserverFilter {
+ noTreeChanges,
+ liveRegionTreeChanges,
+ allTreeChanges
+ };
+
// A listener for changes on the <code>AutomationNode</code> tree.
callback TreeChangeObserver = void(TreeChange treeChange);
@@ -494,6 +502,35 @@
long tableCellRowSpan;
//
+ // Live region attributes.
+ //
+
+ // The type of region if this is the root of a live region.
+ // Possible values are 'polite' and 'assertive'.
+ DOMString liveStatus;
+
+ // The value of aria-relevant for a live region.
+ DOMString liveRelevant;
+
+ // The value of aria-atomic for a live region.
+ boolean liveAtomic;
+
+ // The value of aria-busy for a live region.
+ boolean liveBusy;
+
+ // The type of live region if this node is inside a live region.
+ DOMString containerLiveStatus;
+
+ // The value of aria-relevant if this node is inside a live region.
+ DOMString containerLiveRelevant;
+
+ // The value of aria-atomic if this node is inside a live region.
+ boolean containerLiveAtomic;
+
+ // The value of aria-busy if this node is inside a live region.
+ boolean containerLiveBusy;
+
+ //
// Walking the tree.
//
@@ -579,9 +616,11 @@
[nocompile] static void getDesktop(RootCallback callback);
// Add a tree change observer. Tree change observers are static/global, they
- // listen to changes across all trees.
+ // listen to changes across all trees. Pass a filter to determine what
+ // specific tree changes to listen to, and note that listnening to all
+ // tree changes can be expensive.
[nocompile] static void addTreeChangeObserver(
- TreeChangeObserver observer);
+ TreeChangeObserverFilter filter, TreeChangeObserver observer);
// Remove a tree change observer.
[nocompile] static void removeTreeChangeObserver(
diff --git a/chrome/common/extensions/api/automation_internal.idl b/chrome/common/extensions/api/automation_internal.idl
index 47dacc9..12eca78 100644
--- a/chrome/common/extensions/api/automation_internal.idl
+++ b/chrome/common/extensions/api/automation_internal.idl
@@ -97,6 +97,13 @@ namespace automationInternal {
static void onAccessibilityTreeDestroyed(long treeID);
- static void onTreeChange(long treeID, long nodeID, DOMString changeType);
+ static void onTreeChange(long observerID,
+ long treeID,
+ long nodeID,
+ DOMString changeType);
+
+ static void onChildTreeID(long treeID, long nodeID);
+
+ static void onNodesRemoved(long treeID, long[] nodeIDs);
};
};
diff --git a/chrome/renderer/extensions/automation_internal_custom_bindings.cc b/chrome/renderer/extensions/automation_internal_custom_bindings.cc
index e68edce..c10c211 100644
--- a/chrome/renderer/extensions/automation_internal_custom_bindings.cc
+++ b/chrome/renderer/extensions/automation_internal_custom_bindings.cc
@@ -393,7 +393,10 @@ private:
AutomationInternalCustomBindings::AutomationInternalCustomBindings(
ScriptContext* context)
- : ObjectBackedNativeHandler(context), is_active_profile_(true) {
+ : ObjectBackedNativeHandler(context),
+ is_active_profile_(true),
+ tree_change_observer_overall_filter_(
+ api::automation::TREE_CHANGE_OBSERVER_FILTER_NOTREECHANGES) {
// It's safe to use base::Unretained(this) here because these bindings
// will only be called on a valid AutomationInternalCustomBindings instance
// and none of the functions have any side effects.
@@ -406,6 +409,8 @@ AutomationInternalCustomBindings::AutomationInternalCustomBindings(
ROUTE_FUNCTION(GetRoutingID);
ROUTE_FUNCTION(StartCachingAccessibilityTrees);
ROUTE_FUNCTION(DestroyAccessibilityTree);
+ ROUTE_FUNCTION(AddTreeChangeObserver);
+ ROUTE_FUNCTION(RemoveTreeChangeObserver);
ROUTE_FUNCTION(GetChildIDAtIndex);
#undef ROUTE_FUNCTION
@@ -732,6 +737,51 @@ void AutomationInternalCustomBindings::DestroyAccessibilityTree(
delete cache;
}
+void AutomationInternalCustomBindings::AddTreeChangeObserver(
+ const v8::FunctionCallbackInfo<v8::Value>& args) {
+ if (args.Length() != 2 || !args[0]->IsNumber() || !args[1]->IsString()) {
+ ThrowInvalidArgumentsException(this);
+ return;
+ }
+
+ TreeChangeObserver observer;
+ observer.id = args[0]->Int32Value();
+ std::string filter_str = *v8::String::Utf8Value(args[1]);
+ observer.filter = api::automation::ParseTreeChangeObserverFilter(filter_str);
+
+ tree_change_observers_.push_back(observer);
+ UpdateOverallTreeChangeObserverFilter();
+}
+
+void AutomationInternalCustomBindings::RemoveTreeChangeObserver(
+ const v8::FunctionCallbackInfo<v8::Value>& args) {
+ if (args.Length() != 1 || !args[0]->IsNumber()) {
+ ThrowInvalidArgumentsException(this);
+ return;
+ }
+
+ int observer_id = args[0]->Int32Value();
+
+ for (auto iter = tree_change_observers_.begin();
+ iter != tree_change_observers_.end(); ++iter) {
+ if (iter->id == observer_id) {
+ tree_change_observers_.erase(iter);
+ break;
+ }
+ }
+
+ UpdateOverallTreeChangeObserverFilter();
+}
+
+void AutomationInternalCustomBindings::UpdateOverallTreeChangeObserverFilter() {
+ tree_change_observer_overall_filter_ =
+ api::automation::TREE_CHANGE_OBSERVER_FILTER_NOTREECHANGES;
+ for (const auto& observer : tree_change_observers_) {
+ tree_change_observer_overall_filter_ =
+ std::max(observer.filter, tree_change_observer_overall_filter_);
+ }
+}
+
void AutomationInternalCustomBindings::RouteTreeIDFunction(
const std::string& name,
TreeIDFunction callback) {
@@ -816,15 +866,19 @@ void AutomationInternalCustomBindings::OnAccessibilityEvent(
// Update the internal state whether it's the active profile or not.
cache->location_offset = params.location_offset;
+ deleted_node_ids_.clear();
if (!cache->tree.Unserialize(params.update)) {
LOG(ERROR) << cache->tree.error();
return;
}
- // Don't send the event if it's not the active profile.
+ // Don't send any events if it's not the active profile.
if (!is_active_profile)
return;
+ SendNodesRemovedEvent(&cache->tree, deleted_node_ids_);
+ deleted_node_ids_.clear();
+
v8::Isolate* isolate = GetIsolate();
v8::HandleScope handle_scope(isolate);
v8::Context::Scope context_scope(context()->v8_context());
@@ -847,6 +901,7 @@ void AutomationInternalCustomBindings::OnNodeWillBeDeleted(ui::AXTree* tree,
SendTreeChangeEvent(
api::automation::TREE_CHANGE_TYPE_NODEREMOVED,
tree, node);
+ deleted_node_ids_.push_back(node->id());
}
void AutomationInternalCustomBindings::OnSubtreeWillBeDeleted(
@@ -909,6 +964,62 @@ void AutomationInternalCustomBindings::SendTreeChangeEvent(
if (!is_active_profile_)
return;
+ // Always notify the custom bindings when there's a node with a child tree
+ // ID that might need to be loaded.
+ if (node->data().HasIntAttribute(ui::AX_ATTR_CHILD_TREE_ID))
+ SendChildTreeIDEvent(tree, node);
+
+ switch (tree_change_observer_overall_filter_) {
+ case api::automation::TREE_CHANGE_OBSERVER_FILTER_NOTREECHANGES:
+ default:
+ return;
+ case api::automation::TREE_CHANGE_OBSERVER_FILTER_LIVEREGIONTREECHANGES:
+ if (!node->data().HasStringAttribute(ui::AX_ATTR_CONTAINER_LIVE_STATUS) &&
+ node->data().role != ui::AX_ROLE_ALERT) {
+ return;
+ }
+ break;
+ case api::automation::TREE_CHANGE_OBSERVER_FILTER_ALLTREECHANGES:
+ break;
+ }
+
+ auto iter = axtree_to_tree_cache_map_.find(tree);
+ if (iter == axtree_to_tree_cache_map_.end())
+ return;
+
+ int tree_id = iter->second->tree_id;
+
+ v8::Isolate* isolate = GetIsolate();
+ v8::HandleScope handle_scope(isolate);
+ v8::Context::Scope context_scope(context()->v8_context());
+
+ for (const auto& observer : tree_change_observers_) {
+ switch (observer.filter) {
+ case api::automation::TREE_CHANGE_OBSERVER_FILTER_NOTREECHANGES:
+ default:
+ continue;
+ case api::automation::TREE_CHANGE_OBSERVER_FILTER_LIVEREGIONTREECHANGES:
+ if (!node->data().HasStringAttribute(
+ ui::AX_ATTR_CONTAINER_LIVE_STATUS) &&
+ node->data().role != ui::AX_ROLE_ALERT) {
+ continue;
+ }
+ break;
+ case api::automation::TREE_CHANGE_OBSERVER_FILTER_ALLTREECHANGES:
+ break;
+ }
+
+ v8::Local<v8::Array> args(v8::Array::New(GetIsolate(), 4U));
+ args->Set(0U, v8::Integer::New(GetIsolate(), observer.id));
+ args->Set(1U, v8::Integer::New(GetIsolate(), tree_id));
+ args->Set(2U, v8::Integer::New(GetIsolate(), node->id()));
+ args->Set(3U, CreateV8String(isolate, ToString(change_type)));
+ context()->DispatchEvent("automationInternal.onTreeChange", args);
+ }
+}
+
+void AutomationInternalCustomBindings::SendChildTreeIDEvent(ui::AXTree* tree,
+ ui::AXNode* node) {
auto iter = axtree_to_tree_cache_map_.find(tree);
if (iter == axtree_to_tree_cache_map_.end())
return;
@@ -918,11 +1029,31 @@ void AutomationInternalCustomBindings::SendTreeChangeEvent(
v8::Isolate* isolate = GetIsolate();
v8::HandleScope handle_scope(isolate);
v8::Context::Scope context_scope(context()->v8_context());
- v8::Local<v8::Array> args(v8::Array::New(GetIsolate(), 3U));
+ v8::Local<v8::Array> args(v8::Array::New(GetIsolate(), 2U));
args->Set(0U, v8::Integer::New(GetIsolate(), tree_id));
args->Set(1U, v8::Integer::New(GetIsolate(), node->id()));
- args->Set(2U, CreateV8String(isolate, ToString(change_type)));
- context()->DispatchEvent("automationInternal.onTreeChange", args);
+ context()->DispatchEvent("automationInternal.onChildTreeID", args);
+}
+
+void AutomationInternalCustomBindings::SendNodesRemovedEvent(
+ ui::AXTree* tree,
+ const std::vector<int>& ids) {
+ auto iter = axtree_to_tree_cache_map_.find(tree);
+ if (iter == axtree_to_tree_cache_map_.end())
+ return;
+
+ int tree_id = iter->second->tree_id;
+
+ v8::Isolate* isolate = GetIsolate();
+ v8::HandleScope handle_scope(isolate);
+ v8::Context::Scope context_scope(context()->v8_context());
+ v8::Local<v8::Array> args(v8::Array::New(GetIsolate(), 2U));
+ args->Set(0U, v8::Integer::New(GetIsolate(), tree_id));
+ v8::Local<v8::Array> nodes(v8::Array::New(GetIsolate(), ids.size()));
+ args->Set(1U, nodes);
+ for (size_t i = 0; i < ids.size(); ++i)
+ nodes->Set(i, v8::Integer::New(GetIsolate(), ids[i]));
+ context()->DispatchEvent("automationInternal.onNodesRemoved", args);
}
} // namespace extensions
diff --git a/chrome/renderer/extensions/automation_internal_custom_bindings.h b/chrome/renderer/extensions/automation_internal_custom_bindings.h
index 94e659d..a7c0b75 100644
--- a/chrome/renderer/extensions/automation_internal_custom_bindings.h
+++ b/chrome/renderer/extensions/automation_internal_custom_bindings.h
@@ -29,6 +29,11 @@ struct TreeCache {
ui::AXTree tree;
};
+struct TreeChangeObserver {
+ int id;
+ api::automation::TreeChangeObserverFilter filter;
+};
+
// The native component of custom bindings for the chrome.automationInternal
// API.
class AutomationInternalCustomBindings : public ObjectBackedNativeHandler,
@@ -74,6 +79,11 @@ class AutomationInternalCustomBindings : public ObjectBackedNativeHandler,
void DestroyAccessibilityTree(
const v8::FunctionCallbackInfo<v8::Value>& args);
+ void AddTreeChangeObserver(const v8::FunctionCallbackInfo<v8::Value>& args);
+
+ void RemoveTreeChangeObserver(
+ const v8::FunctionCallbackInfo<v8::Value>& args);
+
void RouteTreeIDFunction(const std::string& name,
void (*callback)(v8::Isolate* isolate,
v8::ReturnValue<v8::Value> result,
@@ -159,6 +169,8 @@ class AutomationInternalCustomBindings : public ObjectBackedNativeHandler,
void OnAccessibilityEvent(const ExtensionMsg_AccessibilityEventParams& params,
bool is_active_profile);
+ void UpdateOverallTreeChangeObserverFilter();
+
// AXTreeDelegate implementation.
void OnTreeDataChanged(ui::AXTree* tree) override;
void OnNodeWillBeDeleted(ui::AXTree* tree, ui::AXNode* node) override;
@@ -168,15 +180,20 @@ class AutomationInternalCustomBindings : public ObjectBackedNativeHandler,
void OnAtomicUpdateFinished(ui::AXTree* tree,
bool root_changed,
const std::vector<Change>& changes) override;
-
void SendTreeChangeEvent(api::automation::TreeChangeType change_type,
ui::AXTree* tree,
ui::AXNode* node);
+ void SendChildTreeIDEvent(ui::AXTree* tree, ui::AXNode* node);
+ void SendNodesRemovedEvent(ui::AXTree* tree, const std::vector<int>& ids);
base::hash_map<int, TreeCache*> tree_id_to_tree_cache_map_;
base::hash_map<ui::AXTree*, TreeCache*> axtree_to_tree_cache_map_;
scoped_refptr<AutomationMessageFilter> message_filter_;
bool is_active_profile_;
+ std::vector<TreeChangeObserver> tree_change_observers_;
+ api::automation::TreeChangeObserverFilter
+ tree_change_observer_overall_filter_;
+ std::vector<int> deleted_node_ids_;
DISALLOW_COPY_AND_ASSIGN(AutomationInternalCustomBindings);
};
diff --git a/chrome/renderer/resources/extensions/automation_custom_bindings.js b/chrome/renderer/resources/extensions/automation_custom_bindings.js
index 1d8c13f..c09243a 100644
--- a/chrome/renderer/resources/extensions/automation_custom_bindings.js
+++ b/chrome/renderer/resources/extensions/automation_custom_bindings.js
@@ -22,6 +22,9 @@ var DestroyAccessibilityTree =
var GetIntAttribute = nativeAutomationInternal.GetIntAttribute;
var StartCachingAccessibilityTrees =
nativeAutomationInternal.StartCachingAccessibilityTrees;
+var AddTreeChangeObserver = nativeAutomationInternal.AddTreeChangeObserver;
+var RemoveTreeChangeObserver =
+ nativeAutomationInternal.RemoveTreeChangeObserver;
var schema = GetSchemaAdditions();
/**
@@ -53,9 +56,15 @@ automationUtil.storeTreeCallback = function(id, callback) {
/**
* Global list of tree change observers.
- * @type {Array<TreeChangeObserver>}
+ * @type {Object<number, TreeChangeObserver>}
*/
-automationUtil.treeChangeObservers = [];
+automationUtil.treeChangeObserverMap = {};
+
+/**
+ * The id of the next tree change observer.
+ * @type {number}
+ */
+automationUtil.nextTreeChangeObserverId = 1;
automation.registerCustomHook(function(bindingsAPI) {
var apiFunctions = bindingsAPI.apiFunctions;
@@ -110,22 +119,27 @@ automation.registerCustomHook(function(bindingsAPI) {
});
function removeTreeChangeObserver(observer) {
- var observers = automationUtil.treeChangeObservers;
- for (var i = 0; i < observers.length; i++) {
- if (observer == observers[i])
- observers.splice(i, 1);
+ for (var id in automationUtil.treeChangeObserverMap) {
+ if (automationUtil.treeChangeObserverMap[id] == observer) {
+ RemoveTreeChangeObserver(id);
+ delete automationUtil.treeChangeObserverMap[id];
+ return;
+ }
}
}
apiFunctions.setHandleRequest('removeTreeChangeObserver', function(observer) {
removeTreeChangeObserver(observer);
});
- function addTreeChangeObserver(observer) {
+ function addTreeChangeObserver(filter, observer) {
removeTreeChangeObserver(observer);
- automationUtil.treeChangeObservers.push(observer);
+ var id = automationUtil.nextTreeChangeObserverId++;
+ AddTreeChangeObserver(id, filter);
+ automationUtil.treeChangeObserverMap[id] = observer;
}
- apiFunctions.setHandleRequest('addTreeChangeObserver', function(observer) {
- addTreeChangeObserver(observer);
+ apiFunctions.setHandleRequest('addTreeChangeObserver',
+ function(filter, observer) {
+ addTreeChangeObserver(filter, observer);
});
apiFunctions.setHandleRequest('setDocumentSelection', function(params) {
@@ -147,7 +161,44 @@ automation.registerCustomHook(function(bindingsAPI) {
});
-automationInternal.onTreeChange.addListener(function(treeID,
+automationInternal.onChildTreeID.addListener(function(treeID,
+ nodeID) {
+ var tree = AutomationRootNode.getOrCreate(treeID);
+ if (!tree)
+ return;
+
+ var node = privates(tree).impl.get(nodeID);
+ if (!node)
+ return;
+
+ // A WebView in the desktop tree has a different AX tree as its child.
+ // When we encounter a WebView with a child AX tree id that we don't
+ // currently have cached, explicitly request that AX tree from the
+ // browser process and set up a callback when it loads to attach that
+ // tree as a child of this node and fire appropriate events.
+ var childTreeID = GetIntAttribute(treeID, nodeID, 'childTreeId');
+ if (!childTreeID)
+ return;
+
+ var subroot = AutomationRootNode.get(childTreeID);
+ if (!subroot) {
+ automationUtil.storeTreeCallback(childTreeID, function(root) {
+ privates(root).impl.setHostNode(node);
+
+ if (root.docLoaded)
+ privates(root).impl.dispatchEvent(schema.EventType.loadComplete);
+
+ privates(node).impl.dispatchEvent(schema.EventType.childrenChanged);
+ });
+
+ automationInternal.enableFrame(childTreeID);
+ } else {
+ privates(subroot).impl.setHostNode(node);
+ }
+});
+
+automationInternal.onTreeChange.addListener(function(observerID,
+ treeID,
nodeID,
changeType) {
var tree = AutomationRootNode.getOrCreate(treeID);
@@ -158,49 +209,25 @@ automationInternal.onTreeChange.addListener(function(treeID,
if (!node)
return;
- if (node.role == 'webView' || node.role == 'embeddedObject') {
- // A WebView in the desktop tree has a different AX tree as its child.
- // When we encounter a WebView with a child AX tree id that we don't
- // currently have cached, explicitly request that AX tree from the
- // browser process and set up a callback when it loads to attach that
- // tree as a child of this node and fire appropriate events.
- var childTreeID = GetIntAttribute(treeID, nodeID, 'childTreeId');
- if (!childTreeID)
- return;
-
- var subroot = AutomationRootNode.get(childTreeID);
- if (!subroot) {
- automationUtil.storeTreeCallback(childTreeID, function(root) {
- privates(root).impl.setHostNode(node);
-
- if (root.docLoaded)
- privates(root).impl.dispatchEvent(schema.EventType.loadComplete);
-
- privates(node).impl.dispatchEvent(schema.EventType.childrenChanged);
- });
+ var observer = automationUtil.treeChangeObserverMap[observerID];
+ if (!observer)
+ return;
- automationInternal.enableFrame(childTreeID);
- } else {
- privates(subroot).impl.setHostNode(node);
- }
+ try {
+ observer({target: node, type: changeType});
+ } catch (e) {
+ exceptionHandler.handle('Error in tree change observer for ' +
+ treeChange.type, e);
}
+});
- var treeChange = {target: node, type: changeType};
-
- // Make a copy of the observers in case one of these callbacks tries
- // to change the list of observers.
- var observers = automationUtil.treeChangeObservers.slice();
- for (var i = 0; i < observers.length; i++) {
- try {
- observers[i](treeChange);
- } catch (e) {
- exceptionHandler.handle('Error in tree change observer for ' +
- treeChange.type, e);
- }
- }
+automationInternal.onNodesRemoved.addListener(function(treeID, nodeIDs) {
+ var tree = AutomationRootNode.getOrCreate(treeID);
+ if (!tree)
+ return;
- if (changeType == schema.TreeChangeType.nodeRemoved) {
- privates(tree).impl.remove(nodeID);
+ for (var i = 0; i < nodeIDs.length; i++) {
+ privates(tree).impl.remove(nodeIDs[i]);
}
});
diff --git a/chrome/test/data/extensions/api_test/automation/sites/tree_change.html b/chrome/test/data/extensions/api_test/automation/sites/tree_change.html
index c51eed4..512acd8 100644
--- a/chrome/test/data/extensions/api_test/automation/sites/tree_change.html
+++ b/chrome/test/data/extensions/api_test/automation/sites/tree_change.html
@@ -14,17 +14,35 @@
</ul>
<button id="add">Add</button>
<button id="remove">Remove</button>
+ <button id="live">Live</button>
+
+ <div id="dead_region1"></div>
+ <div id="live_region" aria-live="polite"></div>
+ <div id="dead_region2"></div>
<script>
document.getElementById('add').addEventListener('click', function() {
var li = document.createElement('li');
- li.innerText = "New";
+ li.innerText = 'New';
document.getElementById('list').appendChild(li);
});
document.getElementById('remove').addEventListener('click', function() {
var list = document.getElementById('list');
list.removeChild(list.lastElementChild);
});
+ document.getElementById('live').addEventListener('click', function() {
+ var p1 = document.createElement('p');
+ p1.innerText = 'Dead';
+ document.getElementById('dead_region1').appendChild(p1);
+
+ var p = document.createElement('p');
+ p.innerText = 'Live';
+ document.getElementById('live_region').appendChild(p);
+
+ var p2 = document.createElement('p');
+ p2.innerText = 'Dead';
+ document.getElementById('dead_region2').appendChild(p2);
+ });
</script>
</body>
diff --git a/chrome/test/data/extensions/api_test/automation/tests/tabs/tree_change.js b/chrome/test/data/extensions/api_test/automation/tests/tabs/tree_change.js
index 19a1416..af4107c 100644
--- a/chrome/test/data/extensions/api_test/automation/tests/tabs/tree_change.js
+++ b/chrome/test/data/extensions/api_test/automation/tests/tabs/tree_change.js
@@ -4,7 +4,7 @@
var allTests = [
function testTreeChangedObserverForCreatingNode() {
- chrome.automation.addTreeChangeObserver(function(change) {
+ chrome.automation.addTreeChangeObserver("allTreeChanges", function(change) {
if (change.type == "subtreeCreated" && change.target.name == "New") {
chrome.test.succeed();
}
@@ -15,7 +15,7 @@ var allTests = [
},
function testTreeChangedObserverForRemovingNode() {
- chrome.automation.addTreeChangeObserver(function(change) {
+ chrome.automation.addTreeChangeObserver("allTreeChanges", function(change) {
if (change.type == "nodeRemoved" && change.target.role == "listItem") {
chrome.test.succeed();
}
@@ -23,7 +23,25 @@ var allTests = [
var removeButton = rootNode.find({ attributes: { name: 'Remove' }});
removeButton.doDefault();
+ },
+
+ function testTreeChangedObserverForLiveRegionsOnly() {
+ // This test would fail if we set the filter to allTreeChanges.
+ chrome.automation.addTreeChangeObserver(
+ "liveRegionTreeChanges",
+ function(change) {
+ if (change.target.name == 'Dead') {
+ chrome.test.fail();
+ }
+ if (change.target.name == 'Live') {
+ chrome.test.succeed();
+ }
+ });
+
+ var liveButton = rootNode.find({ attributes: { name: 'Live' }});
+ liveButton.doDefault();
}
+
];
setUpAndRunTests(allTests, 'tree_change.html');