diff options
Diffstat (limited to 'layout/base/tests/marionette')
4 files changed, 1393 insertions, 0 deletions
diff --git a/layout/base/tests/marionette/manifest.ini b/layout/base/tests/marionette/manifest.ini new file mode 100644 index 0000000000..8249f4010a --- /dev/null +++ b/layout/base/tests/marionette/manifest.ini @@ -0,0 +1,6 @@ +[DEFAULT] +prefs = + gfx.font_loader.delay=0 +run-if = buildapp == 'browser' +[test_accessiblecaret_cursor_mode.py] +[test_accessiblecaret_selection_mode.py] diff --git a/layout/base/tests/marionette/selection.py b/layout/base/tests/marionette/selection.py new file mode 100644 index 0000000000..6cfe155927 --- /dev/null +++ b/layout/base/tests/marionette/selection.py @@ -0,0 +1,335 @@ +# -*- coding: utf-8 -*- +# 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/. + +from marionette_driver.marionette import Actions, errors + + +class CaretActions(Actions): + def __init__(self, marionette): + super(CaretActions, self).__init__(marionette) + self._reset_action_chain() + + def _reset_action_chain(self): + self.mouse_chain = self.sequence( + "pointer", "pointer_id", {"pointerType": "mouse"} + ) + self.key_chain = self.sequence("key", "keyboard_id") + + def flick(self, element, x1, y1, x2, y2, duration=200): + """Perform a flick gesture on the target element. + + :param element: The element to perform the flick gesture on. + :param x1: Starting x-coordinate of flick, relative to the top left + corner of the element. + :param y1: Starting y-coordinate of flick, relative to the top left + corner of the element. + :param x2: Ending x-coordinate of flick, relative to the top left + corner of the element. + :param y2: Ending y-coordinate of flick, relative to the top left + corner of the element. + + """ + rect = element.rect + el_x, el_y = rect["x"], rect["y"] + + # Add element's (x, y) to make the coordinate relative to the viewport. + from_x, from_y = int(el_x + x1), int(el_y + y1) + to_x, to_y = int(el_x + x2), int(el_y + y2) + + self.mouse_chain.pointer_move(from_x, from_y).pointer_down().pointer_move( + to_x, to_y, duration=duration + ).pointer_up() + return self + + def send_keys(self, keys): + """Perform a keyDown and keyUp action for each character in `keys`. + + :param keys: String of keys to perform key actions with. + + """ + self.key_chain.send_keys(keys) + return self + + def perform(self): + """Perform the action chain built so far to the server side for execution + and clears the current chain of actions. + + Warning: This method performs all the mouse actions before all the key + actions! + + """ + self.mouse_chain.perform() + self.key_chain.perform() + self._reset_action_chain() + + +class SelectionManager(object): + """Interface for manipulating the selection and carets of the element. + + We call the blinking cursor (nsCaret) as cursor, and call AccessibleCaret as + caret for short. + + Simple usage example: + + :: + + element = marionette.find_element(By.ID, 'input') + sel = SelectionManager(element) + sel.move_caret_to_front() + + """ + + def __init__(self, element): + self.element = element + + def _input_or_textarea(self): + """Return True if element is either <input> or <textarea>.""" + return self.element.tag_name in ("input", "textarea") + + def js_selection_cmd(self): + """Return a command snippet to get selection object. + + If the element is <input> or <textarea>, return the selection object + associated with it. Otherwise, return the current selection object. + + Note: "element" must be provided as the first argument to + execute_script(). + + """ + if self._input_or_textarea(): + # We must unwrap sel so that DOMRect could be returned to Python + # side. + return """var sel = arguments[0].editor.selection;""" + else: + return """var sel = window.getSelection();""" + + def move_cursor_by_offset(self, offset, backward=False): + """Move cursor in the element by character offset. + + :param offset: Move the cursor to the direction by offset characters. + :param backward: Optional, True to move backward; Default to False to + move forward. + + """ + cmd = ( + self.js_selection_cmd() + + """ + for (let i = 0; i < {0}; ++i) {{ + sel.modify("move", "{1}", "character"); + }} + """.format( + offset, "backward" if backward else "forward" + ) + ) + + self.element.marionette.execute_script( + cmd, script_args=(self.element,), sandbox="system" + ) + + def move_cursor_to_front(self): + """Move cursor in the element to the front of the content.""" + if self._input_or_textarea(): + cmd = """arguments[0].setSelectionRange(0, 0);""" + else: + cmd = """var sel = window.getSelection(); + sel.collapse(arguments[0].firstChild, 0);""" + + self.element.marionette.execute_script( + cmd, script_args=(self.element,), sandbox=None + ) + + def move_cursor_to_end(self): + """Move cursor in the element to the end of the content.""" + if self._input_or_textarea(): + cmd = """var len = arguments[0].value.length; + arguments[0].setSelectionRange(len, len);""" + else: + cmd = """var sel = window.getSelection(); + sel.collapse(arguments[0].lastChild, arguments[0].lastChild.length);""" + + self.element.marionette.execute_script( + cmd, script_args=(self.element,), sandbox=None + ) + + def selection_rect_list(self, idx): + """Return the selection's DOMRectList object for the range at given idx. + + If the element is either <input> or <textarea>, return the DOMRectList of + the range at given idx of the selection within the element. Otherwise, + return the DOMRectList of the of the range at given idx of current selection. + + """ + cmd = ( + self.js_selection_cmd() + + """return sel.getRangeAt({}).getClientRects();""".format(idx) + ) + return self.element.marionette.execute_script( + cmd, script_args=(self.element,), sandbox="system" + ) + + def range_count(self): + """Get selection's range count""" + cmd = self.js_selection_cmd() + """return sel.rangeCount;""" + return self.element.marionette.execute_script( + cmd, script_args=(self.element,), sandbox="system" + ) + + def _selection_location_helper(self, location_type): + """Return the start and end location of the selection in the element. + + Return a tuple containing two pairs of (x, y) coordinates of the start + and end locations in the element. The coordinates are relative to the + top left-hand corner of the element. Both ltr and rtl directions are + considered. + + """ + range_count = self.range_count() + if range_count <= 0: + raise errors.MarionetteException( + "Expect at least one range object in Selection, but found nothing!" + ) + + # FIXME (Bug 1682382): We shouldn't need the retry for-loops if + # selection_rect_list() can reliably return a valid list. + retry_times = 3 + for _ in range(retry_times): + try: + first_rect_list = self.selection_rect_list(0) + first_rect = first_rect_list["0"] + break + except KeyError: + continue + else: + raise errors.MarionetteException( + "Expect at least one rect in the first range, but found nothing!" + ) + + for _ in range(retry_times): + try: + # Making a selection over some non-selectable elements can + # create multiple ranges. + last_rect_list = ( + first_rect_list + if range_count == 1 + else self.selection_rect_list(range_count - 1) + ) + last_list_length = last_rect_list["length"] + last_rect = last_rect_list[str(last_list_length - 1)] + break + except KeyError: + continue + else: + raise errors.MarionetteException( + "Expect at least one rect in the last range, but found nothing!" + ) + + origin_x, origin_y = self.element.rect["x"], self.element.rect["y"] + + if self.element.get_property("dir") == "rtl": # such as Arabic + start_pos, end_pos = "right", "left" + else: + start_pos, end_pos = "left", "right" + + # Calculate y offset according to different needs. + if location_type == "center": + start_y_offset = first_rect["height"] / 2.0 + end_y_offset = last_rect["height"] / 2.0 + elif location_type == "caret": + # Selection carets' tip are below the bottom of the two ends of the + # selection. Add 5px to y should be sufficient to locate them. + caret_tip_y_offset = 5 + start_y_offset = first_rect["height"] + caret_tip_y_offset + end_y_offset = last_rect["height"] + caret_tip_y_offset + else: + start_y_offset = end_y_offset = 0 + + caret1_x = first_rect[start_pos] - origin_x + caret1_y = first_rect["top"] + start_y_offset - origin_y + caret2_x = last_rect[end_pos] - origin_x + caret2_y = last_rect["top"] + end_y_offset - origin_y + + return ((caret1_x, caret1_y), (caret2_x, caret2_y)) + + def selection_location(self): + """Return the start and end location of the selection in the element. + + Return a tuple containing two pairs of (x, y) coordinates of the start + and end of the selection. The coordinates are relative to the top + left-hand corner of the element. Both ltr and rtl direction are + considered. + + """ + return self._selection_location_helper("center") + + def carets_location(self): + """Return a pair of the two carets' location. + + Return a tuple containing two pairs of (x, y) coordinates of the two + carets' tip. The coordinates are relative to the top left-hand corner of + the element. Both ltr and rtl direction are considered. + + """ + return self._selection_location_helper("caret") + + def cursor_location(self): + """Return the blanking cursor's center location within the element. + + Return (x, y) coordinates of the cursor's center relative to the top + left-hand corner of the element. + + """ + return self._selection_location_helper("center")[0] + + def first_caret_location(self): + """Return the first caret's location. + + Return (x, y) coordinates of the first caret's tip relative to the top + left-hand corner of the element. + + """ + return self.carets_location()[0] + + def second_caret_location(self): + """Return the second caret's location. + + Return (x, y) coordinates of the second caret's tip relative to the top + left-hand corner of the element. + + """ + return self.carets_location()[1] + + def select_all(self): + """Select all the content in the element.""" + if self._input_or_textarea(): + cmd = """var len = arguments[0].value.length; + arguments[0].focus(); + arguments[0].setSelectionRange(0, len);""" + else: + cmd = """var range = document.createRange(); + range.setStart(arguments[0].firstChild, 0); + range.setEnd(arguments[0].lastChild, arguments[0].lastChild.length); + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range);""" + + self.element.marionette.execute_script( + cmd, script_args=(self.element,), sandbox=None + ) + + @property + def content(self): + """Return all the content of the element.""" + if self._input_or_textarea(): + return self.element.get_property("value") + else: + return self.element.text + + @property + def selected_content(self): + """Return the selected portion of the content in the element.""" + cmd = self.js_selection_cmd() + """return sel.toString();""" + return self.element.marionette.execute_script( + cmd, script_args=(self.element,), sandbox="system" + ) diff --git a/layout/base/tests/marionette/test_accessiblecaret_cursor_mode.py b/layout/base/tests/marionette/test_accessiblecaret_cursor_mode.py new file mode 100644 index 0000000000..a6c7944817 --- /dev/null +++ b/layout/base/tests/marionette/test_accessiblecaret_cursor_mode.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- +# 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/. + +import string +import sys +import os + +# Add this directory to the import path. +sys.path.append(os.path.dirname(__file__)) + +from selection import ( + CaretActions, + SelectionManager, +) +from marionette_driver.by import By +from marionette_harness.marionette_test import ( + MarionetteTestCase, + parameterized, +) + + +class AccessibleCaretCursorModeTestCase(MarionetteTestCase): + """Test cases for AccessibleCaret under cursor mode. + + We call the blinking cursor (nsCaret) as cursor, and call AccessibleCaret as + caret for short. + + """ + + # Element IDs. + _input_id = "input" + _input_padding_id = "input-padding" + _textarea_id = "textarea" + _textarea_one_line_id = "textarea-one-line" + _contenteditable_id = "contenteditable" + + # Test html files. + _cursor_html = "layout/test_carets_cursor.html" + + def setUp(self): + # Code to execute before every test is running. + super(AccessibleCaretCursorModeTestCase, self).setUp() + self.caret_tested_pref = "layout.accessiblecaret.enabled" + self.hide_carets_for_mouse = ( + "layout.accessiblecaret.hide_carets_for_mouse_input" + ) + self.prefs = { + self.caret_tested_pref: True, + self.hide_carets_for_mouse: False, + # To disable transition, or the caret may not be the desired + # location yet, we cannot press a caret successfully. + "layout.accessiblecaret.transition-duration": "0.0", + # Enabled hapticfeedback on all platforms. The tests shouldn't crash + # on platforms without hapticfeedback support. + "layout.accessiblecaret.hapticfeedback": True, + } + self.marionette.set_prefs(self.prefs) + self.actions = CaretActions(self.marionette) + + def tearDown(self): + self.marionette.actions.release() + super(AccessibleCaretCursorModeTestCase, self).tearDown() + + def open_test_html(self, test_html): + self.marionette.navigate(self.marionette.absolute_url(test_html)) + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_textarea_id, el_id=_textarea_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + def test_move_cursor_to_the_right_by_one_character(self, el_id): + self.open_test_html(self._cursor_html) + el = self.marionette.find_element(By.ID, el_id) + sel = SelectionManager(el) + content_to_add = "!" + target_content = sel.content + target_content = target_content[:1] + content_to_add + target_content[1:] + + # Get first caret (x, y) at position 1 and 2. + el.tap() + sel.move_cursor_to_front() + cursor0_x, cursor0_y = sel.cursor_location() + first_caret0_x, first_caret0_y = sel.first_caret_location() + sel.move_cursor_by_offset(1) + first_caret1_x, first_caret1_y = sel.first_caret_location() + + # Tap the front of the input to make first caret appear. + el.tap(cursor0_x, cursor0_y) + + # Move first caret. + self.actions.flick( + el, first_caret0_x, first_caret0_y, first_caret1_x, first_caret1_y + ).perform() + + self.actions.send_keys(content_to_add).perform() + self.assertEqual(target_content, sel.content) + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_textarea_id, el_id=_textarea_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + def test_move_cursor_to_end_by_dragging_caret_to_bottom_right_corner(self, el_id): + self.open_test_html(self._cursor_html) + el = self.marionette.find_element(By.ID, el_id) + sel = SelectionManager(el) + content_to_add = "!" + target_content = sel.content + content_to_add + + # Tap the front of the input to make first caret appear. + el.tap() + sel.move_cursor_to_front() + el.tap(*sel.cursor_location()) + + # Move first caret to the bottom-right corner of the element. + src_x, src_y = sel.first_caret_location() + dest_x, dest_y = el.rect["width"], el.rect["height"] + self.actions.flick(el, src_x, src_y, dest_x, dest_y).perform() + + self.actions.send_keys(content_to_add).perform() + self.assertEqual(target_content, sel.content) + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_textarea_id, el_id=_textarea_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + def test_move_cursor_to_front_by_dragging_caret_to_front(self, el_id): + self.open_test_html(self._cursor_html) + el = self.marionette.find_element(By.ID, el_id) + sel = SelectionManager(el) + content_to_add = "!" + target_content = content_to_add + sel.content + + # Get first caret location at the front. + el.tap() + sel.move_cursor_to_front() + dest_x, dest_y = sel.first_caret_location() + + # Tap to make first caret appear. Note: it's strange that when the caret + # is at the end, the rect of the caret in <textarea> cannot be obtained. + # A bug perhaps. + el.tap() + sel.move_cursor_to_end() + sel.move_cursor_by_offset(1, backward=True) + el.tap(*sel.cursor_location()) + src_x, src_y = sel.first_caret_location() + + # Move first caret to the front of the input box. + self.actions.flick(el, src_x, src_y, dest_x, dest_y).perform() + + self.actions.send_keys(content_to_add).perform() + self.assertEqual(target_content, sel.content) + + def test_caret_not_appear_when_typing_in_scrollable_content(self): + self.open_test_html(self._cursor_html) + el = self.marionette.find_element(By.ID, self._input_id) + sel = SelectionManager(el) + content_to_add = "!" + non_target_content = content_to_add + sel.content + string.ascii_letters + + el.tap() + sel.move_cursor_to_end() + + # Insert a long string to the end of the <input>, which triggers + # ScrollPositionChanged event. + el.send_keys(string.ascii_letters) + + # The caret should not be visible. If it does appear wrongly due to the + # ScrollPositionChanged event, we can drag it to the front of the + # <input> to change the cursor position. + src_x, src_y = sel.first_caret_location() + dest_x, dest_y = 0, 0 + self.actions.flick(el, src_x, src_y, dest_x, dest_y).perform() + + # The content should not be inserted at the front of the <input>. + el.send_keys(content_to_add) + + self.assertNotEqual(non_target_content, sel.content) + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_input_padding_id, el_id=_input_padding_id) + @parameterized(_textarea_one_line_id, el_id=_textarea_one_line_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + def test_caret_not_jump_when_dragging_to_editable_content_boundary(self, el_id): + self.open_test_html(self._cursor_html) + el = self.marionette.find_element(By.ID, el_id) + sel = SelectionManager(el) + content_to_add = "!" + non_target_content = sel.content + content_to_add + + # Goal: the cursor position is not changed after dragging the caret down + # on the Y-axis. + el.tap() + sel.move_cursor_to_front() + el.tap(*sel.cursor_location()) + x, y = sel.first_caret_location() + + # Drag the caret down by 50px, and insert '!'. + self.actions.flick(el, x, y, x, y + 50).perform() + self.actions.send_keys(content_to_add).perform() + self.assertNotEqual(non_target_content, sel.content) + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_input_padding_id, el_id=_input_padding_id) + @parameterized(_textarea_one_line_id, el_id=_textarea_one_line_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + def test_caret_not_jump_to_front_when_dragging_up_to_editable_content_boundary( + self, el_id + ): + self.open_test_html(self._cursor_html) + el = self.marionette.find_element(By.ID, el_id) + sel = SelectionManager(el) + content_to_add = "!" + non_target_content = content_to_add + sel.content + + # Goal: the cursor position is not changed after dragging the caret down + # on the Y-axis. + el.tap() + sel.move_cursor_to_end() + sel.move_cursor_by_offset(1, backward=True) + el.tap(*sel.cursor_location()) + x, y = sel.first_caret_location() + + # Drag the caret up by 40px, and insert '!'. + self.actions.flick(el, x, y, x, y - 40).perform() + self.actions.send_keys(content_to_add).perform() + self.assertNotEqual(non_target_content, sel.content) + + def test_drag_caret_from_front_to_end_across_columns(self): + self.open_test_html("layout/test_carets_columns.html") + el = self.marionette.find_element(By.ID, "columns-inner") + sel = SelectionManager(el) + content_to_add = "!" + target_content = sel.content + content_to_add + + # Goal: the cursor position can be changed by dragging the caret from + # the front to the end of the content. + + # Tap to make the cursor appear. + before_image_1 = self.marionette.find_element(By.ID, "before-image-1") + before_image_1.tap() + + # Tap the front of the content to make first caret appear. + sel.move_cursor_to_front() + el.tap(*sel.cursor_location()) + src_x, src_y = sel.first_caret_location() + dest_x, dest_y = el.rect["width"], el.rect["height"] + + # Drag the first caret to the bottom-right corner of the element. + self.actions.flick(el, src_x, src_y, dest_x, dest_y).perform() + + self.actions.send_keys(content_to_add).perform() + self.assertEqual(target_content, sel.content) + + def test_move_cursor_to_front_by_dragging_caret_to_front_br_element(self): + self.open_test_html(self._cursor_html) + el = self.marionette.find_element(By.ID, self._contenteditable_id) + sel = SelectionManager(el) + content_to_add_1 = "!" + content_to_add_2 = "\n\n" + target_content = content_to_add_1 + content_to_add_2 + sel.content + + # Goal: the cursor position can be changed by dragging the caret from + # the end of the content to the front br element. Because we cannot get + # caret location if it's on a br element, we need to get the first caret + # location then adding the new lines. + + # Get first caret location at the front. + el.tap() + sel.move_cursor_to_front() + dest_x, dest_y = sel.first_caret_location() + + # Append new line to the front of the content. + el.send_keys(content_to_add_2) + + # Tap to make first caret appear. + el.tap() + sel.move_cursor_to_end() + sel.move_cursor_by_offset(1, backward=True) + el.tap(*sel.cursor_location()) + src_x, src_y = sel.first_caret_location() + + # Move first caret to the front of the input box. + self.actions.flick(el, src_x, src_y, dest_x, dest_y).perform() + + self.actions.send_keys(content_to_add_1).perform() + self.assertEqual(target_content, sel.content) diff --git a/layout/base/tests/marionette/test_accessiblecaret_selection_mode.py b/layout/base/tests/marionette/test_accessiblecaret_selection_mode.py new file mode 100644 index 0000000000..c9ac65e8ef --- /dev/null +++ b/layout/base/tests/marionette/test_accessiblecaret_selection_mode.py @@ -0,0 +1,767 @@ +# -*- coding: utf-8 -*- +# 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/. + +import re +import sys +import os + +# Add this directory to the import path. +sys.path.append(os.path.dirname(__file__)) + +from selection import ( + CaretActions, + SelectionManager, +) +from marionette_driver.by import By +from marionette_harness.marionette_test import ( + MarionetteTestCase, + SkipTest, + parameterized, +) + + +class AccessibleCaretSelectionModeTestCase(MarionetteTestCase): + """Test cases for AccessibleCaret under selection mode.""" + + # Element IDs. + _input_id = "input" + _input_padding_id = "input-padding" + _input_size_id = "input-size" + _textarea_id = "textarea" + _textarea2_id = "textarea2" + _textarea_disabled_id = "textarea-disabled" + _textarea_one_line_id = "textarea-one-line" + _textarea_rtl_id = "textarea-rtl" + _contenteditable_id = "contenteditable" + _contenteditable2_id = "contenteditable2" + _content_id = "content" + _content2_id = "content2" + _non_selectable_id = "non-selectable" + + # Test html files. + _selection_html = "layout/test_carets_selection.html" + _multipleline_html = "layout/test_carets_multipleline.html" + _multiplerange_html = "layout/test_carets_multiplerange.html" + _longtext_html = "layout/test_carets_longtext.html" + _iframe_html = "layout/test_carets_iframe.html" + _iframe_scroll_html = "layout/test_carets_iframe_scroll.html" + _display_none_html = "layout/test_carets_display_none.html" + _svg_shapes_html = "layout/test_carets_svg_shapes.html" + + def setUp(self): + # Code to execute before every test is running. + super(AccessibleCaretSelectionModeTestCase, self).setUp() + self.carets_tested_pref = "layout.accessiblecaret.enabled" + self.prefs = { + "layout.word_select.eat_space_to_next_word": False, + self.carets_tested_pref: True, + # To disable transition, or the caret may not be the desired + # location yet, we cannot press a caret successfully. + "layout.accessiblecaret.transition-duration": "0.0", + # Enabled hapticfeedback on all platforms. The tests shouldn't crash + # on platforms without hapticfeedback support. + "layout.accessiblecaret.hapticfeedback": True, + } + self.marionette.set_prefs(self.prefs) + self.actions = CaretActions(self.marionette) + + def tearDown(self): + self.marionette.actions.release() + super(AccessibleCaretSelectionModeTestCase, self).tearDown() + + def open_test_html(self, test_html): + self.marionette.navigate(self.marionette.absolute_url(test_html)) + + def word_offset(self, text, ordinal): + "Get the character offset of the ordinal-th word in text." + tokens = re.split(r"(\S+)", text) # both words and spaces + spaces = tokens[0::2] # collect spaces at odd indices + words = tokens[1::2] # collect word at even indices + + if ordinal >= len(words): + raise IndexError( + "Only %d words in text, but got ordinal %d" % (len(words), ordinal) + ) + + # Cursor position of the targeting word is behind the the first + # character in the word. For example, offset to 'def' in 'abc def' is + # between 'd' and 'e'. + offset = len(spaces[0]) + 1 + offset += sum(len(words[i]) + len(spaces[i + 1]) for i in range(ordinal)) + return offset + + def test_word_offset(self): + text = " " * 3 + "abc" + " " * 3 + "def" + + self.assertTrue(self.word_offset(text, 0), 4) + self.assertTrue(self.word_offset(text, 1), 10) + with self.assertRaises(IndexError): + self.word_offset(text, 2) + + def word_location(self, el, ordinal): + """Get the location (x, y) of the ordinal-th word in el. + + The ordinal starts from 0. + + Note: this function has a side effect which changes focus to the + target element el. + + """ + sel = SelectionManager(el) + offset = self.word_offset(sel.content, ordinal) + + # Move the blinking cursor to the word. + el.tap() + sel.move_cursor_to_front() + sel.move_cursor_by_offset(offset) + x, y = sel.cursor_location() + + return x, y + + def rect_relative_to_window(self, el): + """Get element's bounding rectangle. + + This function is similar to el.rect, but the coordinate is relative to + the top left corner of the window instead of the document. + + """ + return self.marionette.execute_script( + """ + let rect = arguments[0].getBoundingClientRect(); + return {x: rect.x, y:rect.y, width: rect.width, height: rect.height}; + """, + script_args=[el], + ) + + def long_press_on_location(self, el, x=None, y=None): + """Long press the location (x, y) to select a word. + + If no (x, y) are given, it will be targeted at the center of the + element. On Windows, those spaces after the word will also be selected. + This function sends synthesized eMouseLongTap to gecko. + + """ + rect = self.rect_relative_to_window(el) + target_x = rect["x"] + (x if x is not None else rect["width"] // 2) + target_y = rect["y"] + (y if y is not None else rect["height"] // 2) + + self.marionette.execute_script( + """ + let utils = window.windowUtils; + utils.sendTouchEventToWindow('touchstart', [0], + [arguments[0]], [arguments[1]], + [1], [1], [0], [1], [0], [0], [0], 0); + utils.sendMouseEventToWindow('mouselongtap', arguments[0], arguments[1], + 0, 1, 0); + utils.sendTouchEventToWindow('touchend', [0], + [arguments[0]], [arguments[1]], + [1], [1], [0], [1], [0], [0], [0], 0); + """, + script_args=[target_x, target_y], + sandbox="system", + ) + + def long_press_on_word(self, el, wordOrdinal): + x, y = self.word_location(el, wordOrdinal) + self.long_press_on_location(el, x, y) + + def to_unix_line_ending(self, s): + """Changes all Windows/Mac line endings in s to UNIX line endings.""" + + return s.replace("\r\n", "\n").replace("\r", "\n") + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_textarea_id, el_id=_textarea_id) + @parameterized(_textarea_disabled_id, el_id=_textarea_disabled_id) + @parameterized(_textarea_rtl_id, el_id=_textarea_rtl_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + @parameterized(_content_id, el_id=_content_id) + def test_long_press_to_select_a_word(self, el_id): + self.open_test_html(self._selection_html) + el = self.marionette.find_element(By.ID, el_id) + self._test_long_press_to_select_a_word(el) + + def _test_long_press_to_select_a_word(self, el): + sel = SelectionManager(el) + original_content = sel.content + words = original_content.split() + self.assertTrue(len(words) >= 2, "Expect at least two words in the content.") + target_content = words[0] + + # Goal: Select the first word. + self.long_press_on_word(el, 0) + + # Ignore extra spaces selected after the word. + self.assertEqual(target_content, sel.selected_content) + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_textarea_id, el_id=_textarea_id) + @parameterized(_textarea_disabled_id, el_id=_textarea_disabled_id) + @parameterized(_textarea_rtl_id, el_id=_textarea_rtl_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + @parameterized(_content_id, el_id=_content_id) + def test_drag_carets(self, el_id): + self.open_test_html(self._selection_html) + el = self.marionette.find_element(By.ID, el_id) + sel = SelectionManager(el) + original_content = sel.content + words = original_content.split() + self.assertTrue(len(words) >= 1, "Expect at least one word in the content.") + + # Goal: Select all text after the first word. + target_content = original_content[len(words[0]) :] + + # Get the location of the carets at the end of the content for later + # use. + el.tap() + sel.select_all() + end_caret_x, end_caret_y = sel.second_caret_location() + + self.long_press_on_word(el, 0) + + # Drag the second caret to the end of the content. + (caret1_x, caret1_y), (caret2_x, caret2_y) = sel.carets_location() + self.actions.flick(el, caret2_x, caret2_y, end_caret_x, end_caret_y).perform() + + # Drag the first caret to the previous position of the second caret. + self.actions.flick(el, caret1_x, caret1_y, caret2_x, caret2_y).perform() + + self.assertEqual(target_content, sel.selected_content) + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_textarea_id, el_id=_textarea_id) + @parameterized(_textarea_disabled_id, el_id=_textarea_disabled_id) + @parameterized(_textarea_rtl_id, el_id=_textarea_rtl_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + @parameterized(_content_id, el_id=_content_id) + def test_drag_swappable_carets(self, el_id): + self.open_test_html(self._selection_html) + el = self.marionette.find_element(By.ID, el_id) + sel = SelectionManager(el) + original_content = sel.content + words = original_content.split() + self.assertTrue(len(words) >= 1, "Expect at least one word in the content.") + + target_content1 = words[0] + target_content2 = original_content[len(words[0]) :] + + # Get the location of the carets at the end of the content for later + # use. + el.tap() + sel.select_all() + end_caret_x, end_caret_y = sel.second_caret_location() + + self.long_press_on_word(el, 0) + + # Drag the first caret to the end and back to where it was + # immediately. The selection range should not be collapsed. + caret1_x, caret1_y = sel.first_caret_location() + self.actions.flick(el, caret1_x, caret1_y, end_caret_x, end_caret_y).flick( + el, end_caret_x, end_caret_y, caret1_x, caret1_y + ).perform() + self.assertEqual(target_content1, sel.selected_content) + + # Drag the first caret to the end. + caret1_x, caret1_y = sel.first_caret_location() + self.actions.flick(el, caret1_x, caret1_y, end_caret_x, end_caret_y).perform() + self.assertEqual(target_content2, sel.selected_content) + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_textarea_id, el_id=_textarea_id) + @parameterized(_textarea_disabled_id, el_id=_textarea_disabled_id) + @parameterized(_textarea_rtl_id, el_id=_textarea_rtl_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + @parameterized(_content_id, el_id=_content_id) + def test_minimum_select_one_character(self, el_id): + self.open_test_html(self._selection_html) + el = self.marionette.find_element(By.ID, el_id) + self._test_minimum_select_one_character(el) + + @parameterized(_textarea2_id, el_id=_textarea2_id) + @parameterized(_contenteditable2_id, el_id=_contenteditable2_id) + @parameterized(_content2_id, el_id=_content2_id) + def test_minimum_select_one_character2(self, el_id): + self.open_test_html(self._multipleline_html) + el = self.marionette.find_element(By.ID, el_id) + self._test_minimum_select_one_character(el) + + def _test_minimum_select_one_character(self, el): + sel = SelectionManager(el) + original_content = sel.content + words = original_content.split() + self.assertTrue(len(words) >= 1, "Expect at least one word in the content.") + + # Get the location of the carets at the end of the content for later + # use. + sel.select_all() + end_caret_x, end_caret_y = sel.second_caret_location() + el.tap() + + # Goal: Select the first character. + target_content = original_content[0] + + self.long_press_on_word(el, 0) + + # Drag the second caret to the end of the content. + (caret1_x, caret1_y), (caret2_x, caret2_y) = sel.carets_location() + self.actions.flick(el, caret2_x, caret2_y, end_caret_x, end_caret_y).perform() + + # Drag the second caret to the position of the first caret. + (caret1_x, caret1_y), (caret2_x, caret2_y) = sel.carets_location() + self.actions.flick(el, caret2_x, caret2_y, caret1_x, caret1_y).perform() + + self.assertEqual(target_content, sel.selected_content) + + @parameterized( + _input_id + "_to_" + _textarea_id, el1_id=_input_id, el2_id=_textarea_id + ) + @parameterized( + _input_id + "_to_" + _contenteditable_id, + el1_id=_input_id, + el2_id=_contenteditable_id, + ) + @parameterized( + _input_id + "_to_" + _content_id, el1_id=_input_id, el2_id=_content_id + ) + @parameterized( + _textarea_id + "_to_" + _input_id, el1_id=_textarea_id, el2_id=_input_id + ) + @parameterized( + _textarea_id + "_to_" + _contenteditable_id, + el1_id=_textarea_id, + el2_id=_contenteditable_id, + ) + @parameterized( + _textarea_id + "_to_" + _content_id, el1_id=_textarea_id, el2_id=_content_id + ) + @parameterized( + _contenteditable_id + "_to_" + _input_id, + el1_id=_contenteditable_id, + el2_id=_input_id, + ) + @parameterized( + _contenteditable_id + "_to_" + _textarea_id, + el1_id=_contenteditable_id, + el2_id=_textarea_id, + ) + @parameterized( + _contenteditable_id + "_to_" + _content_id, + el1_id=_contenteditable_id, + el2_id=_content_id, + ) + @parameterized( + _content_id + "_to_" + _input_id, el1_id=_content_id, el2_id=_input_id + ) + @parameterized( + _content_id + "_to_" + _textarea_id, el1_id=_content_id, el2_id=_textarea_id + ) + @parameterized( + _content_id + "_to_" + _contenteditable_id, + el1_id=_content_id, + el2_id=_contenteditable_id, + ) + def test_long_press_changes_focus_from(self, el1_id, el2_id): + self.open_test_html(self._selection_html) + el1 = self.marionette.find_element(By.ID, el1_id) + el2 = self.marionette.find_element(By.ID, el2_id) + + # Compute the content of the first word in el2. + sel = SelectionManager(el2) + original_content = sel.content + words = original_content.split() + target_content = words[0] + + # Goal: Tap to focus el1, and then select the first word on el2. + + # We want to collect the location of the first word in el2 here + # since self.word_location() has the side effect which would + # change the focus. + x, y = self.word_location(el2, 0) + + el1.tap() + self.long_press_on_location(el2, x, y) + self.assertEqual(target_content, sel.selected_content) + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_textarea_id, el_id=_textarea_id) + @parameterized(_textarea_rtl_id, el_id=_textarea_rtl_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + def test_focus_not_changed_by_long_press_on_non_selectable(self, el_id): + self.open_test_html(self._selection_html) + el = self.marionette.find_element(By.ID, el_id) + non_selectable = self.marionette.find_element(By.ID, self._non_selectable_id) + + # Goal: Focus remains on the editable element el after long pressing on + # the non-selectable element. + sel = SelectionManager(el) + self.long_press_on_word(el, 0) + self.long_press_on_location(non_selectable) + active_sel = SelectionManager(self.marionette.get_active_element()) + self.assertEqual(sel.content, active_sel.content) + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_textarea_id, el_id=_textarea_id) + @parameterized(_textarea_disabled_id, el_id=_textarea_disabled_id) + @parameterized(_textarea_rtl_id, el_id=_textarea_rtl_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + @parameterized(_content_id, el_id=_content_id) + def test_handle_tilt_when_carets_overlap_each_other(self, el_id): + """Test tilt handling when carets overlap to each other. + + Let the two carets overlap each other. If they are set to tilted + successfully, tapping the tilted carets should not cause the selection + to be collapsed and the carets should be draggable. + + """ + self.open_test_html(self._selection_html) + el = self.marionette.find_element(By.ID, el_id) + sel = SelectionManager(el) + original_content = sel.content + words = original_content.split() + self.assertTrue(len(words) >= 1, "Expect at least one word in the content.") + + # Goal: Select the first word. + self.long_press_on_word(el, 0) + target_content = sel.selected_content + + # Drag the first caret to the position of the second caret to trigger + # carets overlapping. + (caret1_x, caret1_y), (caret2_x, caret2_y) = sel.carets_location() + self.actions.flick(el, caret1_x, caret1_y, caret2_x, caret2_y).perform() + + # We make two hit tests targeting the left edge of the left tilted caret + # and the right edge of the right tilted caret. If either of the hits is + # missed, selection would be collapsed and both carets should not be + # draggable. + (caret3_x, caret3_y), (caret4_x, caret4_y) = sel.carets_location() + + # The following values are from ua.css and all.js + caret_width = float(self.marionette.get_pref("layout.accessiblecaret.width")) + caret_margin_left = float( + self.marionette.get_pref("layout.accessiblecaret.margin-left") + ) + tilt_right_margin_left = 0.41 * caret_width + tilt_left_margin_left = -0.39 * caret_width + + left_caret_left_edge_x = caret3_x + caret_margin_left + tilt_left_margin_left + el.tap(left_caret_left_edge_x + 2, caret3_y) + + right_caret_right_edge_x = ( + caret4_x + caret_margin_left + tilt_right_margin_left + caret_width + ) + el.tap(right_caret_right_edge_x - 2, caret4_y) + + # Drag the first caret back to the initial selection, the first word. + self.actions.flick(el, caret3_x, caret3_y, caret1_x, caret1_y).perform() + + self.assertEqual(target_content, sel.selected_content) + + def test_drag_caret_over_non_selectable_field(self): + """Test dragging the caret over a non-selectable field. + + The selected content should exclude non-selectable elements and the + second caret should appear in last range's position. + + """ + self.open_test_html(self._multiplerange_html) + body = self.marionette.find_element(By.ID, "bd") + sel3 = self.marionette.find_element(By.ID, "sel3") + sel4 = self.marionette.find_element(By.ID, "sel4") + sel6 = self.marionette.find_element(By.ID, "sel6") + + # Select target element and get target caret location + self.long_press_on_word(sel4, 3) + sel = SelectionManager(body) + end_caret_x, end_caret_y = sel.second_caret_location() + + self.long_press_on_word(sel6, 0) + end_caret2_x, end_caret2_y = sel.second_caret_location() + + # Select start element + self.long_press_on_word(sel3, 3) + + # Drag end caret to target location + (caret1_x, caret1_y), (caret2_x, caret2_y) = sel.carets_location() + self.actions.flick( + body, caret2_x, caret2_y, end_caret_x, end_caret_y, 1 + ).perform() + self.assertEqual( + self.to_unix_line_ending(sel.selected_content.strip()), + "this 3\nuser can select this", + ) + + (caret1_x, caret1_y), (caret2_x, caret2_y) = sel.carets_location() + self.actions.flick( + body, caret2_x, caret2_y, end_caret2_x, end_caret2_y, 1 + ).perform() + self.assertEqual( + self.to_unix_line_ending(sel.selected_content.strip()), + "this 3\nuser can select this 4\nuser can select this 5\nuser", + ) + + # Drag first caret to target location + (caret1_x, caret1_y), (caret2_x, caret2_y) = sel.carets_location() + self.actions.flick( + body, caret1_x, caret1_y, end_caret_x, end_caret_y, 1 + ).perform() + self.assertEqual( + self.to_unix_line_ending(sel.selected_content.strip()), + "4\nuser can select this 5\nuser", + ) + + def test_drag_swappable_caret_over_non_selectable_field(self): + self.open_test_html(self._multiplerange_html) + body = self.marionette.find_element(By.ID, "bd") + sel3 = self.marionette.find_element(By.ID, "sel3") + sel4 = self.marionette.find_element(By.ID, "sel4") + sel = SelectionManager(body) + + self.long_press_on_word(sel4, 3) + ( + (end_caret1_x, end_caret1_y), + (end_caret2_x, end_caret2_y), + ) = sel.carets_location() + + self.long_press_on_word(sel3, 3) + (caret1_x, caret1_y), (caret2_x, caret2_y) = sel.carets_location() + + # Drag the first caret down, which will across the second caret. + self.actions.flick( + body, caret1_x, caret1_y, end_caret1_x, end_caret1_y + ).perform() + self.assertEqual( + self.to_unix_line_ending(sel.selected_content.strip()), "3\nuser can select" + ) + + # The old second caret becomes the first caret. Drag it down again. + self.actions.flick( + body, caret2_x, caret2_y, end_caret2_x, end_caret2_y + ).perform() + self.assertEqual(self.to_unix_line_ending(sel.selected_content.strip()), "this") + + def test_drag_caret_to_beginning_of_a_line(self): + """Bug 1094056 + Test caret visibility when caret is dragged to beginning of a line + """ + self.open_test_html(self._multiplerange_html) + body = self.marionette.find_element(By.ID, "bd") + sel1 = self.marionette.find_element(By.ID, "sel1") + sel2 = self.marionette.find_element(By.ID, "sel2") + + # Select the first word in the second line + self.long_press_on_word(sel2, 0) + sel = SelectionManager(body) + ( + (start_caret_x, start_caret_y), + (end_caret_x, end_caret_y), + ) = sel.carets_location() + + # Select target word in the first line + self.long_press_on_word(sel1, 2) + + # Drag end caret to the beginning of the second line + (caret1_x, caret1_y), (caret2_x, caret2_y) = sel.carets_location() + self.actions.flick( + body, caret2_x, caret2_y, start_caret_x, start_caret_y + ).perform() + + # Drag end caret back to the target word + self.actions.flick( + body, start_caret_x, start_caret_y, caret2_x, caret2_y + ).perform() + + self.assertEqual(self.to_unix_line_ending(sel.selected_content), "select") + + def test_select_word_inside_an_iframe(self): + """Bug 1088552 + The scroll offset in iframe should be taken into consideration properly. + In this test, we scroll content in the iframe to the bottom to cause a + huge offset. If we use the right coordinate system, selection should + work. Otherwise, it would be hard to trigger select word. + """ + self.open_test_html(self._iframe_html) + iframe = self.marionette.find_element(By.ID, "frame") + + # switch to inner iframe and scroll to the bottom + self.marionette.switch_to_frame(iframe) + self.marionette.execute_script('document.getElementById("bd").scrollTop += 999') + + # long press to select bottom text + body = self.marionette.find_element(By.ID, "bd") + sel = SelectionManager(body) + self._bottomtext = self.marionette.find_element(By.ID, "bottomtext") + self.long_press_on_location(self._bottomtext) + + self.assertNotEqual(self.to_unix_line_ending(sel.selected_content), "") + + def test_select_word_inside_an_unfocused_iframe(self): + """Bug 1306634: Test we can long press to select a word in an unfocused iframe.""" + self.open_test_html(self._iframe_html) + + el = self.marionette.find_element(By.ID, self._input_id) + sel = SelectionManager(el) + + # First, we select the first word in the input of the parent document. + el_first_word = sel.content.split()[0] # first world is "ABC" + self.long_press_on_word(el, 0) + self.assertEqual(el_first_word, sel.selected_content) + + # Then, we long press on the center of the iframe. It should select a + # word inside of the document, not the placehoder in the parent + # document. + iframe = self.marionette.find_element(By.ID, "frame") + self.long_press_on_location(iframe) + self.marionette.switch_to_frame(iframe) + body = self.marionette.find_element(By.ID, "bd") + sel = SelectionManager(body) + self.assertNotEqual("", sel.selected_content) + + def test_carets_initialized_in_display_none(self): + """Test AccessibleCaretEventHub is properly initialized on a <html> with + display: none. + + """ + self.open_test_html(self._display_none_html) + html = self.marionette.find_element(By.ID, "html") + content = self.marionette.find_element(By.ID, "content") + + # Remove 'display: none' from <html> + self.marionette.execute_script( + 'arguments[0].style.display = "unset";', script_args=[html] + ) + + # If AccessibleCaretEventHub is initialized successfully, select a word + # should work. + self._test_long_press_to_select_a_word(content) + + def test_long_press_to_select_when_partial_visible_word_is_selected(self): + self.open_test_html(self._selection_html) + el = self.marionette.find_element(By.ID, self._input_size_id) + sel = SelectionManager(el) + + original_content = sel.content + words = original_content.split() + + # We cannot use self.long_press_on_word() for the second long press + # on the first word because it has side effect that changes the + # cursor position. We need to save the location of the first word to + # be used later. + word0_x, word0_y = self.word_location(el, 0) + + # Long press on the second word. + self.long_press_on_word(el, 1) + self.assertEqual(words[1], sel.selected_content) + + # Long press on the first word. + self.long_press_on_location(el, word0_x, word0_y) + self.assertEqual(words[0], sel.selected_content) + + # If the second caret is visible, it can be dragged to the position + # of the first caret. After that, selection will contain only the + # first character. + (caret1_x, caret1_y), (caret2_x, caret2_y) = sel.carets_location() + self.actions.flick(el, caret2_x, caret2_y, caret1_x, caret1_y).perform() + self.assertEqual(words[0][0], sel.selected_content) + + @parameterized(_input_id, el_id=_input_id) + @parameterized(_input_padding_id, el_id=_input_padding_id) + @parameterized(_textarea_one_line_id, el_id=_textarea_one_line_id) + @parameterized(_contenteditable_id, el_id=_contenteditable_id) + def test_carets_not_jump_when_dragging_to_editable_content_boundary(self, el_id): + self.open_test_html(self._selection_html) + el = self.marionette.find_element(By.ID, el_id) + sel = SelectionManager(el) + original_content = sel.content + words = original_content.split() + self.assertTrue(len(words) >= 3, "Expect at least three words in the content.") + + # Goal: the selection is not changed after dragging the caret on the + # Y-axis. + target_content = words[1] + + self.long_press_on_word(el, 1) + (caret1_x, caret1_y), (caret2_x, caret2_y) = sel.carets_location() + + # Drag the first caret up by 40px. + self.actions.flick(el, caret1_x, caret1_y, caret1_x, caret1_y - 40).perform() + self.assertEqual(target_content, sel.selected_content) + + # Drag the second caret down by 50px. + self.actions.flick(el, caret2_x, caret2_y, caret2_x, caret2_y + 50).perform() + self.assertEqual(target_content, sel.selected_content) + + def test_carets_should_not_appear_when_long_pressing_svg_shapes(self): + self.open_test_html(self._svg_shapes_html) + + rect = self.marionette.find_element(By.ID, "rect") + text = self.marionette.find_element(By.ID, "text") + + sel = SelectionManager(text) + num_words_in_text = len(sel.content.split()) + + # Goal: the carets should not appear when long-pressing on the + # unselectable SVG rect. + + # Get the position of the end of last word in text, i.e. the + # position of the second caret when selecting the last word. + self.long_press_on_word(text, num_words_in_text - 1) + (_, _), (x2, y2) = sel.carets_location() + + # Long press to select the unselectable SVG rect. + self.long_press_on_location(rect) + (_, _), (x, y) = sel.carets_location() + + # Drag the second caret to (x2, y2). + self.actions.flick(text, x, y, x2, y2).perform() + + # If the carets should appear on the rect, the selection will be + # extended to cover all the words in text. Assert this should not + # happen. + self.assertNotEqual(sel.content, sel.selected_content.strip()) + + def test_select_word_scroll_then_drag_caret(self): + """Bug 1657256: Test select word, scroll page up , and then drag the second + caret down to cover "EEEEEE". + + Note the selection should be extended to just cover "EEEEEE", not extend + to other lines below "EEEEEE". + + """ + + self.open_test_html(self._iframe_scroll_html) + iframe = self.marionette.find_element(By.ID, "iframe") + + # Switch to the inner iframe. + self.marionette.switch_to_frame(iframe) + body = self.marionette.find_element(By.ID, "bd") + sel = SelectionManager(body) + + # Select "EEEEEE" to get the y position of the second caret. This is the + # y position we are going to drag the caret to. + content2 = self.marionette.find_element(By.ID, self._content2_id) + self.long_press_on_word(content2, 0) + (_, _), (x, y2) = sel.carets_location() + + # Step 1: Select "DDDDDD". + content = self.marionette.find_element(By.ID, self._content_id) + self.long_press_on_word(content, 0) + (_, _), (_, y1) = sel.carets_location() + + # The y-axis offset of the second caret needed to extend the selection. + y_offset = y2 - y1 + + # Step 2: Scroll the page upwards by 40px. + scroll_offset = 40 + self.marionette.execute_script( + "document.documentElement.scrollTop += arguments[0]", + script_args=[scroll_offset], + ) + + # Step 3: Drag the second caret down. + self.actions.flick( + body, x, y1 - scroll_offset, x, y1 - scroll_offset + y_offset + ).perform() + + self.assertEqual("DDDDDD EEEEEE", sel.selected_content) |