diff --git a/.gitignore b/.gitignore index 237805bebfa43..d710a24428736 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ dotnet/.idea/ dotnet/packages/ java/client/src/org/openqa/selenium/ie/IeReturnTypes.java java/server/test/org/openqa/selenium/example -javascript/deps.js javascript/selenium-webdriver/node_modules/ javascript/selenium-webdriver/lib/atoms/find-elements.js javascript/selenium-webdriver/lib/atoms/get-attribute.js @@ -67,6 +66,7 @@ __pycache__ .tox *.pyc dist/ +*.shim.js py/selenium/webdriver/common/devtools/**/* !py/selenium/webdriver/common/devtools/util.py py/selenium/webdriver/common/linux/ diff --git a/javascript/atoms/BUILD.bazel b/javascript/atoms/BUILD.bazel index b6b787107fb39..d2b7b1f080f2c 100644 --- a/javascript/atoms/BUILD.bazel +++ b/javascript/atoms/BUILD.bazel @@ -1,3 +1,5 @@ +load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_run_binary") +load("@aspect_rules_ts//ts:defs.bzl", "ts_project") load("@rules_closure//closure:defs.bzl", "closure_js_library") load("//javascript:defs.bzl", "closure_js_deps", "closure_test_suite") @@ -21,12 +23,16 @@ filegroup( closure_js_library( name = "action", - srcs = ["action.js"], + srcs = [":generate_action_shim"], suppress = [ "JSC_IMPLICITLY_NULLABLE_JSDOC", + "JSC_INEXISTENT_PROPERTY", "JSC_STRICT_INEXISTENT_PROPERTY", "JSC_UNKNOWN_EXPR_TYPE", "JSC_USE_OF_GOOG_PROVIDE", + "JSC_WRONG_ARGUMENT_COUNT", + "checkTypes", + "missingProperties", ], visibility = [ "//javascript/atoms/fragments:__pkg__", @@ -53,7 +59,7 @@ closure_js_library( closure_js_library( name = "bot", - srcs = ["bot.js"], + srcs = [":generate_bot_shim"], suppress = [ "JSC_USE_OF_GOOG_PROVIDE", ], @@ -61,29 +67,30 @@ closure_js_library( closure_js_library( name = "color", - srcs = ["color.js"], + srcs = [":generate_color_shim"], suppress = [ + "JSC_UNKNOWN_EXPR_TYPE", "JSC_USE_OF_GOOG_PROVIDE", ], - deps = [ - "//third_party/closure/goog/array", - "//third_party/closure/goog/color:names", - ], ) closure_js_library( name = "devices", srcs = [ - "device.js", - "keyboard.js", - "mouse.js", - "touchscreen.js", + ":generate_device_shim", + ":generate_keyboard_shim", + ":generate_mouse_shim", + ":generate_touchscreen_shim", ], suppress = [ "JSC_IMPLICITLY_NULLABLE_JSDOC", + "JSC_INEXISTENT_PROPERTY", "JSC_STRICT_INEXISTENT_PROPERTY", + "JSC_TYPE_MISMATCH", "JSC_UNKNOWN_EXPR_TYPE", "JSC_USE_OF_GOOG_PROVIDE", + "checkTypes", + "missingProperties", ], deps = [ ":bot", @@ -107,98 +114,75 @@ closure_js_library( closure_js_library( name = "domcore", - srcs = ["domcore.js"], + srcs = [":generate_domcore_shim"], suppress = [ - "JSC_IMPLICITLY_NULLABLE_JSDOC", - "JSC_STRICT_INEXISTENT_PROPERTY", "JSC_UNKNOWN_EXPR_TYPE", "JSC_USE_OF_GOOG_PROVIDE", ], deps = [ ":errors", ":useragent", - "//third_party/closure/goog/array", - "//third_party/closure/goog/dom", - "//third_party/closure/goog/dom:nodetype", - "//third_party/closure/goog/dom:tagname", ], ) closure_js_library( name = "dom", - srcs = ["dom.js"], + srcs = [":generate_dom_shim"], suppress = [ - "JSC_DEPRECATED_PROP", - "JSC_IMPLICITLY_NULLABLE_JSDOC", - "JSC_STRICT_INEXISTENT_PROPERTY", + "JSC_INEXISTENT_PROPERTY", "JSC_UNKNOWN_EXPR_TYPE", "JSC_USE_OF_GOOG_PROVIDE", + "JSC_WRONG_ARGUMENT_COUNT", + "checkTypes", + "missingProperties", ], deps = [ - ":bot", ":color", ":css", ":domcore", - ":json", ":useragent", - "//third_party/closure/goog/array", - "//third_party/closure/goog/dom", - "//third_party/closure/goog/dom:nodetype", - "//third_party/closure/goog/dom:tagname", - "//third_party/closure/goog/math", - "//third_party/closure/goog/math:coordinate", - "//third_party/closure/goog/math:rect", - "//third_party/closure/goog/string", - "//third_party/closure/goog/style", - "//third_party/closure/goog/useragent", ], ) closure_js_library( name = "errors", srcs = [ - "error.js", - "response.js", + ":generate_error_shim", + ":generate_response_shim", ], suppress = [ - "JSC_IMPLICITLY_NULLABLE_JSDOC", - "JSC_STRICT_INEXISTENT_PROPERTY", - "JSC_UNKNOWN_EXPR_TYPE", "JSC_USE_OF_GOOG_PROVIDE", ], deps = [ + ":bot", "//third_party/closure/goog/utils", ], ) closure_js_library( name = "events", - srcs = ["events.js"], + srcs = [":generate_events_shim"], suppress = [ "JSC_IMPLICITLY_NULLABLE_JSDOC", + "JSC_INEXISTENT_PROPERTY", "JSC_STRICT_INEXISTENT_PROPERTY", "JSC_UNKNOWN_EXPR_TYPE", "JSC_USE_OF_GOOG_PROVIDE", + "checkTypes", + "missingProperties", ], deps = [ ":bot", - ":dom", ":errors", - ":json", ":useragent", - "//third_party/closure/goog/array", - "//third_party/closure/goog/dom", - "//third_party/closure/goog/events:browserevent", - "//third_party/closure/goog/style", "//third_party/closure/goog/useragent", "//third_party/closure/goog/useragent:product", - "//third_party/closure/goog/utils", ], ) closure_js_library( name = "frame", - srcs = ["frame.js"], + srcs = [":generate_frame_shim"], suppress = [ "JSC_IMPLICITLY_NULLABLE_JSDOC", "JSC_UNKNOWN_EXPR_TYPE", @@ -219,7 +203,13 @@ closure_js_library( closure_js_library( name = "html5", - srcs = glob(["html5/*.js"]), + srcs = [ + ":generate_appcache_shim", + ":generate_database_shim", + ":generate_html5_shim", + ":generate_location_shim", + ":generate_storage_shim", + ], suppress = [ "JSC_IMPLICITLY_NULLABLE_JSDOC", "JSC_UNKNOWN_EXPR_TYPE", @@ -230,19 +220,19 @@ closure_js_library( ":errors", ":json", ":useragent", - "//third_party/closure/goog/useragent", - "//third_party/closure/goog/useragent:product", ], ) closure_js_library( name = "inject", - srcs = ["inject.js"], + srcs = [":generate_inject_shim"], suppress = [ "JSC_IMPLICITLY_NULLABLE_JSDOC", + "JSC_INVALID_PARAM", "JSC_STRICT_INEXISTENT_PROPERTY", "JSC_UNKNOWN_EXPR_TYPE", "JSC_USE_OF_GOOG_PROVIDE", + "nonStandardJsDocs", ], visibility = [ "//javascript/atoms/fragments:__pkg__", @@ -250,36 +240,32 @@ closure_js_library( "//javascript/webdriver/atoms/inject:__pkg__", ], deps = [ - ":bot", ":errors", ":json", - "//third_party/closure/goog/array", - "//third_party/closure/goog/dom:nodetype", - "//third_party/closure/goog/object", - "//third_party/closure/goog/useragent", - "//third_party/closure/goog/utils", ], ) closure_js_library( name = "json", - srcs = ["json.js"], + srcs = [":generate_json_shim"], suppress = [ "JSC_USE_OF_GOOG_PROVIDE", ], - deps = [ - ":useragent", - "//third_party/closure/goog/json", - "//third_party/closure/goog/useragent", - ], ) closure_js_library( name = "locators", - srcs = glob( - ["locators/*.js"], - exclude = ["locators/css.js"], - ), + srcs = [ + ":generate_classname_shim", + ":generate_id_shim", + ":generate_link_text_shim", + ":generate_locators_shim", + ":generate_name_shim", + ":generate_partial_link_text_shim", + ":generate_relative_shim", + ":generate_tag_name_shim", + ":generate_xpath_shim", + ], suppress = [ "JSC_IMPLICITLY_NULLABLE_JSDOC", "JSC_LATE_PROVIDE_ERROR", @@ -290,6 +276,7 @@ closure_js_library( ":bot", ":css", ":dom", + ":domcore", ":errors", ":json", ":useragent", @@ -307,9 +294,9 @@ closure_js_library( closure_js_library( name = "useragent", - srcs = ["userAgent.js"], + srcs = [":generate_userAgent_shim"], suppress = [ - "JSC_STRICT_INEXISTENT_PROPERTY", + "JSC_INEXISTENT_PROPERTY", "JSC_UNKNOWN_EXPR_TYPE", "JSC_USE_OF_GOOG_PROVIDE", ], @@ -324,38 +311,33 @@ closure_js_library( closure_js_library( name = "window", srcs = [ - "frame.js", - "window.js", + ":generate_frame_shim", + ":generate_window_shim", ], suppress = [ + "JSC_UNKNOWN_EXPR_TYPE", "JSC_USE_OF_GOOG_PROVIDE", ], deps = [ ":bot", + ":dom", ":errors", ":events", ":json", ":locators", + ":useragent", ], ) closure_js_library( name = "css", - srcs = ["locators/css.js"], + srcs = [":generate_css_shim"], suppress = [ - "JSC_IMPLICITLY_NULLABLE_JSDOC", - "JSC_STRICT_INEXISTENT_PROPERTY", "JSC_UNKNOWN_EXPR_TYPE", "JSC_USE_OF_GOOG_PROVIDE", ], deps = [ - ":bot", ":errors", - ":useragent", - "//third_party/closure/goog/dom:nodetype", - "//third_party/closure/goog/string", - "//third_party/closure/goog/useragent", - "//third_party/closure/goog/utils", ], ) @@ -382,7 +364,7 @@ closure_js_deps( ], deps = [ ":action", - ":bot", + ":bot", # Generated from bot.ts via shim ":color", ":css", ":devices", @@ -409,3 +391,620 @@ closure_test_suite( ":deps", ], ) + +# ============================================================================ +# TypeScript Migration Infrastructure +# ============================================================================ +# This section contains the rules for compiling TypeScript implementations +# and generating Closure-compatible shims to maintain test compatibility +# during the migration from Closure to TypeScript. +# +# Each TypeScript file gets its own ts_project target. TS targets can depend +# on other TS targets via deps. Closure targets depend on the generated shims. + +# Shim generator tool +js_binary( + name = "shim_generator", + entry_point = "scripts/generate-shim.js", +) + +# ---------------------------------------------------------------------------- +# Core TypeScript modules that depend on each other +# These are compiled together since they have cross-imports. +# bot.ts is included here as it is imported by other TS modules. +# ---------------------------------------------------------------------------- +ts_project( + name = "errors_ts", + srcs = [ + "action.ts", + "bot.ts", + "color.ts", + "device.ts", + "dom.ts", + "domcore.ts", + "error.ts", + "events.ts", + "frame.ts", + "html5/appcache.ts", + "html5/database.ts", + "html5/html5.ts", + "html5/location.ts", + "html5/storage.ts", + "inject.ts", + "json.ts", + "keyboard.ts", + "locators/classname.ts", + "locators/css.ts", + "locators/id.ts", + "locators/link_text.ts", + "locators/locators.ts", + "locators/name.ts", + "locators/relative.ts", + "locators/tag_name.ts", + "locators/xpath.ts", + "mouse.ts", + "response.ts", + "touchscreen.ts", + "userAgent.ts", + "window.ts", + ], + declaration = True, + declaration_map = True, + out_dir = "dist", + resolve_json_module = True, + source_map = True, + tsconfig = "tsconfig.json", +) + +# ---------------------------------------------------------------------------- +# bot.ts - Core bot module shim generation +# Note: bot.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_bot_shim", + srcs = [ + "bot.ts", + "dist/bot.js", + ], + args = [ + "$(location bot.ts)", + "bot", + "$(location dist/bot.js)", + ], + stdout = "bot.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_error_shim", + srcs = [ + "dist/error.js", + "error.ts", + ], + args = [ + "$(location error.ts)", + "bot", + "$(location dist/error.js)", + ], + stdout = "error.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_response_shim", + srcs = [ + "dist/response.js", + "response.ts", + ], + args = [ + "$(location response.ts)", + "bot.response", + "$(location dist/response.js)", + ], + stdout = "response.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# color.ts - Color utilities shim generation +# Note: color.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_color_shim", + srcs = [ + "color.ts", + "dist/color.js", + ], + args = [ + "$(location color.ts)", + "bot.color", + "$(location dist/color.js)", + ], + stdout = "color.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# userAgent.ts - User agent detection shim generation +# Note: userAgent.ts is compiled as part of errors_ts since domcore.ts imports it +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_userAgent_shim", + srcs = [ + "dist/userAgent.js", + "userAgent.ts", + ], + args = [ + "$(location userAgent.ts)", + "bot.userAgent", + "$(location dist/userAgent.js)", + ], + stdout = "userAgent.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# json.ts - JSON utilities shim generation +# Note: json.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_json_shim", + srcs = [ + "dist/json.js", + "json.ts", + ], + args = [ + "$(location json.ts)", + "bot.json", + "$(location dist/json.js)", + ], + stdout = "json.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# domcore.ts - Core DOM utilities shim generation +# Note: domcore.ts is compiled as part of errors_ts since it imports error.ts and userAgent.ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_domcore_shim", + srcs = [ + "dist/domcore.js", + "domcore.ts", + ], + args = [ + "$(location domcore.ts)", + "bot.dom.core", + "$(location dist/domcore.js)", + ], + stdout = "domcore.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# locators/css.ts - CSS selector utilities shim generation +# Note: css.ts is compiled as part of errors_ts since it imports error.ts +# Output is at dist/locators/css.js +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_css_shim", + srcs = [ + "dist/locators/css.js", + "locators/css.ts", + ], + args = [ + "$(location locators/css.ts)", + "bot.locators.css", + "$(location dist/locators/css.js)", + ], + stdout = "locators/css.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# dom.ts - DOM utilities shim generation +# Note: dom.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_dom_shim", + srcs = [ + "dist/dom.js", + "dom.ts", + ], + args = [ + "$(location dom.ts)", + "bot.dom", + "$(location dist/dom.js)", + ], + stdout = "dom.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# events.ts - Event firing utilities shim generation +# Note: events.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_events_shim", + srcs = [ + "dist/events.js", + "events.ts", + ], + args = [ + "$(location events.ts)", + "bot.events", + "$(location dist/events.js)", + ], + stdout = "events.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# device.ts - Base input device class shim generation +# Note: device.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_device_shim", + srcs = [ + "device.ts", + "dist/device.js", + ], + args = [ + "$(location device.ts)", + "bot.Device", + "$(location dist/device.js)", + ], + stdout = "device.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# keyboard.ts - Keyboard input device shim generation +# Note: keyboard.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_keyboard_shim", + srcs = [ + "dist/keyboard.js", + "keyboard.ts", + ], + args = [ + "$(location keyboard.ts)", + "bot.Keyboard", + "$(location dist/keyboard.js)", + ], + stdout = "keyboard_shim.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# touchscreen.ts - Touchscreen input device shim generation +# Note: touchscreen.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_touchscreen_shim", + srcs = [ + "dist/touchscreen.js", + "touchscreen.ts", + ], + args = [ + "$(location touchscreen.ts)", + "bot.Touchscreen", + "$(location dist/touchscreen.js)", + ], + stdout = "touchscreen.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# mouse.ts - Mouse input device shim generation +# Note: mouse.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_mouse_shim", + srcs = [ + "dist/mouse.js", + "mouse.ts", + ], + args = [ + "$(location mouse.ts)", + "bot.Mouse", + "$(location dist/mouse.js)", + ], + stdout = "mouse.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# frame.ts - Frame utilities shim generation +# Note: frame.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_frame_shim", + srcs = [ + "dist/frame.js", + "frame.ts", + ], + args = [ + "$(location frame.ts)", + "bot.frame", + "$(location dist/frame.js)", + ], + stdout = "frame.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# window.ts - Window utilities shim generation +# Note: window.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_window_shim", + srcs = [ + "dist/window.js", + "window.ts", + ], + args = [ + "$(location window.ts)", + "bot.window", + "$(location dist/window.js)", + ], + stdout = "window.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# action.ts - Action utilities shim generation +# Note: action.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_action_shim", + srcs = [ + "action.ts", + "dist/action.js", + ], + args = [ + "$(location action.ts)", + "bot.action", + "$(location dist/action.js)", + ], + stdout = "action.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# Locator modules - TypeScript to Closure shim generation +# ---------------------------------------------------------------------------- + +js_run_binary( + name = "generate_id_shim", + srcs = [ + "dist/locators/id.js", + "locators/id.ts", + ], + args = [ + "$(location locators/id.ts)", + "bot.locators.id", + "$(location dist/locators/id.js)", + ], + stdout = "locators/id.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_name_shim", + srcs = [ + "dist/locators/name.js", + "locators/name.ts", + ], + args = [ + "$(location locators/name.ts)", + "bot.locators.name", + "$(location dist/locators/name.js)", + ], + stdout = "locators/name.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_classname_shim", + srcs = [ + "dist/locators/classname.js", + "locators/classname.ts", + ], + args = [ + "$(location locators/classname.ts)", + "bot.locators.className", + "$(location dist/locators/classname.js)", + ], + stdout = "locators/classname.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_tag_name_shim", + srcs = [ + "dist/locators/tag_name.js", + "locators/tag_name.ts", + ], + args = [ + "$(location locators/tag_name.ts)", + "bot.locators.tagName", + "$(location dist/locators/tag_name.js)", + ], + stdout = "locators/tag_name.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_link_text_shim", + srcs = [ + "dist/locators/link_text.js", + "locators/link_text.ts", + ], + args = [ + "$(location locators/link_text.ts)", + "bot.locators.linkText", + "$(location dist/locators/link_text.js)", + ], + stdout = "locators/link_text.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_partial_link_text_shim", + srcs = [ + "dist/locators/link_text.js", + "locators/link_text.ts", + ], + args = [ + "$(location locators/link_text.ts)", + "bot.locators.partialLinkText", + "$(location dist/locators/link_text.js)", + ], + stdout = "locators/partial_link_text.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_xpath_shim", + srcs = [ + "dist/locators/xpath.js", + "locators/xpath.ts", + ], + args = [ + "$(location locators/xpath.ts)", + "bot.locators.xpath", + "$(location dist/locators/xpath.js)", + ], + stdout = "locators/xpath.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_relative_shim", + srcs = [ + "dist/locators/relative.js", + "locators/relative.ts", + ], + args = [ + "$(location locators/relative.ts)", + "bot.locators.relative", + "$(location dist/locators/relative.js)", + ], + stdout = "locators/relative.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_locators_shim", + srcs = [ + "dist/locators/locators.js", + "locators/locators.ts", + ], + args = [ + "$(location locators/locators.ts)", + "bot.locators", + "$(location dist/locators/locators.js)", + ], + stdout = "locators/locators.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# inject.ts - Script injection utilities shim generation +# Note: inject.ts is compiled as part of errors_ts +# ---------------------------------------------------------------------------- +js_run_binary( + name = "generate_inject_shim", + srcs = [ + "dist/inject.js", + "inject.ts", + ], + args = [ + "$(location inject.ts)", + "bot.inject", + "$(location dist/inject.js)", + ], + stdout = "inject.js", + tool = ":shim_generator", +) + +# ---------------------------------------------------------------------------- +# HTML5 modules - TypeScript to Closure shim generation +# ---------------------------------------------------------------------------- + +js_run_binary( + name = "generate_html5_shim", + srcs = [ + "dist/html5/html5.js", + "html5/html5.ts", + ], + args = [ + "$(location html5/html5.ts)", + "bot.html5", + "$(location dist/html5/html5.js)", + ], + stdout = "html5/html5_browser.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_appcache_shim", + srcs = [ + "dist/html5/appcache.js", + "html5/appcache.ts", + ], + args = [ + "$(location html5/appcache.ts)", + "bot.appcache", + "$(location dist/html5/appcache.js)", + ], + stdout = "html5/appcache.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_database_shim", + srcs = [ + "dist/html5/database.js", + "html5/database.ts", + ], + args = [ + "$(location html5/database.ts)", + "bot.storage.database", + "$(location dist/html5/database.js)", + ], + stdout = "html5/database.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_location_shim", + srcs = [ + "dist/html5/location.js", + "html5/location.ts", + ], + args = [ + "$(location html5/location.ts)", + "bot.geolocation", + "$(location dist/html5/location.js)", + ], + stdout = "html5/location.js", + tool = ":shim_generator", +) + +js_run_binary( + name = "generate_storage_shim", + srcs = [ + "dist/html5/storage.js", + "html5/storage.ts", + ], + args = [ + "$(location html5/storage.ts)", + "bot.storage", + "$(location dist/html5/storage.js)", + ], + stdout = "html5/storage.js", + tool = ":shim_generator", +) diff --git a/javascript/atoms/action.js b/javascript/atoms/action.js deleted file mode 100644 index 12a803f7ec078..0000000000000 --- a/javascript/atoms/action.js +++ /dev/null @@ -1,759 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Atoms for simulating user actions against the DOM. - * The bot.action namespace is required since these atoms would otherwise form a - * circular dependency between bot.dom and bot.events. - * - */ - -goog.provide('bot.action'); - -goog.require('bot'); -goog.require('bot.Device'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.Keyboard'); -goog.require('bot.Mouse'); -goog.require('bot.Touchscreen'); -goog.require('bot.dom'); -goog.require('bot.events'); -goog.require('goog.array'); -goog.require('goog.dom.TagName'); -goog.require('goog.math.Coordinate'); -goog.require('goog.math.Vec2'); -goog.require('goog.style'); -goog.require('goog.userAgent'); -goog.require('goog.userAgent.product'); -goog.require('goog.utils'); - - -/** - * Throws an exception if an element is not shown to the user, ignoring its - * opacity. - - * - * @param {!Element} element The element to check. - * @see bot.dom.isShown. - * @private - */ -bot.action.checkShown_ = function (element) { - if (!bot.dom.isShown(element, /*ignoreOpacity=*/true)) { - throw new bot.Error(bot.ErrorCode.ELEMENT_NOT_VISIBLE, - 'Element is not currently visible and may not be manipulated'); - } -}; - - -/** - * Throws an exception if the given element cannot be interacted with. - * - * @param {!Element} element The element to check. - * @throws {bot.Error} If the element cannot be interacted with. - * @see bot.dom.isInteractable. - * @private - */ -bot.action.checkInteractable_ = function (element) { - if (!bot.dom.isInteractable(element)) { - throw new bot.Error(bot.ErrorCode.INVALID_ELEMENT_STATE, - 'Element is not currently interactable and may not be manipulated'); - - } -}; - - -/** - * Clears the given `element` if it is a editable text field. - * - * @param {!Element} element The element to clear. - * @throws {bot.Error} If the element is not an editable text field. - */ -bot.action.clear = function (element) { - bot.action.checkInteractable_(element); - if (!bot.dom.isEditable(element)) { - throw new bot.Error(bot.ErrorCode.INVALID_ELEMENT_STATE, - 'Element must be user-editable in order to clear it.'); - } - - if (element.value) { - bot.action.LegacyDevice_.focusOnElement(element); - if (goog.userAgent.IE && bot.dom.isInputType(element, 'range')) { - var min = element.min ? element.min : 0; - var max = element.max ? element.max : 100; - element.value = (max < min) ? min : min + (max - min) / 2; - } else { - element.value = ''; - } - bot.events.fire(element, bot.events.EventType.CHANGE); - if (goog.userAgent.IE) { - bot.events.fire(element, bot.events.EventType.BLUR); - } - var body = bot.getDocument().body; - if (body) { - bot.action.LegacyDevice_.focusOnElement(body); - } else { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Cannot unfocus element after clearing.'); - } - } else if (bot.dom.isElement(element, goog.dom.TagName.INPUT) && - (element.getAttribute('type') && element.getAttribute('type').toLowerCase() == "number")) { - // number input fields that have invalid inputs - // report their value as empty string with no way to tell if there is a - // current value or not - bot.action.LegacyDevice_.focusOnElement(element); - element.value = ''; - } else if (bot.dom.isContentEditable(element)) { - // A single space is required, if you put empty string here you'll not be - // able to interact with this element anymore in Firefox. - bot.action.LegacyDevice_.focusOnElement(element); - if (goog.userAgent.GECKO) { - element.textContent = ' '; - } else { - element.textContent = ''; - } - var body = bot.getDocument().body; - if (body) { - bot.action.LegacyDevice_.focusOnElement(body); - } else { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Cannot unfocus element after clearing.'); - } - // contentEditable does not generate onchange event. - } -}; - - -/** - * Focuses on the given element if it is not already the active element. - * - * @param {!Element} element The element to focus on. - */ -bot.action.focusOnElement = function (element) { - bot.action.checkInteractable_(element); - bot.action.LegacyDevice_.focusOnElement(element); -}; - - -/** - * Types keys on the given `element` with a virtual keyboard. - * - *

Callers can pass in a string, a key in bot.Keyboard.Key, or an array - * of strings or keys. If a modifier key is provided, it is pressed but not - * released, until it is either is listed again or the function ends. - * - *

Example: - * bot.keys.type(element, ['ab', bot.Keyboard.Key.LEFT, - * bot.Keyboard.Key.SHIFT, 'cd']); - * - * @param {!Element} element The element receiving the event. - * @param {(string|!bot.Keyboard.Key|!Array.<(string|!bot.Keyboard.Key)>)} - * values Value or values to type on the element. - * @param {bot.Keyboard=} opt_keyboard Keyboard to use; if not provided, - * constructs one. - * @param {boolean=} opt_persistModifiers Whether modifier keys should remain - * pressed when this function ends. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.type = function ( - element, values, opt_keyboard, opt_persistModifiers) { - // If the element has already been brought into focus somehow, typing is - // always allowed to proceed. Otherwise, we require the element be in an - // "interactable" state. For example, an element that is hidden by overflow - // can be typed on, so long as the user first tabs to it or the app calls - // focus() on the element first. - if (element != bot.dom.getActiveElement(element)) { - bot.action.checkInteractable_(element); - bot.action.scrollIntoView(element); - } - - var keyboard = opt_keyboard || new bot.Keyboard(); - keyboard.moveCursor(element); - - function typeValue(value) { - if (typeof value === 'string') { - goog.array.forEach(value.split(''), function (ch) { - var keyShiftPair = bot.Keyboard.Key.fromChar(ch); - var shiftIsPressed = keyboard.isPressed(bot.Keyboard.Keys.SHIFT); - if (keyShiftPair.shift && !shiftIsPressed) { - keyboard.pressKey(bot.Keyboard.Keys.SHIFT); - } - keyboard.pressKey(keyShiftPair.key); - keyboard.releaseKey(keyShiftPair.key); - if (keyShiftPair.shift && !shiftIsPressed) { - keyboard.releaseKey(bot.Keyboard.Keys.SHIFT); - } - }); - } else if (goog.array.contains(bot.Keyboard.MODIFIERS, value)) { - if (keyboard.isPressed(/** @type {!bot.Keyboard.Key} */(value))) { - keyboard.releaseKey(value); - } else { - keyboard.pressKey(value); - } - } else { - keyboard.pressKey(value); - keyboard.releaseKey(value); - } - } - - // mobile safari (iPhone / iPad). one cannot 'type' in a date field - // chrome implements this, but desktop Safari doesn't, what's webkit again? - if ((!(goog.userAgent.product.SAFARI && !goog.userAgent.MOBILE)) && - goog.userAgent.WEBKIT && element.type == 'date') { - var val = Array.isArray(values) ? values = values.join("") : values; - var datePattern = /\d{4}-\d{2}-\d{2}/; - if (val.match(datePattern)) { - // The following events get fired on iOS first - if (goog.userAgent.MOBILE && goog.userAgent.product.SAFARI) { - bot.events.fire(element, bot.events.EventType.TOUCHSTART); - bot.events.fire(element, bot.events.EventType.TOUCHEND); - } - bot.events.fire(element, bot.events.EventType.FOCUS); - element.value = val.match(datePattern)[0]; - bot.events.fire(element, bot.events.EventType.CHANGE); - bot.events.fire(element, bot.events.EventType.BLUR); - return; - } - } - - if (Array.isArray(values)) { - goog.array.forEach(values, typeValue); - } else { - typeValue(values); - } - - if (!opt_persistModifiers) { - // Release all the modifier keys. - goog.array.forEach(bot.Keyboard.MODIFIERS, function (key) { - if (keyboard.isPressed(key)) { - keyboard.releaseKey(key); - } - }); - } -}; - - -/** - * Submits the form containing the given `element`. - * - *

Note this function submits the form, but does not simulate user input - * (a click or key press). - * - * @param {!Element} element The element to submit. - * @deprecated Click on a submit button or type ENTER in a text box instead. - */ -bot.action.submit = function (element) { - var form = bot.action.LegacyDevice_.findAncestorForm(element); - if (!form) { - throw new bot.Error(bot.ErrorCode.NO_SUCH_ELEMENT, - 'Element was not in a form, so could not submit.'); - } - bot.action.LegacyDevice_.submitForm(element, form); -}; - - -/** - * Moves the mouse over the given `element` with a virtual mouse. - * - * @param {!Element} element The element to click. - * @param {goog.math.Coordinate=} opt_coords Mouse position relative to the - * element. - * @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.moveMouse = function (element, opt_coords, opt_mouse) { - var coords = bot.action.prepareToInteractWith_(element, opt_coords); - var mouse = opt_mouse || new bot.Mouse(); - mouse.move(element, coords); -}; - - -/** - * Clicks on the given `element` with a virtual mouse. - * - * @param {!Element} element The element to click. - * @param {goog.math.Coordinate=} opt_coords Mouse position relative to the - * element. - * @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one. - * @param {boolean=} opt_force Whether the release event should be fired even if the - * element is not interactable. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.click = function (element, opt_coords, opt_mouse, opt_force) { - var coords = bot.action.prepareToInteractWith_(element, opt_coords); - var mouse = opt_mouse || new bot.Mouse(); - mouse.move(element, coords); - mouse.pressButton(bot.Mouse.Button.LEFT); - mouse.releaseButton(opt_force); -}; - - -/** - * Right-clicks on the given `element` with a virtual mouse. - * - * @param {!Element} element The element to click. - * @param {goog.math.Coordinate=} opt_coords Mouse position relative to the - * element. - * @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.rightClick = function (element, opt_coords, opt_mouse) { - var coords = bot.action.prepareToInteractWith_(element, opt_coords); - var mouse = opt_mouse || new bot.Mouse(); - mouse.move(element, coords); - mouse.pressButton(bot.Mouse.Button.RIGHT); - mouse.releaseButton(); -}; - - -/** - * Double-clicks on the given `element` with a virtual mouse. - * - * @param {!Element} element The element to click. - * @param {goog.math.Coordinate=} opt_coords Mouse position relative to the - * element. - * @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.doubleClick = function (element, opt_coords, opt_mouse) { - var coords = bot.action.prepareToInteractWith_(element, opt_coords); - var mouse = opt_mouse || new bot.Mouse(); - mouse.move(element, coords); - mouse.pressButton(bot.Mouse.Button.LEFT); - mouse.releaseButton(); - mouse.pressButton(bot.Mouse.Button.LEFT); - mouse.releaseButton(); -}; - - -/** - * Double-clicks on the given `element` with a virtual mouse. - * - * @param {!Element} element The element to click. - * @param {goog.math.Coordinate=} opt_coords Mouse position relative to the - * element. - * @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.doubleClick2 = function (element, opt_coords, opt_mouse) { - var coords = bot.action.prepareToInteractWith_(element, opt_coords); - var mouse = opt_mouse || new bot.Mouse(); - mouse.move(element, coords); - mouse.pressButton(bot.Mouse.Button.LEFT, 2); - mouse.releaseButton(true, 2); -}; - - -/** - * Scrolls the mouse wheel on the given `element` with a virtual mouse. - * - * @param {!Element} element The element to scroll the mouse wheel on. - * @param {number} ticks Number of ticks to scroll the mouse wheel; a positive - * number scrolls down and a negative scrolls up. - * @param {goog.math.Coordinate=} opt_coords Mouse position relative to the - * element. - * @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.scrollMouse = function (element, ticks, opt_coords, opt_mouse) { - var coords = bot.action.prepareToInteractWith_(element, opt_coords); - var mouse = opt_mouse || new bot.Mouse(); - mouse.move(element, coords); - mouse.scroll(ticks); -}; - - -/** - * Drags the given `element` by (dx, dy) with a virtual mouse. - * - * @param {!Element} element The element to drag. - * @param {number} dx Increment in x coordinate. - * @param {number} dy Increment in y coordinate. - * @param {number=} opt_steps The number of steps that should occur as part of - * the drag, default is 2. - * @param {goog.math.Coordinate=} opt_coords Drag start position relative to the - * element. - * @param {bot.Mouse=} opt_mouse Mouse to use; if not provided, constructs one. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.drag = function (element, dx, dy, opt_steps, opt_coords, opt_mouse) { - var coords = bot.action.prepareToInteractWith_(element, opt_coords); - var initRect = bot.dom.getClientRect(element); - var mouse = opt_mouse || new bot.Mouse(); - mouse.move(element, coords); - mouse.pressButton(bot.Mouse.Button.LEFT); - var steps = opt_steps !== undefined ? opt_steps : 2; - if (steps < 1) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'There must be at least one step as part of a drag.'); - } - for (var i = 1; i <= steps; i++) { - moveTo(Math.floor(i * dx / steps), Math.floor(i * dy / steps)); - } - mouse.releaseButton(); - - function moveTo(x, y) { - var currRect = bot.dom.getClientRect(element); - var newPos = new goog.math.Coordinate( - coords.x + initRect.left + x - currRect.left, - coords.y + initRect.top + y - currRect.top); - mouse.move(element, newPos); - } -}; - - -/** - * Taps on the given `element` with a virtual touch screen. - * - * @param {!Element} element The element to tap. - * @param {goog.math.Coordinate=} opt_coords Finger position relative to the - * target. - * @param {bot.Touchscreen=} opt_touchscreen Touchscreen to use; if not - * provided, constructs one. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.tap = function (element, opt_coords, opt_touchscreen) { - var coords = bot.action.prepareToInteractWith_(element, opt_coords); - var touchscreen = opt_touchscreen || new bot.Touchscreen(); - touchscreen.move(element, coords); - touchscreen.press(); - touchscreen.release(); -}; - - -/** - * Swipes the given `element` by (dx, dy) with a virtual touch screen. - * - * @param {!Element} element The element to swipe. - * @param {number} dx Increment in x coordinate. - * @param {number} dy Increment in y coordinate. - * @param {number=} opt_steps The number of steps that should occurs as part of - * the swipe, default is 2. - * @param {goog.math.Coordinate=} opt_coords Swipe start position relative to - * the element. - * @param {bot.Touchscreen=} opt_touchscreen Touchscreen to use; if not - * provided, constructs one. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.swipe = function (element, dx, dy, opt_steps, opt_coords, - opt_touchscreen) { - var coords = bot.action.prepareToInteractWith_(element, opt_coords); - var touchscreen = opt_touchscreen || new bot.Touchscreen(); - var initRect = bot.dom.getClientRect(element); - touchscreen.move(element, coords); - touchscreen.press(); - var steps = opt_steps !== undefined ? opt_steps : 2; - if (steps < 1) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'There must be at least one step as part of a swipe.'); - } - for (var i = 1; i <= steps; i++) { - moveTo(Math.floor(i * dx / steps), Math.floor(i * dy / steps)); - } - touchscreen.release(); - - function moveTo(x, y) { - var currRect = bot.dom.getClientRect(element); - var newPos = new goog.math.Coordinate( - coords.x + initRect.left + x - currRect.left, - coords.y + initRect.top + y - currRect.top); - touchscreen.move(element, newPos); - } -}; - - -/** - * Pinches the given `element` by the given distance with a virtual touch - * screen. A positive distance moves two fingers inward toward each and a - * negative distances spreads them outward. The optional coordinate is the point - * the fingers move towards (for positive distances) or away from (for negative - * distances); and if not provided, defaults to the center of the element. - * - * @param {!Element} element The element to pinch. - * @param {number} distance The distance by which to pinch the element. - * @param {goog.math.Coordinate=} opt_coords Position relative to the element - * at the center of the pinch. - * @param {bot.Touchscreen=} opt_touchscreen Touchscreen to use; if not - * provided, constructs one. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.pinch = function (element, distance, opt_coords, opt_touchscreen) { - if (distance == 0) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Cannot pinch by a distance of zero.'); - } - function startSoThatEndsAtMax(offsetVec) { - if (distance < 0) { - var magnitude = offsetVec.magnitude(); - offsetVec.scale(magnitude ? (magnitude + distance) / magnitude : 0); - } - } - var halfDistance = distance / 2; - function scaleByHalfDistance(offsetVec) { - var magnitude = offsetVec.magnitude(); - offsetVec.scale(magnitude ? (magnitude - halfDistance) / magnitude : 0); - } - bot.action.multiTouchAction_(element, - startSoThatEndsAtMax, - scaleByHalfDistance, - opt_coords, - opt_touchscreen); -}; - - -/** - * Rotates the given `element` by the given angle with a virtual touch - * screen. A positive angle moves two fingers clockwise and a negative angle - * moves them counter-clockwise. The optional coordinate is the point to - * rotate around; and if not provided, defaults to the center of the element. - * - * @param {!Element} element The element to rotate. - * @param {number} angle The angle by which to rotate the element. - * @param {goog.math.Coordinate=} opt_coords Position relative to the element - * at the center of the rotation. - * @param {bot.Touchscreen=} opt_touchscreen Touchscreen to use; if not - * provided, constructs one. - * @throws {bot.Error} If the element cannot be interacted with. - */ -bot.action.rotate = function (element, angle, opt_coords, opt_touchscreen) { - if (angle == 0) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Cannot rotate by an angle of zero.'); - } - function startHalfwayToMax(offsetVec) { - offsetVec.scale(0.5); - } - var halfRadians = Math.PI * (angle / 180) / 2; - function rotateByHalfAngle(offsetVec) { - offsetVec.rotate(halfRadians); - } - bot.action.multiTouchAction_(element, - startHalfwayToMax, - rotateByHalfAngle, - opt_coords, - opt_touchscreen); -}; - - -/** - * Performs a multi-touch action with two fingers on the given element. This - * helper function works by manipulating an "offsetVector", which is the vector - * away from the center of the interaction at which the fingers are positioned. - * It computes the maximum offset vector and passes it to transformStart to - * find the starting position of the fingers; it then passes it to transformHalf - * twice to find the midpoint and final position of the fingers. - * - * @param {!Element} element Element to interact with. - * @param {function(goog.math.Vec2)} transformStart Function to transform the - * maximum offset vector to the starting offset vector. - * @param {function(goog.math.Vec2)} transformHalf Function to transform the - * offset vector halfway to its destination. - * @param {goog.math.Coordinate=} opt_coords Position relative to the element - * at the center of the pinch. - * @param {bot.Touchscreen=} opt_touchscreen Touchscreen to use; if not - * provided, constructs one. - * @private - */ -bot.action.multiTouchAction_ = function (element, transformStart, transformHalf, - opt_coords, opt_touchscreen) { - var center = bot.action.prepareToInteractWith_(element, opt_coords); - var size = bot.action.getInteractableSize(element); - var offsetVec = new goog.math.Vec2( - Math.min(center.x, size.width - center.x), - Math.min(center.y, size.height - center.y)); - - var touchScreen = opt_touchscreen || new bot.Touchscreen(); - transformStart(offsetVec); - var start1 = goog.math.Vec2.sum(center, offsetVec); - var start2 = goog.math.Vec2.difference(center, offsetVec); - touchScreen.move(element, start1, start2); - touchScreen.press(/*Two Finger Press*/ true); - - var initRect = bot.dom.getClientRect(element); - transformHalf(offsetVec); - var mid1 = goog.math.Vec2.sum(center, offsetVec); - var mid2 = goog.math.Vec2.difference(center, offsetVec); - touchScreen.move(element, mid1, mid2); - - var midRect = bot.dom.getClientRect(element); - var movedVec = goog.math.Vec2.difference( - new goog.math.Vec2(midRect.left, midRect.top), - new goog.math.Vec2(initRect.left, initRect.top)); - transformHalf(offsetVec); - var end1 = goog.math.Vec2.sum(center, offsetVec).subtract(movedVec); - var end2 = goog.math.Vec2.difference(center, offsetVec).subtract(movedVec); - touchScreen.move(element, end1, end2); - touchScreen.release(); -}; - - -/** - * Prepares to interact with the given `element`. It checks if the the - * element is shown, scrolls the element into view, and returns the coordinates - * of the interaction, which if not provided, is the center of the element. - * - * @param {!Element} element The element to be interacted with. - * @param {goog.math.Coordinate=} opt_coords Position relative to the target. - * @return {!goog.math.Vec2} Coordinates at the center of the interaction. - * @throws {bot.Error} If the element cannot be interacted with. - * @private - */ -bot.action.prepareToInteractWith_ = function (element, opt_coords) { - bot.action.checkShown_(element); - bot.action.scrollIntoView(element, opt_coords || undefined); - - // NOTE: Ideally, we would check that any provided coordinates fall - // within the bounds of the element, but this has proven difficult, because: - // (1) Browsers sometimes lie about the true size of elements, e.g. when text - // overflows the bounding box of an element, browsers report the size of the - // box even though the true area that can be interacted with is larger; and - // (2) Elements with children styled as position:absolute will often not have - // a bounding box that surrounds all of their children, but it is useful for - // the user to be able to interact with this parent element as if it does. - if (opt_coords) { - return goog.math.Vec2.fromCoordinate(opt_coords); - } else { - var size = bot.action.getInteractableSize(element); - return new goog.math.Vec2(size.width / 2, size.height / 2); - } -}; - - -/** - * Returns the interactable size of an element. - * - * @param {!Element} elem Element. - * @return {!goog.math.Size} size Size of the element. - */ -bot.action.getInteractableSize = function (elem) { - var size = goog.style.getSize(elem); - return ((size.width > 0 && size.height > 0) || !elem.offsetParent) ? size : - bot.action.getInteractableSize(elem.offsetParent); -}; - - - -/** - * A Device that is intended to allows access to protected members of the - * Device superclass. A singleton. - * - * @constructor - * @extends {bot.Device} - * @private - */ -bot.action.LegacyDevice_ = function () { - bot.Device.call(this); -}; -goog.utils.inherits(bot.action.LegacyDevice_, bot.Device); -goog.utils.addSingletonGetter(bot.action.LegacyDevice_); - - -/** - * Focuses on the given element. See {@link bot.device.focusOnElement}. - * @param {!Element} element The element to focus on. - * @return {boolean} True if element.focus() was called on the element. - */ -bot.action.LegacyDevice_.focusOnElement = function (element) { - var instance = bot.action.LegacyDevice_.getInstance(); - instance.setElement(element); - return instance.focusOnElement(); -}; - - -/** - * Submit the form for the element. See {@link bot.device.submit}. - * @param {!Element} element The element to submit a form on. - * @param {!Element} form The form to submit. - */ -bot.action.LegacyDevice_.submitForm = function (element, form) { - var instance = bot.action.LegacyDevice_.getInstance(); - instance.setElement(element); - instance.submitForm(form); -}; - - -/** - * Find FORM element that is an ancestor of the passed in element. See - * {@link bot.device.findAncestorForm}. - * @param {!Element} element The element to find an ancestor form. - * @return {Element} form The ancestor form, or null if none. - */ -bot.action.LegacyDevice_.findAncestorForm = function (element) { - return bot.Device.findAncestorForm(element); -}; - - -/** - * Scrolls the given `element` in to the current viewport. Aims to do the - * minimum scrolling necessary, but prefers too much scrolling to too little. - * - * If an optional coordinate or rectangle region is provided, scrolls that - * region relative to the element into view. A coordinate is treated as a 1x1 - * region whose top-left corner is positioned at that coordinate. - * - * @param {!Element} element The element to scroll in to view. - * @param {!(goog.math.Coordinate|goog.math.Rect)=} opt_region - * Region relative to the top-left corner of the element. - * @return {boolean} Whether the element is in view after scrolling. - */ -bot.action.scrollIntoView = function (element, opt_region) { - // If the element is already in view, return true; if hidden, return false. - var overflow = bot.dom.getOverflowState(element, opt_region); - if (overflow != bot.dom.OverflowState.SCROLL) { - return overflow == bot.dom.OverflowState.NONE; - } - - // Some elements may not have a scrollIntoView function - for example, - // elements under an SVG element. Call those only if they exist. - if (element.scrollIntoView) { - element.scrollIntoView(); - if (bot.dom.OverflowState.NONE == - bot.dom.getOverflowState(element, opt_region)) { - return true; - } - } - - // There may have not been a scrollIntoView function, or the specified - // coordinate may not be in view, so scroll "manually". - var region = bot.dom.getClientRegion(element, opt_region); - for (var container = bot.dom.getParentElement(element); - container; - container = bot.dom.getParentElement(container)) { - scrollClientRegionIntoContainerView(container); - } - return bot.dom.OverflowState.NONE == - bot.dom.getOverflowState(element, opt_region); - - function scrollClientRegionIntoContainerView(container) { - // Based largely from goog.style.scrollIntoContainerView. - var containerRect = bot.dom.getClientRect(container); - var containerBorder = goog.style.getBorderBox(container); - - // Relative position of the region to the container's content box. - var relX = region.left - containerRect.left - containerBorder.left; - var relY = region.top - containerRect.top - containerBorder.top; - - // How much the region can move in the container. Use the container's - // clientWidth/Height, not containerRect, to account for the scrollbar. - var spaceX = container.clientWidth + region.left - region.right; - var spaceY = container.clientHeight + region.top - region.bottom; - - // Scroll the element into view of the container. - container.scrollLeft += Math.min(relX, Math.max(relX - spaceX, 0)); - container.scrollTop += Math.min(relY, Math.max(relY - spaceY, 0)); - } -}; diff --git a/javascript/atoms/action.ts b/javascript/atoms/action.ts new file mode 100644 index 0000000000000..ef6a100e7213d --- /dev/null +++ b/javascript/atoms/action.ts @@ -0,0 +1,782 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Atoms for simulating user actions against the DOM. + * The bot.action namespace is required since these atoms would otherwise form a + * circular dependency between bot.dom and bot.events. + */ + +import { BotError, ErrorCode } from './error'; +import { getDocument } from './bot'; +import { Device, findAncestorForm } from './device'; +import { Keyboard, Key, Keys, MODIFIERS } from './keyboard'; +import { Mouse, Button } from './mouse'; +import { Touchscreen } from './touchscreen'; +import { + isShown, + isInteractable, + isEditable, + isElement, + isContentEditable, + isInputType, + getActiveElement, + getOverflowState, + getClientRect, + getClientRegion, + getParentElement, + OverflowState, + Rect, +} from './dom'; +import { fire, EventType } from './events'; +import { GECKO, IE } from './userAgent'; + +// Browser detection from userAgent +const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''; +const IS_MOBILE = /Mobile/.test(userAgent); +const IS_WEBKIT = /AppleWebKit/.test(userAgent); +const IS_SAFARI = /Safari/.test(userAgent) && !/Chrome/.test(userAgent); + +// ============================================================================ +// Coordinate and Size types +// ============================================================================ + +/** + * A 2D coordinate. + */ +export interface Coordinate { + x: number; + y: number; +} + +/** + * A 2D size. + */ +export interface Size { + width: number; + height: number; +} + +/** + * A 2D vector class with math operations. + */ +export class Vec2 { + x: number; + y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + + /** + * Create a Vec2 from a Coordinate. + */ + static fromCoordinate(coord: Coordinate): Vec2 { + return new Vec2(coord.x, coord.y); + } + + /** + * Returns the sum of two vectors. + */ + static sum(a: Vec2, b: Vec2): Vec2 { + return new Vec2(a.x + b.x, a.y + b.y); + } + + /** + * Returns the difference of two vectors (a - b). + */ + static difference(a: Vec2, b: Vec2): Vec2 { + return new Vec2(a.x - b.x, a.y - b.y); + } + + /** + * Returns the magnitude of the vector. + */ + magnitude(): number { + return Math.sqrt(this.x * this.x + this.y * this.y); + } + + /** + * Scales the vector in place. + */ + scale(factor: number): Vec2 { + this.x *= factor; + this.y *= factor; + return this; + } + + /** + * Rotates the vector in place by the given angle in radians. + */ + rotate(radians: number): Vec2 { + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const newX = this.x * cos - this.y * sin; + const newY = this.x * sin + this.y * cos; + this.x = newX; + this.y = newY; + return this; + } + + /** + * Subtracts another vector from this one in place. + */ + subtract(other: Vec2): Vec2 { + this.x -= other.x; + this.y -= other.y; + return this; + } +} + +// ============================================================================ +// Style utilities (replacing goog.style) +// ============================================================================ + +/** + * Gets the size of an element. + */ +function getSize(elem: Element): Size { + const rect = elem.getBoundingClientRect(); + return { + width: rect.width, + height: rect.height, + }; +} + +/** + * Gets the border box of an element. + */ +function getBorderBox(elem: Element): { + top: number; + right: number; + bottom: number; + left: number; +} { + const style = window.getComputedStyle(elem); + return { + top: parseFloat(style.borderTopWidth) || 0, + right: parseFloat(style.borderRightWidth) || 0, + bottom: parseFloat(style.borderBottomWidth) || 0, + left: parseFloat(style.borderLeftWidth) || 0, + }; +} + +// ============================================================================ +// Private helpers +// ============================================================================ + +/** + * Throws an exception if an element is not shown to the user, ignoring its + * opacity. + */ +function checkShown_(element: Element): void { + if (!isShown(element, true)) { + throw new BotError( + ErrorCode.ELEMENT_NOT_VISIBLE, + 'Element is not currently visible and may not be manipulated' + ); + } +} + +/** + * Throws an exception if the given element cannot be interacted with. + */ +function checkInteractable_(element: Element): void { + if (!isInteractable(element)) { + throw new BotError( + ErrorCode.INVALID_ELEMENT_STATE, + 'Element is not currently interactable and may not be manipulated' + ); + } +} + +// ============================================================================ +// LegacyDevice_ - A singleton Device for static helper methods +// ============================================================================ + +let legacyDeviceInstance: Device | null = null; + +/** + * Gets the singleton LegacyDevice instance. + */ +function getLegacyDeviceInstance(): Device { + if (!legacyDeviceInstance) { + legacyDeviceInstance = new Device(); + } + return legacyDeviceInstance; +} + +/** + * Focuses on the given element. See Device.focusOnElement. + */ +export function legacyDeviceFocusOnElement(element: Element): boolean { + const instance = getLegacyDeviceInstance(); + instance.setElement(element); + return instance.focusOnElement(); +} + +/** + * Submit the form for the element. See Device.submitForm. + */ +export function legacyDeviceSubmitForm( + element: Element, + form: HTMLFormElement +): void { + const instance = getLegacyDeviceInstance(); + instance.setElement(element); + instance.submitForm(form); +} + +/** + * Find FORM element that is an ancestor of the passed in element. + */ +export function legacyDeviceFindAncestorForm( + element: Element +): HTMLFormElement | null { + return findAncestorForm(element); +} + +// ============================================================================ +// Public action functions +// ============================================================================ + +/** + * Clears the given element if it is an editable text field. + */ +export function clear(element: Element): void { + checkInteractable_(element); + if (!isEditable(element)) { + throw new BotError( + ErrorCode.INVALID_ELEMENT_STATE, + 'Element must be user-editable in order to clear it.' + ); + } + + const inputElement = element as HTMLInputElement; + if (inputElement.value) { + legacyDeviceFocusOnElement(element); + if (IE && isInputType(element, 'range')) { + const min = inputElement.min ? parseFloat(inputElement.min) : 0; + const max = inputElement.max ? parseFloat(inputElement.max) : 100; + inputElement.value = String(max < min ? min : min + (max - min) / 2); + } else { + inputElement.value = ''; + } + fire(element, EventType.CHANGE); + if (IE) { + fire(element, EventType.BLUR); + } + const body = getDocument().body; + if (body) { + legacyDeviceFocusOnElement(body); + } else { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Cannot unfocus element after clearing.' + ); + } + } else if ( + isElement(element, 'INPUT') && + element.getAttribute('type')?.toLowerCase() === 'number' + ) { + legacyDeviceFocusOnElement(element); + inputElement.value = ''; + } else if (isContentEditable(element)) { + legacyDeviceFocusOnElement(element); + if (GECKO) { + (element as HTMLElement).textContent = ' '; + } else { + (element as HTMLElement).textContent = ''; + } + const body = getDocument().body; + if (body) { + legacyDeviceFocusOnElement(body); + } else { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Cannot unfocus element after clearing.' + ); + } + } +} + +/** + * Focuses on the given element if it is not already the active element. + */ +export function focusOnElement(element: Element): void { + checkInteractable_(element); + legacyDeviceFocusOnElement(element); +} + +/** + * Types keys on the given element with a virtual keyboard. + */ +export function type( + element: Element, + values: string | Key | (string | Key)[], + opt_keyboard?: Keyboard, + opt_persistModifiers?: boolean +): void { + if (element !== getActiveElement(element)) { + checkInteractable_(element); + scrollIntoView(element); + } + + const keyboard = opt_keyboard || new Keyboard(); + keyboard.moveCursor(element); + + function typeValue(value: string | Key): void { + if (typeof value === 'string') { + value.split('').forEach((ch) => { + const keyShiftPair = Key.fromChar(ch); + const shiftIsPressed = keyboard.isPressed(Keys.SHIFT); + if (keyShiftPair.shift && !shiftIsPressed) { + keyboard.pressKey(Keys.SHIFT); + } + keyboard.pressKey(keyShiftPair.key); + keyboard.releaseKey(keyShiftPair.key); + if (keyShiftPair.shift && !shiftIsPressed) { + keyboard.releaseKey(Keys.SHIFT); + } + }); + } else if (MODIFIERS.includes(value)) { + if (keyboard.isPressed(value)) { + keyboard.releaseKey(value); + } else { + keyboard.pressKey(value); + } + } else { + keyboard.pressKey(value); + keyboard.releaseKey(value); + } + } + + const inputElement = element as HTMLInputElement; + if ( + !(IS_SAFARI && !IS_MOBILE) && + IS_WEBKIT && + inputElement.type === 'date' + ) { + const val = Array.isArray(values) ? values.join('') : String(values); + const datePattern = /\d{4}-\d{2}-\d{2}/; + if (val.match(datePattern)) { + if (IS_MOBILE && IS_SAFARI) { + fire(element, EventType.TOUCHSTART); + fire(element, EventType.TOUCHEND); + } + fire(element, EventType.FOCUS); + inputElement.value = val.match(datePattern)![0]; + fire(element, EventType.CHANGE); + fire(element, EventType.BLUR); + return; + } + } + + if (Array.isArray(values)) { + values.forEach(typeValue); + } else { + typeValue(values); + } + + if (!opt_persistModifiers) { + MODIFIERS.forEach((key) => { + if (keyboard.isPressed(key)) { + keyboard.releaseKey(key); + } + }); + } +} + +/** + * Submits the form containing the given element. + * @deprecated Click on a submit button or type ENTER in a text box instead. + */ +export function submit(element: Element): void { + const form = legacyDeviceFindAncestorForm(element); + if (!form) { + throw new BotError( + ErrorCode.NO_SUCH_ELEMENT, + 'Element was not in a form, so could not submit.' + ); + } + legacyDeviceSubmitForm(element, form); +} + +/** + * Moves the mouse over the given element with a virtual mouse. + */ +export function moveMouse( + element: Element, + opt_coords?: Coordinate, + opt_mouse?: Mouse +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); +} + +/** + * Clicks on the given element with a virtual mouse. + */ +export function click( + element: Element, + opt_coords?: Coordinate, + opt_mouse?: Mouse, + opt_force?: boolean +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); + mouse.pressButton(Button.LEFT); + mouse.releaseButton(opt_force); +} + +/** + * Right-clicks on the given element with a virtual mouse. + */ +export function rightClick( + element: Element, + opt_coords?: Coordinate, + opt_mouse?: Mouse +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); + mouse.pressButton(Button.RIGHT); + mouse.releaseButton(); +} + +/** + * Double-clicks on the given element with a virtual mouse. + */ +export function doubleClick( + element: Element, + opt_coords?: Coordinate, + opt_mouse?: Mouse +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); + mouse.pressButton(Button.LEFT); + mouse.releaseButton(); + mouse.pressButton(Button.LEFT); + mouse.releaseButton(); +} + +/** + * Double-clicks on the given element with a virtual mouse (variant 2). + */ +export function doubleClick2( + element: Element, + opt_coords?: Coordinate, + opt_mouse?: Mouse +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); + mouse.pressButton(Button.LEFT, 2); + mouse.releaseButton(true, 2); +} + +/** + * Scrolls the mouse wheel on the given element with a virtual mouse. + */ +export function scrollMouse( + element: Element, + ticks: number, + opt_coords?: Coordinate, + opt_mouse?: Mouse +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); + mouse.scroll(ticks); +} + +/** + * Drags the given element by (dx, dy) with a virtual mouse. + */ +export function drag( + element: Element, + dx: number, + dy: number, + opt_steps?: number, + opt_coords?: Coordinate, + opt_mouse?: Mouse +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const initRect = getClientRect(element); + const mouse = opt_mouse || new Mouse(); + mouse.move(element, coords); + mouse.pressButton(Button.LEFT); + const steps = opt_steps !== undefined ? opt_steps : 2; + if (steps < 1) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'There must be at least one step as part of a drag.' + ); + } + for (let i = 1; i <= steps; i++) { + moveTo(Math.floor((i * dx) / steps), Math.floor((i * dy) / steps)); + } + mouse.releaseButton(); + + function moveTo(x: number, y: number): void { + const currRect = getClientRect(element); + const newPos: Coordinate = { + x: coords.x + initRect.left + x - currRect.left, + y: coords.y + initRect.top + y - currRect.top, + }; + mouse.move(element, newPos); + } +} + +/** + * Taps on the given element with a virtual touch screen. + */ +export function tap( + element: Element, + opt_coords?: Coordinate, + opt_touchscreen?: Touchscreen +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const touchscreen = opt_touchscreen || new Touchscreen(); + touchscreen.move(element, coords); + touchscreen.press(); + touchscreen.release(); +} + +/** + * Swipes the given element by (dx, dy) with a virtual touch screen. + */ +export function swipe( + element: Element, + dx: number, + dy: number, + opt_steps?: number, + opt_coords?: Coordinate, + opt_touchscreen?: Touchscreen +): void { + const coords = prepareToInteractWith_(element, opt_coords); + const touchscreen = opt_touchscreen || new Touchscreen(); + const initRect = getClientRect(element); + touchscreen.move(element, coords); + touchscreen.press(); + const steps = opt_steps !== undefined ? opt_steps : 2; + if (steps < 1) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'There must be at least one step as part of a swipe.' + ); + } + for (let i = 1; i <= steps; i++) { + moveTo(Math.floor((i * dx) / steps), Math.floor((i * dy) / steps)); + } + touchscreen.release(); + + function moveTo(x: number, y: number): void { + const currRect = getClientRect(element); + const newPos: Coordinate = { + x: coords.x + initRect.left + x - currRect.left, + y: coords.y + initRect.top + y - currRect.top, + }; + touchscreen.move(element, newPos); + } +} + +/** + * Pinches the given element by the given distance with a virtual touch screen. + */ +export function pinch( + element: Element, + distance: number, + opt_coords?: Coordinate, + opt_touchscreen?: Touchscreen +): void { + if (distance === 0) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Cannot pinch by a distance of zero.' + ); + } + function startSoThatEndsAtMax(offsetVec: Vec2): void { + if (distance < 0) { + const magnitude = offsetVec.magnitude(); + offsetVec.scale(magnitude ? (magnitude + distance) / magnitude : 0); + } + } + const halfDistance = distance / 2; + function scaleByHalfDistance(offsetVec: Vec2): void { + const magnitude = offsetVec.magnitude(); + offsetVec.scale(magnitude ? (magnitude - halfDistance) / magnitude : 0); + } + multiTouchAction_( + element, + startSoThatEndsAtMax, + scaleByHalfDistance, + opt_coords, + opt_touchscreen + ); +} + +/** + * Rotates the given element by the given angle with a virtual touch screen. + */ +export function rotate( + element: Element, + angle: number, + opt_coords?: Coordinate, + opt_touchscreen?: Touchscreen +): void { + if (angle === 0) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Cannot rotate by an angle of zero.' + ); + } + function startHalfwayToMax(offsetVec: Vec2): void { + offsetVec.scale(0.5); + } + const halfRadians = (Math.PI * (angle / 180)) / 2; + function rotateByHalfAngle(offsetVec: Vec2): void { + offsetVec.rotate(halfRadians); + } + multiTouchAction_( + element, + startHalfwayToMax, + rotateByHalfAngle, + opt_coords, + opt_touchscreen + ); +} + +/** + * Performs a multi-touch action with two fingers on the given element. + */ +function multiTouchAction_( + element: Element, + transformStart: (offsetVec: Vec2) => void, + transformHalf: (offsetVec: Vec2) => void, + opt_coords?: Coordinate, + opt_touchscreen?: Touchscreen +): void { + const center = prepareToInteractWith_(element, opt_coords); + const size = getInteractableSize(element); + const offsetVec = new Vec2( + Math.min(center.x, size.width - center.x), + Math.min(center.y, size.height - center.y) + ); + + const touchScreen = opt_touchscreen || new Touchscreen(); + transformStart(offsetVec); + const start1 = Vec2.sum(center, offsetVec); + const start2 = Vec2.difference(center, offsetVec); + touchScreen.move(element, start1, start2); + touchScreen.press(true); + + const initRect = getClientRect(element); + transformHalf(offsetVec); + const mid1 = Vec2.sum(center, offsetVec); + const mid2 = Vec2.difference(center, offsetVec); + touchScreen.move(element, mid1, mid2); + + const midRect = getClientRect(element); + const movedVec = Vec2.difference( + new Vec2(midRect.left, midRect.top), + new Vec2(initRect.left, initRect.top) + ); + transformHalf(offsetVec); + const end1 = Vec2.sum(center, offsetVec).subtract(movedVec); + const end2 = Vec2.difference(center, offsetVec).subtract(movedVec); + touchScreen.move(element, end1, end2); + touchScreen.release(); +} + +/** + * Prepares to interact with the given element. + */ +function prepareToInteractWith_( + element: Element, + opt_coords?: Coordinate +): Vec2 { + checkShown_(element); + scrollIntoView(element, opt_coords); + + if (opt_coords) { + return Vec2.fromCoordinate(opt_coords); + } else { + const size = getInteractableSize(element); + return new Vec2(size.width / 2, size.height / 2); + } +} + +/** + * Returns the interactable size of an element. + */ +export function getInteractableSize(elem: Element): Size { + const size = getSize(elem); + const htmlElem = elem as HTMLElement; + if ((size.width > 0 && size.height > 0) || !htmlElem.offsetParent) { + return size; + } + return getInteractableSize(htmlElem.offsetParent as Element); +} + +/** + * Scrolls the given element into the current viewport. + */ +export function scrollIntoView( + element: Element, + opt_region?: Coordinate | Rect +): boolean { + const overflow = getOverflowState(element, opt_region); + if (overflow !== OverflowState.SCROLL) { + return overflow === OverflowState.NONE; + } + + if (element.scrollIntoView) { + element.scrollIntoView(); + if (getOverflowState(element, opt_region) === OverflowState.NONE) { + return true; + } + } + + const region = getClientRegion(element, opt_region); + let container = getParentElement(element); + while (container) { + scrollClientRegionIntoContainerView(container); + container = getParentElement(container); + } + return getOverflowState(element, opt_region) === OverflowState.NONE; + + function scrollClientRegionIntoContainerView(container: Element): void { + const containerRect = getClientRect(container); + const containerBorder = getBorderBox(container); + + const relX = region.left - containerRect.left - containerBorder.left; + const relY = region.top - containerRect.top - containerBorder.top; + + const spaceX = container.clientWidth + region.left - region.right; + const spaceY = container.clientHeight + region.top - region.bottom; + + container.scrollLeft += Math.min(relX, Math.max(relX - spaceX, 0)); + container.scrollTop += Math.min(relY, Math.max(relY - spaceY, 0)); + } +} + + diff --git a/javascript/atoms/bot.js b/javascript/atoms/bot.ts similarity index 75% rename from javascript/atoms/bot.js rename to javascript/atoms/bot.ts index 6b70e6d4a6b62..696b8445ea250 100644 --- a/javascript/atoms/bot.js +++ b/javascript/atoms/bot.ts @@ -19,52 +19,44 @@ * @fileoverview Overall configuration of the browser automation atoms. */ - -goog.provide('bot'); - - /** * Frameworks using the atoms keep track of which window or frame is currently * being used for command execution. Note that "window" may not always be * defined (for example in firefox extensions) - * @private {!Window} */ -bot.window_; +let currentWindow: Window; try { - bot.window_ = window; + currentWindow = window; } catch (ignored) { // We only reach this place in a firefox extension. - bot.window_ = goog.global; + currentWindow = globalThis as any as Window; } - /** * Returns the window currently being used for command execution. * - * @return {!Window} The window for command execution. + * @return The window for command execution. */ -bot.getWindow = function () { - return bot.window_; -}; - +export function getWindow(): Window { + return currentWindow; +} /** * Sets the window to be used for command execution. * - * @param {!Window} win The window for command execution. + * @param win The window for command execution. */ -bot.setWindow = function (win) { - bot.window_ = win; -}; - +export function setWindow(win: Window): void { + currentWindow = win; +} /** * Returns the document of the window currently being used for * command execution. * - * @return {!Document} The current window's document. + * @return The current window's document. */ -bot.getDocument = function () { - return bot.window_.document; -}; +export function getDocument(): Document { + return currentWindow.document; +} diff --git a/javascript/atoms/color.js b/javascript/atoms/color.js deleted file mode 100644 index 3b5797ac1e1fd..0000000000000 --- a/javascript/atoms/color.js +++ /dev/null @@ -1,190 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Utilities related to color and color conversion. - * Some of this code is borrowed and modified from goog.color and - * goog.color.alpha. - */ - -goog.provide('bot.color'); - -goog.require('goog.array'); -goog.require('goog.color.names'); - - -/** - * Returns a property, with a standardized color if it contains a - * convertible color. - * @param {string} propertyName Name of the CSS property in camelCase. - * @param {string} propertyValue The value of the CSS property. - * @return {string} The value, in a standardized format - * if it is a color property. - */ -bot.color.standardizeColor = function (propertyName, propertyValue) { - if (!goog.array.contains(bot.color.COLOR_PROPERTIES_, propertyName)) { - return propertyValue; - } - var rgba = - bot.color.maybeParseRgbaColor_(propertyValue) || - bot.color.maybeParseRgbColor_(propertyValue) || - bot.color.maybeConvertHexOrColorName_(propertyValue); - return rgba ? 'rgba(' + rgba.join(', ') + ')' : propertyValue; -}; - - -/** - * Used to determine whether a css property contains a color and - * should therefore be standardized to rgba. - * These are extracted from the W3C CSS spec: - * - * http://www.w3.org/TR/CSS/#properties - * - * @const - * @private {!Array.} - */ -bot.color.COLOR_PROPERTIES_ = [ - 'backgroundColor', - 'borderTopColor', - 'borderRightColor', - 'borderBottomColor', - 'borderLeftColor', - 'color', - 'outlineColor' -]; - - -/** - * Regular expression for extracting the digits in a hex color triplet. - * @private {!RegExp} - * @const - */ -bot.color.HEX_TRIPLET_RE_ = /#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])/; - - -/** - * Converts a hex representation of a color to RGB. - * @param {string} hexOrColorName Color to convert. - * @return {?Array} array containing [r, g, b, 1] as ints in [0, 255] or null - * for invalid colors. - * @private - */ -bot.color.maybeConvertHexOrColorName_ = function (hexOrColorName) { - hexOrColorName = hexOrColorName.toLowerCase(); - var hex = goog.color.names[hexOrColorName.toLowerCase()]; - if (!hex) { - hex = hexOrColorName.charAt(0) == '#' ? - hexOrColorName : '#' + hexOrColorName; - if (hex.length == 4) { // of the form #RGB - hex = hex.replace(bot.color.HEX_TRIPLET_RE_, '#$1$1$2$2$3$3'); - } - - if (!bot.color.VALID_HEX_COLOR_RE_.test(hex)) { - return null; - } - } - - var r = parseInt(hex.substr(1, 2), 16); - var g = parseInt(hex.substr(3, 2), 16); - var b = parseInt(hex.substr(5, 2), 16); - - return [r, g, b, 1]; -}; - - -/** - * Helper for isValidHexColor_. - * @private {!RegExp} - * @const - */ -bot.color.VALID_HEX_COLOR_RE_ = /^#(?:[0-9a-f]{3}){1,2}$/i; - - -/** - * Regular expression for matching and capturing RGBA style strings. - * @private {!RegExp} - * @const - */ -bot.color.RGBA_COLOR_RE_ = - /^(?:rgba)?\((\d{1,3}),\s?(\d{1,3}),\s?(\d{1,3}),\s?(0|1|0\.\d*)\)$/i; - - -/** - * Attempts to parse a string as an rgba color. We expect strings of the - * format '(r, g, b, a)', or 'rgba(r, g, b, a)', where r, g, b are ints in - * [0, 255] and a is a float in [0, 1]. - * @param {string} str String to check. - * @return {?Array.} the integers [r, g, b, a] for valid colors or null - * for invalid colors. - * @private - */ -bot.color.maybeParseRgbaColor_ = function (str) { - // Each component is separate (rather than using a repeater) so we can - // capture the match. Also, we explicitly set each component to be either 0, - // or start with a non-zero, to prevent octal numbers from slipping through. - var regExpResultArray = str.match(bot.color.RGBA_COLOR_RE_); - if (regExpResultArray) { - var r = Number(regExpResultArray[1]); - var g = Number(regExpResultArray[2]); - var b = Number(regExpResultArray[3]); - var a = Number(regExpResultArray[4]); - if (r >= 0 && r <= 255 && - g >= 0 && g <= 255 && - b >= 0 && b <= 255 && - a >= 0 && a <= 1) { - return [r, g, b, a]; - } - } - return null; -}; - - -/** - * Regular expression for matching and capturing RGB style strings. - * @private {!RegExp} - * @const - */ -bot.color.RGB_COLOR_RE_ = - /^(?:rgb)?\((0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2})\)$/i; - - -/** - * Attempts to parse a string as an rgb color. We expect strings of the format - * '(r, g, b)', or 'rgb(r, g, b)', where each color component is an int in - * [0, 255]. - * @param {string} str String to check. - * @return {?Array.} the integers [r, g, b, 1] for valid colors or null - * for invalid colors. - * @private - */ -bot.color.maybeParseRgbColor_ = function (str) { - // Each component is separate (rather than using a repeater) so we can - // capture the match. Also, we explicitly set each component to be either 0, - // or start with a non-zero, to prevent octal numbers from slipping through. - var regExpResultArray = str.match(bot.color.RGB_COLOR_RE_); - if (regExpResultArray) { - var r = Number(regExpResultArray[1]); - var g = Number(regExpResultArray[2]); - var b = Number(regExpResultArray[3]); - if (r >= 0 && r <= 255 && - g >= 0 && g <= 255 && - b >= 0 && b <= 255) { - return [r, g, b, 1]; - } - } - return null; -}; diff --git a/javascript/atoms/color.ts b/javascript/atoms/color.ts new file mode 100644 index 0000000000000..8ed9fba3778a2 --- /dev/null +++ b/javascript/atoms/color.ts @@ -0,0 +1,307 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Utilities related to color and color conversion. + * Some of this code is borrowed and modified from goog.color and + * goog.color.alpha. + */ + +/** + * A map of CSS color names to their hex values. + * This list is larger than the minimal one dictated by W3C. + */ +const COLOR_NAMES: Record = { + aliceblue: '#f0f8ff', + antiquewhite: '#faebd7', + aqua: '#00ffff', + aquamarine: '#7fffd4', + azure: '#f0ffff', + beige: '#f5f5dc', + bisque: '#ffe4c4', + black: '#000000', + blanchedalmond: '#ffebcd', + blue: '#0000ff', + blueviolet: '#8a2be2', + brown: '#a52a2a', + burlywood: '#deb887', + cadetblue: '#5f9ea0', + chartreuse: '#7fff00', + chocolate: '#d2691e', + coral: '#ff7f50', + cornflowerblue: '#6495ed', + cornsilk: '#fff8dc', + crimson: '#dc143c', + cyan: '#00ffff', + darkblue: '#00008b', + darkcyan: '#008b8b', + darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', + darkgreen: '#006400', + darkgrey: '#a9a9a9', + darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', + darkolivegreen: '#556b2f', + darkorange: '#ff8c00', + darkorchid: '#9932cc', + darkred: '#8b0000', + darksalmon: '#e9967a', + darkseagreen: '#8fbc8f', + darkslateblue: '#483d8b', + darkslategray: '#2f4f4f', + darkslategrey: '#2f4f4f', + darkturquoise: '#00ced1', + darkviolet: '#9400d3', + deeppink: '#ff1493', + deepskyblue: '#00bfff', + dimgray: '#696969', + dimgrey: '#696969', + dodgerblue: '#1e90ff', + firebrick: '#b22222', + floralwhite: '#fffaf0', + forestgreen: '#228b22', + fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', + ghostwhite: '#f8f8ff', + gold: '#ffd700', + goldenrod: '#daa520', + gray: '#808080', + green: '#008000', + greenyellow: '#adff2f', + grey: '#808080', + honeydew: '#f0fff0', + hotpink: '#ff69b4', + indianred: '#cd5c5c', + indigo: '#4b0082', + ivory: '#fffff0', + khaki: '#f0e68c', + lavender: '#e6e6fa', + lavenderblush: '#fff0f5', + lawngreen: '#7cfc00', + lemonchiffon: '#fffacd', + lightblue: '#add8e6', + lightcoral: '#f08080', + lightcyan: '#e0ffff', + lightgoldenrodyellow: '#fafad2', + lightgray: '#d3d3d3', + lightgreen: '#90ee90', + lightgrey: '#d3d3d3', + lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', + lightseagreen: '#20b2aa', + lightskyblue: '#87cefa', + lightslategray: '#778899', + lightslategrey: '#778899', + lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', + lime: '#00ff00', + limegreen: '#32cd32', + linen: '#faf0e6', + magenta: '#ff00ff', + maroon: '#800000', + mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', + mediumorchid: '#ba55d3', + mediumpurple: '#9370db', + mediumseagreen: '#3cb371', + mediumslateblue: '#7b68ee', + mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', + mediumvioletred: '#c71585', + midnightblue: '#191970', + mintcream: '#f5fffa', + mistyrose: '#ffe4e1', + moccasin: '#ffe4b5', + navajowhite: '#ffdead', + navy: '#000080', + oldlace: '#fdf5e6', + olive: '#808000', + olivedrab: '#6b8e23', + orange: '#ffa500', + orangered: '#ff4500', + orchid: '#da70d6', + palegoldenrod: '#eee8aa', + palegreen: '#98fb98', + paleturquoise: '#afeeee', + palevioletred: '#db7093', + papayawhip: '#ffefd5', + peachpuff: '#ffdab9', + peru: '#cd853f', + pink: '#ffc0cb', + plum: '#dda0dd', + powderblue: '#b0e0e6', + purple: '#800080', + red: '#ff0000', + rosybrown: '#bc8f8f', + royalblue: '#4169e1', + saddlebrown: '#8b4513', + salmon: '#fa8072', + sandybrown: '#f4a460', + seagreen: '#2e8b57', + seashell: '#fff5ee', + sienna: '#a0522d', + silver: '#c0c0c0', + skyblue: '#87ceeb', + slateblue: '#6a5acd', + slategray: '#708090', + slategrey: '#708090', + snow: '#fffafa', + springgreen: '#00ff7f', + steelblue: '#4682b4', + tan: '#d2b48c', + teal: '#008080', + thistle: '#d8bfd8', + tomato: '#ff6347', + turquoise: '#40e0d0', + violet: '#ee82ee', + wheat: '#f5deb3', + white: '#ffffff', + whitesmoke: '#f5f5f5', + yellow: '#ffff00', + yellowgreen: '#9acd32', +}; + +/** + * CSS properties that contain color values and should be standardized to rgba. + * Extracted from the W3C CSS spec: http://www.w3.org/TR/CSS/#properties + */ +const COLOR_PROPERTIES: string[] = [ + 'backgroundColor', + 'borderTopColor', + 'borderRightColor', + 'borderBottomColor', + 'borderLeftColor', + 'color', + 'outlineColor', +]; + +/** + * Regular expression for extracting the digits in a hex color triplet. + */ +const HEX_TRIPLET_RE = /#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])/; + +/** + * Regular expression for validating hex colors. + */ +const VALID_HEX_COLOR_RE = /^#(?:[0-9a-f]{3}){1,2}$/i; + +/** + * Regular expression for matching and capturing RGBA style strings. + */ +const RGBA_COLOR_RE = + /^(?:rgba)?\((\d{1,3}),\s?(\d{1,3}),\s?(\d{1,3}),\s?(0|1|0\.\d*)\)$/i; + +/** + * Regular expression for matching and capturing RGB style strings. + */ +const RGB_COLOR_RE = + /^(?:rgb)?\((0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2})\)$/i; + +/** + * Converts a hex representation of a color to RGBA. + * @param hexOrColorName Color to convert. + * @return Array containing [r, g, b, 1] as ints in [0, 255] or null for invalid colors. + */ +export function maybeConvertHexOrColorName_( + hexOrColorName: string +): [number, number, number, number] | null { + hexOrColorName = hexOrColorName.toLowerCase(); + let hex = COLOR_NAMES[hexOrColorName]; + if (!hex) { + hex = + hexOrColorName.charAt(0) === '#' ? hexOrColorName : '#' + hexOrColorName; + if (hex.length === 4) { + hex = hex.replace(HEX_TRIPLET_RE, '#$1$1$2$2$3$3'); + } + + if (!VALID_HEX_COLOR_RE.test(hex)) { + return null; + } + } + + const r = parseInt(hex.substr(1, 2), 16); + const g = parseInt(hex.substr(3, 2), 16); + const b = parseInt(hex.substr(5, 2), 16); + + return [r, g, b, 1]; +} + +/** + * Attempts to parse a string as an rgba color. + * We expect strings of the format '(r, g, b, a)', or 'rgba(r, g, b, a)', + * where r, g, b are ints in [0, 255] and a is a float in [0, 1]. + * @param str String to check. + * @return The integers [r, g, b, a] for valid colors or null for invalid colors. + */ +export function maybeParseRgbaColor_( + str: string +): [number, number, number, number] | null { + const regExpResultArray = str.match(RGBA_COLOR_RE); + if (regExpResultArray) { + const r = Number(regExpResultArray[1]); + const g = Number(regExpResultArray[2]); + const b = Number(regExpResultArray[3]); + const a = Number(regExpResultArray[4]); + if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255 && a >= 0 && a <= 1) { + return [r, g, b, a]; + } + } + return null; +} + +/** + * Attempts to parse a string as an rgb color. + * We expect strings of the format '(r, g, b)', or 'rgb(r, g, b)', + * where each color component is an int in [0, 255]. + * @param str String to check. + * @return The integers [r, g, b, 1] for valid colors or null for invalid colors. + */ +export function maybeParseRgbColor_( + str: string +): [number, number, number, number] | null { + const regExpResultArray = str.match(RGB_COLOR_RE); + if (regExpResultArray) { + const r = Number(regExpResultArray[1]); + const g = Number(regExpResultArray[2]); + const b = Number(regExpResultArray[3]); + if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) { + return [r, g, b, 1]; + } + } + return null; +} + +/** + * Returns a property, with a standardized color if it contains a + * convertible color. + * @param propertyName Name of the CSS property in camelCase. + * @param propertyValue The value of the CSS property. + * @return The value, in a standardized format if it is a color property. + */ +export function standardizeColor( + propertyName: string, + propertyValue: string +): string { + if (COLOR_PROPERTIES.indexOf(propertyName) === -1) { + return propertyValue; + } + const rgba = + maybeParseRgbaColor_(propertyValue) || + maybeParseRgbColor_(propertyValue) || + maybeConvertHexOrColorName_(propertyValue); + return rgba ? 'rgba(' + rgba.join(', ') + ')' : propertyValue; +} diff --git a/javascript/atoms/device.js b/javascript/atoms/device.js deleted file mode 100644 index 1b880c4ef9b0f..0000000000000 --- a/javascript/atoms/device.js +++ /dev/null @@ -1,986 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview The file contains the base class for input devices such as - * the keyboard, mouse, and touchscreen. - */ - -goog.provide('bot.Device'); -goog.provide('bot.Device.EventEmitter'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.dom'); -goog.require('bot.events'); -goog.require('bot.locators'); -goog.require('bot.userAgent'); -goog.require('goog.array'); -goog.require('goog.dom'); -goog.require('goog.dom.TagName'); -goog.require('goog.userAgent'); -goog.require('goog.userAgent.product'); - - - -/** - * A Device class that provides common functionality for input devices. - * @param {bot.Device.ModifiersState=} opt_modifiersState state of modifier - * keys. The state is shared, not copied from this parameter. - * @param {bot.Device.EventEmitter=} opt_eventEmitter An object that should be - * used to fire events. - * @constructor - */ -bot.Device = function (opt_modifiersState, opt_eventEmitter) { - /** - * Element being interacted with. - * @private {!Element} - */ - this.element_ = bot.getDocument().documentElement; - - /** - * If the element is an option, this is its parent select element. - * @private {Element} - */ - this.select_ = null; - - // If there is an active element, make that the current element instead. - var activeElement = bot.dom.getActiveElement(this.element_); - if (activeElement) { - this.setElement(activeElement); - } - - /** - * State of modifier keys for this device. - * @protected {bot.Device.ModifiersState} - */ - this.modifiersState = opt_modifiersState || new bot.Device.ModifiersState(); - - /** @protected {!bot.Device.EventEmitter} */ - this.eventEmitter = opt_eventEmitter || new bot.Device.EventEmitter(); -}; - - -/** - * Returns the element with which the device is interacting. - * - * @return {!Element} Element being interacted with. - * @protected - */ -bot.Device.prototype.getElement = function () { - return this.element_; -}; - - -/** - * Sets the element with which the device is interacting. - * - * @param {!Element} element Element being interacted with. - * @protected - */ -bot.Device.prototype.setElement = function (element) { - this.element_ = element; - if (bot.dom.isElement(element, goog.dom.TagName.OPTION)) { - this.select_ = /** @type {Element} */ (goog.dom.getAncestor(element, - function (node) { - return bot.dom.isElement(node, goog.dom.TagName.SELECT); - })); - } else { - this.select_ = null; - } -}; - - -/** - * Fires an HTML event given the state of the device. - * - * @param {!bot.events.EventFactory_} type HTML Event type. - * @return {boolean} Whether the event fired successfully; false if cancelled. - * @protected - */ -bot.Device.prototype.fireHtmlEvent = function (type) { - return this.eventEmitter.fireHtmlEvent(this.element_, type); -}; - - -/** - * Fires a keyboard event given the state of the device and the given arguments. - * TODO: Populate the modifier keys in this method. - * - * @param {!bot.events.EventFactory_} type Keyboard event type. - * @param {bot.events.KeyboardArgs} args Keyboard event arguments. - * @return {boolean} Whether the event fired successfully; false if cancelled. - * @protected - */ -bot.Device.prototype.fireKeyboardEvent = function (type, args) { - return this.eventEmitter.fireKeyboardEvent(this.element_, type, args); -}; - - -/** - * Fires a mouse event given the state of the device and the given arguments. - * TODO: Populate the modifier keys in this method. - * - * @param {!bot.events.EventFactory_} type Mouse event type. - * @param {!goog.math.Coordinate} coord The coordinate where event will fire. - * @param {number} button The mouse button value for the event. - * @param {Element=} opt_related The related element of this event. - * @param {?number=} opt_wheelDelta The wheel delta value for the event. - * @param {boolean=} opt_force Whether the event should be fired even if the - * element is not interactable, such as the case of a mousemove or - * mouseover event that immediately follows a mouseout. - * @param {?number=} opt_pointerId The pointerId associated with the event. - * @param {?number=} opt_count Number of clicks that have been performed. - * @return {boolean} Whether the event fired successfully; false if cancelled. - * @protected - */ -bot.Device.prototype.fireMouseEvent = function (type, coord, button, - opt_related, opt_wheelDelta, opt_force, opt_pointerId, opt_count) { - if (!opt_force && !bot.dom.isInteractable(this.element_)) { - return false; - } - - if (opt_related && - !(bot.events.EventType.MOUSEOVER == type || - bot.events.EventType.MOUSEOUT == type)) { - throw new bot.Error(bot.ErrorCode.INVALID_ELEMENT_STATE, - 'Event type does not allow related target: ' + type); - } - - var args = { - clientX: coord.x, - clientY: coord.y, - button: button, - altKey: this.modifiersState.isAltPressed(), - ctrlKey: this.modifiersState.isControlPressed(), - shiftKey: this.modifiersState.isShiftPressed(), - metaKey: this.modifiersState.isMetaPressed(), - wheelDelta: opt_wheelDelta || 0, - relatedTarget: opt_related || null, - count: opt_count || 1 - }; - - var pointerId = opt_pointerId || bot.Device.MOUSE_MS_POINTER_ID; - - var target = this.element_; - // On click and mousedown events, captured pointers are ignored and the - // event always fires on the original element. - if (type != bot.events.EventType.CLICK && - type != bot.events.EventType.MOUSEDOWN && - pointerId in bot.Device.pointerElementMap_) { - target = bot.Device.pointerElementMap_[pointerId]; - } else if (this.select_) { - target = this.getTargetOfOptionMouseEvent_(type); - } - return target ? this.eventEmitter.fireMouseEvent(target, type, args) : true; -}; - - -/** - * Fires a touch event given the state of the device and the given arguments. - * - * @param {!bot.events.EventFactory_} type Event type. - * @param {number} id The touch identifier. - * @param {!goog.math.Coordinate} coord The coordinate where event will fire. - * @param {number=} opt_id2 The touch identifier of the second finger. - * @param {!goog.math.Coordinate=} opt_coord2 The coordinate of the second - * finger, if any. - * @return {boolean} Whether the event fired successfully or was cancelled. - * @protected - */ -bot.Device.prototype.fireTouchEvent = function (type, id, coord, opt_id2, - opt_coord2) { - var args = { - touches: [], - targetTouches: [], - changedTouches: [], - altKey: this.modifiersState.isAltPressed(), - ctrlKey: this.modifiersState.isControlPressed(), - shiftKey: this.modifiersState.isShiftPressed(), - metaKey: this.modifiersState.isMetaPressed(), - relatedTarget: null, - scale: 0, - rotation: 0 - }; - var pageOffset = goog.dom.getDomHelper(this.element_).getDocumentScroll(); - - function addTouch(identifier, coords) { - // Android devices leave identifier to zero. - var touch = { - identifier: identifier, - screenX: coords.x, - screenY: coords.y, - clientX: coords.x, - clientY: coords.y, - pageX: coords.x + pageOffset.x, - pageY: coords.y + pageOffset.y - }; - - args.changedTouches.push(touch); - if (type == bot.events.EventType.TOUCHSTART || - type == bot.events.EventType.TOUCHMOVE) { - args.touches.push(touch); - args.targetTouches.push(touch); - } - } - - addTouch(id, coord); - if (opt_id2 !== undefined) { - addTouch(opt_id2, opt_coord2); - } - - return this.eventEmitter.fireTouchEvent(this.element_, type, args); -}; - - -/** - * Fires a MSPointer event given the state of the device and the given - * arguments. - * - * @param {!bot.events.EventFactory_} type MSPointer event type. - * @param {!goog.math.Coordinate} coord The coordinate where event will fire. - * @param {number} button The mouse button value for the event. - * @param {number} pointerId The pointer id for this event. - * @param {number} device The device type used for this event. - * @param {boolean} isPrimary Whether the pointer represents the primary point - * of contact. - * @param {Element=} opt_related The related element of this event. - * @param {boolean=} opt_force Whether the event should be fired even if the - * element is not interactable, such as the case of a mousemove or - * mouseover event that immediately follows a mouseout. - * @return {boolean} Whether the event fired successfully; false if cancelled. - * @protected - */ -bot.Device.prototype.fireMSPointerEvent = function (type, coord, button, - pointerId, device, isPrimary, opt_related, opt_force) { - if (!opt_force && !bot.dom.isInteractable(this.element_)) { - return false; - } - - if (opt_related && - !(bot.events.EventType.MSPOINTEROVER == type || - bot.events.EventType.MSPOINTEROUT == type)) { - throw new bot.Error(bot.ErrorCode.INVALID_ELEMENT_STATE, - 'Event type does not allow related target: ' + type); - } - - var args = { - clientX: coord.x, - clientY: coord.y, - button: button, - altKey: false, - ctrlKey: false, - shiftKey: false, - metaKey: false, - relatedTarget: opt_related || null, - width: 0, - height: 0, - pressure: 0, // Pressure is only given when a stylus is used. - rotation: 0, - pointerId: pointerId, - tiltX: 0, - tiltY: 0, - pointerType: device, - isPrimary: isPrimary - }; - - var target = this.select_ ? - this.getTargetOfOptionMouseEvent_(type) : this.element_; - if (bot.Device.pointerElementMap_[pointerId]) { - target = bot.Device.pointerElementMap_[pointerId]; - } - var owner = goog.dom.getWindow(goog.dom.getOwnerDocument(this.element_)); - var originalMsSetPointerCapture; - if (owner && type == bot.events.EventType.MSPOINTERDOWN) { - // Overwrite msSetPointerCapture on the Element's msSetPointerCapture - // because synthetic pointer events cause an access denied exception. - // The prototype is modified because the pointer event will bubble up and - // we do not know which element will handle the pointer event. - originalMsSetPointerCapture = - owner['Element'].prototype.msSetPointerCapture; - owner['Element'].prototype.msSetPointerCapture = function (id) { - bot.Device.pointerElementMap_[id] = this; - }; - } - var result = - target ? this.eventEmitter.fireMSPointerEvent(target, type, args) : true; - if (originalMsSetPointerCapture) { - owner['Element'].prototype.msSetPointerCapture = - originalMsSetPointerCapture; - } - return result; -}; - - -/** - * A mouse event fired "on" an option element, doesn't always fire on the - * option element itself. Sometimes it fires on the parent select element - * and sometimes not at all, depending on the browser and event type. This - * returns the true target element of the event, or null if none is fired. - * - * @param {!bot.events.EventFactory_} type Type of event. - * @return {Element} Element the event should be fired on, null if none. - * @private - */ -bot.Device.prototype.getTargetOfOptionMouseEvent_ = function (type) { - // IE either fires the event on the parent select or not at all. - if (goog.userAgent.IE) { - switch (type) { - case bot.events.EventType.MOUSEOVER: - case bot.events.EventType.MSPOINTEROVER: - return null; - case bot.events.EventType.CONTEXTMENU: - case bot.events.EventType.MOUSEMOVE: - case bot.events.EventType.MSPOINTERMOVE: - return this.select_.multiple ? this.select_ : null; - default: - return this.select_; - } - } - - // WebKit always fires on the option element of multi-selects. - // On single-selects, it either fires on the parent or not at all. - if (goog.userAgent.WEBKIT) { - switch (type) { - case bot.events.EventType.CLICK: - case bot.events.EventType.MOUSEUP: - return this.select_.multiple ? this.element_ : this.select_; - default: - return this.select_.multiple ? this.element_ : null; - } - } - - // Firefox fires every event or the option element. - return this.element_; -}; - - -/** - * A helper function to fire click events. This method is shared between - * the mouse and touchscreen devices. - * - * @param {!goog.math.Coordinate} coord The coordinate where event will fire. - * @param {number} button The mouse button value for the event. - * @param {boolean=} opt_force Whether the click should occur even if the - * element is not interactable, such as when an element is hidden by a - * mouseup handler. - * @param {?number=} opt_pointerId The pointer id associated with the click. - * @protected - */ -bot.Device.prototype.clickElement = function (coord, button, opt_force, - opt_pointerId) { - if (!opt_force && !bot.dom.isInteractable(this.element_)) { - return; - } - - // bot.events.fire(element, 'click') can trigger all onclick events, but may - // not follow links (FORM.action or A.href). - // TAG IE GECKO WebKit - // A(href) No No Yes - // FORM(action) No Yes Yes - var targetLink = null; - var targetButton = null; - if (!bot.Device.ALWAYS_FOLLOWS_LINKS_ON_CLICK_) { - for (var e = this.element_; e; e = e.parentNode) { - if (bot.dom.isElement(e, goog.dom.TagName.A)) { - targetLink = /**@type {!Element}*/ (e); - break; - } else if (bot.Device.isFormSubmitElement(e)) { - targetButton = e; - break; - } - } - } - - // When an element is toggled as the result of a click, the toggling and the - // change event happens before the click event on some browsers. However, on - // radio buttons and checkboxes, the click handler can prevent the toggle from - // happening, so we must fire the click first to see if it is cancelled. - var isRadioOrCheckbox = !this.select_ && bot.dom.isSelectable(this.element_); - var wasChecked = isRadioOrCheckbox && bot.dom.isSelected(this.element_); - - // NOTE: Clicking on a form submit button is a little broken: - // (1) When clicking a form submit button in IE, firing a click event or - // calling Form.submit() will not by itself submit the form, so we call - // Element.click() explicitly, but as a result, the coordinates of the click - // event are not provided. Also, when clicking on an , the - // coordinates click that are submitted with the form are always (0, 0). - // (2) When clicking a form submit button in GECKO, while the coordinates of - // the click event are correct, those submitted with the form are always (0,0) - // . - // TODO: See if either of these can be resolved, perhaps by adding - // hidden form elements with the coordinates before the form is submitted. - if (goog.userAgent.IE && targetButton) { - targetButton.click(); - return; - } - - var performDefault = this.fireMouseEvent( - bot.events.EventType.CLICK, coord, button, null, 0, opt_force, - opt_pointerId); - if (!performDefault) { - return; - } - - if (targetLink && bot.Device.shouldFollowHref_(targetLink)) { - bot.Device.followHref_(targetLink); - } else if (isRadioOrCheckbox) { - this.toggleRadioButtonOrCheckbox_(wasChecked); - } -}; - - -/** - * Focuses on the given element and returns true if it supports being focused - * and does not already have focus; otherwise, returns false. If another element - * has focus, that element will be blurred before focusing on the given element. - * - * @return {boolean} Whether the element was given focus. - * @protected - */ -bot.Device.prototype.focusOnElement = function () { - var elementToFocus = goog.dom.getAncestor( - this.element_, - function (node) { - return !!node && bot.dom.isElement(node) && - bot.dom.isFocusable(/** @type {!Element} */(node)); - }, - true /* Return this.element_ if it is focusable. */); - elementToFocus = elementToFocus || this.element_; - - var activeElement = bot.dom.getActiveElement(elementToFocus); - if (elementToFocus == activeElement) { - return false; - } - - // If there is a currently active element, try to blur it. - if (activeElement && (typeof activeElement.blur === 'function' || - // IE reports native functions as being objects. - goog.userAgent.IE && (typeof activeElement.blur === 'object' && activeElement.blur !== null))) { - // In IE, the focus() and blur() functions fire their respective events - // asynchronously, and as the result, the focus/blur events fired by the - // the atoms actions will often be in the wrong order on IE. Firing a blur - // out of order sometimes causes IE to throw an "Unspecified error", so we - // wrap it in a try-catch and catch and ignore the error in this case. - if (!bot.dom.isElement(activeElement, goog.dom.TagName.BODY)) { - try { - activeElement.blur(); - } catch (e) { - if (!(goog.userAgent.IE && e.message == 'Unspecified error.')) { - throw e; - } - } - } - - // Sometimes IE6 and IE7 will not fire an onblur event after blur() - // is called, unless window.focus() is called immediately afterward. - // Note that IE8 will hit this branch unless the page is forced into - // IE8-strict mode. This shouldn't hurt anything, we just use the - // useragent sniff so we can compile this out for proper browsers. - if (goog.userAgent.IE && !bot.userAgent.isEngineVersion(8)) { - goog.dom.getWindow(goog.dom.getOwnerDocument(elementToFocus)).focus(); - } - } - - // Try to focus on the element. - if (typeof elementToFocus.focus === 'function' || - goog.userAgent.IE && (typeof elementToFocus.focus === 'object' && elementToFocus.focus !== null)) { - /** @type {function()} */ (elementToFocus.focus).call(elementToFocus); - return true; - } - - return false; -}; - - -/** - * Whether links must be manually followed when clicking (because firing click - * events doesn't follow them). - * @private {boolean} - * @const - */ -bot.Device.ALWAYS_FOLLOWS_LINKS_ON_CLICK_ = goog.userAgent.WEBKIT; - - -/** - * @param {Node} element The element to check. - * @return {boolean} Whether the element is a submit element in form. - * @protected - */ -bot.Device.isFormSubmitElement = function (element) { - if (bot.dom.isElement(element, goog.dom.TagName.INPUT)) { - var type = element.type.toLowerCase(); - if (type == 'submit' || type == 'image') { - return true; - } - } - - if (bot.dom.isElement(element, goog.dom.TagName.BUTTON)) { - var type = element.type.toLowerCase(); - if (type == 'submit') { - return true; - } - } - return false; -}; - - -/** - * Indicates whether we should manually follow the href of the element we're - * clicking. - * - * Versions of firefox from 4+ will handle links properly when this is used in - * an extension. Versions of Firefox prior to this may or may not do the right - * thing depending on whether a target window is opened and whether the click - * has caused a change in just the hash part of the URL. - * - * @param {!Element} element The element to consider. - * @return {boolean} Whether following an href should be skipped. - * @private - */ -bot.Device.shouldFollowHref_ = function (element) { - if (bot.Device.ALWAYS_FOLLOWS_LINKS_ON_CLICK_ || !element.href) { - return false; - } - - if (!(bot.userAgent.WEBEXTENSION)) { - return true; - } - - if (element.target || element.href.toLowerCase().indexOf('javascript') == 0) { - return false; - } - - var owner = goog.dom.getWindow(goog.dom.getOwnerDocument(element)); - var sourceUrl = owner.location.href; - var destinationUrl = bot.Device.resolveUrl_(owner.location, element.href); - var isOnlyHashChange = - sourceUrl.split('#')[0] === destinationUrl.split('#')[0]; - - return !isOnlyHashChange; -}; - - -/** - * Explicitly follows the href of an anchor. - * - * @param {!Element} anchorElement An anchor element. - * @private - */ -bot.Device.followHref_ = function (anchorElement) { - var targetHref = anchorElement.href; - var owner = goog.dom.getWindow(goog.dom.getOwnerDocument(anchorElement)); - - // IE7 and earlier incorrect resolve a relative href against the top window - // location instead of the window to which the href is assigned. As a result, - // we have to resolve the relative URL ourselves. We do not use Closure's - // goog.Uri to resolve, because it incorrectly fails to support empty but - // undefined query and fragment components and re-encodes the given url. - if (goog.userAgent.IE && !bot.userAgent.isEngineVersion(8)) { - targetHref = bot.Device.resolveUrl_(owner.location, targetHref); - } - - if (anchorElement.target) { - owner.open(targetHref, anchorElement.target); - } else { - owner.location.href = targetHref; - } -}; - - -/** - * Toggles the selected state of the current element if it is an option. This - * is a noop if the element is not an option, or if it is selected and belongs - * to a single-select, because it can't be toggled off. - * - * @protected - */ -bot.Device.prototype.maybeToggleOption = function () { - // If this is not an

- * then calling bot.dom.getParentElement on B will return A, but calling - * getDisplayParentElement on B will return D. - * - * @param {!Node} node The node whose parent is desired. - * @return {Node} The parent node, if available, null otherwise. - */ -bot.dom.getParentNodeInComposedDom = function (node) { - var /**@type {Node}*/ parent = node.parentNode; - - // Shadow DOM v1 - if (parent && parent.shadowRoot && node.assignedSlot !== undefined) { - // Can be null on purpose, meaning it has no parent as - // it hasn't yet been slotted - return node.assignedSlot ? node.assignedSlot.parentNode : null; - } - - // Shadow DOM V0 (deprecated) - if (node.getDestinationInsertionPoints) { - var destinations = node.getDestinationInsertionPoints(); - if (destinations.length > 0) { - return destinations[destinations.length - 1]; - } - } - - return parent; -}; - - -/** - * @param {!Node} node Node. - * @param {!Array.} lines Accumulated visible lines of text. - * @param {boolean} shown whether the node is visible - * @param {?string} whitespace the node's 'white-space' effectiveStyle - * @param {?string} textTransform the node's 'text-transform' effectiveStyle - * @private - * @suppress {missingProperties} - */ -bot.dom.appendVisibleTextLinesFromNodeInComposedDom_ = function ( - node, lines, shown, whitespace, textTransform) { - - if (node.nodeType == goog.dom.NodeType.TEXT && shown) { - var textNode = /** @type {!Text} */ (node); - bot.dom.appendVisibleTextLinesFromTextNode_(textNode, lines, - whitespace, textTransform); - } else if (bot.dom.isElement(node)) { - var castElem = /** @type {!Element} */ (node); - - if (bot.dom.isElement(node, 'CONTENT') || bot.dom.isElement(node, 'SLOT')) { - var parentNode = node; - while (parentNode.parentNode) { - parentNode = parentNode.parentNode; - } - if (parentNode instanceof ShadowRoot) { - // If the element is and we're inside a shadow DOM then just - // append the contents of the nodes that have been distributed into it. - var contentElem = /** @type {!Object} */ (node); - var shadowChildren; - if (bot.dom.isElement(node, 'CONTENT')) { - shadowChildren = contentElem.getDistributedNodes(); - } else { - shadowChildren = contentElem.assignedNodes(); - } - const childrenToTraverse = - shadowChildren.length > 0 ? shadowChildren : contentElem.childNodes; - goog.array.forEach(childrenToTraverse, function (node) { - bot.dom.appendVisibleTextLinesFromNodeInComposedDom_( - node, lines, shown, whitespace, textTransform); - }); - } else { - // if we're not inside a shadow DOM, then we just treat - // as an unknown element and use anything inside the tag - bot.dom.appendVisibleTextLinesFromElementInComposedDom_( - castElem, lines); - } - } else if (bot.dom.isElement(node, 'SHADOW')) { - // if the element is then find the owning shadowRoot - var parentNode = node; - while (parentNode.parentNode) { - parentNode = parentNode.parentNode; - } - if (parentNode instanceof ShadowRoot) { - var thisShadowRoot = /** @type {!ShadowRoot} */ (parentNode); - if (thisShadowRoot) { - // then go through the owning shadowRoots older siblings and append - // their contents - var olderShadowRoot = thisShadowRoot.olderShadowRoot; - while (olderShadowRoot) { - goog.array.forEach( - olderShadowRoot.childNodes, function (childNode) { - bot.dom.appendVisibleTextLinesFromNodeInComposedDom_( - childNode, lines, shown, whitespace, textTransform); - }); - olderShadowRoot = olderShadowRoot.olderShadowRoot; - } - } - } - } else { - // otherwise append the contents of an element as per normal. - bot.dom.appendVisibleTextLinesFromElementInComposedDom_( - castElem, lines); - } - } -}; - - -/** - * Determines whether a given node has been distributed into a ShadowDOM - * element somewhere. - * @param {!Node} node The node to check - * @return {boolean} True if the node has been distributed. - */ -bot.dom.isNodeDistributedIntoShadowDom = function (node) { - var elemOrText = null; - if (node.nodeType == goog.dom.NodeType.ELEMENT) { - elemOrText = /** @type {!Element} */ (node); - } else if (node.nodeType == goog.dom.NodeType.TEXT) { - elemOrText = /** @type {!Text} */ (node); - } - return elemOrText != null && - (elemOrText.assignedSlot != null || - (elemOrText.getDestinationInsertionPoints && - elemOrText.getDestinationInsertionPoints().length > 0) - ); -}; - - -/** - * @param {!Element} elem Element. - * @param {!Array.} lines Accumulated visible lines of text. - * @private - */ -bot.dom.appendVisibleTextLinesFromElementInComposedDom_ = function ( - elem, lines) { - if (elem.shadowRoot) { - // Get the effective styles from the shadow host element for text nodes in shadow DOM - var whitespace = bot.dom.getEffectiveStyle(elem, 'white-space'); - var textTransform = bot.dom.getEffectiveStyle(elem, 'text-transform'); - - goog.array.forEach(elem.shadowRoot.childNodes, function (node) { - bot.dom.appendVisibleTextLinesFromNodeInComposedDom_( - node, lines, true, whitespace, textTransform); - }); - } - - bot.dom.appendVisibleTextLinesFromElementCommon_( - elem, lines, bot.dom.isShown, - function (node, lines, shown, whitespace, textTransform) { - // If the node has been distributed into a shadowDom element - // to be displayed elsewhere, then we shouldn't append - // its contents here). - if (!bot.dom.isNodeDistributedIntoShadowDom(node)) { - bot.dom.appendVisibleTextLinesFromNodeInComposedDom_( - node, lines, shown, whitespace, textTransform); - } - }); -}; diff --git a/javascript/atoms/dom.ts b/javascript/atoms/dom.ts new file mode 100644 index 0000000000000..36c84c31b35db --- /dev/null +++ b/javascript/atoms/dom.ts @@ -0,0 +1,1143 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview DOM manipulation and querying routines. + */ + +import { + isElement, + isSelectable, + isSelected, + getAttribute, + getProperty, +} from './domcore'; +import { standardizeColor } from './color'; +import { IE_DOC_PRE9, isEngineVersion } from './userAgent'; +import { single as cssSingle } from './locators/css'; + +// Re-export domcore functions +export { isElement, isSelectable, isSelected, getAttribute, getProperty }; + +// Node type constants +const NODE_TYPE_ELEMENT = 1; +const NODE_TYPE_TEXT = 3; +const NODE_TYPE_DOCUMENT = 9; +const NODE_TYPE_DOCUMENT_FRAGMENT = 11; + +// Browser detection +const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''; +const IS_IE = /MSIE|Trident/.test(userAgent); +const IS_GECKO = /Gecko/.test(userAgent) && !/like Gecko/.test(userAgent); + +/** + * Simple Rect class to replace goog.math.Rect + */ +export class Rect { + constructor( + public left: number, + public top: number, + public width: number, + public height: number + ) {} + + toBox(): Box { + return { + left: this.left, + top: this.top, + right: this.left + this.width, + bottom: this.top + this.height, + }; + } +} + +/** + * Box interface + */ +export interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * Coordinate interface + */ +export interface Coordinate { + x: number; + y: number; +} + +/** + * Overflow state enum + */ +export enum OverflowState { + NONE = 'none', + HIDDEN = 'hidden', + SCROLL = 'scroll', +} + +/** + * Converts a string to camelCase. + */ +function toCamelCase(str: string): string { + return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +/** + * Canonicalizes newlines to \n. + */ +function canonicalizeNewlines(str: string): string { + return str.replace(/\r\n|\r/g, '\n'); +} + +/** + * Gets an ancestor matching the predicate. + */ +function getAncestor( + node: Node, + predicate: (n: Node) => boolean, + includeNode?: boolean +): Node | null { + let current: Node | null = includeNode ? node : node.parentNode; + while (current) { + if (predicate(current)) { + return current; + } + current = current.parentNode; + } + return null; +} + +/** + * Whether Shadow DOM operations are supported by the browser. + */ +export const IS_SHADOW_DOM_ENABLED: boolean = typeof ShadowRoot === 'function'; + +/** + * Retrieves the active element for a node's owner document. + */ +export function getActiveElement(nodeOrWindow: Node | Window): Element | null { + const doc = + 'ownerDocument' in nodeOrWindow + ? nodeOrWindow.ownerDocument || document + : (nodeOrWindow as Window).document; + const active = doc.activeElement; + if (IS_IE && active && typeof (active as unknown as Record).nodeType === 'undefined') { + return null; + } + return active; +} + +/** + * Returns whether an element is in an interactable state. + */ +export function isInteractable(element: Element): boolean { + return ( + isShown(element, true) && + isEnabled(element) && + !hasPointerEventsDisabled(element) + ); +} + +/** + * Whether element has pointer-events disabled. + */ +function hasPointerEventsDisabled(element: Element): boolean { + if (IS_IE || (IS_GECKO && !isEngineVersion('1.9.2'))) { + return false; + } + return getEffectiveStyle(element, 'pointer-events') === 'none'; +} + +/** + * Focusable form field tag names. + */ +/** + * Focusable form field tag names. + * @private + */ +export const FOCUSABLE_FORM_FIELDS_ = ['A', 'AREA', 'BUTTON', 'INPUT', 'LABEL', 'SELECT', 'TEXTAREA']; + +/** + * Returns whether a node is a focusable element. + */ +export function isFocusable(element: Element): boolean { + const tagMatches = FOCUSABLE_FORM_FIELDS_.some((tag) => isElement(element, tag)); + return ( + tagMatches || + (getAttribute(element, 'tabindex') !== null && + Number(getProperty(element, 'tabIndex')) >= 0) || + isEditable(element) + ); +} + +/** + * Elements that support the "disabled" attribute. + */ +const DISABLED_ATTRIBUTE_SUPPORTED = ['BUTTON', 'INPUT', 'OPTGROUP', 'OPTION', 'SELECT', 'TEXTAREA']; + +/** + * Determines if an element is enabled. + */ +export function isEnabled(el: Element): boolean { + const isSupported = DISABLED_ATTRIBUTE_SUPPORTED.some((tag) => isElement(el, tag)); + if (!isSupported) { + return true; + } + + if (getProperty(el, 'disabled')) { + return false; + } + + if ( + el.parentNode && + el.parentNode.nodeType === NODE_TYPE_ELEMENT && + (isElement(el, 'OPTGROUP') || isElement(el, 'OPTION')) + ) { + return isEnabled(el.parentNode as Element); + } + + return !getAncestor( + el, + (e) => { + const parent = e.parentNode; + if ( + parent && + isElement(parent as Node, 'FIELDSET') && + getProperty(parent as Element, 'disabled') + ) { + if (!isElement(e as Node, 'LEGEND')) { + return true; + } + let sibling: Element | null = (e as Element).previousElementSibling; + while (sibling) { + if (sibling.tagName.toUpperCase() === 'LEGEND') { + return true; + } + sibling = sibling.previousElementSibling; + } + } + return false; + }, + true + ); +} + +/** + * Input types that create text fields. + */ +const TEXTUAL_INPUT_TYPES = ['text', 'search', 'tel', 'url', 'email', 'password', 'number']; + +/** + * Returns whether the element accepts user-typed text. + */ +export function isTextual(element: Element): boolean { + if (isElement(element, 'TEXTAREA')) { + return true; + } + if (isElement(element, 'INPUT')) { + const type = (element as HTMLInputElement).type.toLowerCase(); + return TEXTUAL_INPUT_TYPES.includes(type); + } + if (isContentEditable(element)) { + return true; + } + return false; +} + +/** + * Returns whether the element is a file input. + */ +export function isFileInput(element: Element): boolean { + if (isElement(element, 'INPUT')) { + return (element as HTMLInputElement).type.toLowerCase() === 'file'; + } + return false; +} + +/** + * Returns whether the element is an input with specified type. + */ +export function isInputType(element: Element, inputType: string): boolean { + if (isElement(element, 'INPUT')) { + return (element as HTMLInputElement).type.toLowerCase() === inputType; + } + return false; +} + +/** + * Returns whether the element is contentEditable. + */ +export function isContentEditable(element: Element): boolean { + const el = element as HTMLElement; + if (el.contentEditable === undefined) { + return false; + } + if (!IS_IE && el.isContentEditable !== undefined) { + return el.isContentEditable; + } + function legacyIsContentEditable(e: HTMLElement): boolean { + if (e.contentEditable === 'inherit') { + const parent = getParentElement(e); + return parent ? legacyIsContentEditable(parent as HTMLElement) : false; + } + return e.contentEditable === 'true'; + } + return legacyIsContentEditable(el); +} + +/** + * Whether the element may contain text the user can edit. + */ +export function isEditable(element: Element): boolean { + return ( + (isTextual(element) || + isFileInput(element) || + isInputType(element, 'range') || + isInputType(element, 'date') || + isInputType(element, 'month') || + isInputType(element, 'week') || + isInputType(element, 'time') || + isInputType(element, 'datetime-local') || + isInputType(element, 'color')) && + !getProperty(element, 'readOnly') + ); +} + +/** + * Returns the parent element of the given node, or null. + */ +export function getParentElement(node: Node): Element | null { + let elem = node.parentNode; + while ( + elem && + elem.nodeType !== NODE_TYPE_ELEMENT && + elem.nodeType !== NODE_TYPE_DOCUMENT && + elem.nodeType !== NODE_TYPE_DOCUMENT_FRAGMENT + ) { + elem = elem.parentNode; + } + return isElement(elem as Node) ? (elem as Element) : null; +} + +/** + * Retrieves an explicitly-set, inline style value of an element. + */ +export function getInlineStyle(elem: Element, styleName: string): string { + return (elem as HTMLElement).style.getPropertyValue(styleName) || ''; +} + +/** + * Retrieves the implicitly-set, effective style of an element. + */ +export function getEffectiveStyle(elem: Element, propertyName: string): string | null { + let styleName = toCamelCase(propertyName); + if (styleName === 'float' || styleName === 'cssFloat' || styleName === 'styleFloat') { + styleName = IE_DOC_PRE9 ? 'styleFloat' : 'cssFloat'; + } + const style = + window.getComputedStyle(elem).getPropertyValue(propertyName) || + getCascadedStyle(elem, styleName); + if (style === null) { + return null; + } + return standardizeColor(styleName, style); +} + +/** + * Looks up the DOM tree for the first style value not equal to 'inherit'. + */ +function getCascadedStyle(elem: Element, styleName: string): string | null { + const el = elem as HTMLElement; + const style = (el as unknown as { currentStyle?: CSSStyleDeclaration }).currentStyle || el.style; + const value = (style as unknown as Record)[styleName]; + if (value === undefined && typeof style.getPropertyValue === 'function') { + const propValue = style.getPropertyValue(styleName); + if (propValue !== 'inherit') { + return propValue || null; + } + } + if (value !== 'inherit') { + return value !== undefined ? String(value) : null; + } + const parent = getParentElement(elem); + return parent ? getCascadedStyle(parent, styleName) : null; +} + +/** + * Core isShown implementation. + */ +function isShownCore( + elem: Element, + ignoreOpacity: boolean, + displayedFn: (e: Element) => boolean +): boolean { + if (!isElement(elem)) { + throw new Error('Argument to isShown must be of type Element'); + } + + if (isElement(elem, 'BODY')) { + return true; + } + + if (isElement(elem, 'OPTION') || isElement(elem, 'OPTGROUP')) { + const select = getAncestor(elem, (e) => isElement(e as Node, 'SELECT')) as Element | null; + return !!select && isShownCore(select, true, displayedFn); + } + + const imageMap = maybeFindImageMap(elem); + if (imageMap) { + return ( + !!imageMap.image && + imageMap.rect.width > 0 && + imageMap.rect.height > 0 && + isShownCore(imageMap.image, ignoreOpacity, displayedFn) + ); + } + + if (isElement(elem, 'INPUT') && (elem as HTMLInputElement).type.toLowerCase() === 'hidden') { + return false; + } + + if (isElement(elem, 'NOSCRIPT')) { + return false; + } + + const visibility = getEffectiveStyle(elem, 'visibility'); + if (visibility === 'collapse' || visibility === 'hidden') { + return false; + } + + if (!displayedFn(elem)) { + return false; + } + + if (!ignoreOpacity && getOpacity(elem) === 0) { + return false; + } + + function positiveSize(e: Element): boolean { + const rect = getClientRect(e); + if (rect.height > 0 && rect.width > 0) { + return true; + } + if (isElement(e, 'PATH') && (rect.height > 0 || rect.width > 0)) { + const strokeWidth = getEffectiveStyle(e, 'stroke-width'); + return !!strokeWidth && parseInt(strokeWidth, 10) > 0; + } + const vis = getEffectiveStyle(e, 'visibility'); + if (vis === 'collapse' || vis === 'hidden') { + return false; + } + if (!displayedFn(e)) { + return false; + } + return ( + getEffectiveStyle(e, 'overflow') !== 'hidden' && + Array.from(e.childNodes).some((n) => { + if (n.nodeType === NODE_TYPE_TEXT) { + const text = n.nodeValue || ''; + if (/^[\s]*$/.test(text) && /[\n\r\t]/.test(text)) { + return false; + } + return true; + } + return isElement(n) && positiveSize(n as Element); + }) + ); + } + + if (!positiveSize(elem)) { + return false; + } + + function hiddenByOverflow(e: Element): boolean { + return ( + getOverflowState(e) === OverflowState.HIDDEN && + Array.from(e.childNodes).every( + (n) => !isElement(n) || hiddenByOverflow(n as Element) || !positiveSize(n as Element) + ) + ); + } + + return !hiddenByOverflow(elem); +} + +/** + * Determines whether an element is what a user would call "shown". + */ +export function isShown(elem: Element, ignoreOpacity?: boolean): boolean { + function displayed(e: Node): boolean { + if (isElement(e)) { + const el = e as Element; + if ( + getEffectiveStyle(el, 'display') === 'none' || + getEffectiveStyle(el, 'content-visibility') === 'hidden' + ) { + return false; + } + } + + let parent: Node | null = getParentNodeInComposedDom(e); + + if (IS_SHADOW_DOM_ENABLED && parent instanceof ShadowRoot) { + const host = parent.host as HTMLElement; + if (host.shadowRoot && host.shadowRoot !== parent) { + return false; + } + parent = host; + } + + if ( + parent && + (parent.nodeType === NODE_TYPE_DOCUMENT || parent.nodeType === NODE_TYPE_DOCUMENT_FRAGMENT) + ) { + return true; + } + + if ( + parent && + isElement(parent as Node, 'DETAILS') && + !(parent as HTMLDetailsElement).open && + !isElement(e as Node, 'SUMMARY') + ) { + return false; + } + + return !!parent && displayed(parent); + } + + return isShownCore(elem, !!ignoreOpacity, displayed); +} + +/** + * Returns the overflow state of the given element. + */ +export function getOverflowState(elem: Element, optRegion?: Coordinate | Rect): OverflowState { + const region = getClientRegion(elem, optRegion); + const ownerDoc = elem.ownerDocument || document; + const htmlElem = ownerDoc.documentElement; + const bodyElem = ownerDoc.body; + const htmlOverflowStyle = getEffectiveStyle(htmlElem, 'overflow'); + let treatAsFixedPosition = false; + + function getOverflowParent(e: Element): Element | null { + const position = getEffectiveStyle(e, 'position'); + if (position === 'fixed') { + treatAsFixedPosition = true; + return e === htmlElem ? null : htmlElem; + } + let parent = getParentElement(e); + while (parent && !canBeOverflowed(parent)) { + parent = getParentElement(parent); + } + return parent; + + function canBeOverflowed(container: Element): boolean { + if (container === htmlElem) { + return true; + } + const containerDisplay = getEffectiveStyle(container, 'display') || ''; + if (containerDisplay.startsWith('inline') || containerDisplay === 'contents') { + return false; + } + if (position === 'absolute' && getEffectiveStyle(container, 'position') === 'static') { + return false; + } + return true; + } + } + + function getOverflowStyles(e: Element): { x: string | null; y: string | null } { + let overflowElem = e; + if (htmlOverflowStyle === 'visible') { + if (e === htmlElem && bodyElem) { + overflowElem = bodyElem; + } else if (e === bodyElem) { + return { x: 'visible', y: 'visible' }; + } + } + let overflow = { + x: getEffectiveStyle(overflowElem, 'overflow-x'), + y: getEffectiveStyle(overflowElem, 'overflow-y'), + }; + if (e === htmlElem) { + overflow = { + x: overflow.x === 'visible' ? 'auto' : overflow.x, + y: overflow.y === 'visible' ? 'auto' : overflow.y, + }; + } + return overflow; + } + + function getScroll(e: Element): Coordinate { + if (e === htmlElem) { + const win = ownerDoc.defaultView || window; + return { x: win.pageXOffset || htmlElem.scrollLeft, y: win.pageYOffset || htmlElem.scrollTop }; + } + return { x: e.scrollLeft, y: e.scrollTop }; + } + + for ( + let container = getOverflowParent(elem); + container; + container = getOverflowParent(container) + ) { + const containerOverflow = getOverflowStyles(container); + if (containerOverflow.x === 'visible' && containerOverflow.y === 'visible') { + continue; + } + + const containerRect = getClientRect(container); + if (containerRect.width === 0 || containerRect.height === 0) { + return OverflowState.HIDDEN; + } + + const underflowsX = region.right < containerRect.left; + const underflowsY = region.bottom < containerRect.top; + if ( + (underflowsX && containerOverflow.x === 'hidden') || + (underflowsY && containerOverflow.y === 'hidden') + ) { + return OverflowState.HIDDEN; + } + if ( + (underflowsX && containerOverflow.x !== 'visible') || + (underflowsY && containerOverflow.y !== 'visible') + ) { + const containerScroll = getScroll(container); + const unscrollableX = region.right < containerRect.left - containerScroll.x; + const unscrollableY = region.bottom < containerRect.top - containerScroll.y; + if ( + (unscrollableX && containerOverflow.x !== 'visible') || + (unscrollableY && containerOverflow.y !== 'visible') + ) { + return OverflowState.HIDDEN; + } + const containerState = getOverflowState(container); + return containerState === OverflowState.HIDDEN ? OverflowState.HIDDEN : OverflowState.SCROLL; + } + + const overflowsX = region.left >= containerRect.left + containerRect.width; + const overflowsY = region.top >= containerRect.top + containerRect.height; + if ( + (overflowsX && containerOverflow.x === 'hidden') || + (overflowsY && containerOverflow.y === 'hidden') + ) { + return OverflowState.HIDDEN; + } + if ( + (overflowsX && containerOverflow.x !== 'visible') || + (overflowsY && containerOverflow.y !== 'visible') + ) { + if (treatAsFixedPosition) { + const docScroll = getScroll(container); + if ( + region.left >= htmlElem.scrollWidth - docScroll.x || + region.right >= htmlElem.scrollHeight - docScroll.y + ) { + return OverflowState.HIDDEN; + } + } + const containerState = getOverflowState(container); + return containerState === OverflowState.HIDDEN ? OverflowState.HIDDEN : OverflowState.SCROLL; + } + } + + return OverflowState.NONE; +} + +/** + * Gets the client rectangle of the DOM element. + */ +export function getClientRect(elem: Element): Rect { + const imageMap = maybeFindImageMap(elem); + if (imageMap) { + return imageMap.rect; + } + if (elem.tagName.toUpperCase() === 'HTML') { + const doc = elem.ownerDocument || document; + const win = doc.defaultView || window; + return new Rect(0, 0, win.innerWidth, win.innerHeight); + } + let nativeRect: DOMRect; + try { + nativeRect = elem.getBoundingClientRect(); + } catch (e) { + return new Rect(0, 0, 0, 0); + } + + const rect = new Rect( + nativeRect.left, + nativeRect.top, + nativeRect.right - nativeRect.left, + nativeRect.bottom - nativeRect.top + ); + + if (IS_IE && elem.ownerDocument?.body) { + const doc = elem.ownerDocument; + rect.left -= doc.documentElement.clientLeft + doc.body.clientLeft; + rect.top -= doc.documentElement.clientTop + doc.body.clientTop; + } + + return rect; +} + +/** + * If given a or element, finds the corresponding image and rectangle. + */ +function maybeFindImageMap(elem: Element): { image: Element | null; rect: Rect } | null { + const tagName = elem.tagName.toUpperCase(); + const isMapElem = tagName === 'MAP'; + if (!isMapElem && tagName !== 'AREA') { + return null; + } + + const parentIsMap = elem.parentNode && isElement(elem.parentNode as Node, 'MAP'); + const map = isMapElem ? elem : parentIsMap ? elem.parentNode : null; + + let image: Element | null = null; + let rect: Rect | null = null; + if (map && (map as HTMLMapElement).name) { + const mapDoc = map.ownerDocument || document; + const locator = '*[usemap="#' + (map as HTMLMapElement).name + '"]'; + image = cssSingle(locator, mapDoc); + + if (image) { + rect = getClientRect(image); + if (!isMapElem && (elem as HTMLAreaElement).shape.toLowerCase() !== 'default') { + const relRect = getAreaRelativeRect(elem as HTMLAreaElement); + const relX = Math.min(Math.max(relRect.left, 0), rect.width); + const relY = Math.min(Math.max(relRect.top, 0), rect.height); + const w = Math.min(relRect.width, rect.width - relX); + const h = Math.min(relRect.height, rect.height - relY); + rect = new Rect(relX + rect.left, relY + rect.top, w, h); + } + } + } + + return { image, rect: rect || new Rect(0, 0, 0, 0) }; +} + +/** + * Returns the bounding box around an element relative to its enclosing . + */ +function getAreaRelativeRect(area: HTMLAreaElement): Rect { + const shape = area.shape.toLowerCase(); + const coords = area.coords.split(',').map(Number); + if (shape === 'rect' && coords.length === 4) { + const [x, y, x2, y2] = coords; + return new Rect(x, y, x2 - x, y2 - y); + } + if (shape === 'circle' && coords.length === 3) { + const [cx, cy, r] = coords; + return new Rect(cx - r, cy - r, 2 * r, 2 * r); + } + if (shape === 'poly' && coords.length > 2) { + let minX = coords[0], + minY = coords[1], + maxX = minX, + maxY = minY; + for (let i = 2; i + 1 < coords.length; i += 2) { + minX = Math.min(minX, coords[i]); + maxX = Math.max(maxX, coords[i]); + minY = Math.min(minY, coords[i + 1]); + maxY = Math.max(maxY, coords[i + 1]); + } + return new Rect(minX, minY, maxX - minX, maxY - minY); + } + return new Rect(0, 0, 0, 0); +} + +/** + * Gets the element's client rectangle as a box. + */ +export function getClientRegion(elem: Element, optRegion?: Coordinate | Rect): Box { + const region = getClientRect(elem).toBox(); + + if (optRegion) { + // Duck-type check: if it has width and height, treat as Rect (works with goog.math.Rect too) + const isRectLike = + 'width' in optRegion && 'height' in optRegion && typeof optRegion.width === 'number'; + let rect: Rect; + if (isRectLike) { + const r = optRegion as { left?: number; top?: number; x?: number; y?: number; width: number; height: number }; + // Support both our Rect (left/top) and goog.math.Rect (which uses left/top as well) + const left = r.left !== undefined ? r.left : (r.x !== undefined ? r.x : 0); + const top = r.top !== undefined ? r.top : (r.y !== undefined ? r.y : 0); + rect = new Rect(left, top, r.width, r.height); + } else { + const coord = optRegion as Coordinate; + rect = new Rect(coord.x, coord.y, 1, 1); + } + region.left = Math.min(Math.max(region.left + rect.left, region.left), region.right); + region.top = Math.min(Math.max(region.top + rect.top, region.top), region.bottom); + region.right = Math.min(Math.max(region.left + rect.width, region.left), region.right); + region.bottom = Math.min(Math.max(region.top + rect.height, region.top), region.bottom); + } + + return region; +} + +/** + * Trims leading and trailing whitespace, preserving non-breaking spaces. + */ +function trimExcludingNonBreakingSpaceCharacters(str: string): string { + return str.replace(/^[^\S\xa0]+|[^\S\xa0]+$/g, ''); +} + +/** + * Concatenates and cleans visible text lines. + */ +function concatenateCleanedLines(lines: string[]): string { + const trimmedLines = lines.map(trimExcludingNonBreakingSpaceCharacters); + const joined = trimmedLines.join('\n'); + const trimmed = trimExcludingNonBreakingSpaceCharacters(joined); + return trimmed.replace(/\xa0/g, ' '); +} + +/** + * Gets the visible text of an element. + */ +export function getVisibleText(elem: Element): string { + const lines: string[] = []; + if (IS_SHADOW_DOM_ENABLED) { + appendVisibleTextLinesFromElementInComposedDom(elem, lines); + } else { + appendVisibleTextLinesFromElement(elem, lines); + } + return concatenateCleanedLines(lines); +} + +/** + * Inline display box types. + */ +const INLINE_DISPLAY_BOXES = [ + 'inline', + 'inline-block', + 'inline-table', + 'none', + 'table-cell', + 'table-column', + 'table-column-group', +]; + +/** + * Common helper for appending visible text lines. + */ +function appendVisibleTextLinesFromElementCommon( + elem: Element, + lines: string[], + isShownFn: (e: Element) => boolean, + childNodeFn: ( + node: Node, + lines: string[], + shown: boolean, + whitespace: string | null, + textTransform: string | null + ) => void +): void { + function currLine(): string { + return lines[lines.length - 1] || ''; + } + + const elemTag = elem.tagName.toUpperCase(); + if (elemTag === 'BR') { + lines.push(''); + } else { + const isTD = elemTag === 'TD'; + const display = getEffectiveStyle(elem, 'display'); + const isBlock = !isTD && !INLINE_DISPLAY_BOXES.includes(display || ''); + + const previousElementSibling = (elem as HTMLElement).previousElementSibling; + const prevDisplay = previousElementSibling + ? getEffectiveStyle(previousElementSibling, 'display') + : ''; + const thisFloat = + getEffectiveStyle(elem, 'float') || + getEffectiveStyle(elem, 'cssFloat') || + getEffectiveStyle(elem, 'styleFloat'); + const runIntoThis = prevDisplay === 'run-in' && thisFloat === 'none'; + + if (isBlock && !runIntoThis && currLine().trim() !== '') { + lines.push(''); + } + + const shown = isShownFn(elem); + let whitespace: string | null = null; + let textTransform: string | null = null; + if (shown) { + whitespace = getEffectiveStyle(elem, 'white-space'); + textTransform = getEffectiveStyle(elem, 'text-transform'); + } + + Array.from(elem.childNodes).forEach((node) => { + childNodeFn(node, lines, shown, whitespace, textTransform); + }); + + const line = currLine(); + if ((isTD || display === 'table-cell') && line && !line.endsWith(' ')) { + lines[lines.length - 1] += ' '; + } + + if (isBlock && display !== 'run-in' && line.trim() !== '') { + lines.push(''); + } + } +} + +/** + * Appends visible text lines from an element. + */ +function appendVisibleTextLinesFromElement(elem: Element, lines: string[]): void { + appendVisibleTextLinesFromElementCommon( + elem, + lines, + isShown, + (node, lines, shown, whitespace, textTransform) => { + if (node.nodeType === NODE_TYPE_TEXT && shown) { + appendVisibleTextLinesFromTextNode(node as Text, lines, whitespace, textTransform); + } else if (isElement(node)) { + appendVisibleTextLinesFromElement(node as Element, lines); + } + } + ); +} + +/** + * Appends visible text lines from a text node. + */ +function appendVisibleTextLinesFromTextNode( + textNode: Text, + lines: string[], + whitespace: string | null, + textTransform: string | null +): void { + let text = (textNode.nodeValue || '').replace(/[\u200b\u200e\u200f]/g, ''); + text = canonicalizeNewlines(text); + + if (whitespace === 'normal' || whitespace === 'nowrap') { + text = text.replace(/\n/g, ' '); + } + + if (whitespace === 'pre' || whitespace === 'pre-wrap') { + text = text.replace(/[ \f\t\v\u2028\u2029]/g, '\xa0'); + } else { + text = text.replace(/[ \f\t\v\u2028\u2029]+/g, ' '); + } + + if (textTransform === 'capitalize') { + const re = + /(^|[^'_0-9A-Za-z\u00C0-\u02AF\u1E00-\u1EFF\u24B6-\u24E9\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF])([A-Za-z\u00C0-\u02AF\u1E00-\u1EFF\u24B6-\u24E9])/g; + text = text.replace(re, (_, p1, p2) => p1 + p2.toUpperCase()); + const re2 = + /(^|[^'_0-9A-Za-z\u00C0-\u02AF\u1E00-\u1EFF\u24B6-\u24E9])([_*])([A-Za-z\u00C0-\u02AF\u1E00-\u1EFF\u24D0-\u24E9])/g; + text = text.replace(re2, (_, p1, p2, p3) => p1 + p2 + p3.toUpperCase()); + } else if (textTransform === 'uppercase') { + text = text.toUpperCase(); + } else if (textTransform === 'lowercase') { + text = text.toLowerCase(); + } + + const currLine = lines.pop() || ''; + if (currLine.endsWith(' ') && text.startsWith(' ')) { + text = text.substring(1); + } + lines.push(currLine + text); +} + +/** + * Gets the opacity of an element. + */ +export function getOpacity(elem: Element): number { + if (!IE_DOC_PRE9) { + return getOpacityNonIE(elem); + } + if (getEffectiveStyle(elem, 'position') === 'relative') { + return 1; + } + const opacityStyle = getEffectiveStyle(elem, 'filter') || ''; + const groups = + opacityStyle.match(/^alpha\(opacity=(\d*)\)/) || + opacityStyle.match(/^progid:DXImageTransform.Microsoft.Alpha\(Opacity=(\d*)\)/); + if (groups) { + return Number(groups[1]) / 100; + } + return 1; +} + +/** + * Gets opacity for non-IE browsers. + */ +function getOpacityNonIE(elem: Element): number { + let elemOpacity = 1; + const opacityStyle = getEffectiveStyle(elem, 'opacity'); + if (opacityStyle) { + elemOpacity = Number(opacityStyle); + } + const parentElement = getParentElement(elem); + if (parentElement) { + elemOpacity = elemOpacity * getOpacityNonIE(parentElement); + } + return elemOpacity; +} + +/** + * Returns the display parent element in the composed DOM. + */ +export function getParentNodeInComposedDom(node: Node): Node | null { + const parent = node.parentNode; + + // Shadow DOM v1: Check if parent is a shadow host (has shadowRoot property) + // and the node has the assignedSlot API (is slottable) + if ( + parent && + (parent as Element).shadowRoot && + (node as HTMLElement).assignedSlot !== undefined + ) { + // Can be null on purpose, meaning it has no parent as + // it hasn't yet been slotted + const slot = (node as HTMLElement).assignedSlot; + return slot ? slot.parentNode : null; + } + + // Shadow DOM V0 (deprecated) + const nodeWithLegacyAPI = node as unknown as { getDestinationInsertionPoints?: () => NodeList }; + if (nodeWithLegacyAPI.getDestinationInsertionPoints) { + const destinations = nodeWithLegacyAPI.getDestinationInsertionPoints(); + if (destinations.length > 0) { + return destinations[destinations.length - 1]; + } + } + + return parent; +} + +/** + * Determines whether a node has been distributed into a ShadowDOM. + */ +export function isNodeDistributedIntoShadowDom(node: Node): boolean { + if (node.nodeType === NODE_TYPE_ELEMENT || node.nodeType === NODE_TYPE_TEXT) { + const el = node as HTMLElement; + const elWithLegacyAPI = el as unknown as { getDestinationInsertionPoints?: () => NodeList }; + if (el.assignedSlot !== null) { + return true; + } + if ( + elWithLegacyAPI.getDestinationInsertionPoints && + elWithLegacyAPI.getDestinationInsertionPoints().length > 0 + ) { + return true; + } + } + return false; +} + +/** + * Appends visible text lines from an element in composed DOM. + */ +function appendVisibleTextLinesFromElementInComposedDom(elem: Element, lines: string[]): void { + const el = elem as HTMLElement; + if (el.shadowRoot) { + const whitespace = getEffectiveStyle(elem, 'white-space'); + const textTransform = getEffectiveStyle(elem, 'text-transform'); + Array.from(el.shadowRoot.childNodes).forEach((node) => { + appendVisibleTextLinesFromNodeInComposedDom(node, lines, true, whitespace, textTransform); + }); + } + + appendVisibleTextLinesFromElementCommon(elem, lines, isShown, (node, lines, shown, ws, tt) => { + if (!isNodeDistributedIntoShadowDom(node)) { + appendVisibleTextLinesFromNodeInComposedDom(node, lines, shown, ws, tt); + } + }); +} + +/** + * Appends visible text lines from a node in composed DOM. + */ +function appendVisibleTextLinesFromNodeInComposedDom( + node: Node, + lines: string[], + shown: boolean, + whitespace: string | null, + textTransform: string | null +): void { + if (node.nodeType === NODE_TYPE_TEXT && shown) { + appendVisibleTextLinesFromTextNode(node as Text, lines, whitespace, textTransform); + } else if (node.nodeType === NODE_TYPE_ELEMENT) { + const castElem = node as Element; + const nodeTag = castElem.tagName.toUpperCase(); + + if (nodeTag === 'CONTENT' || nodeTag === 'SLOT') { + let pNode: Node | null = node; + while (pNode?.parentNode) { + pNode = pNode.parentNode; + } + if (pNode instanceof ShadowRoot) { + const contentElem = node as unknown as { + getDistributedNodes?: () => NodeList; + assignedNodes?: () => Node[]; + childNodes: NodeListOf; + }; + const shadowChildren = + nodeTag === 'CONTENT' + ? contentElem.getDistributedNodes?.() || [] + : contentElem.assignedNodes?.() || []; + const childrenToTraverse = + (shadowChildren as NodeList).length > 0 ? shadowChildren : contentElem.childNodes; + Array.from(childrenToTraverse as NodeListOf).forEach((child) => { + appendVisibleTextLinesFromNodeInComposedDom( + child, + lines, + shown, + whitespace, + textTransform + ); + }); + } else { + appendVisibleTextLinesFromElementInComposedDom(castElem, lines); + } + } else if (nodeTag === 'SHADOW') { + let pNode: Node | null = node; + while (pNode?.parentNode) { + pNode = pNode.parentNode; + } + if (pNode instanceof ShadowRoot) { + let olderShadowRoot = (pNode as ShadowRoot & { olderShadowRoot?: ShadowRoot }) + .olderShadowRoot; + while (olderShadowRoot) { + Array.from(olderShadowRoot.childNodes).forEach((childNode) => { + appendVisibleTextLinesFromNodeInComposedDom( + childNode, + lines, + shown, + whitespace, + textTransform + ); + }); + olderShadowRoot = (olderShadowRoot as ShadowRoot & { olderShadowRoot?: ShadowRoot }) + .olderShadowRoot; + } + } + } else { + appendVisibleTextLinesFromElementInComposedDom(castElem, lines); + } + } +} diff --git a/javascript/atoms/domcore.js b/javascript/atoms/domcore.js deleted file mode 100644 index 10264a35d589e..0000000000000 --- a/javascript/atoms/domcore.js +++ /dev/null @@ -1,220 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Defines the core DOM querying library for the atoms, with a - * minimal set of dependencies. Notably, this file should never have a - * dependency on CSS libraries such as sizzle. - */ - -goog.provide('bot.dom.core'); - -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.userAgent'); -goog.require('goog.array'); -goog.require('goog.dom'); -goog.require('goog.dom.NodeType'); -goog.require('goog.dom.TagName'); - - -/** - * Get the user-specified value of the given attribute of the element, or null - * if the attribute is not present. - * - *

For boolean attributes such as "selected" or "checked", this method - * returns the value of element.getAttribute(attributeName) cast to a String - * when attribute is present. For modern browsers, this will be the string the - * attribute is given in the HTML, but for IE8 it will be the name of the - * attribute, and for IE7, it will be the string "true". To test whether a - * boolean attribute is present, test whether the return value is non-null, the - * same as one would for non-boolean attributes. Specifically, do *not* test - * whether the boolean evaluation of the return value is true, because the value - * of a boolean attribute that is present will often be the empty string. - * - *

For the style attribute, it standardizes the value by lower-casing the - * property names and always including a trailing semicolon. - * - * @param {!Element} element The element to use. - * @param {string} attributeName The name of the attribute to return. - * @return {?string} The value of the attribute or "null" if entirely missing. - */ -bot.dom.core.getAttribute = function (element, attributeName) { - attributeName = attributeName.toLowerCase(); - - // The style attribute should be a css text string that includes only what - // the HTML element specifies itself (excluding what is inherited from parent - // elements or style sheets). We standardize the format of this string via the - // standardizeStyleAttribute method. - if (attributeName == 'style') { - return bot.dom.core.standardizeStyleAttribute_(element.style.cssText); - } - - // In IE doc mode < 8, the "value" attribute of an is only accessible - // as a property. - if (bot.userAgent.IE_DOC_PRE8 && attributeName == 'value' && - bot.dom.core.isElement(element, goog.dom.TagName.INPUT)) { - return element['value']; - } - - // In IE < 9, element.getAttributeNode will return null for some boolean - // attributes that are present, such as the selected attribute on

To see which events bubble and are cancelable, see: - * http://en.wikipedia.org/wiki/DOM_events and - * http://www.w3.org/Submission/pointer-events/#pointer-event-types - * - * @const {!Object} - */ -bot.events.EventType = { - BLUR: new bot.events.EventFactory_('blur', false, false), - CHANGE: new bot.events.EventFactory_('change', true, false), - FOCUS: new bot.events.EventFactory_('focus', false, false), - FOCUSIN: new bot.events.EventFactory_('focusin', true, false), - FOCUSOUT: new bot.events.EventFactory_('focusout', true, false), - INPUT: new bot.events.EventFactory_('input', true, false), - ORIENTATIONCHANGE: new bot.events.EventFactory_( - 'orientationchange', false, false), - PROPERTYCHANGE: new bot.events.EventFactory_('propertychange', false, false), - SELECT: new bot.events.EventFactory_('select', true, false), - SUBMIT: new bot.events.EventFactory_('submit', true, true), - TEXTINPUT: new bot.events.EventFactory_('textInput', true, true), - - // Mouse events. - CLICK: new bot.events.MouseEventFactory_('click', true, true), - CONTEXTMENU: new bot.events.MouseEventFactory_('contextmenu', true, true), - DBLCLICK: new bot.events.MouseEventFactory_('dblclick', true, true), - MOUSEDOWN: new bot.events.MouseEventFactory_('mousedown', true, true), - MOUSEMOVE: new bot.events.MouseEventFactory_('mousemove', true, false), - MOUSEOUT: new bot.events.MouseEventFactory_('mouseout', true, true), - MOUSEOVER: new bot.events.MouseEventFactory_('mouseover', true, true), - MOUSEUP: new bot.events.MouseEventFactory_('mouseup', true, true), - MOUSEWHEEL: new bot.events.MouseEventFactory_( - goog.userAgent.GECKO ? 'DOMMouseScroll' : 'mousewheel', true, true), - MOUSEPIXELSCROLL: new bot.events.MouseEventFactory_( - 'MozMousePixelScroll', true, true), - - // Keyboard events. - KEYDOWN: new bot.events.KeyboardEventFactory_('keydown', true, true), - KEYPRESS: new bot.events.KeyboardEventFactory_('keypress', true, true), - KEYUP: new bot.events.KeyboardEventFactory_('keyup', true, true), - - // Touch events. - TOUCHEND: new bot.events.TouchEventFactory_('touchend', true, true), - TOUCHMOVE: new bot.events.TouchEventFactory_('touchmove', true, true), - TOUCHSTART: new bot.events.TouchEventFactory_('touchstart', true, true), - - // MSGesture events - MSGESTURECHANGE: new bot.events.MSGestureEventFactory_( - 'MSGestureChange', true, true), - MSGESTUREEND: new bot.events.MSGestureEventFactory_( - 'MSGestureEnd', true, true), - MSGESTUREHOLD: new bot.events.MSGestureEventFactory_( - 'MSGestureHold', true, true), - MSGESTURESTART: new bot.events.MSGestureEventFactory_( - 'MSGestureStart', true, true), - MSGESTURETAP: new bot.events.MSGestureEventFactory_( - 'MSGestureTap', true, true), - MSINERTIASTART: new bot.events.MSGestureEventFactory_( - 'MSInertiaStart', true, true), - - // MSPointer events - MSGOTPOINTERCAPTURE: new bot.events.MSPointerEventFactory_( - 'MSGotPointerCapture', true, false), - MSLOSTPOINTERCAPTURE: new bot.events.MSPointerEventFactory_( - 'MSLostPointerCapture', true, false), - MSPOINTERCANCEL: new bot.events.MSPointerEventFactory_( - 'MSPointerCancel', true, true), - MSPOINTERDOWN: new bot.events.MSPointerEventFactory_( - 'MSPointerDown', true, true), - MSPOINTERMOVE: new bot.events.MSPointerEventFactory_( - 'MSPointerMove', true, true), - MSPOINTEROVER: new bot.events.MSPointerEventFactory_( - 'MSPointerOver', true, true), - MSPOINTEROUT: new bot.events.MSPointerEventFactory_( - 'MSPointerOut', true, true), - MSPOINTERUP: new bot.events.MSPointerEventFactory_( - 'MSPointerUp', true, true) -}; - - -/** - * Fire a named event on a particular element. - * - * @param {!Element|!Window} target The element on which to fire the event. - * @param {!bot.events.EventFactory_} type Event type. - * @param {bot.events.EventArgs=} opt_args Arguments to initialize the event. - * @return {boolean} Whether the event fired successfully or was cancelled. - */ -bot.events.fire = function (target, type, opt_args) { - var event = type.create(target, opt_args); - - // Ensure the event's isTrusted property is set to false, so that - // bot.events.isSynthetic() can identify synthetic events from native ones. - if (!('isTrusted' in event)) { - event['isTrusted'] = false; - } - return target.dispatchEvent(event); -}; - - -/** - * Returns whether the event was synthetically created by the atoms; - * if false, was created by the browser in response to a live user action. - * - * @param {!(Event|goog.events.BrowserEvent)} event An event. - * @return {boolean} Whether the event was synthetically created. - */ -bot.events.isSynthetic = function (event) { - var e = event.getBrowserEvent ? event.getBrowserEvent() : event; - return 'isTrusted' in e ? !e['isTrusted'] : false; -}; diff --git a/javascript/atoms/events.ts b/javascript/atoms/events.ts new file mode 100644 index 0000000000000..da4589fe028c2 --- /dev/null +++ b/javascript/atoms/events.ts @@ -0,0 +1,907 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Functions to do with firing and simulating events. + */ + +import { BotError, ErrorCode } from './error'; +import { + IE, + GECKO, + WEBKIT, + EDGE, + ANDROID, + IOS, + isEngineVersion, + isProductVersion, +} from './userAgent'; +import { getWindow } from './bot'; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/** + * Arguments to initialize a mouse event. + */ +export interface MouseArgs { + clientX: number; + clientY: number; + button: number; + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + metaKey: boolean; + relatedTarget: Element | null; + wheelDelta: number; + count?: number; +} + +/** + * Arguments to initialize a keyboard event. + */ +export interface KeyboardArgs { + keyCode: number; + charCode: number; + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + metaKey: boolean; + preventDefault: boolean; +} + +/** + * Touch information. + */ +export interface TouchInfo { + identifier: number; + screenX: number; + screenY: number; + clientX: number; + clientY: number; + pageX: number; + pageY: number; +} + +/** + * Arguments to initialize a touch event. + */ +export interface TouchArgs { + touches: TouchInfo[]; + targetTouches: TouchInfo[]; + changedTouches: TouchInfo[]; + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + metaKey: boolean; + relatedTarget: Element | null; + scale: number; + rotation: number; + clientX?: number; + clientY?: number; +} + +/** + * Arguments to initialize an MSGesture event. + */ +export interface MSGestureArgs { + clientX: number; + clientY: number; + translationX: number; + translationY: number; + scale: number; + expansion: number; + rotation: number; + velocityX: number; + velocityY: number; + velocityExpansion: number; + velocityAngular: number; + relatedTarget: Element | null; +} + +/** + * Arguments to initialize an MSPointer event. + */ +export interface MSPointerArgs { + clientX: number; + clientY: number; + button: number; + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + metaKey: boolean; + relatedTarget: Element | null; + width: number; + height: number; + pressure: number; + rotation: number; + pointerId: number; + tiltX: number; + tiltY: number; + pointerType: number; + isPrimary: boolean; +} + +/** + * Union of all event arguments. + */ +export type EventArgs = + | MouseArgs + | KeyboardArgs + | TouchArgs + | MSGestureArgs + | MSPointerArgs; + +// ============================================================================ +// Browser Capabilities +// ============================================================================ + +/** + * Whether the browser supports the construction of touch events. + */ +export const SUPPORTS_TOUCH_EVENTS: boolean = !(IE && !isEngineVersion(10)); + +/** + * Whether the browser supports a native touch api. + */ +const BROKEN_TOUCH_API: boolean = (function () { + if (ANDROID) { + return !isProductVersion(4); + } + return !IOS; +})(); + +/** + * Whether the browser supports the construction of MSPointer events. + */ +export const SUPPORTS_MSPOINTER_EVENTS: boolean = + IE && !!(getWindow().navigator as { msPointerEnabled?: boolean })?.msPointerEnabled; + +// ============================================================================ +// Event Factory Base Class +// ============================================================================ + +/** + * Factory for event objects of a specific type. + */ +export class EventFactory { + constructor( + protected type: string, + protected bubbles: boolean, + protected cancelable: boolean + ) {} + + /** + * Creates an event. + */ + create(target: Element | Window, _args?: EventArgs): Event { + const doc = + 'ownerDocument' in target + ? target.ownerDocument! + : (target as Window).document; + + const event = doc.createEvent('HTMLEvents'); + event.initEvent(this.type, this.bubbles, this.cancelable); + + return event; + } + + /** + * Returns the event type string. + */ + toString(): string { + return this.type; + } +} + +// ============================================================================ +// Mouse Event Factory +// ============================================================================ + +/** + * Factory for mouse event objects of a specific type. + */ +export class MouseEventFactory extends EventFactory { + override create(target: Element | Window, opt_args?: EventArgs): Event { + const args = opt_args as MouseArgs; + const doc = + 'ownerDocument' in target + ? target.ownerDocument! + : (target as Window).document; + + const view = 'defaultView' in doc ? doc.defaultView : window; + const event = doc.createEvent('MouseEvents') as MouseEvent; + let detail = args.count || 1; + + if (this.type === 'mousewheel' || this.type === 'DOMMouseScroll') { + if (!GECKO) { + (event as MouseEvent & { wheelDelta?: number }).wheelDelta = + args.wheelDelta; + } + if (GECKO) { + detail = args.wheelDelta / -40; + } + } + + if (GECKO && this.type === 'MozMousePixelScroll') { + detail = args.wheelDelta; + } + + event.initMouseEvent( + this.type, + this.bubbles, + this.cancelable, + view!, + detail, + args.clientX, + args.clientY, + args.clientX, + args.clientY, + args.ctrlKey, + args.altKey, + args.shiftKey, + args.metaKey, + args.button, + args.relatedTarget + ); + + if ( + IE && + event.pageX === 0 && + event.pageY === 0 && + Object.defineProperty + ) { + const scrollElem = doc.documentElement || doc.body; + const clientElem = doc.documentElement || doc.body; + const pageX = + args.clientX + scrollElem.scrollLeft - (clientElem.clientLeft || 0); + const pageY = + args.clientY + scrollElem.scrollTop - (clientElem.clientTop || 0); + + Object.defineProperty(event, 'pageX', { + get: function () { + return pageX; + }, + }); + Object.defineProperty(event, 'pageY', { + get: function () { + return pageY; + }, + }); + } + + return event; + } +} + +// ============================================================================ +// Keyboard Event Factory +// ============================================================================ + +interface KeyboardEventWithInit extends KeyboardEvent { + initKeyEvent?( + type: string, + bubbles: boolean, + cancelable: boolean, + view: Window | null, + ctrlKey: boolean, + altKey: boolean, + shiftKey: boolean, + metaKey: boolean, + keyCode: number, + charCode: number + ): void; +} + +/** + * Factory for keyboard event objects of a specific type. + */ +export class KeyboardEventFactory extends EventFactory { + override create(target: Element | Window, opt_args?: EventArgs): Event { + const args = opt_args as KeyboardArgs; + const doc = + 'ownerDocument' in target + ? target.ownerDocument! + : (target as Window).document; + + let event: Event & { + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + shiftKey?: boolean; + keyCode?: number; + charCode?: number; + }; + + if (GECKO && !isEngineVersion(93)) { + const view = 'defaultView' in doc ? doc.defaultView : window; + const keyCode = args.charCode ? 0 : args.keyCode; + event = doc.createEvent('KeyboardEvent') as KeyboardEventWithInit; + (event as KeyboardEventWithInit).initKeyEvent?.( + this.type, + this.bubbles, + this.cancelable, + view, + args.ctrlKey, + args.altKey, + args.shiftKey, + args.metaKey, + keyCode, + args.charCode + ); + if (this.type === 'keypress' && args.preventDefault) { + event.preventDefault(); + } + } else { + event = doc.createEvent('Events'); + event.initEvent(this.type, this.bubbles, this.cancelable); + event.altKey = args.altKey; + event.ctrlKey = args.ctrlKey; + event.metaKey = args.metaKey; + event.shiftKey = args.shiftKey; + if (GECKO) { + event.keyCode = args.charCode ? 0 : args.keyCode; + event.charCode = args.charCode; + } else { + event.keyCode = args.charCode || args.keyCode; + if (WEBKIT || EDGE) { + event.charCode = this.type === 'keypress' ? event.keyCode : 0; + } + } + } + + return event; + } +} + +// ============================================================================ +// Touch Event Factory +// ============================================================================ + +const enum TouchEventStrategy { + MOUSE_EVENTS = 1, + INIT_TOUCH_EVENT = 2, + TOUCH_EVENT_CTOR = 3, +} + +interface TouchEventWithInit extends TouchEvent { + initTouchEvent?(...args: unknown[]): void; +} + +/** + * Factory for touch event objects of a specific type. + */ +export class TouchEventFactory extends EventFactory { + override create(target: Element | Window, opt_args?: EventArgs): Event { + if (!SUPPORTS_TOUCH_EVENTS) { + throw new BotError( + ErrorCode.UNSUPPORTED_OPERATION, + 'Browser does not support firing touch events.' + ); + } + + const args = opt_args as TouchArgs; + const doc = + 'ownerDocument' in target + ? target.ownerDocument! + : (target as Window).document; + const view = 'defaultView' in doc ? doc.defaultView : window; + + const createNativeTouchList = ( + touchListArgs: TouchInfo[] + ): TouchList | null => { + const createTouch = (doc as Document & { + createTouch?: ( + view: Window | null, + target: EventTarget, + identifier: number, + pageX: number, + pageY: number, + screenX: number, + screenY: number + ) => Touch; + }).createTouch; + const createTouchList = (doc as Document & { + createTouchList?: (...touches: Touch[]) => TouchList; + }).createTouchList; + + if (!createTouch || !createTouchList) return null; + + const touches = touchListArgs.map((touchArg) => + createTouch.call( + doc, + view, + target, + touchArg.identifier, + touchArg.pageX, + touchArg.pageY, + touchArg.screenX, + touchArg.screenY + ) + ); + + return createTouchList.apply(doc, touches); + }; + + const createGenericTouchList = ( + touchListArgs: TouchInfo[] + ): TouchInfo[] & { item: (i: number) => TouchInfo } => { + const result: TouchInfo[] & { item: (i: number) => TouchInfo } = [] as unknown as TouchInfo[] & { item: (i: number) => TouchInfo }; + touchListArgs.forEach((touchArg) => { + result.push({ + identifier: touchArg.identifier, + screenX: touchArg.screenX, + screenY: touchArg.screenY, + clientX: touchArg.clientX, + clientY: touchArg.clientY, + pageX: touchArg.pageX, + pageY: touchArg.pageY, + }); + }); + result.item = function (i: number) { + return result[i]; + }; + return result; + }; + + const createTouchEventTouchList = ( + touchListArgs: TouchInfo[] + ): Touch[] => { + return touchListArgs.map( + (touchArg) => + new Touch({ + identifier: touchArg.identifier, + target: target as EventTarget, + screenX: touchArg.screenX, + screenY: touchArg.screenY, + clientX: touchArg.clientX, + clientY: touchArg.clientY, + pageX: touchArg.pageX, + pageY: touchArg.pageY, + }) + ); + }; + + const createTouchList = ( + touchStrategy: TouchEventStrategy, + touches: TouchInfo[] + ): TouchList | Touch[] | (TouchInfo[] & { item: (i: number) => TouchInfo }) | null => { + switch (touchStrategy) { + case TouchEventStrategy.MOUSE_EVENTS: + return createGenericTouchList(touches); + case TouchEventStrategy.INIT_TOUCH_EVENT: + return createNativeTouchList(touches); + case TouchEventStrategy.TOUCH_EVENT_CTOR: + return createTouchEventTouchList(touches); + } + return null; + }; + + let strategy: TouchEventStrategy; + if (BROKEN_TOUCH_API) { + strategy = TouchEventStrategy.MOUSE_EVENTS; + } else { + const TouchEventProto = (window as unknown as { TouchEvent?: { prototype?: { initTouchEvent?: unknown }; length?: number } }).TouchEvent; + if (TouchEventProto?.prototype?.initTouchEvent) { + strategy = TouchEventStrategy.INIT_TOUCH_EVENT; + } else if (TouchEventProto && (TouchEventProto.length ?? 0) > 0) { + strategy = TouchEventStrategy.TOUCH_EVENT_CTOR; + } else { + throw new BotError( + ErrorCode.UNSUPPORTED_OPERATION, + 'Not able to create touch events in this browser' + ); + } + } + + const changedTouches = createTouchList(strategy, args.changedTouches); + const touches = + args.touches === args.changedTouches + ? changedTouches + : createTouchList(strategy, args.touches); + const targetTouches = + args.targetTouches === args.changedTouches + ? changedTouches + : createTouchList(strategy, args.targetTouches); + + let event: Event & { + touches?: unknown; + targetTouches?: unknown; + changedTouches?: unknown; + scale?: number; + rotation?: number; + relatedTarget?: Element | null; + }; + + if (strategy === TouchEventStrategy.MOUSE_EVENTS) { + const mouseEvent = doc.createEvent('MouseEvents'); + mouseEvent.initMouseEvent( + this.type, + this.bubbles, + this.cancelable, + view!, + 1, + 0, + 0, + args.clientX || 0, + args.clientY || 0, + args.ctrlKey, + args.altKey, + args.shiftKey, + args.metaKey, + 0, + args.relatedTarget + ); + event = mouseEvent as unknown as Event & { + touches?: unknown; + targetTouches?: unknown; + changedTouches?: unknown; + scale?: number; + rotation?: number; + relatedTarget?: Element | null; + }; + event.touches = touches; + event.targetTouches = targetTouches; + event.changedTouches = changedTouches; + event.scale = args.scale; + event.rotation = args.rotation; + } else if (strategy === TouchEventStrategy.INIT_TOUCH_EVENT) { + event = doc.createEvent('TouchEvent') as TouchEventWithInit; + const initFn = (event as TouchEventWithInit).initTouchEvent; + if (initFn && initFn.length === 0) { + initFn.call( + event, + touches, + targetTouches, + changedTouches, + this.type, + view, + 0, + 0, + args.clientX || 0, + args.clientY || 0, + args.ctrlKey, + args.altKey, + args.shiftKey, + args.metaKey + ); + } else if (initFn) { + initFn.call( + event, + this.type, + this.bubbles, + this.cancelable, + view, + 1, + 0, + 0, + args.clientX || 0, + args.clientY || 0, + args.ctrlKey, + args.altKey, + args.shiftKey, + args.metaKey, + touches, + targetTouches, + changedTouches, + args.scale, + args.rotation + ); + } + event.relatedTarget = args.relatedTarget; + } else if (strategy === TouchEventStrategy.TOUCH_EVENT_CTOR) { + const touchProperties: TouchEventInit = { + touches: touches as Touch[], + targetTouches: targetTouches as Touch[], + changedTouches: changedTouches as Touch[], + bubbles: this.bubbles, + cancelable: this.cancelable, + ctrlKey: args.ctrlKey, + shiftKey: args.shiftKey, + altKey: args.altKey, + metaKey: args.metaKey, + }; + event = new TouchEvent(this.type, touchProperties); + } else { + throw new BotError( + ErrorCode.UNSUPPORTED_OPERATION, + 'Illegal TouchEventStrategy value (this is a bug)' + ); + } + + return event; + } +} + +// ============================================================================ +// MSGesture Event Factory +// ============================================================================ + +interface MSGestureEvent extends Event { + initGestureEvent( + type: string, + bubbles: boolean, + cancelable: boolean, + view: Window | null, + detail: number, + screenX: number, + screenY: number, + clientX: number, + clientY: number, + offsetX: number, + offsetY: number, + translationX: number, + translationY: number, + scale: number, + expansion: number, + rotation: number, + velocityX: number, + velocityY: number, + velocityExpansion: number, + velocityAngular: number, + timestamp: number, + relatedTarget: Element | null + ): void; +} + +/** + * Factory for MSGesture event objects of a specific type. + */ +export class MSGestureEventFactory extends EventFactory { + override create(target: Element | Window, opt_args?: EventArgs): Event { + if (!SUPPORTS_MSPOINTER_EVENTS) { + throw new BotError( + ErrorCode.UNSUPPORTED_OPERATION, + 'Browser does not support MSGesture events.' + ); + } + + const args = opt_args as MSGestureArgs; + const doc = + 'ownerDocument' in target + ? target.ownerDocument! + : (target as Window).document; + const view = 'defaultView' in doc ? doc.defaultView : window; + const event = doc.createEvent('MSGestureEvent') as MSGestureEvent; + const timestamp = new Date().getTime(); + + event.initGestureEvent( + this.type, + this.bubbles, + this.cancelable, + view, + 1, + 0, + 0, + args.clientX, + args.clientY, + 0, + 0, + args.translationX, + args.translationY, + args.scale, + args.expansion, + args.rotation, + args.velocityX, + args.velocityY, + args.velocityExpansion, + args.velocityAngular, + timestamp, + args.relatedTarget + ); + + return event; + } +} + +// ============================================================================ +// MSPointer Event Factory +// ============================================================================ + +interface MSPointerEvent extends Event { + initPointerEvent( + type: string, + bubbles: boolean, + cancelable: boolean, + view: Window | null, + detail: number, + screenX: number, + screenY: number, + clientX: number, + clientY: number, + ctrlKey: boolean, + altKey: boolean, + shiftKey: boolean, + metaKey: boolean, + button: number, + relatedTarget: Element | null, + offsetX: number, + offsetY: number, + width: number, + height: number, + pressure: number, + rotation: number, + tiltX: number, + tiltY: number, + pointerId: number, + pointerType: number, + hwTimestamp: number, + isPrimary: boolean + ): void; +} + +/** + * Factory for MSPointer event objects of a specific type. + */ +export class MSPointerEventFactory extends EventFactory { + override create(target: Element | Window, opt_args?: EventArgs): Event { + if (!SUPPORTS_MSPOINTER_EVENTS) { + throw new BotError( + ErrorCode.UNSUPPORTED_OPERATION, + 'Browser does not support MSPointer events.' + ); + } + + const args = opt_args as MSPointerArgs; + const doc = + 'ownerDocument' in target + ? target.ownerDocument! + : (target as Window).document; + const view = 'defaultView' in doc ? doc.defaultView : window; + const event = doc.createEvent('MSPointerEvent') as MSPointerEvent; + + event.initPointerEvent( + this.type, + this.bubbles, + this.cancelable, + view, + 0, + 0, + 0, + args.clientX, + args.clientY, + args.ctrlKey, + args.altKey, + args.shiftKey, + args.metaKey, + args.button, + args.relatedTarget, + 0, + 0, + args.width, + args.height, + args.pressure, + args.rotation, + args.tiltX, + args.tiltY, + args.pointerId, + args.pointerType, + 0, + args.isPrimary + ); + + return event; + } +} + +// ============================================================================ +// Event Type Registry +// ============================================================================ + +/** + * The types of events this module supports firing. + */ +export const EventType = { + BLUR: new EventFactory('blur', false, false), + CHANGE: new EventFactory('change', true, false), + FOCUS: new EventFactory('focus', false, false), + FOCUSIN: new EventFactory('focusin', true, false), + FOCUSOUT: new EventFactory('focusout', true, false), + INPUT: new EventFactory('input', true, false), + ORIENTATIONCHANGE: new EventFactory('orientationchange', false, false), + PROPERTYCHANGE: new EventFactory('propertychange', false, false), + SELECT: new EventFactory('select', true, false), + SUBMIT: new EventFactory('submit', true, true), + TEXTINPUT: new EventFactory('textInput', true, true), + + // Mouse events. + CLICK: new MouseEventFactory('click', true, true), + CONTEXTMENU: new MouseEventFactory('contextmenu', true, true), + DBLCLICK: new MouseEventFactory('dblclick', true, true), + MOUSEDOWN: new MouseEventFactory('mousedown', true, true), + MOUSEMOVE: new MouseEventFactory('mousemove', true, false), + MOUSEOUT: new MouseEventFactory('mouseout', true, true), + MOUSEOVER: new MouseEventFactory('mouseover', true, true), + MOUSEUP: new MouseEventFactory('mouseup', true, true), + MOUSEWHEEL: new MouseEventFactory( + GECKO ? 'DOMMouseScroll' : 'mousewheel', + true, + true + ), + MOUSEPIXELSCROLL: new MouseEventFactory('MozMousePixelScroll', true, true), + + // Keyboard events. + KEYDOWN: new KeyboardEventFactory('keydown', true, true), + KEYPRESS: new KeyboardEventFactory('keypress', true, true), + KEYUP: new KeyboardEventFactory('keyup', true, true), + + // Touch events. + TOUCHEND: new TouchEventFactory('touchend', true, true), + TOUCHMOVE: new TouchEventFactory('touchmove', true, true), + TOUCHSTART: new TouchEventFactory('touchstart', true, true), + + // MSGesture events + MSGESTURECHANGE: new MSGestureEventFactory('MSGestureChange', true, true), + MSGESTUREEND: new MSGestureEventFactory('MSGestureEnd', true, true), + MSGESTUREHOLD: new MSGestureEventFactory('MSGestureHold', true, true), + MSGESTURESTART: new MSGestureEventFactory('MSGestureStart', true, true), + MSGESTURETAP: new MSGestureEventFactory('MSGestureTap', true, true), + MSINERTIASTART: new MSGestureEventFactory('MSInertiaStart', true, true), + + // MSPointer events + MSGOTPOINTERCAPTURE: new MSPointerEventFactory( + 'MSGotPointerCapture', + true, + false + ), + MSLOSTPOINTERCAPTURE: new MSPointerEventFactory( + 'MSLostPointerCapture', + true, + false + ), + MSPOINTERCANCEL: new MSPointerEventFactory('MSPointerCancel', true, true), + MSPOINTERDOWN: new MSPointerEventFactory('MSPointerDown', true, true), + MSPOINTERMOVE: new MSPointerEventFactory('MSPointerMove', true, true), + MSPOINTEROVER: new MSPointerEventFactory('MSPointerOver', true, true), + MSPOINTEROUT: new MSPointerEventFactory('MSPointerOut', true, true), + MSPOINTERUP: new MSPointerEventFactory('MSPointerUp', true, true), +} as const; + +// ============================================================================ +// Event Firing Functions +// ============================================================================ + +/** + * Fire a named event on a particular element. + */ +export function fire( + target: Element | Window, + type: EventFactory, + args?: EventArgs +): boolean { + const event = type.create(target, args); + + if (!('isTrusted' in event)) { + (event as Event & { isTrusted?: boolean }).isTrusted = false; + } + return target.dispatchEvent(event); +} + +/** + * Returns whether the event was synthetically created by the atoms; + * if false, was created by the browser in response to a live user action. + */ +export function isSynthetic( + event: Event | { getBrowserEvent?: () => Event } +): boolean { + const e = + 'getBrowserEvent' in event && event.getBrowserEvent + ? event.getBrowserEvent() + : event; + return 'isTrusted' in e ? !(e as Event & { isTrusted?: boolean }).isTrusted : false; +} diff --git a/javascript/atoms/fragments/BUILD.bazel b/javascript/atoms/fragments/BUILD.bazel index 2bbe6e6678fc2..62111f6aaeca1 100644 --- a/javascript/atoms/fragments/BUILD.bazel +++ b/javascript/atoms/fragments/BUILD.bazel @@ -1,5 +1,17 @@ load("//javascript:defs.bzl", "closure_fragment") +# Temporary fragment for comparing Closure vs esbuild output sizes +closure_fragment( + name = "standardize-color-closure", + defs = ["--jscomp_off=reportUnknownTypes"], + function = "bot.color.standardizeColor", + module = "bot.color", + visibility = ["//visibility:private"], + deps = [ + "//javascript/atoms:color", + ], +) + closure_fragment( name = "clear", function = "bot.action.clear", diff --git a/javascript/atoms/frame.js b/javascript/atoms/frame.js deleted file mode 100644 index 832350f1deb76..0000000000000 --- a/javascript/atoms/frame.js +++ /dev/null @@ -1,180 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Atoms for frame handling. - * - */ - - -goog.provide('bot.frame'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.dom'); -goog.require('bot.locators'); -goog.require('goog.dom'); -goog.require('goog.dom.TagName'); - - -/** - * @return {!Window} The top window. - */ -bot.frame.defaultContent = function () { - return bot.getWindow().top; -}; - - -/** - * @return {!Element} The currently active element. - */ -bot.frame.activeElement = function () { - return document.activeElement || document.body; -}; - - -/** - * Gets the parent frame of the specified frame. - * - * @param {!Window=} opt_root The window get the parent of. - * Defaults to `bot.getWindow()`. - * @return {!Window} The frame if found, self otherwise. - */ -bot.frame.parentFrame = function (opt_root) { - var domWindow = opt_root || bot.getWindow(); - return domWindow.parent; -}; - - -/** - * Returns a reference to the window object corresponding to the given element. - * Note that the element must be a frame or an iframe. - * - * @param {!(HTMLIFrameElement|HTMLFrameElement)} element The iframe or frame - * element. - * @return {Window} The window reference for the given iframe or frame element. - */ -bot.frame.getFrameWindow = function (element) { - if (bot.frame.isFrame_(element)) { - var frame = /** @type {HTMLFrameElement|HTMLIFrameElement} */ (element); - return goog.dom.getFrameContentWindow(frame); - } - throw new bot.Error(bot.ErrorCode.NO_SUCH_FRAME, - "The given element isn't a frame or an iframe."); -}; - - -/** - * Returns whether an element is a frame (or iframe). - * - * @param {!Element} element The element to check. - * @return {boolean} Whether the element is a frame (or iframe). - * @private - */ -bot.frame.isFrame_ = function (element) { - return bot.dom.isElement(element, goog.dom.TagName.FRAME) || - bot.dom.isElement(element, goog.dom.TagName.IFRAME); -}; - - -/** - * Looks for a frame by its name or id (preferring name over id) - * under the given root. If no frame was found, we look for an - * iframe by name or id. - * - * @param {(string|number)} nameOrId The frame's name, the frame's id, or the - * index of the frame in the containing window. - * @param {!Window=} opt_root The window to perform the search under. - * Defaults to `bot.getWindow()`. - * @return {Window} The window if found, null otherwise. - */ -bot.frame.findFrameByNameOrId = function (nameOrId, opt_root) { - var domWindow = opt_root || bot.getWindow(); - - // Lookup frame by name - var numFrames = domWindow.frames.length; - for (var i = 0; i < numFrames; i++) { - var frame = domWindow.frames[i]; - var frameElement = frame.frameElement || frame; - if (frameElement.name == nameOrId) { - // This is needed because Safari 4 returns - // an HTMLFrameElement instead of a Window object. - if (frame.document) { - return frame; - } else { - return goog.dom.getFrameContentWindow(frame); - } - } - } - - // Lookup frame by id - var elements = bot.locators.findElements({ id: nameOrId }, domWindow.document); - for (var i = 0; i < elements.length; i++) { - var frameElement = elements[i]; - if (frameElement && bot.frame.isFrame_(frameElement)) { - return goog.dom.getFrameContentWindow(frameElement); - } - } - return null; -}; - - -/** - * Looks for a frame by its index under the given root. - * - * @param {number} index The frame's index. - * @param {!Window=} opt_root The window to perform - * the search under. Defaults to `bot.getWindow()`. - * @return {Window} The frame if found, null otherwise. - */ -bot.frame.findFrameByIndex = function (index, opt_root) { - var domWindow = opt_root || bot.getWindow(); - return domWindow.frames[index] || null; -}; - - -/** - * Gets the index of a frame in the given window. Note that the element must - * be a frame or an iframe. - * - * @param {!(HTMLIFrameElement|HTMLFrameElement)} element The iframe or frame - * element. - * @param {!Window=} opt_root The window to perform the search under. Defaults - * to `bot.getWindow()`. - * @return {?number} The index of the frame if found, null otherwise. - */ -bot.frame.getFrameIndex = function (element, opt_root) { - try { - var elementWindow = element.contentWindow; - } catch (e) { - // Happens in IE{7,8} if a frame doesn't have an enclosing frameset. - return null; - } - - if (!bot.frame.isFrame_(element)) { - return null; - } - - var domWindow = opt_root || bot.getWindow(); - for (var i = 0; i < domWindow.frames.length; i++) { - if (elementWindow == domWindow.frames[i]) { - return i; - } - } - return null; -}; diff --git a/javascript/atoms/frame.ts b/javascript/atoms/frame.ts new file mode 100644 index 0000000000000..1c686b1c5b36c --- /dev/null +++ b/javascript/atoms/frame.ts @@ -0,0 +1,162 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Atoms for frame handling. + */ + +import { getWindow } from './bot'; +import { BotError, ErrorCode } from './error'; +import { isElement } from './dom'; +import { findElements } from './locators/locators'; + +// Type declarations for frame-related DOM types +interface FrameElement extends HTMLElement { + contentWindow: Window | null; + name: string; +} + +/** + * Gets the frame content window from a frame element. + */ +function getFrameContentWindow( + frame: HTMLFrameElement | HTMLIFrameElement +): Window | null { + return frame.contentWindow; +} + +/** + * Returns whether an element is a frame (or iframe). + */ +function isFrame_(element: Element): element is HTMLFrameElement | HTMLIFrameElement { + return isElement(element, 'FRAME') || isElement(element, 'IFRAME'); +} + +/** + * @return The top window. + */ +export function defaultContent(): Window { + const win = getWindow(); + return win.top || win; +} + +/** + * @return The currently active element. + */ +export function activeElement(): Element { + return document.activeElement || document.body; +} + +/** + * Gets the parent frame of the specified frame. + */ +export function parentFrame(opt_root?: Window): Window { + const domWindow = opt_root || getWindow(); + return domWindow.parent; +} + +/** + * Returns a reference to the window object corresponding to the given element. + * Note that the element must be a frame or an iframe. + */ +export function getFrameWindow( + element: HTMLIFrameElement | HTMLFrameElement +): Window | null { + if (isFrame_(element)) { + return getFrameContentWindow(element); + } + throw new BotError( + ErrorCode.NO_SUCH_FRAME, + "The given element isn't a frame or an iframe." + ); +} + +/** + * Looks for a frame by its name or id (preferring name over id) + * under the given root. If no frame was found, we look for an + * iframe by name or id. + */ +export function findFrameByNameOrId( + nameOrId: string | number, + opt_root?: Window +): Window | null { + const domWindow = opt_root || getWindow(); + + // Lookup frame by name + const numFrames = domWindow.frames.length; + for (let i = 0; i < numFrames; i++) { + const frame = domWindow.frames[i] as Window; + // frameElement can be accessed from within the frame + const frameElement = (frame as Window & { frameElement?: FrameElement }).frameElement || (frame as unknown as FrameElement); + if (frameElement.name == nameOrId) { + // This is needed because Safari 4 returns + // an HTMLFrameElement instead of a Window object. + if ((frame as Window).document) { + return frame; + } else { + return getFrameContentWindow(frameElement as HTMLFrameElement | HTMLIFrameElement); + } + } + } + + // Lookup frame by id + const elements = findElements({ id: nameOrId }, domWindow.document); + for (let i = 0; i < elements.length; i++) { + const frameElement = elements[i]; + if (frameElement && isFrame_(frameElement)) { + return getFrameContentWindow(frameElement); + } + } + return null; +} + +/** + * Looks for a frame by its index under the given root. + */ +export function findFrameByIndex(index: number, opt_root?: Window): Window | null { + const domWindow = opt_root || getWindow(); + return (domWindow.frames[index] as Window) || null; +} + +/** + * Gets the index of a frame in the given window. Note that the element must + * be a frame or an iframe. + */ +export function getFrameIndex( + element: HTMLIFrameElement | HTMLFrameElement, + opt_root?: Window +): number | null { + let elementWindow: Window | null; + try { + elementWindow = element.contentWindow; + } catch (e) { + // Happens in IE{7,8} if a frame doesn't have an enclosing frameset. + return null; + } + + if (!isFrame_(element)) { + return null; + } + + const domWindow = opt_root || getWindow(); + for (let i = 0; i < domWindow.frames.length; i++) { + if (elementWindow === domWindow.frames[i]) { + return i; + } + } + return null; +} diff --git a/javascript/atoms/html5/appcache.js b/javascript/atoms/html5/appcache.ts similarity index 62% rename from javascript/atoms/html5/appcache.js rename to javascript/atoms/html5/appcache.ts index 64268bb836178..983131cbc94dc 100644 --- a/javascript/atoms/html5/appcache.js +++ b/javascript/atoms/html5/appcache.ts @@ -17,31 +17,27 @@ /** * @fileoverview Atom to access application cache status. - * */ -goog.provide('bot.appcache'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.html5'); +import { getWindow } from '../bot'; +import { BotError, ErrorCode } from '../error'; +import { API, isSupported } from './html5'; +interface WindowWithAppCache extends Window { + applicationCache?: { + status: number; + }; +} /** * Returns the current state of the application cache. - * - * @param {Window=} opt_window The window object whose cache is checked; - * defaults to the main window. - * @return {number} The state. */ -bot.appcache.getStatus = function(opt_window) { - var win = opt_window || bot.getWindow(); +export function getStatus(opt_window?: Window): number { + const win = (opt_window || getWindow()) as WindowWithAppCache; - if (bot.html5.isSupported(bot.html5.API.APPCACHE, win)) { - return win.applicationCache.status; + if (isSupported(API.APPCACHE, win)) { + return win.applicationCache!.status; } else { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Undefined application cache'); + throw new BotError(ErrorCode.UNKNOWN_ERROR, 'Undefined application cache'); } -}; +} diff --git a/javascript/atoms/html5/database.js b/javascript/atoms/html5/database.js deleted file mode 100644 index 72817097fd04c..0000000000000 --- a/javascript/atoms/html5/database.js +++ /dev/null @@ -1,141 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Atoms for executing SQL queries on web client database. - * - */ - -goog.provide('bot.storage.database'); -goog.provide('bot.storage.database.ResultSet'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); - - -/** - * Opens the database to access its contents. This function will create the - * database if it does not exist. For details, - * @see http://www.w3.org/TR/webdatabase/#databases - * - * @param {string} databaseName The name of the database. - * @param {string=} opt_version The expected database version to be opened; - * defaults to the empty string. - * @param {string=} opt_displayName The name to be displayed to the user; - * defaults to the databaseName. - * @param {number=} opt_size The estimated initial quota size of the database; - * default value is 5MB. - * @param {!Window=} opt_window The window associated with the database; - * defaults to the main window. - * @return {!Database} The object to access the web database. - * - */ -bot.storage.database.openOrCreate = function(databaseName, opt_version, - opt_displayName, opt_size, opt_window) { - var version = opt_version || ''; - var displayName = opt_displayName || (databaseName + 'name'); - var size = opt_size || 5 * 1024 * 1024; - var win = opt_window || bot.getWindow(); - - return win.openDatabase(databaseName, version, displayName, size); -}; - - -/** - * It executes a single SQL query on a given web database storage. - * - * @param {string} databaseName The name of the database. - * @param {string} query The SQL statement. - * @param {!Array.<*>} args Arguments needed for the SQL statement. - * @param {!function(!SQLTransaction, !bot.storage.database.ResultSet)} - * queryResultCallback Callback function to be invoked on successful query - * statement execution. - * @param {!function(!SQLError)} txErrorCallback - * Callback function to be invoked on transaction (commit) failure. - * @param {!function()=} opt_txSuccessCallback - * Callback function to be invoked on successful transaction execution. - * @param {function(!SQLTransaction, !SQLError)=} opt_queryErrorCallback - * Callback function to be invoked on successful query statement execution. - * @see http://www.w3.org/TR/webdatabase/#executing-sql-statements - */ -bot.storage.database.executeSql = function(databaseName, query, args, - queryResultCallback, txErrorCallback, opt_txSuccessCallback, - opt_queryErrorCallback) { - - var db; - - try { - db = bot.storage.database.openOrCreate(databaseName); - } catch (e) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, e.message); - } - - var queryCallback = function(tx, result) { - var wrappedResult = new bot.storage.database.ResultSet(result); - queryResultCallback(tx, wrappedResult); - }; - - var transactionCallback = function(tx) { - tx.executeSql(query, args, queryCallback, opt_queryErrorCallback); - }; - - db.transaction(transactionCallback, txErrorCallback, - opt_txSuccessCallback); -}; - - - -/** - * A wrapper of the SQLResultSet object returned by the SQL statement. - * - * @param {!SQLResultSet} sqlResultSet The original SQLResultSet object. - * @constructor - */ -bot.storage.database.ResultSet = function(sqlResultSet) { - - /** - * The database rows returned from the SQL query. - * @type {!Array.<*>} - */ - this.rows = []; - for (var i = 0; i < sqlResultSet.rows.length; i++) { - this.rows[i] = sqlResultSet.rows.item(i); - } - - /** - * The number of rows that were changed by the SQL statement - * @type {number} - */ - this.rowsAffected = sqlResultSet.rowsAffected; - - /** - * The row ID of the row that the SQLResultSet object's SQL statement - * inserted into the database, if the statement inserted a row; else - * it is assigned to -1. Originally, accessing insertId attribute of - * a SQLResultSet object returns the exception INVALID_ACCESS_ERR - * if no rows are inserted. - * @type {number} - */ - this.insertId = -1; - try { - this.insertId = sqlResultSet.insertId; - } catch (error) { - // If accessing sqlResultSet.insertId results in INVALID_ACCESS_ERR - // exception, this.insertId will be assigned to -1. - } -}; diff --git a/javascript/atoms/html5/database.ts b/javascript/atoms/html5/database.ts new file mode 100644 index 0000000000000..a6562bc1386d5 --- /dev/null +++ b/javascript/atoms/html5/database.ts @@ -0,0 +1,151 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Atoms for executing SQL queries on web client database. + */ + +import { getWindow } from '../bot'; +import { BotError, ErrorCode } from '../error'; + +// Web SQL Database types (deprecated but still used in some browsers) +interface Database { + transaction( + callback: (tx: SQLTransaction) => void, + errorCallback?: (error: SQLError) => void, + successCallback?: () => void + ): void; +} + +interface SQLTransaction { + executeSql( + sqlStatement: string, + args?: unknown[], + callback?: (tx: SQLTransaction, result: SQLResultSet) => void, + errorCallback?: (tx: SQLTransaction, error: SQLError) => void + ): void; +} + +interface SQLResultSet { + insertId: number; + rowsAffected: number; + rows: SQLResultSetRowList; +} + +interface SQLResultSetRowList { + length: number; + item(index: number): unknown; +} + +interface SQLError { + code: number; + message: string; +} + +interface WindowWithDatabase extends Window { + openDatabase?: ( + name: string, + version: string, + displayName: string, + estimatedSize: number + ) => Database; +} + +/** + * A wrapper of the SQLResultSet object returned by the SQL statement. + */ +export class ResultSet { + rows: unknown[]; + rowsAffected: number; + insertId: number; + + constructor(sqlResultSet: SQLResultSet) { + this.rows = []; + for (let i = 0; i < sqlResultSet.rows.length; i++) { + this.rows[i] = sqlResultSet.rows.item(i); + } + + this.rowsAffected = sqlResultSet.rowsAffected; + + // Originally, accessing insertId attribute of a SQLResultSet object + // returns the exception INVALID_ACCESS_ERR if no rows are inserted. + this.insertId = -1; + try { + this.insertId = sqlResultSet.insertId; + } catch (error) { + // If accessing sqlResultSet.insertId results in INVALID_ACCESS_ERR + // exception, this.insertId will be assigned to -1. + } + } +} + +/** + * Opens the database to access its contents. This function will create the + * database if it does not exist. + * @see http://www.w3.org/TR/webdatabase/#databases + */ +export function openOrCreate( + databaseName: string, + opt_version?: string, + opt_displayName?: string, + opt_size?: number, + opt_window?: Window +): Database { + const version = opt_version || ''; + const displayName = opt_displayName || databaseName + 'name'; + const size = opt_size || 5 * 1024 * 1024; + const win = (opt_window || getWindow()) as WindowWithDatabase; + + if (!win.openDatabase) { + throw new BotError(ErrorCode.UNKNOWN_ERROR, 'openDatabase is not supported'); + } + + return win.openDatabase(databaseName, version, displayName, size); +} + +/** + * It executes a single SQL query on a given web database storage. + * @see http://www.w3.org/TR/webdatabase/#executing-sql-statements + */ +export function executeSql( + databaseName: string, + query: string, + args: unknown[], + queryResultCallback: (tx: SQLTransaction, result: ResultSet) => void, + txErrorCallback: (error: SQLError) => void, + opt_txSuccessCallback?: () => void, + opt_queryErrorCallback?: (tx: SQLTransaction, error: SQLError) => void +): void { + let db: Database; + + try { + db = openOrCreate(databaseName); + } catch (e) { + throw new BotError(ErrorCode.UNKNOWN_ERROR, (e as Error).message); + } + + const queryCallback = function (tx: SQLTransaction, result: SQLResultSet): void { + const wrappedResult = new ResultSet(result); + queryResultCallback(tx, wrappedResult); + }; + + const transactionCallback = function (tx: SQLTransaction): void { + tx.executeSql(query, args, queryCallback, opt_queryErrorCallback); + }; + + db.transaction(transactionCallback, txErrorCallback, opt_txSuccessCallback); +} diff --git a/javascript/atoms/html5/html5.ts b/javascript/atoms/html5/html5.ts new file mode 100644 index 0000000000000..4f115c79e5b67 --- /dev/null +++ b/javascript/atoms/html5/html5.ts @@ -0,0 +1,131 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Helper function to determine which HTML5 features are + * supported by browsers. + */ + +import { getWindow } from '../bot'; +import { BotError, ErrorCode } from '../error'; +import { isEngineVersion, isProductVersion } from '../userAgent'; + +// Browser detection +const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''; +const IS_IE = /MSIE|Trident/.test(userAgent); +const IS_SAFARI = /Safari/.test(userAgent) && !/Chrome/.test(userAgent); +const IS_ANDROID = /Android/.test(userAgent); +const IS_WINDOWS = /Windows/.test(userAgent); + +/** + * Identifier for supported HTML5 API in Webdriver. + */ +export enum API { + APPCACHE = 'appcache', + BROWSER_CONNECTION = 'browser_connection', + DATABASE = 'database', + GEOLOCATION = 'location', + LOCAL_STORAGE = 'local_storage', + SESSION_STORAGE = 'session_storage', + VIDEO = 'video', + AUDIO = 'audio', + CANVAS = 'canvas', +} + +/** + * True if the current browser is IE version 8 or earlier. + */ +const IS_IE8_OR_EARLIER = IS_IE && !isEngineVersion(9); + +/** + * True if the current browser is Safari version 4 or earlier. + */ +const IS_SAFARI4_OR_EARLIER = IS_SAFARI && !isProductVersion(5); + +/** + * True if the browser is Android version 2.2 (Froyo) or earlier. + */ +const IS_ANDROID_FROYO_OR_EARLIER = IS_ANDROID && !isProductVersion(2.3); + +/** + * True if the current browser is Safari 5 on Windows. + */ +const IS_SAFARI_WINDOWS = + IS_WINDOWS && IS_SAFARI && isProductVersion(4) && !isProductVersion(6); + +/** + * Checks if the browser supports an HTML5 feature. + */ +export function isSupported(api: API, opt_window?: Window): boolean { + const win = opt_window || getWindow(); + + switch (api) { + case API.APPCACHE: + // IE8 does not support application cache, though the APIs exist. + if (IS_IE8_OR_EARLIER) { + return false; + } + return (win as Window & { applicationCache?: unknown }).applicationCache != null; + + case API.BROWSER_CONNECTION: + return win.navigator != null && win.navigator.onLine != null; + + case API.DATABASE: + // Safari4 database API does not allow writes. + if (IS_SAFARI4_OR_EARLIER) { + return false; + } + // Android Froyo does not support database, though the APIs exist. + if (IS_ANDROID_FROYO_OR_EARLIER) { + return false; + } + return (win as Window & { openDatabase?: unknown }).openDatabase != null; + + case API.GEOLOCATION: + // Safari 4,5 on Windows do not support geolocation, see: + // https://discussions.apple.com/thread/3547900 + if (IS_SAFARI_WINDOWS) { + return false; + } + return win.navigator != null && win.navigator.geolocation != null; + + case API.LOCAL_STORAGE: + // IE8 does not support local storage, though the APIs exist. + if (IS_IE8_OR_EARLIER) { + return false; + } + return win.localStorage != null; + + case API.SESSION_STORAGE: + // IE8 does not support session storage, though the APIs exist. + if (IS_IE8_OR_EARLIER) { + return false; + } + return ( + win.sessionStorage != null && + // To avoid browsers that only support this API partially + // like some versions of FF. + win.sessionStorage.clear != null + ); + + default: + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Unsupported API identifier provided as parameter' + ); + } +} diff --git a/javascript/atoms/html5/html5_browser.js b/javascript/atoms/html5/html5_browser.js deleted file mode 100644 index 465ab4f441150..0000000000000 --- a/javascript/atoms/html5/html5_browser.js +++ /dev/null @@ -1,153 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Helper function to determine which HTML5 features are - * supported by browsers.. - */ - -goog.provide('bot.html5'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.userAgent'); -goog.require('goog.userAgent'); -goog.require('goog.userAgent.product'); - - -/** - * Identifier for supported HTML5 API in Webdriver. - * - * @enum {string} - */ -bot.html5.API = { - APPCACHE: 'appcache', - BROWSER_CONNECTION: 'browser_connection', - DATABASE: 'database', - GEOLOCATION: 'location', - LOCAL_STORAGE: 'local_storage', - SESSION_STORAGE: 'session_storage', - VIDEO: 'video', - AUDIO: 'audio', - CANVAS: 'canvas' -}; - - -/** - * True if the current browser is IE version 8 or earlier. - * @private {boolean} - * @const - */ -bot.html5.IS_IE8_OR_EARLIER_ = goog.userAgent.IE && - !bot.userAgent.isEngineVersion(9); - - -/** - * True if the current browser is Safari version 4 or earlier. - * @private {boolean} - * @const - */ -bot.html5.IS_SAFARI4_OR_EARLIER_ = goog.userAgent.product.SAFARI && - !bot.userAgent.isProductVersion(5); - - -/** - * True if the browser is Android version 2.2 (Froyo) or earlier. - * @private {boolean} - * @const - */ -bot.html5.IS_ANDROID_FROYO_OR_EARLIER_ = goog.userAgent.product.ANDROID && - !bot.userAgent.isProductVersion(2.3); - - -/** - * True if the current browser is Safari 5 on Windows. - * @private {boolean} - * @const - */ -bot.html5.IS_SAFARI_WINDOWS_ = goog.userAgent.WINDOWS && - goog.userAgent.product.SAFARI && - (bot.userAgent.isProductVersion(4)) && - !bot.userAgent.isProductVersion(6); - - -/** - * Checks if the browser supports an HTML5 feature. - * - * @param {bot.html5.API} api HTML5 API identifier. - * @param {!Window=} opt_window The window to be accessed; - * defaults to the main window. - * @return {boolean} Whether the browser supports the feature. - */ -bot.html5.isSupported = function(api, opt_window) { - var win = opt_window || bot.getWindow(); - - switch (api) { - case bot.html5.API.APPCACHE: - // IE8 does not support application cache, though the APIs exist. - if (bot.html5.IS_IE8_OR_EARLIER_) { - return false; - } - return win.applicationCache != null; - - case bot.html5.API.BROWSER_CONNECTION: - return win.navigator != null && - win.navigator.onLine != null; - - case bot.html5.API.DATABASE: - // Safari4 database API does not allow writes. - if (bot.html5.IS_SAFARI4_OR_EARLIER_) { - return false; - } - // Android Froyo does not support database, though the APIs exist. - if (bot.html5.IS_ANDROID_FROYO_OR_EARLIER_) { - return false; - } - return win.openDatabase != null; - - case bot.html5.API.GEOLOCATION: - // Safari 4,5 on Windows do not support geolocation, see: - // https://discussions.apple.com/thread/3547900 - if (bot.html5.IS_SAFARI_WINDOWS_) { - return false; - } - return win.navigator != null && - win.navigator.geolocation != null; - - case bot.html5.API.LOCAL_STORAGE: - // IE8 does not support local storage, though the APIs exist. - if (bot.html5.IS_IE8_OR_EARLIER_) { - return false; - } - return win.localStorage != null; - - case bot.html5.API.SESSION_STORAGE: - // IE8 does not support session storage, though the APIs exist. - if (bot.html5.IS_IE8_OR_EARLIER_) { - return false; - } - return win.sessionStorage != null && - // To avoid browsers that only support this API partially - // like some versions of FF. - win.sessionStorage.clear != null; - - default: - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Unsupported API identifier provided as parameter'); - } -}; diff --git a/javascript/atoms/html5/location.js b/javascript/atoms/html5/location.ts similarity index 55% rename from javascript/atoms/html5/location.js rename to javascript/atoms/html5/location.ts index 6018081b9cc02..4ae2cd5e4cbd9 100644 --- a/javascript/atoms/html5/location.js +++ b/javascript/atoms/html5/location.ts @@ -17,56 +17,42 @@ /** * @fileoverview Atom to retrieve the physical location of the device. - * */ -goog.provide('bot.geolocation'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.html5'); - +import { getWindow } from '../bot'; +import { BotError, ErrorCode } from '../error'; +import { API, isSupported } from './html5'; /** * Default parameters used to configure the geolocation.getCurrentPosition * method. These parameters mean retrieval of any cached position with high * accuracy within a timeout interval of 5s. - * @const - * @type {!GeolocationPositionOptions} * @see http://dev.w3.org/geo/api/spec-source.html#position-options */ -bot.geolocation.DEFAULT_OPTIONS = /** @type {!GeolocationPositionOptions} */ ({ +export const DEFAULT_OPTIONS: PositionOptions = { enableHighAccuracy: true, maximumAge: Infinity, - timeout: 5000 -}); - + timeout: 5000, +}; /** - * Provides a mechanism to retrieve the geolocation of the device. It invokes + * Provides a mechanism to retrieve the geolocation of the device. It invokes * the navigator.geolocation.getCurrentPosition method of the HTML5 API which * later callbacks with either position value or any error. The position/ * error is updated with the callback functions. - * - * @param {function(?GeolocationPosition)} successCallback The callback method - * which is invoked on success. - * @param {function(?GeolocationPositionError)=} opt_errorCallback The callback - * method which is invoked on error. - * @param {?GeolocationPositionOptions=} opt_options The optional parameters to - * navigator.geolocation.getCurrentPosition; defaults to - * bot.geolocation.DEFAULT_OPTIONS. */ -bot.geolocation.getCurrentPosition = function(successCallback, - opt_errorCallback, opt_options) { - var win = bot.getWindow(); - var posOptions = opt_options || bot.geolocation.DEFAULT_OPTIONS; - - if (bot.html5.isSupported(bot.html5.API.GEOLOCATION, win)) { - var geolocation = win.navigator.geolocation; - geolocation.getCurrentPosition(successCallback, - opt_errorCallback, posOptions); +export function getCurrentPosition( + successCallback: (position: GeolocationPosition) => void, + opt_errorCallback?: (error: GeolocationPositionError) => void, + opt_options?: PositionOptions | null +): void { + const win = getWindow(); + const posOptions = opt_options || DEFAULT_OPTIONS; + + if (isSupported(API.GEOLOCATION, win)) { + const geolocation = win.navigator.geolocation; + geolocation.getCurrentPosition(successCallback, opt_errorCallback, posOptions); } else { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, 'Geolocation undefined'); + throw new BotError(ErrorCode.UNKNOWN_ERROR, 'Geolocation undefined'); } -}; +} diff --git a/javascript/atoms/html5/storage.js b/javascript/atoms/html5/storage.js deleted file mode 100644 index 44f524d67ec18..0000000000000 --- a/javascript/atoms/html5/storage.js +++ /dev/null @@ -1,197 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Atoms for accessing HTML5 web storage maps (localStorage, - * sessionStorage). These storage objects store each item as a key-value - * mapping pair. - * - */ - -goog.provide('bot.storage'); -goog.provide('bot.storage.Storage'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.html5'); - - -/** - * A factory method to create a wrapper to access the HTML5 localStorage - * object. - * Note: We are not using Closure from goog.storage, - * Closure uses "window" object directly, which may not always be - * defined (for example in firefox extensions). - * We use bot.window() from bot.js instead to keep track of the window or frame - * is currently being used for command execution. The implementation is - * otherwise similar to the implementation in the Closure library - * (goog.storage.mechanism.HTML5LocalStorage). - * - * @param {Window=} opt_window The window whose storage to access; - * defaults to the main window. - * @return {!bot.storage.Storage} The wrapper Storage object. - */ -bot.storage.getLocalStorage = function(opt_window) { - var win = opt_window || bot.getWindow(); - - if (!bot.html5.isSupported(bot.html5.API.LOCAL_STORAGE, win)) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, 'Local storage undefined'); - } - var storageMap = win.localStorage; - return new bot.storage.Storage(storageMap); -}; - - -/** - * A factory method to create a wrapper to access the HTML5 sessionStorage - * object. - * - * @param {Window=} opt_window The window whose storage to access; - * defaults to the main window. - * @return {!bot.storage.Storage} The wrapper Storage object. - */ -bot.storage.getSessionStorage = function(opt_window) { - var win = opt_window || bot.getWindow(); - - if (bot.html5.isSupported(bot.html5.API.SESSION_STORAGE, win)) { - var storageMap = win.sessionStorage; - return new bot.storage.Storage(storageMap); - } - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Session storage undefined'); -}; - - - -/** - * Provides a wrapper object to the HTML5 web storage object. - * @constructor - * - * @param {Storage} storageMap HTML5 storage object e.g. localStorage, - * sessionStorage. - */ -bot.storage.Storage = function(storageMap) { - /** - * Member variable to access the assigned HTML5 storage object. - * @private {Storage} - * @const - */ - this.storageMap_ = storageMap; -}; - - -/** - * Sets the value item of a key/value pair in the Storage object. - * If the value given is null, the string 'null' will be inserted - * instead. - * - * @param {string} key The key of the item. - * @param {*} value The value of the item. - */ -bot.storage.Storage.prototype.setItem = function(key, value) { - try { - // Note: Ideally, browsers should set a null value. But the browsers - // report arbitrarily. Firefox returns , while Chrome reports - // the string "null". We are setting the value to the string "null". - this.storageMap_.setItem(key, value + ''); - } catch (e) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, e.message); - } -}; - - -/** - * Returns the value item of a key in the Storage object. - * - * @param {string} key The key of the returned value. - * @return {?string} The mapped value if present in the storage object, - * otherwise null. If a null value was inserted for a given - * key, then the string 'null' is returned. - */ -bot.storage.Storage.prototype.getItem = function(key) { - var value = this.storageMap_.getItem(key); - return /** @type {?string} */ (value); -}; - - -/** - * Returns an array of keys of all keys of the Storage object. - * - * @return {!Array.} The array of stored keys.. - */ -bot.storage.Storage.prototype.keySet = function() { - var keys = []; - var length = this.size(); - for (var i = 0; i < length; i++) { - keys[i] = this.storageMap_.key(i); - } - return keys; -}; - - -/** - * Removes an item with a given key. - * - * @param {string} key The key item of the key/value pair. - * @return {?string} The removed value if present, otherwise null. - */ -bot.storage.Storage.prototype.removeItem = function(key) { - var value = this.getItem(key); - this.storageMap_.removeItem(key); - return value; -}; - - -/** - * Removes all items. - */ -bot.storage.Storage.prototype.clear = function() { - this.storageMap_.clear(); -}; - - -/** - * Returns the number of items in the Storage object. - * - * @return {number} The number of the key/value pairs. - */ -bot.storage.Storage.prototype.size = function() { - return this.storageMap_.length; -}; - - -/** - * Returns the key item of the key/value pairs in the Storage object - * of a given index. - * - * @param {number} index The index of the key/value pair list. - * @return {?string} The key item of a given index. - */ -bot.storage.Storage.prototype.key = function(index) { - return this.storageMap_.key(index); -}; - - -/** - * Returns HTML5 storage object of the wrapper Storage object - * - * @return {Storage} The storageMap attribute. - */ -bot.storage.Storage.prototype.getStorageMap = function() { - return this.storageMap_; -}; diff --git a/javascript/atoms/html5/storage.ts b/javascript/atoms/html5/storage.ts new file mode 100644 index 0000000000000..ef75afc54f934 --- /dev/null +++ b/javascript/atoms/html5/storage.ts @@ -0,0 +1,141 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Atoms for accessing HTML5 web storage maps (localStorage, + * sessionStorage). These storage objects store each item as a key-value + * mapping pair. + */ + +import { getWindow } from '../bot'; +import { BotError, ErrorCode } from '../error'; +import { API, isSupported } from './html5'; + +/** + * Provides a wrapper object to the HTML5 web storage object. + */ +export class StorageWrapper { + private storageMap_: Storage; + + constructor(storageMap: Storage) { + this.storageMap_ = storageMap; + } + + /** + * Sets the value item of a key/value pair in the Storage object. + * If the value given is null, the string 'null' will be inserted instead. + */ + setItem(key: string, value: unknown): void { + try { + // Note: Ideally, browsers should set a null value. But the browsers + // report arbitrarily. Firefox returns , while Chrome reports + // the string "null". We are setting the value to the string "null". + this.storageMap_.setItem(key, value + ''); + } catch (e) { + throw new BotError(ErrorCode.UNKNOWN_ERROR, (e as Error).message); + } + } + + /** + * Returns the value item of a key in the Storage object. + */ + getItem(key: string): string | null { + const value = this.storageMap_.getItem(key); + return value; + } + + /** + * Returns an array of keys of all keys of the Storage object. + */ + keySet(): (string | null)[] { + const keys: (string | null)[] = []; + const length = this.size(); + for (let i = 0; i < length; i++) { + keys[i] = this.storageMap_.key(i); + } + return keys; + } + + /** + * Removes an item with a given key. + */ + removeItem(key: string): string | null { + const value = this.getItem(key); + this.storageMap_.removeItem(key); + return value; + } + + /** + * Removes all items. + */ + clear(): void { + this.storageMap_.clear(); + } + + /** + * Returns the number of items in the Storage object. + */ + size(): number { + return this.storageMap_.length; + } + + /** + * Returns the key item of the key/value pairs in the Storage object + * of a given index. + */ + key(index: number): string | null { + return this.storageMap_.key(index); + } + + /** + * Returns HTML5 storage object of the wrapper Storage object. + */ + getStorageMap(): Storage { + return this.storageMap_; + } +} + +/** + * A factory method to create a wrapper to access the HTML5 localStorage object. + * Note: We are not using Closure from goog.storage, + * Closure uses "window" object directly, which may not always be + * defined (for example in firefox extensions). + * We use bot.window() from bot.js instead to keep track of the window or frame + * is currently being used for command execution. + */ +export function getLocalStorage(opt_window?: Window): StorageWrapper { + const win = opt_window || getWindow(); + + if (!isSupported(API.LOCAL_STORAGE, win)) { + throw new BotError(ErrorCode.UNKNOWN_ERROR, 'Local storage undefined'); + } + const storageMap = win.localStorage; + return new StorageWrapper(storageMap); +} + +/** + * A factory method to create a wrapper to access the HTML5 sessionStorage object. + */ +export function getSessionStorage(opt_window?: Window): StorageWrapper { + const win = opt_window || getWindow(); + + if (isSupported(API.SESSION_STORAGE, win)) { + const storageMap = win.sessionStorage; + return new StorageWrapper(storageMap); + } + throw new BotError(ErrorCode.UNKNOWN_ERROR, 'Session storage undefined'); +} diff --git a/javascript/atoms/inject.js b/javascript/atoms/inject.js deleted file mode 100644 index 23052461b14f9..0000000000000 --- a/javascript/atoms/inject.js +++ /dev/null @@ -1,537 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Browser atom for injecting JavaScript into the page under - * test. There is no point in using this atom directly from JavaScript. - * Instead, it is intended to be used in its compiled form when injecting - * script from another language (e.g. C++). - * - * TODO: Add an example - */ - -goog.provide('bot.inject'); -goog.provide('bot.inject.cache'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.json'); -/** - * @suppress {extraRequire} Used as a forward declaration which causes - * compilation errors if missing. - */ -goog.require('bot.response.ResponseObject'); -goog.require('goog.array'); -goog.require('goog.dom.NodeType'); -goog.require('goog.object'); -goog.require('goog.userAgent'); -goog.require('goog.utils'); - - -/** - * Type definition for the WebDriver's JSON wire protocol representation - * of a DOM element. - * @typedef {{ELEMENT: string}} - * @see bot.inject.ELEMENT_KEY - * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol - */ -bot.inject.JsonElement; - - -/** - * Type definition for a cached Window object that can be referenced in - * WebDriver's JSON wire protocol. Note, this is a non-standard - * representation. - * @typedef {{WINDOW: string}} - * @see bot.inject.WINDOW_KEY - */ -bot.inject.JsonWindow; - - -/** - * Key used to identify DOM elements in the WebDriver wire protocol. - * @type {string} - * @const - * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol - */ -bot.inject.ELEMENT_KEY = 'ELEMENT'; - - -/** - * Key used to identify Window objects in the WebDriver wire protocol. - * @type {string} - * @const - */ -bot.inject.WINDOW_KEY = 'WINDOW'; - - -/** - * Converts an element to a JSON friendly value so that it can be - * stringified for transmission to the injector. Values are modified as - * follows: - *

    - *
  • booleans, numbers, strings, and null are returned as is
  • - *
  • undefined values are returned as null
  • - *
  • functions are returned as a string
  • - *
  • each element in an array is recursively processed
  • - *
  • DOM Elements are wrapped in object-literals as dictated by the - * WebDriver wire protocol
  • - *
  • all other objects will be treated as hash-maps, and will be - * recursively processed for any string and number key types (all - * other key types are discarded as they cannot be converted to JSON). - *
- * - * @param {*} value The value to make JSON friendly. - * @return {*} The JSON friendly value. - * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol - */ -bot.inject.wrapValue = function (value) { - var _wrap = function (value, seen) { - switch (goog.utils.typeOf(value)) { - case 'string': - case 'number': - case 'boolean': - return value; - - case 'function': - return value.toString(); - - case 'array': - return goog.array.map(/**@type {IArrayLike}*/(value), - function (v) { return _wrap(v, seen); }); - - case 'object': - // Since {*} expands to {Object|boolean|number|string|undefined}, the - // JSCompiler complains that it is too broad a type for the remainder of - // this block where {!Object} is expected. Downcast to prevent generating - // a ton of compiler warnings. - value = /**@type {!Object}*/ (value); - if (seen.indexOf(value) >= 0) { - throw new bot.Error(bot.ErrorCode.JAVASCRIPT_ERROR, - 'Recursive object cannot be transferred'); - } - - // Sniff out DOM elements. We're using duck-typing instead of an - // instanceof check since the instanceof might not always work - // (e.g. if the value originated from another Firefox component) - if (goog.object.containsKey(value, 'nodeType') && - (value['nodeType'] == goog.dom.NodeType.ELEMENT || - value['nodeType'] == goog.dom.NodeType.DOCUMENT)) { - var ret = {}; - ret[bot.inject.ELEMENT_KEY] = - bot.inject.cache.addElement(/**@type {!Element}*/(value)); - return ret; - } - - // Check if this is a Window - if (goog.object.containsKey(value, 'document')) { - var ret = {}; - ret[bot.inject.WINDOW_KEY] = - bot.inject.cache.addElement(/**@type{!Window}*/(value)); - return ret; - } - - seen.push(value); - if (goog.utils.isArrayLike(value)) { - return goog.array.map(/**@type {IArrayLike}*/(value), - function (v) { return _wrap(v, seen); }); - } - - var filtered = goog.object.filter(value, function (val, key) { - return typeof key === 'number' || typeof key === 'string'; - }); - return goog.object.map(filtered, function (v) { return _wrap(v, seen); }); - - default: // goog.typeOf(value) == 'undefined' || 'null' - return null; - } - }; - return _wrap(value, []); -}; - - -/** - * Unwraps any DOM element's encoded in the given `value`. - * @param {*} value The value to unwrap. - * @param {Document=} opt_doc The document whose cache to retrieve wrapped - * elements from. Defaults to the current document. - * @return {*} The unwrapped value. - */ -bot.inject.unwrapValue = function (value, opt_doc) { - if (Array.isArray(value)) { - return goog.array.map(/**@type {IArrayLike}*/(value), - function (v) { return bot.inject.unwrapValue(v, opt_doc); }); - } else if (goog.utils.isObject(value)) { - if (typeof value == 'function') { - return value; - } - - var obj = /** @type {!Object} */ (value); - if (goog.object.containsKey(obj, bot.inject.ELEMENT_KEY)) { - return bot.inject.cache.getElement(obj[bot.inject.ELEMENT_KEY], - opt_doc); - } - - if (goog.object.containsKey(obj, bot.inject.WINDOW_KEY)) { - return bot.inject.cache.getElement(obj[bot.inject.WINDOW_KEY], - opt_doc); - } - - return goog.object.map(obj, function (val) { - return bot.inject.unwrapValue(val, opt_doc); - }); - } - return value; -}; - - -/** - * Recompiles `fn` in the context of another window so that the - * correct symbol table is used when the function is executed. This - * function assumes the `fn` can be decompiled to its source using - * `Function.prototype.toString` and that it only refers to symbols - * defined in the target window's context. - * - * @param {!(Function|string)} fn Either the function that should be - * recompiled, or a string defining the body of an anonymous function - * that should be compiled in the target window's context. - * @param {!Window} theWindow The window to recompile the function in. - * @return {!Function} The recompiled function. - * @private - */ -bot.inject.recompileFunction_ = function (fn, theWindow) { - if (typeof fn === 'string') { - try { - return new theWindow['Function'](fn); - } catch (ex) { - // Try to recover if in IE5-quirks mode - // Need to initialize the script engine on the passed-in window - if (goog.userAgent.IE && theWindow.execScript) { - theWindow.execScript(';'); - return new theWindow['Function'](fn); - } - throw ex; - } - } - return theWindow == window ? fn : new theWindow['Function']( - 'return (' + fn + ').apply(null,arguments);'); -}; - - -/** - * Executes an injected script. This function should never be called from - * within JavaScript itself. Instead, it is used from an external source that - * is injecting a script for execution. - * - *

For example, in a WebDriver Java test, one might have: - *


- * Object result = ((JavascriptExecutor) driver).executeScript(
- *     "return arguments[0] + arguments[1];", 1, 2);
- * 
- * - *

Once transmitted to the driver, this command would be injected into the - * page for evaluation as: - *


- * bot.inject.executeScript(
- *     function() {return arguments[0] + arguments[1];},
- *     [1, 2]);
- * 
- * - *

The details of how this actually gets injected for evaluation is left - * as an implementation detail for clients of this library. - * - * @param {!(Function|string)} fn Either the function to execute, or a string - * defining the body of an anonymous function that should be executed. This - * function should only contain references to symbols defined in the context - * of the target window (`opt_window`). Any references to symbols - * defined in this context will likely generate a ReferenceError. - * @param {Array.<*>} args An array of wrapped script arguments, as defined by - * the WebDriver wire protocol. - * @param {boolean=} opt_stringify Whether the result should be returned as a - * serialized JSON string. - * @param {!Window=} opt_window The window in whose context the function should - * be invoked; defaults to the current window. - * @return {!(string|bot.response.ResponseObject)} The response object. If - * opt_stringify is true, the result will be serialized and returned in - * string format. - */ -bot.inject.executeScript = function (fn, args, opt_stringify, opt_window) { - var win = opt_window || bot.getWindow(); - var ret; - try { - fn = bot.inject.recompileFunction_(fn, win); - var unwrappedArgs = /**@type {Object}*/ (bot.inject.unwrapValue(args, - win.document)); - ret = bot.inject.wrapResponse(fn.apply(null, unwrappedArgs)); - } catch (ex) { - ret = bot.inject.wrapError(ex); - } - return opt_stringify ? bot.json.stringify(ret) : ret; -}; - - -/** - * Executes an injected script, which is expected to finish asynchronously - * before the given `timeout`. When the script finishes or an error - * occurs, the given `onDone` callback will be invoked. This callback - * will have a single argument, a {@link bot.response.ResponseObject} object. - * - * The script signals its completion by invoking a supplied callback given - * as its last argument. The callback may be invoked with a single value. - * - * The script timeout event will be scheduled with the provided window, - * ensuring the timeout is synchronized with that window's event queue. - * Furthermore, asynchronous scripts do not work across new page loads; if an - * "unload" event is fired on the window while an asynchronous script is - * pending, the script will be aborted and an error will be returned. - * - * Like `bot.inject.executeScript`, this function should only be called - * from an external source. It handles wrapping and unwrapping of input/output - * values. - * - * @param {(!Function|string)} fn Either the function to execute, or a string - * defining the body of an anonymous function that should be executed. This - * function should only contain references to symbols defined in the context - * of the target window (`opt_window`). Any references to symbols - * defined in this context will likely generate a ReferenceError. - * @param {Array.<*>} args An array of wrapped script arguments, as defined by - * the WebDriver wire protocol. - * @param {number} timeout The amount of time, in milliseconds, the script - * should be permitted to run; must be non-negative. - * @param {function(string)|function(!bot.response.ResponseObject)} onDone - * The function to call when the given `fn` invokes its callback, - * or when an exception or timeout occurs. This will always be called. - * @param {boolean=} opt_stringify Whether the result should be returned as a - * serialized JSON string. - * @param {!Window=} opt_window The window to synchronize the script with; - * defaults to the current window. - */ -bot.inject.executeAsyncScript = function (fn, args, timeout, onDone, - opt_stringify, opt_window) { - var win = opt_window || window; - var timeoutId; - var responseSent = false; - - function sendResponse(status, value) { - if (!responseSent) { - if (win.removeEventListener) { - win.removeEventListener('unload', onunload, true); - } else { - win.detachEvent('onunload', onunload); - } - - win.clearTimeout(timeoutId); - if (status != bot.ErrorCode.SUCCESS) { - var err = new bot.Error(status, value.message || value + ''); - err.stack = value.stack; - value = bot.inject.wrapError(err); - } else { - value = bot.inject.wrapResponse(value); - } - onDone(opt_stringify ? bot.json.stringify(value) : value); - responseSent = true; - } - } - var sendError = goog.utils.partial(sendResponse, bot.ErrorCode.UNKNOWN_ERROR); - - if (win.closed) { - sendError('Unable to execute script; the target window is closed.'); - return; - } - - fn = bot.inject.recompileFunction_(fn, win); - - args = /** @type {Array.<*>} */ (bot.inject.unwrapValue(args, win.document)); - args.push(goog.utils.partial(sendResponse, bot.ErrorCode.SUCCESS)); - - if (win.addEventListener) { - win.addEventListener('unload', onunload, true); - } else { - win.attachEvent('onunload', onunload); - } - - var startTime = goog.utils.now(); - try { - fn.apply(win, args); - - // Register our timeout *after* the function has been invoked. This will - // ensure we don't timeout on a function that invokes its callback after - // a 0-based timeout. - timeoutId = win.setTimeout(function () { - sendResponse(bot.ErrorCode.SCRIPT_TIMEOUT, - Error('Timed out waiting for asynchronous script result ' + - 'after ' + (goog.utils.now() - startTime) + ' ms')); - }, Math.max(0, timeout)); - } catch (ex) { - sendResponse(ex.code || bot.ErrorCode.UNKNOWN_ERROR, ex); - } - - function onunload() { - sendResponse(bot.ErrorCode.UNKNOWN_ERROR, - Error('Detected a page unload event; asynchronous script ' + - 'execution does not work across page loads.')); - } -}; - - -/** - * Wraps the response to an injected script that executed successfully so it - * can be JSON-ified for transmission to the process that injected this - * script. - * @param {*} value The script result. - * @return {{status:bot.ErrorCode,value:*}} The wrapped value. - * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#responses - */ -bot.inject.wrapResponse = function (value) { - return { - 'status': bot.ErrorCode.SUCCESS, - 'value': bot.inject.wrapValue(value) - }; -}; - - -/** - * Wraps a JavaScript error in an object-literal so that it can be JSON-ified - * for transmission to the process that injected this script. - * @param {Error} err The error to wrap. - * @return {{status:bot.ErrorCode,value:*}} The wrapped error object. - * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#failed-commands - */ -bot.inject.wrapError = function (err) { - // TODO: Parse stackTrace - return { - 'status': goog.object.containsKey(err, 'code') ? - err['code'] : bot.ErrorCode.UNKNOWN_ERROR, - // TODO: Parse stackTrace - 'value': { - 'message': err.message - } - }; -}; - - -/** - * The property key used to store the element cache on the DOCUMENT node - * when it is injected into the page. Since compiling each browser atom results - * in a different symbol table, we must use this known key to access the cache. - * This ensures the same object is used between injections of different atoms. - * @private {string} - * @const - */ -bot.inject.cache.CACHE_KEY_ = '$wdc_'; - - -/** - * The prefix for each key stored in an cache. - * @type {string} - * @const - */ -bot.inject.cache.ELEMENT_KEY_PREFIX = ':wdc:'; - - -/** - * Retrieves the cache object for the given window. Will initialize the cache - * if it does not yet exist. - * @param {Document=} opt_doc The document whose cache to retrieve. Defaults to - * the current document. - * @return {Object.} The cache object. - * @private - */ -bot.inject.cache.getCache_ = function (opt_doc) { - var doc = opt_doc || document; - var cache = doc[bot.inject.cache.CACHE_KEY_]; - if (!cache) { - cache = doc[bot.inject.cache.CACHE_KEY_] = {}; - // Store the counter used for generated IDs in the cache so that it gets - // reset whenever the cache does. - cache.nextId = goog.utils.now(); - } - // Sometimes the nextId does not get initialized and returns NaN - // TODO: Generate UID on the fly instead. - if (!cache.nextId) { - cache.nextId = goog.utils.now(); - } - return cache; -}; - - -/** - * Adds an element to its ownerDocument's cache. - * @param {(Element|Window)} el The element or Window object to add. - * @return {string} The key generated for the cached element. - */ -bot.inject.cache.addElement = function (el) { - // Check if the element already exists in the cache. - var cache = bot.inject.cache.getCache_(el.ownerDocument); - var id = goog.object.findKey(cache, function (value) { - return value == el; - }); - if (!id) { - id = bot.inject.cache.ELEMENT_KEY_PREFIX + cache.nextId++; - cache[id] = el; - } - return id; -}; - - -/** - * Retrieves an element from the cache. Will verify that the element is - * still attached to the DOM before returning. - * @param {string} key The element's key in the cache. - * @param {Document=} opt_doc The document whose cache to retrieve the element - * from. Defaults to the current document. - * @return {Element|Window} The cached element. - */ -bot.inject.cache.getElement = function (key, opt_doc) { - key = decodeURIComponent(key); - var doc = opt_doc || document; - var cache = bot.inject.cache.getCache_(doc); - if (!goog.object.containsKey(cache, key)) { - // Throw STALE_ELEMENT_REFERENCE instead of NO_SUCH_ELEMENT since the - // key may have been defined by a prior document's cache. - throw new bot.Error(bot.ErrorCode.STALE_ELEMENT_REFERENCE, - 'Element does not exist in cache'); - } - - var el = cache[key]; - - // If this is a Window check if it's closed - if (goog.object.containsKey(el, 'setInterval')) { - if (el.closed) { - delete cache[key]; - throw new bot.Error(bot.ErrorCode.NO_SUCH_WINDOW, - 'Window has been closed.'); - } - return el; - } - - // Make sure the element is still attached to the DOM before returning. - var node = el; - while (node) { - if (node == doc.documentElement) { - return el; - } - if (node.host && node.nodeType === 11) { - node = node.host; - } - node = node.parentNode; - } - delete cache[key]; - throw new bot.Error(bot.ErrorCode.STALE_ELEMENT_REFERENCE, - 'Element is no longer attached to the DOM'); -}; diff --git a/javascript/atoms/inject.ts b/javascript/atoms/inject.ts new file mode 100644 index 0000000000000..745373ae911a2 --- /dev/null +++ b/javascript/atoms/inject.ts @@ -0,0 +1,574 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Browser atom for injecting JavaScript into the page under + * test. There is no point in using this atom directly from JavaScript. + * Instead, it is intended to be used in its compiled form when injecting + * script from another language (e.g. C++). + */ + +import { BotError, ErrorCode } from './error'; +import { stringify } from './json'; + +/** + * Type definition for the WebDriver's JSON wire protocol representation + * of a DOM element. + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol + */ +export interface JsonElement { + ELEMENT: string; +} + +/** + * Type definition for a cached Window object that can be referenced in + * WebDriver's JSON wire protocol. Note, this is a non-standard + * representation. + */ +export interface JsonWindow { + WINDOW: string; +} + +/** + * Response object as defined by the JSON wire protocol. + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#responses + */ +export interface ResponseObject { + status: ErrorCode; + value: unknown; +} + +/** + * Key used to identify DOM elements in the WebDriver wire protocol. + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol + */ +export const ELEMENT_KEY = 'ELEMENT'; + +/** + * Key used to identify Window objects in the WebDriver wire protocol. + */ +export const WINDOW_KEY = 'WINDOW'; + +/** + * The property key used to store the element cache on the DOCUMENT node + * when it is injected into the page. Since compiling each browser atom results + * in a different symbol table, we must use this known key to access the cache. + * This ensures the same object is used between injections of different atoms. + */ +const CACHE_KEY = '$wdc_'; + +/** + * The prefix for each key stored in an cache. + */ +export const ELEMENT_KEY_PREFIX = ':wdc:'; + +/** + * Gets the type of a value, similar to goog.utils.typeOf. + */ +function typeOf(value: unknown): string { + const s = typeof value; + if (s === 'object') { + if (!value) { + return 'null'; + } + if (Array.isArray(value)) { + return 'array'; + } + return 'object'; + } + return s; +} + +/** + * Checks if a value is "object-like" (an object or function). + */ +function isObject(val: unknown): val is object { + const type = typeof val; + return (type === 'object' && val !== null) || type === 'function'; +} + +/** + * Checks if a value is array-like. + */ +function isArrayLike(val: unknown): val is ArrayLike { + if (!val || typeof val !== 'object') { + return false; + } + const obj = val as { length?: unknown }; + if (typeof obj.length !== 'number') { + return false; + } + if (typeof obj.propertyIsEnumerable !== 'function') { + return false; + } + return !obj.propertyIsEnumerable('length'); +} + +// Type guard for objects with a specific key (checks prototype chain) +function hasKey(obj: object, key: string): boolean { + return obj !== null && key in obj; +} + +/** + * Cache interface for the element cache stored on the document. + */ +interface ElementCache { + nextId: number; + [key: string]: Element | Window | number; +} + +/** + * Retrieves the cache object for the given window. Will initialize the cache + * if it does not yet exist. + * @param doc The document whose cache to retrieve. Defaults to the current document. + * @return The cache object. + */ +function getCache(doc?: Document): ElementCache { + const d = doc || document; + let cache = (d as unknown as Record)[CACHE_KEY]; + if (!cache) { + cache = (d as unknown as Record)[CACHE_KEY] = { + nextId: Date.now(), + }; + } + if (!cache.nextId) { + cache.nextId = Date.now(); + } + return cache; +} + +/** + * Adds an element to its ownerDocument's cache. + * @param el The element or Window object to add. + * @return The key generated for the cached element. + */ +export function addElement(el: Element | Window): string { + const cache = getCache((el as Element).ownerDocument); + const existingId = Object.keys(cache).find((key) => cache[key] === el); + if (existingId) { + return existingId; + } + const id = ELEMENT_KEY_PREFIX + cache.nextId++; + cache[id] = el; + return id; +} + +/** + * Retrieves an element from the cache. Will verify that the element is + * still attached to the DOM before returning. + * @param key The element's key in the cache. + * @param doc The document whose cache to retrieve the element from. Defaults to the current document. + * @return The cached element. + */ +export function getElement(key: string, doc?: Document): Element | Window { + const decodedKey = decodeURIComponent(key); + const d = doc || document; + const cache = getCache(d); + if (!hasKey(cache, decodedKey)) { + throw new BotError( + ErrorCode.STALE_ELEMENT_REFERENCE, + 'Element does not exist in cache' + ); + } + + const el = cache[decodedKey] as Element | Window; + + // If this is a Window check if it's closed + if (hasKey(el as object, 'setInterval')) { + if ((el as Window).closed) { + delete cache[decodedKey]; + throw new BotError(ErrorCode.NO_SUCH_WINDOW, 'Window has been closed.'); + } + return el; + } + + // Make sure the element is still attached to the DOM before returning. + let node: Node | null = el as Node; + while (node) { + if (node === d.documentElement) { + return el; + } + const nodeWithHost = node as Node & { host?: Node }; + if (nodeWithHost.host && node.nodeType === 11) { + node = nodeWithHost.host; + } else { + node = node.parentNode; + } + } + delete cache[decodedKey]; + throw new BotError( + ErrorCode.STALE_ELEMENT_REFERENCE, + 'Element is no longer attached to the DOM' + ); +} + +/** + * Converts an element to a JSON friendly value so that it can be + * stringified for transmission to the injector. Values are modified as + * follows: + * - booleans, numbers, strings, and null are returned as is + * - undefined values are returned as null + * - functions are returned as a string + * - each element in an array is recursively processed + * - DOM Elements are wrapped in object-literals as dictated by the + * WebDriver wire protocol + * - all other objects will be treated as hash-maps, and will be + * recursively processed for any string and number key types (all + * other key types are discarded as they cannot be converted to JSON). + * + * @param value The value to make JSON friendly. + * @return The JSON friendly value. + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol + */ +export function wrapValue(value: unknown): unknown { + function wrap(val: unknown, seen: object[]): unknown { + switch (typeOf(val)) { + case 'string': + case 'number': + case 'boolean': + return val; + + case 'function': + return (val as () => void).toString(); + + case 'array': + return (val as unknown[]).map((v) => wrap(v, seen)); + + case 'object': { + const obj = val as Record; + if (seen.indexOf(obj) >= 0) { + throw new BotError( + ErrorCode.JAVASCRIPT_ERROR, + 'Recursive object cannot be transferred' + ); + } + + // Sniff out DOM elements. We're using duck-typing instead of an + // instanceof check since the instanceof might not always work + // (e.g. if the value originated from another Firefox component) + if ( + hasKey(obj, 'nodeType') && + (obj['nodeType'] === 1 || obj['nodeType'] === 9) + ) { + const ret: JsonElement = { ELEMENT: '' }; + ret[ELEMENT_KEY] = addElement(obj as unknown as Element); + return ret; + } + + // Check if this is a Window + if (hasKey(obj, 'document')) { + const ret: JsonWindow = { WINDOW: '' }; + ret[WINDOW_KEY] = addElement(obj as unknown as Window); + return ret; + } + + seen.push(obj); + if (isArrayLike(val)) { + return Array.prototype.map.call(val, (v: unknown) => wrap(v, seen)); + } + + const filtered: Record = {}; + for (const key in obj) { + if (typeof key === 'number' || typeof key === 'string') { + filtered[key] = obj[key]; + } + } + const result: Record = {}; + for (const key in filtered) { + result[key] = wrap(filtered[key], seen); + } + return result; + } + + default: + return null; + } + } + return wrap(value, []); +} + +/** + * Unwraps any DOM element's encoded in the given `value`. + * @param value The value to unwrap. + * @param doc The document whose cache to retrieve wrapped elements from. Defaults to the current document. + * @return The unwrapped value. + */ +export function unwrapValue(value: unknown, doc?: Document): unknown { + if (Array.isArray(value)) { + return value.map((v) => unwrapValue(v, doc)); + } else if (isObject(value)) { + if (typeof value === 'function') { + return value; + } + + const obj = value as Record; + if (hasKey(obj, ELEMENT_KEY)) { + return getElement(obj[ELEMENT_KEY] as string, doc); + } + + if (hasKey(obj, WINDOW_KEY)) { + return getElement(obj[WINDOW_KEY] as string, doc); + } + + const result: Record = {}; + for (const key in obj) { + result[key] = unwrapValue(obj[key], doc); + } + return result; + } + return value; +} + +/** + * Recompiles `fn` in the context of another window so that the + * correct symbol table is used when the function is executed. This + * function assumes the `fn` can be decompiled to its source using + * `Function.prototype.toString` and that it only refers to symbols + * defined in the target window's context. + * + * @param fn Either the function that should be recompiled, or a string + * defining the body of an anonymous function that should be compiled + * in the target window's context. + * @param theWindow The window to recompile the function in. + * @return The recompiled function. + */ +function recompileFunction( + fn: ((...args: unknown[]) => unknown) | string, + theWindow: Window +): (...args: unknown[]) => unknown { + if (typeof fn === 'string') { + try { + return new (theWindow as unknown as Record)[ + 'Function' + ](fn) as (...args: unknown[]) => unknown; + } catch (ex) { + // Try to recover if in IE5-quirks mode + // Need to initialize the script engine on the passed-in window + const winWithExecScript = theWindow as Window & { + execScript?: (code: string) => void; + }; + if (winWithExecScript.execScript) { + winWithExecScript.execScript(';'); + return new (theWindow as unknown as Record)[ + 'Function' + ](fn) as (...args: unknown[]) => unknown; + } + throw ex; + } + } + return theWindow === window + ? fn + : (new (theWindow as unknown as Record)['Function']( + 'return (' + fn + ').apply(null,arguments);' + ) as (...args: unknown[]) => unknown); +} + +/** + * Wraps the response to an injected script that executed successfully so it + * can be JSON-ified for transmission to the process that injected this + * script. + * @param value The script result. + * @return The wrapped value. + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#responses + */ +export function wrapResponse(value: unknown): ResponseObject { + return { + status: ErrorCode.SUCCESS, + value: wrapValue(value), + }; +} + +/** + * Wraps a JavaScript error in an object-literal so that it can be JSON-ified + * for transmission to the process that injected this script. + * @param err The error to wrap. + * @return The wrapped error object. + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#failed-commands + */ +export function wrapError(err: Error & { code?: ErrorCode }): ResponseObject { + return { + status: + hasKey(err, 'code') && typeof err.code === 'number' + ? err.code + : ErrorCode.UNKNOWN_ERROR, + value: { + message: err.message, + }, + }; +} + +/** + * Executes an injected script. This function should never be called from + * within JavaScript itself. Instead, it is used from an external source that + * is injecting a script for execution. + * + * For example, in a WebDriver Java test, one might have: + * ``` + * Object result = ((JavascriptExecutor) driver).executeScript( + * "return arguments[0] + arguments[1];", 1, 2); + * ``` + * + * Once transmitted to the driver, this command would be injected into the + * page for evaluation as: + * ``` + * bot.inject.executeScript( + * function(){ return arguments[0] + arguments[1]; }, + * [1, 2]); + * ``` + * + * The details of how this function is invoked is left to clients of this + * library. + * + * @param fn Either the function to execute, or a string defining the body of + * an anonymous function that should be executed. This function should only + * contain references to symbols defined in the context of the target window. + * @param args An array of wrapped script arguments, as defined by the WebDriver wire protocol. + * @param stringify Whether the result should be returned as a serialized JSON string. + * @param win The window in whose context the function should be invoked; + * defaults to the current window. + * @return The result of the executed script, wrapped in a WebDriver response object. + * If stringify is true, the response is a JSON string. + */ +export function executeScript( + fn: ((...args: unknown[]) => unknown) | string, + args: unknown[], + stringifyResult?: boolean, + win?: Window +): ResponseObject | string { + const theWindow = win || window; + try { + const func = recompileFunction(fn, theWindow); + const unwrappedArgs = unwrapValue(args, theWindow.document) as unknown[]; + const result = func.apply(theWindow, unwrappedArgs); + const response = wrapResponse(result); + return stringifyResult ? stringify(response) : response; + } catch (ex) { + const error = ex as Error & { code?: ErrorCode }; + const response = wrapError(error); + return stringifyResult ? stringify(response) : response; + } +} + +/** + * Executes an injected script, which is expected to finish asynchronously + * before the given `timeout`. When the script finishes or an error + * occurs, the given `onDone` callback will be invoked. This callback + * will have a single argument, a ResponseObject object. + * + * The script signals its completion by invoking a supplied callback given + * as its last argument. The callback may be invoked with a single value. + * + * The script timeout event will be scheduled with the provided window, + * ensuring the timeout is synchronized with that window's event queue. + * Furthermore, asynchronous scripts do not work across new page loads; if an + * "unload" event is fired on the window while an asynchronous script is + * pending, the script will be aborted and an error will be returned. + * + * Like `executeScript`, this function should only be called from an external + * source. It handles wrapping and unwrapping of input/output values. + * + * @param fn Either the function to execute, or a string defining the body of + * an anonymous function that should be executed. This function should only + * contain references to symbols defined in the context of the target window. + * @param args An array of wrapped script arguments, as defined by the WebDriver wire protocol. + * @param timeout The amount of time, in milliseconds, the script should be + * permitted to run; must be non-negative. + * @param onDone The function to call when the given `fn` invokes its callback, + * or when an exception or timeout occurs. This will always be called. + * @param stringifyResult Whether the result should be returned as a serialized JSON string. + * @param win The window to synchronize the script with; defaults to the current window. + */ +export function executeAsyncScript( + fn: ((...args: unknown[]) => unknown) | string, + args: unknown[], + timeout: number, + onDone: (result: ResponseObject | string) => void, + stringifyResult?: boolean, + win?: Window +): void { + const theWindow = win || window; + let timeoutId: number; + let responseSent = false; + + function sendResponse(status: ErrorCode, value: unknown): void { + if (!responseSent) { + theWindow.removeEventListener('unload', onunload, true); + theWindow.clearTimeout(timeoutId); + + let response: ResponseObject; + if (status !== ErrorCode.SUCCESS) { + const errorValue = value as Error & { stack?: string }; + const err = new BotError(status, errorValue.message || errorValue + ''); + (err as Error & { stack?: string }).stack = errorValue.stack; + response = wrapError(err); + } else { + response = wrapResponse(value); + } + onDone(stringifyResult ? stringify(response) : response); + responseSent = true; + } + } + + function sendError(msg: string): void { + sendResponse(ErrorCode.UNKNOWN_ERROR, { message: msg }); + } + + function onunload(): void { + sendResponse( + ErrorCode.UNKNOWN_ERROR, + new Error( + 'Detected a page unload event; asynchronous script ' + + 'execution does not work across page loads.' + ) + ); + } + + if (theWindow.closed) { + sendError('Unable to execute script; the target window is closed.'); + return; + } + + const func = recompileFunction(fn, theWindow); + + const unwrappedArgs = unwrapValue(args, theWindow.document) as unknown[]; + unwrappedArgs.push((result: unknown) => sendResponse(ErrorCode.SUCCESS, result)); + + theWindow.addEventListener('unload', onunload, true); + + const startTime = Date.now(); + try { + func.apply(theWindow, unwrappedArgs); + + // Register our timeout *after* the function has been invoked. This will + // ensure we don't timeout on a function that invokes its callback after + // a 0-based timeout. + timeoutId = theWindow.setTimeout(() => { + sendResponse( + ErrorCode.SCRIPT_TIMEOUT, + new Error( + 'Timed out waiting for asynchronous script result ' + + 'after ' + + (Date.now() - startTime) + + ' ms' + ) + ); + }, Math.max(0, timeout)); + } catch (ex) { + const error = ex as Error & { code?: ErrorCode }; + sendResponse(error.code || ErrorCode.UNKNOWN_ERROR, error); + } +} diff --git a/javascript/atoms/json.js b/javascript/atoms/json.js deleted file mode 100644 index 77c871fe154b9..0000000000000 --- a/javascript/atoms/json.js +++ /dev/null @@ -1,79 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Provides JSON utilities that uses native JSON parsing where - * possible (a feature not currently offered by Closure). - */ - -goog.provide('bot.json'); - -goog.require('bot.userAgent'); -goog.require('goog.json'); -goog.require('goog.userAgent'); - - -/** - * @define {boolean} NATIVE_JSON indicates whether the code should rely on the - * native `JSON` functions, if available. - * - *

The JSON functions can be defined by external libraries like Prototype - * and setting this flag to false forces the use of Closure's goog.json - * implementation. - * - *

If your JavaScript can be loaded by a third_party site and you are wary - * about relying on the native functions, specify - * "--define bot.json.NATIVE_JSON=false" to the Closure compiler. - */ -bot.json.NATIVE_JSON = true; - - -/** - * Whether the current browser supports the native JSON interface. - * @const - * @see http://caniuse.com/#search=JSON - * @private {boolean} - */ -bot.json.SUPPORTS_NATIVE_JSON_ = - // List WebKit first since every supported version supports - // native JSON (and we can compile away large chunks of code for - // individual fragments by setting the appropriate compiler flags). - goog.userAgent.WEBKIT || - (goog.userAgent.GECKO && bot.userAgent.isEngineVersion(3.5)) || - (goog.userAgent.IE && bot.userAgent.isEngineVersion(8)); - - -/** - * Converts a JSON object to its string representation. - * @param {*} jsonObj The input object. - * @param {?(function(string, *): *)=} opt_replacer A replacer function called - * for each (key, value) pair that determines how the value should be - * serialized. By default, this just returns the value and allows default - * serialization to kick in. - * @return {string} A JSON string representation of the input object. - */ -bot.json.stringify = bot.json.NATIVE_JSON && bot.json.SUPPORTS_NATIVE_JSON_ ? - JSON.stringify : goog.json.serialize; - - -/** - * Parses a JSON string and returns the result. - * @param {string} jsonStr The string to parse. - * @return {*} The JSON object. - * @throws {Error} If the input string is an invalid JSON string. - */ -bot.json.parse = JSON.parse; diff --git a/javascript/atoms/json.ts b/javascript/atoms/json.ts new file mode 100644 index 0000000000000..45e86bbbcd00f --- /dev/null +++ b/javascript/atoms/json.ts @@ -0,0 +1,36 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview JSON utilities using native JSON parsing. + */ + +/** + * Converts a JSON object to its string representation. + * @param jsonObj The input object. + * @param replacer A replacer function called for each (key, value) pair. + * @return A JSON string representation of the input object. + */ +export const stringify: typeof JSON.stringify = JSON.stringify; + +/** + * Parses a JSON string and returns the result. + * @param jsonStr The string to parse. + * @return The JSON object. + * @throws If the input string is an invalid JSON string. + */ +export const parse: typeof JSON.parse = JSON.parse; diff --git a/javascript/atoms/keyboard.js b/javascript/atoms/keyboard.js deleted file mode 100644 index a1c2312ee4866..0000000000000 --- a/javascript/atoms/keyboard.js +++ /dev/null @@ -1,1043 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview The file contains an abstraction of a keyboard - * for simulating the pressing and releasing of keys. - */ - -goog.provide('bot.Keyboard'); -goog.provide('bot.Keyboard.Key'); -goog.provide('bot.Keyboard.Keys'); - -goog.require('bot.Device'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.dom'); -goog.require('bot.events.EventType'); -goog.require('bot.userAgent'); -goog.require('goog.array'); -goog.require('goog.dom.TagName'); -goog.require('goog.dom.selection'); -goog.require('goog.structs.Map'); -goog.require('goog.structs.Set'); -goog.require('goog.userAgent'); -goog.require('goog.utils'); - - - -/** - * A keyboard that provides atomic typing actions. - * - * @constructor - * @param {bot.Keyboard.State=} opt_state Optional keyboard state. - * @extends {bot.Device} - * @suppress {deprecated} - */ -bot.Keyboard = function (opt_state) { - bot.Device.call(this); - - /** @private {boolean} */ - this.editable_ = bot.dom.isEditable(this.getElement()); - - /** @private {number} */ - this.currentPos_ = 0; - - /** @private {!goog.structs.Set.} */ - this.pressed_ = new goog.structs.Set(); - - if (opt_state) { - // If a state is passed, let's assume we were passed an object with - // the correct properties. - goog.array.forEach(opt_state['pressed'], function (key) { - this.setKeyPressed_(/** @type {!bot.Keyboard.Key} */(key), true); - }, this); - - this.currentPos_ = opt_state['currentPos'] || 0; - } -}; -goog.utils.inherits(bot.Keyboard, bot.Device); - - -/** - * Describes the current state of a keyboard. - * @typedef {{pressed: !Array., - * currentPos: number}} - */ -bot.Keyboard.State; - - -/** - * Maps characters to (key,boolean) pairs, where the key generates the - * character and the boolean is true when the shift must be pressed. - * @private {!Object.} - * @const - */ -bot.Keyboard.CHAR_TO_KEY_ = {}; - - -/** - * Constructs a new key and, if it is a character key, adds a mapping from the - * character to is in the CHAR_TO_KEY_ map. Using this factory function instead - * of the new keyword, also helps reduce the size of the compiled Js fragment. - * - * @param {null|number| - * {gecko: (?number), ieWebkit: (?number)}} code - * Either a single keycode or a record of per-browser keycodes. - * @param {string=} opt_char Character when shift is not pressed. - * @param {string=} opt_shiftChar Character when shift is pressed. - * @return {!bot.Keyboard.Key} The new key. - * @private - */ -bot.Keyboard.newKey_ = function (code, opt_char, opt_shiftChar) { - if (goog.utils.isObject(code)) { - if (goog.userAgent.GECKO) { - code = code.gecko; - } else { // IE and Webkit - code = code.ieWebkit; - } - } - var key = new bot.Keyboard.Key(/** @type {?number} */ (code), opt_char, opt_shiftChar); - - // For a character key, potentially map the character to the key in the - // CHAR_TO_KEY_ map. Because of numpad, multiple keys may have the same - // character. To avoid mapping numpad keys, we overwrite a mapping only if - // the key has a distinct shift character. - if (opt_char && (!(opt_char in bot.Keyboard.CHAR_TO_KEY_) || opt_shiftChar)) { - bot.Keyboard.CHAR_TO_KEY_[opt_char] = { key: key, shift: false }; - if (opt_shiftChar) { - bot.Keyboard.CHAR_TO_KEY_[opt_shiftChar] = { key: key, shift: true }; - } - } - - return key; -}; - - - -/** - * A key on the keyboard. - * - * @constructor - * @param {?number} code Keycode for the key; null for the (rare) case - * that pressing the key issues no key events. - * @param {string=} opt_char Character when shift is not pressed; null - * when the key does not cause a character to be typed. - * @param {string=} opt_shiftChar Character when shift is pressed; null - * when the key does not cause a character to be typed. - */ -bot.Keyboard.Key = function (code, opt_char, opt_shiftChar) { - /** @type {?number} */ - this.code = code; - - /** @type {?string} */ - this.character = opt_char || null; - - /** @type {?string} */ - this.shiftChar = opt_shiftChar || this.character; -}; - - -/** - * Type definition for the keyboard keys object. - * @typedef {{ - * BACKSPACE: !bot.Keyboard.Key, - * TAB: !bot.Keyboard.Key, - * ENTER: !bot.Keyboard.Key, - * SHIFT: !bot.Keyboard.Key, - * CONTROL: !bot.Keyboard.Key, - * ALT: !bot.Keyboard.Key, - * PAUSE: !bot.Keyboard.Key, - * CAPS_LOCK: !bot.Keyboard.Key, - * ESC: !bot.Keyboard.Key, - * SPACE: !bot.Keyboard.Key, - * PAGE_UP: !bot.Keyboard.Key, - * PAGE_DOWN: !bot.Keyboard.Key, - * END: !bot.Keyboard.Key, - * HOME: !bot.Keyboard.Key, - * LEFT: !bot.Keyboard.Key, - * UP: !bot.Keyboard.Key, - * RIGHT: !bot.Keyboard.Key, - * DOWN: !bot.Keyboard.Key, - * PRINT_SCREEN: !bot.Keyboard.Key, - * INSERT: !bot.Keyboard.Key, - * DELETE: !bot.Keyboard.Key, - * ZERO: !bot.Keyboard.Key, - * ONE: !bot.Keyboard.Key, - * TWO: !bot.Keyboard.Key, - * THREE: !bot.Keyboard.Key, - * FOUR: !bot.Keyboard.Key, - * FIVE: !bot.Keyboard.Key, - * SIX: !bot.Keyboard.Key, - * SEVEN: !bot.Keyboard.Key, - * EIGHT: !bot.Keyboard.Key, - * NINE: !bot.Keyboard.Key, - * A: !bot.Keyboard.Key, - * B: !bot.Keyboard.Key, - * C: !bot.Keyboard.Key, - * D: !bot.Keyboard.Key, - * E: !bot.Keyboard.Key, - * F: !bot.Keyboard.Key, - * G: !bot.Keyboard.Key, - * H: !bot.Keyboard.Key, - * I: !bot.Keyboard.Key, - * J: !bot.Keyboard.Key, - * K: !bot.Keyboard.Key, - * L: !bot.Keyboard.Key, - * M: !bot.Keyboard.Key, - * N: !bot.Keyboard.Key, - * O: !bot.Keyboard.Key, - * P: !bot.Keyboard.Key, - * Q: !bot.Keyboard.Key, - * R: !bot.Keyboard.Key, - * S: !bot.Keyboard.Key, - * T: !bot.Keyboard.Key, - * U: !bot.Keyboard.Key, - * V: !bot.Keyboard.Key, - * W: !bot.Keyboard.Key, - * X: !bot.Keyboard.Key, - * Y: !bot.Keyboard.Key, - * Z: !bot.Keyboard.Key, - * META: !bot.Keyboard.Key, - * META_RIGHT: !bot.Keyboard.Key, - * CONTEXT_MENU: !bot.Keyboard.Key, - * NUM_ZERO: !bot.Keyboard.Key, - * NUM_ONE: !bot.Keyboard.Key, - * NUM_TWO: !bot.Keyboard.Key, - * NUM_THREE: !bot.Keyboard.Key, - * NUM_FOUR: !bot.Keyboard.Key, - * NUM_FIVE: !bot.Keyboard.Key, - * NUM_SIX: !bot.Keyboard.Key, - * NUM_SEVEN: !bot.Keyboard.Key, - * NUM_EIGHT: !bot.Keyboard.Key, - * NUM_NINE: !bot.Keyboard.Key, - * NUM_MULTIPLY: !bot.Keyboard.Key, - * NUM_PLUS: !bot.Keyboard.Key, - * NUM_MINUS: !bot.Keyboard.Key, - * NUM_PERIOD: !bot.Keyboard.Key, - * NUM_DIVISION: !bot.Keyboard.Key, - * NUM_LOCK: !bot.Keyboard.Key, - * F1: !bot.Keyboard.Key, - * F2: !bot.Keyboard.Key, - * F3: !bot.Keyboard.Key, - * F4: !bot.Keyboard.Key, - * F5: !bot.Keyboard.Key, - * F6: !bot.Keyboard.Key, - * F7: !bot.Keyboard.Key, - * F8: !bot.Keyboard.Key, - * F9: !bot.Keyboard.Key, - * F10: !bot.Keyboard.Key, - * F11: !bot.Keyboard.Key, - * F12: !bot.Keyboard.Key, - * EQUALS: !bot.Keyboard.Key, - * SEPARATOR: !bot.Keyboard.Key, - * HYPHEN: !bot.Keyboard.Key, - * COMMA: !bot.Keyboard.Key, - * PERIOD: !bot.Keyboard.Key, - * SLASH: !bot.Keyboard.Key, - * BACKTICK: !bot.Keyboard.Key, - * OPEN_BRACKET: !bot.Keyboard.Key, - * BACKSLASH: !bot.Keyboard.Key, - * CLOSE_BRACKET: !bot.Keyboard.Key, - * SEMICOLON: !bot.Keyboard.Key, - * APOSTROPHE: !bot.Keyboard.Key - * }} - */ -bot.Keyboard.KeysType; - - -/** - * The set of keys known to this module. - * - * @const {!bot.Keyboard.KeysType} - */ -bot.Keyboard.Keys = /** @type {!bot.Keyboard.KeysType} */ ({ - BACKSPACE: bot.Keyboard.newKey_(8), - TAB: bot.Keyboard.newKey_(9), - ENTER: bot.Keyboard.newKey_(13), - SHIFT: bot.Keyboard.newKey_(16), - CONTROL: bot.Keyboard.newKey_(17), - ALT: bot.Keyboard.newKey_(18), - PAUSE: bot.Keyboard.newKey_(19), - CAPS_LOCK: bot.Keyboard.newKey_(20), - ESC: bot.Keyboard.newKey_(27), - SPACE: bot.Keyboard.newKey_(32, ' '), - PAGE_UP: bot.Keyboard.newKey_(33), - PAGE_DOWN: bot.Keyboard.newKey_(34), - END: bot.Keyboard.newKey_(35), - HOME: bot.Keyboard.newKey_(36), - LEFT: bot.Keyboard.newKey_(37), - UP: bot.Keyboard.newKey_(38), - RIGHT: bot.Keyboard.newKey_(39), - DOWN: bot.Keyboard.newKey_(40), - PRINT_SCREEN: bot.Keyboard.newKey_(44), - INSERT: bot.Keyboard.newKey_(45), - DELETE: bot.Keyboard.newKey_(46), - - // Number keys - ZERO: bot.Keyboard.newKey_(48, '0', ')'), - ONE: bot.Keyboard.newKey_(49, '1', '!'), - TWO: bot.Keyboard.newKey_(50, '2', '@'), - THREE: bot.Keyboard.newKey_(51, '3', '#'), - FOUR: bot.Keyboard.newKey_(52, '4', '$'), - FIVE: bot.Keyboard.newKey_(53, '5', '%'), - SIX: bot.Keyboard.newKey_(54, '6', '^'), - SEVEN: bot.Keyboard.newKey_(55, '7', '&'), - EIGHT: bot.Keyboard.newKey_(56, '8', '*'), - NINE: bot.Keyboard.newKey_(57, '9', '('), - - // Letter keys - A: bot.Keyboard.newKey_(65, 'a', 'A'), - B: bot.Keyboard.newKey_(66, 'b', 'B'), - C: bot.Keyboard.newKey_(67, 'c', 'C'), - D: bot.Keyboard.newKey_(68, 'd', 'D'), - E: bot.Keyboard.newKey_(69, 'e', 'E'), - F: bot.Keyboard.newKey_(70, 'f', 'F'), - G: bot.Keyboard.newKey_(71, 'g', 'G'), - H: bot.Keyboard.newKey_(72, 'h', 'H'), - I: bot.Keyboard.newKey_(73, 'i', 'I'), - J: bot.Keyboard.newKey_(74, 'j', 'J'), - K: bot.Keyboard.newKey_(75, 'k', 'K'), - L: bot.Keyboard.newKey_(76, 'l', 'L'), - M: bot.Keyboard.newKey_(77, 'm', 'M'), - N: bot.Keyboard.newKey_(78, 'n', 'N'), - O: bot.Keyboard.newKey_(79, 'o', 'O'), - P: bot.Keyboard.newKey_(80, 'p', 'P'), - Q: bot.Keyboard.newKey_(81, 'q', 'Q'), - R: bot.Keyboard.newKey_(82, 'r', 'R'), - S: bot.Keyboard.newKey_(83, 's', 'S'), - T: bot.Keyboard.newKey_(84, 't', 'T'), - U: bot.Keyboard.newKey_(85, 'u', 'U'), - V: bot.Keyboard.newKey_(86, 'v', 'V'), - W: bot.Keyboard.newKey_(87, 'w', 'W'), - X: bot.Keyboard.newKey_(88, 'x', 'X'), - Y: bot.Keyboard.newKey_(89, 'y', 'Y'), - Z: bot.Keyboard.newKey_(90, 'z', 'Z'), - - // Branded keys - META: bot.Keyboard.newKey_( - goog.userAgent.WINDOWS ? { gecko: 91, ieWebkit: 91 } : - (goog.userAgent.MAC ? { gecko: 224, ieWebkit: 91 } : - { gecko: 0, ieWebkit: 91 })), // Linux - META_RIGHT: bot.Keyboard.newKey_( - goog.userAgent.WINDOWS ? { gecko: 92, ieWebkit: 92 } : - (goog.userAgent.MAC ? { gecko: 224, ieWebkit: 93 } : - { gecko: 0, ieWebkit: 92 })), // Linux - CONTEXT_MENU: bot.Keyboard.newKey_( - goog.userAgent.WINDOWS ? { gecko: 93, ieWebkit: 93 } : - (goog.userAgent.MAC ? { gecko: 0, ieWebkit: 0 } : - { gecko: 93, ieWebkit: null })), // Linux - - // Numpad keys - NUM_ZERO: bot.Keyboard.newKey_({ gecko: 96, ieWebkit: 96 }, '0'), - NUM_ONE: bot.Keyboard.newKey_({ gecko: 97, ieWebkit: 97 }, '1'), - NUM_TWO: bot.Keyboard.newKey_({ gecko: 98, ieWebkit: 98 }, '2'), - NUM_THREE: bot.Keyboard.newKey_({ gecko: 99, ieWebkit: 99 }, '3'), - NUM_FOUR: bot.Keyboard.newKey_({ gecko: 100, ieWebkit: 100 }, '4'), - NUM_FIVE: bot.Keyboard.newKey_({ gecko: 101, ieWebkit: 101 }, '5'), - NUM_SIX: bot.Keyboard.newKey_({ gecko: 102, ieWebkit: 102 }, '6'), - NUM_SEVEN: bot.Keyboard.newKey_({ gecko: 103, ieWebkit: 103 }, '7'), - NUM_EIGHT: bot.Keyboard.newKey_({ gecko: 104, ieWebkit: 104 }, '8'), - NUM_NINE: bot.Keyboard.newKey_({ gecko: 105, ieWebkit: 105 }, '9'), - NUM_MULTIPLY: bot.Keyboard.newKey_( - { gecko: 106, ieWebkit: 106 }, '*'), - NUM_PLUS: bot.Keyboard.newKey_( - { gecko: 107, ieWebkit: 107 }, '+'), - NUM_MINUS: bot.Keyboard.newKey_( - { gecko: 109, ieWebkit: 109 }, '-'), - NUM_PERIOD: bot.Keyboard.newKey_( - { gecko: 110, ieWebkit: 110 }, '.'), - NUM_DIVISION: bot.Keyboard.newKey_( - { gecko: 111, ieWebkit: 111 }, '/'), - NUM_LOCK: bot.Keyboard.newKey_(144), - - // Function keys - F1: bot.Keyboard.newKey_(112), - F2: bot.Keyboard.newKey_(113), - F3: bot.Keyboard.newKey_(114), - F4: bot.Keyboard.newKey_(115), - F5: bot.Keyboard.newKey_(116), - F6: bot.Keyboard.newKey_(117), - F7: bot.Keyboard.newKey_(118), - F8: bot.Keyboard.newKey_(119), - F9: bot.Keyboard.newKey_(120), - F10: bot.Keyboard.newKey_(121), - F11: bot.Keyboard.newKey_(122), - F12: bot.Keyboard.newKey_(123), - - // Punctuation keys - EQUALS: bot.Keyboard.newKey_( - { gecko: 107, ieWebkit: 187 }, '=', '+'), - SEPARATOR: bot.Keyboard.newKey_(108, ','), - HYPHEN: bot.Keyboard.newKey_( - { gecko: 109, ieWebkit: 189 }, '-', '_'), - COMMA: bot.Keyboard.newKey_(188, ',', '<'), - PERIOD: bot.Keyboard.newKey_(190, '.', '>'), - SLASH: bot.Keyboard.newKey_(191, '/', '?'), - BACKTICK: bot.Keyboard.newKey_(192, '`', '~'), - OPEN_BRACKET: bot.Keyboard.newKey_(219, '[', '{'), - BACKSLASH: bot.Keyboard.newKey_(220, '\\', '|'), - CLOSE_BRACKET: bot.Keyboard.newKey_(221, ']', '}'), - SEMICOLON: bot.Keyboard.newKey_( - { gecko: 59, ieWebkit: 186 }, ';', ':'), - APOSTROPHE: bot.Keyboard.newKey_(222, '\'', '"') -}); - - -/** - * Given a character, returns a pair of a key and a boolean: the key being one - * that types the character and the boolean indicating whether the key must be - * shifted to type it. This function will never return a numpad key; that is, - * it will always return a symbol key when given a number or math symbol. - * - * If given a character for which this module does not know the key (the key - * is not in the bot.Keyboard.Keys enumeration), returns a key that types the - * given character but has a (likely incorrect) keycode of zero. - * - * @param {string} ch Single character. - * @return {{key: !bot.Keyboard.Key, shift: boolean}} A pair of a key and - * a boolean indicating whether shift must be pressed for the character. - */ -bot.Keyboard.Key.fromChar = function (ch) { - if (ch.length != 1) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Argument not a single character: ' + ch); - } - var keyShiftPair = bot.Keyboard.CHAR_TO_KEY_[ch]; - if (!keyShiftPair) { - // We don't know the true keycode of non-US keyboard characters, but - // ch.toUpperCase().charCodeAt(0) should occasionally be right, and - // at least yield a positive number. - var upperCase = ch.toUpperCase(); - var keyCode = upperCase.charCodeAt(0); - var key = bot.Keyboard.newKey_(keyCode, ch.toLowerCase(), upperCase); - keyShiftPair = { key: key, shift: (ch != key.character) }; - } - return keyShiftPair; -}; - - -/** - * Array of modifier keys. - * - * @type {!Array.} - * @const - */ -bot.Keyboard.MODIFIERS = [ - bot.Keyboard.Keys.ALT, - bot.Keyboard.Keys.CONTROL, - bot.Keyboard.Keys.META, - bot.Keyboard.Keys.SHIFT -]; - - -/** - * Map of modifier to key. - * @private {!goog.structs.Map.} - * @suppress {deprecated} - */ -bot.Keyboard.MODIFIER_TO_KEY_MAP_ = (function () { - var modifiersMap = new goog.structs.Map(); - modifiersMap.set(bot.Device.Modifier.SHIFT, - bot.Keyboard.Keys.SHIFT); - modifiersMap.set(bot.Device.Modifier.CONTROL, - bot.Keyboard.Keys.CONTROL); - modifiersMap.set(bot.Device.Modifier.ALT, - bot.Keyboard.Keys.ALT); - modifiersMap.set(bot.Device.Modifier.META, - bot.Keyboard.Keys.META); - - return modifiersMap; -})(); - - -/** - * The reverse map - key to modifier. - * @private {!goog.structs.Map.} - * @suppress {deprecated} - */ -bot.Keyboard.KEY_TO_MODIFIER_ = (function (modifiersMap) { - var keyToModifierMap = new goog.structs.Map(); - goog.array.forEach(modifiersMap.getKeys(), function (m) { - keyToModifierMap.set(modifiersMap.get(m).code, m); - }); - - return keyToModifierMap; -})(bot.Keyboard.MODIFIER_TO_KEY_MAP_); - - -/** - * Set the modifier state if the provided key is one, otherwise just add - * to the list of pressed keys. - * @param {!bot.Keyboard.Key} key The key to update. - * @param {boolean} isPressed Whether the key is pressed. - * @private - */ -bot.Keyboard.prototype.setKeyPressed_ = function (key, isPressed) { - if (goog.array.contains(bot.Keyboard.MODIFIERS, key)) { - var modifier = /** @type {bot.Device.Modifier}*/ ( - bot.Keyboard.KEY_TO_MODIFIER_.get(key.code)); - this.modifiersState.setPressed(modifier, isPressed); - } - - if (isPressed) { - this.pressed_.add(key); - } else { - this.pressed_.remove(key); - } -}; - - -/** - * The value used for newlines in the current browser/OS combination. Although - * the line endings look platform dependent, they are browser dependent. - * - * @private {string} - * @const - */ -bot.Keyboard.NEW_LINE_ = goog.userAgent.IE ? '\r\n' : '\n'; - - -/** - * Returns whether the key is currently pressed. - * - * @param {!bot.Keyboard.Key} key Key. - * @return {boolean} Whether the key is pressed. - */ -bot.Keyboard.prototype.isPressed = function (key) { - return this.pressed_.contains(key); -}; - - -/** - * Presses the given key on the keyboard. Keys that are pressed can be pressed - * again before releasing, to simulate repeated keys, except for modifier keys, - * which must be released before they can be pressed again. - * - * @param {!bot.Keyboard.Key} key Key to press. - */ -bot.Keyboard.prototype.pressKey = function (key) { - if (goog.array.contains(bot.Keyboard.MODIFIERS, key) && this.isPressed(key)) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Cannot press a modifier key that is already pressed.'); - } - - // Note that GECKO is special-cased below because of - // https://bugzilla.mozilla.org/show_bug.cgi?id=501496. "preventDefault on - // keydown does not cancel following keypress" - var performDefault = key.code !== null && - this.fireKeyEvent_(bot.events.EventType.KEYDOWN, key); - - // Fires keydown and stops if unsuccessful. - if (performDefault || goog.userAgent.GECKO) { - // Fires keypress if required and stops if unsuccessful. - if (!this.requiresKeyPress_(key) || - this.fireKeyEvent_( - bot.events.EventType.KEYPRESS, key, !performDefault)) { - if (performDefault) { - this.maybeSubmitForm_(key); - if (this.editable_) { - this.maybeEditText_(key); - } - } - } - } - - this.setKeyPressed_(key, true); -}; - - -/** - * Whether the given key currently requires a keypress. - * TODO: Make this dependent on the state of the modifier keys. - * - * @param {bot.Keyboard.Key} key Key. - * @return {boolean} Whether it requires a keypress event. - * @private - */ -bot.Keyboard.prototype.requiresKeyPress_ = function (key) { - if (key.character || key == bot.Keyboard.Keys.ENTER) { - return true; - } else if (goog.userAgent.WEBKIT || goog.userAgent.EDGE) { - return false; - } else if (goog.userAgent.IE) { - return key == bot.Keyboard.Keys.ESC; - } else { // Gecko - switch (key) { - case bot.Keyboard.Keys.SHIFT: - case bot.Keyboard.Keys.CONTROL: - case bot.Keyboard.Keys.ALT: - return false; - case bot.Keyboard.Keys.META: - case bot.Keyboard.Keys.META_RIGHT: - case bot.Keyboard.Keys.CONTEXT_MENU: - return goog.userAgent.GECKO; - default: - return true; - } - } -}; - - -/** - * Maybe submit a form if the ENTER key is released. On non-FF browsers, firing - * the keyPress and keyRelease events for the ENTER key does not result in a - * form being submitted so we have to fire the form submit event as well. - * - * @param {bot.Keyboard.Key} key Key. - * @private - */ -bot.Keyboard.prototype.maybeSubmitForm_ = function (key) { - if (key != bot.Keyboard.Keys.ENTER) { - return; - } - if ((goog.userAgent.GECKO && !bot.userAgent.isEngineVersion(93)) || - !bot.dom.isElement(this.getElement(), goog.dom.TagName.INPUT)) { - return; - } - - var form = bot.Device.findAncestorForm(this.getElement()); - if (form) { - var inputs = form.getElementsByTagName('input'); - var hasSubmit = goog.array.some(inputs, function (e) { - return bot.Device.isFormSubmitElement(e); - }); - // The second part of this if statement will always include forms on Safari - // version < 5. - if (hasSubmit || inputs.length == 1 || - (goog.userAgent.WEBKIT && !bot.userAgent.isEngineVersion(534))) { - this.submitForm(form); - } - } -}; - - -/** - * Maybe edit text when a key is pressed in an editable form. - * - * @param {!bot.Keyboard.Key} key Key that was pressed. - * @private - */ -bot.Keyboard.prototype.maybeEditText_ = function (key) { - if (key.character) { - this.updateOnCharacter_(key); - } else { - switch (key) { - case bot.Keyboard.Keys.ENTER: - this.updateOnEnter_(); - break; - case bot.Keyboard.Keys.BACKSPACE: - case bot.Keyboard.Keys.DELETE: - this.updateOnBackspaceOrDelete_(key); - break; - case bot.Keyboard.Keys.LEFT: - case bot.Keyboard.Keys.RIGHT: - this.updateOnLeftOrRight_(key); - break; - case bot.Keyboard.Keys.HOME: - case bot.Keyboard.Keys.END: - this.updateOnHomeOrEnd_(key); - break; - } - } -}; - - -/** - * Releases the given key on the keyboard. Releasing a key that is not - * pressed results in an exception. - * - * @param {!bot.Keyboard.Key} key Key to release. - */ -bot.Keyboard.prototype.releaseKey = function (key) { - if (!this.isPressed(key)) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Cannot release a key that is not pressed. (' + key.code + ')'); - } - if (key.code !== null) { - this.fireKeyEvent_(bot.events.EventType.KEYUP, key); - } - - this.setKeyPressed_(key, false); -}; - - -/** - * Given the current state of the SHIFT and CAPS_LOCK key, returns the - * character that will be typed is the specified key is pressed. - * - * @param {!bot.Keyboard.Key} key Key. - * @return {string} Character to be typed. - * @private - */ -bot.Keyboard.prototype.getChar_ = function (key) { - if (!key.character) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, 'not a character key'); - } - var shiftPressed = this.isPressed(bot.Keyboard.Keys.SHIFT); - return /** @type {string} */ (shiftPressed ? key.shiftChar : key.character); -}; - - -/** - * Whether firing a keypress event causes text to be edited without any - * additional logic to surgically apply the edit. - * @private {boolean} - * @const - */ -bot.Keyboard.KEYPRESS_EDITS_TEXT_ = goog.userAgent.GECKO && - !bot.userAgent.isEngineVersion(12); - - -/** - * @param {!bot.Keyboard.Key} key Key with character to insert. - * @private - */ -bot.Keyboard.prototype.updateOnCharacter_ = function (key) { - if (bot.Keyboard.KEYPRESS_EDITS_TEXT_) { - return; - } - - var character = this.getChar_(key); - var newPos = goog.dom.selection.getStart(this.getElement()) + 1; - if (bot.Keyboard.supportsSelection(this.getElement())) { - goog.dom.selection.setText(this.getElement(), character); - goog.dom.selection.setStart(this.getElement(), newPos); - } else { - this.getElement().value += character; - } - if (goog.userAgent.WEBKIT) { - this.fireHtmlEvent(bot.events.EventType.TEXTINPUT); - } - if (!bot.userAgent.IE_DOC_PRE9) { - this.fireHtmlEvent(bot.events.EventType.INPUT); - } - this.updateCurrentPos_(newPos); -}; - - -/** @private */ -bot.Keyboard.prototype.updateOnEnter_ = function () { - if (bot.Keyboard.KEYPRESS_EDITS_TEXT_) { - return; - } - - // WebKit fires text input regardless of whether a new line is added, see: - // https://bugs.webkit.org/show_bug.cgi?id=54152 - if (goog.userAgent.WEBKIT) { - this.fireHtmlEvent(bot.events.EventType.TEXTINPUT); - } - if (bot.dom.isElement(this.getElement(), goog.dom.TagName.TEXTAREA)) { - var newPos = goog.dom.selection.getStart(this.getElement()) + - bot.Keyboard.NEW_LINE_.length; - if (bot.Keyboard.supportsSelection(this.getElement())) { - goog.dom.selection.setText(this.getElement(), bot.Keyboard.NEW_LINE_); - goog.dom.selection.setStart(this.getElement(), newPos); - } else { - this.getElement().value += bot.Keyboard.NEW_LINE_; - } - if (!goog.userAgent.IE) { - this.fireHtmlEvent(bot.events.EventType.INPUT); - } - this.updateCurrentPos_(newPos); - } -}; - - -/** - * @param {!bot.Keyboard.Key} key Backspace or delete key. - * @private - */ -bot.Keyboard.prototype.updateOnBackspaceOrDelete_ = function (key) { - if (bot.Keyboard.KEYPRESS_EDITS_TEXT_) { - return; - } - - // Determine what should be deleted. If text is already selected, that - // text is deleted, else we move left/right from the current cursor. - bot.Keyboard.checkCanUpdateSelection_(this.getElement()); - var endpoints = goog.dom.selection.getEndPoints(this.getElement()); - if (endpoints[0] == endpoints[1]) { - if (key == bot.Keyboard.Keys.BACKSPACE) { - goog.dom.selection.setStart(this.getElement(), endpoints[1] - 1); - // On IE, changing goog.dom.selection.setStart also changes the end. - goog.dom.selection.setEnd(this.getElement(), endpoints[1]); - } else { - goog.dom.selection.setEnd(this.getElement(), endpoints[1] + 1); - } - } - - // If the endpoints are equal (e.g., the cursor was at the beginning/end - // of the input), the text field won't be changed. - endpoints = goog.dom.selection.getEndPoints(this.getElement()); - var textChanged = !(endpoints[0] == this.getElement().value.length || - endpoints[1] == 0); - goog.dom.selection.setText(this.getElement(), ''); - - // Except for IE and GECKO, we need to fire the input event manually, but - // only if the text was actually changed. - // Note: Gecko has some strange behavior with the input event. In a - // textarea, backspace always sends an input event, while delete only - // sends one if you actually change the text. - // In a textbox/password box, backspace always sends an input event unless - // the box has no text. Delete behaves the same way in Firefox 3.0, but - // in later versions it only fires an input event if no text changes. - if (!goog.userAgent.IE && textChanged || - (goog.userAgent.GECKO && key == bot.Keyboard.Keys.BACKSPACE)) { - this.fireHtmlEvent(bot.events.EventType.INPUT); - } - - // Update the cursor position - endpoints = goog.dom.selection.getEndPoints(this.getElement()); - this.updateCurrentPos_(endpoints[1]); -}; - - -/** - * @param {!bot.Keyboard.Key} key Special key to press. - * @private - */ -bot.Keyboard.prototype.updateOnLeftOrRight_ = function (key) { - bot.Keyboard.checkCanUpdateSelection_(this.getElement()); - var element = this.getElement(); - var start = goog.dom.selection.getStart(element); - var end = goog.dom.selection.getEnd(element); - - var newPos, startPos = 0, endPos = 0; - if (key == bot.Keyboard.Keys.LEFT) { - if (this.isPressed(bot.Keyboard.Keys.SHIFT)) { - // If the current position of the cursor is at the start of the - // selection, pressing left expands the selection one character to the - // left; otherwise, pressing left collapses it one character to the - // left. - if (this.currentPos_ == start) { - // Never attempt to move further left than the beginning of the text. - startPos = Math.max(start - 1, 0); - endPos = end; - newPos = startPos; - } else { - startPos = start; - endPos = end - 1; - newPos = endPos; - } - } else { - // With no current selection, pressing left moves the cursor one - // character to the left; with an existing selection, it collapses the - // selection to the beginning of the selection. - newPos = start == end ? Math.max(start - 1, 0) : start; - } - } else { // (key == bot.Keyboard.Keys.RIGHT) - if (this.isPressed(bot.Keyboard.Keys.SHIFT)) { - // If the current position of the cursor is at the end of the selection, - // pressing right expands the selection one character to the right; - // otherwise, pressing right collapses it one character to the right. - if (this.currentPos_ == end) { - startPos = start; - // Never attempt to move further right than the end of the text. - endPos = Math.min(end + 1, element.value.length); - newPos = endPos; - } else { - startPos = start + 1; - endPos = end; - newPos = startPos; - } - } else { - // With no current selection, pressing right moves the cursor one - // character to the right; with an existing selection, it collapses the - // selection to the end of the selection. - newPos = start == end ? Math.min(end + 1, element.value.length) : end; - } - } - - if (this.isPressed(bot.Keyboard.Keys.SHIFT)) { - goog.dom.selection.setStart(element, startPos); - // On IE, changing goog.dom.selection.setStart also changes the end. - goog.dom.selection.setEnd(element, endPos); - } else { - goog.dom.selection.setCursorPosition(element, newPos); - } - this.updateCurrentPos_(newPos); -}; - - -/** - * @param {!bot.Keyboard.Key} key Special key to press. - * @private - */ -bot.Keyboard.prototype.updateOnHomeOrEnd_ = function (key) { - bot.Keyboard.checkCanUpdateSelection_(this.getElement()); - var element = this.getElement(); - var start = goog.dom.selection.getStart(element); - var end = goog.dom.selection.getEnd(element); - // TODO: Handle multiline (TEXTAREA) elements. - if (key == bot.Keyboard.Keys.HOME) { - if (this.isPressed(bot.Keyboard.Keys.SHIFT)) { - goog.dom.selection.setStart(element, 0); - // If current position is at the end of the selection, typing home - // changes the selection to begin at the beginning of the text, running - // to the where the current selection begins. - var endPos = this.currentPos_ == start ? end : start; - // On IE, changing goog.dom.selection.setStart also changes the end. - goog.dom.selection.setEnd(element, endPos); - } else { - goog.dom.selection.setCursorPosition(element, 0); - } - this.updateCurrentPos_(0); - } else { // (key == bot.Keyboard.Keys.END) - if (this.isPressed(bot.Keyboard.Keys.SHIFT)) { - if (this.currentPos_ == start) { - // Current position is at the beginning of the selection. Typing end - // changes the selection to begin where the current selection ends, - // running to the end of the text. - goog.dom.selection.setStart(element, end); - } - goog.dom.selection.setEnd(element, element.value.length); - } else { - goog.dom.selection.setCursorPosition(element, element.value.length); - } - this.updateCurrentPos_(element.value.length); - } -}; - - -/** - * Checks that the cursor position can be updated for the given element. - * @param {!Element} element The element to test. - * @throws {Error} If the cursor position cannot be updated for the given - * element. - * @see https://code.google.com/p/chromium/issues/detail?id=330456 - * @private - * @suppress {uselessCode} - */ -bot.Keyboard.checkCanUpdateSelection_ = function (element) { - try { - if (typeof element.selectionStart == 'number') { - return; - } - } catch (ex) { - // The native error message is actually pretty informative, just add a - // reference to the relevant Chrome bug to provide more context. - if (ex.message.indexOf('does not support selection.') != -1) { - // message is a readonly property, so need to rethrow. - throw Error(ex.message + ' (For more information, see ' + - 'https://code.google.com/p/chromium/issues/detail?id=330456)'); - } - throw ex; - } - throw Error('Element does not support selection'); -}; - - -/** - * @param {!Element} element The element to test. - * @return {boolean} Whether the given element supports the input element - * selection API. - * @see https://code.google.com/p/chromium/issues/detail?id=330456 - */ -bot.Keyboard.supportsSelection = function (element) { - try { - bot.Keyboard.checkCanUpdateSelection_(element); - } catch (ex) { - return false; - } - return true; -}; - - -/** -* @param {number} pos New position of the cursor. -* @private -*/ -bot.Keyboard.prototype.updateCurrentPos_ = function (pos) { - this.currentPos_ = pos; -}; - - -/** -* @param {!bot.events.EventFactory_} type Event type. -* @param {!bot.Keyboard.Key} key Key. -* @param {boolean=} opt_preventDefault Whether the default event should be -* prevented. Defaults to false. -* @return {boolean} Whether the event fired successfully or was cancelled. -* @private -*/ -bot.Keyboard.prototype.fireKeyEvent_ = function (type, key, opt_preventDefault) { - if (key.code === null) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Key must have a keycode to be fired.'); - } - - var args = { - altKey: this.isPressed(bot.Keyboard.Keys.ALT), - ctrlKey: this.isPressed(bot.Keyboard.Keys.CONTROL), - metaKey: this.isPressed(bot.Keyboard.Keys.META), - shiftKey: this.isPressed(bot.Keyboard.Keys.SHIFT), - keyCode: key.code, - charCode: (key.character && type == bot.events.EventType.KEYPRESS) ? - this.getChar_(key).charCodeAt(0) : 0, - preventDefault: !!opt_preventDefault - }; - - return this.fireKeyboardEvent(type, args); -}; - - -/** - * Sets focus to the element. If the element does not have focus, place cursor - * at the end of the text in the element. - * - * @param {!Element} element Element that is moved to. - */ -bot.Keyboard.prototype.moveCursor = function (element) { - this.setElement(element); - this.editable_ = bot.dom.isEditable(element); - - var focusChanged = this.focusOnElement(); - if (this.editable_ && focusChanged) { - goog.dom.selection.setCursorPosition(element, element.value.length); - this.updateCurrentPos_(element.value.length); - } -}; - - -/** - * Serialize the current state of the keyboard. - * - * @return {bot.Keyboard.State} The current keyboard state. - */ -bot.Keyboard.prototype.getState = function () { - // Need to use quoted literals here, so the compiler will not rename the - // properties of the emitted object. When the object is created via the - // "constructor", we will look for these *specific* properties. Everywhere - // else internally, we use the dot-notation, so it's okay if the compiler - // renames the internal variable name. - return { - 'pressed': this.pressed_.getValues(), - 'currentPos': this.currentPos_ - }; -}; - - -/** - * Returns the state of the modifier keys, to be shared with other input - * devices. - * - * @return {bot.Device.ModifiersState} Modifiers state. - */ -bot.Keyboard.prototype.getModifiersState = function () { - return this.modifiersState; -}; diff --git a/javascript/atoms/keyboard.ts b/javascript/atoms/keyboard.ts new file mode 100644 index 0000000000000..9f4c6b7f8dcea --- /dev/null +++ b/javascript/atoms/keyboard.ts @@ -0,0 +1,852 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview The file contains an abstraction of a keyboard + * for simulating the pressing and releasing of keys. + */ + +import { BotError, ErrorCode } from './error'; +import { + Device, + Modifier, + ModifiersState, + findAncestorForm, + isFormSubmitElement, +} from './device'; +import { isElement, isEditable } from './dom'; +import { EventType, EventFactory } from './events'; +import { GECKO, WEBKIT, IE, EDGE, isEngineVersion, IE_DOC_PRE9 } from './userAgent'; + +// Browser detection +const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''; +const IS_WINDOWS = /Windows/.test(userAgent); +const IS_MAC = /Macintosh/.test(userAgent); + +// ============================================================================ +// Key Class +// ============================================================================ + +/** + * Maps characters to (key,boolean) pairs, where the key generates the + * character and the boolean is true when the shift must be pressed. + */ +const CHAR_TO_KEY_: Record = {}; + +/** + * A key on the keyboard. + */ +export class Key { + code: number | null; + character: string | null; + shiftChar: string | null; + + constructor(code: number | null, character?: string, shiftChar?: string) { + this.code = code; + this.character = character || null; + this.shiftChar = shiftChar || this.character; + } + + /** + * Given a character, returns a pair of a key and a boolean: the key being one + * that types the character and the boolean indicating whether the key must be + * shifted to type it. + */ + static fromChar(ch: string): { key: Key; shift: boolean } { + if (ch.length !== 1) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Argument not a single character: ' + ch + ); + } + let keyShiftPair = CHAR_TO_KEY_[ch]; + if (!keyShiftPair) { + const upperCase = ch.toUpperCase(); + const keyCode = upperCase.charCodeAt(0); + const key = newKey_(keyCode, ch.toLowerCase(), upperCase); + keyShiftPair = { key: key, shift: ch !== key.character }; + } + return keyShiftPair; + } +} + +/** + * Constructs a new key and, if it is a character key, adds a mapping from the + * character to it in the CHAR_TO_KEY_ map. + */ +function newKey_( + code: number | null | { gecko: number | null; ieWebkit: number | null }, + character?: string, + shiftChar?: string +): Key { + if (code !== null && typeof code === 'object') { + if (GECKO) { + code = code.gecko; + } else { + code = code.ieWebkit; + } + } + const key = new Key(code as number | null, character, shiftChar); + + if (character && (!(character in CHAR_TO_KEY_) || shiftChar)) { + CHAR_TO_KEY_[character] = { key: key, shift: false }; + if (shiftChar) { + CHAR_TO_KEY_[shiftChar] = { key: key, shift: true }; + } + } + + return key; +} + +// ============================================================================ +// Keys Definition +// ============================================================================ + +/** + * The set of keys known to this module. + */ +export const Keys = { + BACKSPACE: newKey_(8), + TAB: newKey_(9), + ENTER: newKey_(13), + SHIFT: newKey_(16), + CONTROL: newKey_(17), + ALT: newKey_(18), + PAUSE: newKey_(19), + CAPS_LOCK: newKey_(20), + ESC: newKey_(27), + SPACE: newKey_(32, ' '), + PAGE_UP: newKey_(33), + PAGE_DOWN: newKey_(34), + END: newKey_(35), + HOME: newKey_(36), + LEFT: newKey_(37), + UP: newKey_(38), + RIGHT: newKey_(39), + DOWN: newKey_(40), + PRINT_SCREEN: newKey_(44), + INSERT: newKey_(45), + DELETE: newKey_(46), + + // Number keys + ZERO: newKey_(48, '0', ')'), + ONE: newKey_(49, '1', '!'), + TWO: newKey_(50, '2', '@'), + THREE: newKey_(51, '3', '#'), + FOUR: newKey_(52, '4', '$'), + FIVE: newKey_(53, '5', '%'), + SIX: newKey_(54, '6', '^'), + SEVEN: newKey_(55, '7', '&'), + EIGHT: newKey_(56, '8', '*'), + NINE: newKey_(57, '9', '('), + + // Letter keys + A: newKey_(65, 'a', 'A'), + B: newKey_(66, 'b', 'B'), + C: newKey_(67, 'c', 'C'), + D: newKey_(68, 'd', 'D'), + E: newKey_(69, 'e', 'E'), + F: newKey_(70, 'f', 'F'), + G: newKey_(71, 'g', 'G'), + H: newKey_(72, 'h', 'H'), + I: newKey_(73, 'i', 'I'), + J: newKey_(74, 'j', 'J'), + K: newKey_(75, 'k', 'K'), + L: newKey_(76, 'l', 'L'), + M: newKey_(77, 'm', 'M'), + N: newKey_(78, 'n', 'N'), + O: newKey_(79, 'o', 'O'), + P: newKey_(80, 'p', 'P'), + Q: newKey_(81, 'q', 'Q'), + R: newKey_(82, 'r', 'R'), + S: newKey_(83, 's', 'S'), + T: newKey_(84, 't', 'T'), + U: newKey_(85, 'u', 'U'), + V: newKey_(86, 'v', 'V'), + W: newKey_(87, 'w', 'W'), + X: newKey_(88, 'x', 'X'), + Y: newKey_(89, 'y', 'Y'), + Z: newKey_(90, 'z', 'Z'), + + // Branded keys + META: newKey_( + IS_WINDOWS + ? { gecko: 91, ieWebkit: 91 } + : IS_MAC + ? { gecko: 224, ieWebkit: 91 } + : { gecko: 0, ieWebkit: 91 } + ), + META_RIGHT: newKey_( + IS_WINDOWS + ? { gecko: 92, ieWebkit: 92 } + : IS_MAC + ? { gecko: 224, ieWebkit: 93 } + : { gecko: 0, ieWebkit: 92 } + ), + CONTEXT_MENU: newKey_( + IS_WINDOWS + ? { gecko: 93, ieWebkit: 93 } + : IS_MAC + ? { gecko: 0, ieWebkit: 0 } + : { gecko: 93, ieWebkit: null } + ), + + // Numpad keys + NUM_ZERO: newKey_({ gecko: 96, ieWebkit: 96 }, '0'), + NUM_ONE: newKey_({ gecko: 97, ieWebkit: 97 }, '1'), + NUM_TWO: newKey_({ gecko: 98, ieWebkit: 98 }, '2'), + NUM_THREE: newKey_({ gecko: 99, ieWebkit: 99 }, '3'), + NUM_FOUR: newKey_({ gecko: 100, ieWebkit: 100 }, '4'), + NUM_FIVE: newKey_({ gecko: 101, ieWebkit: 101 }, '5'), + NUM_SIX: newKey_({ gecko: 102, ieWebkit: 102 }, '6'), + NUM_SEVEN: newKey_({ gecko: 103, ieWebkit: 103 }, '7'), + NUM_EIGHT: newKey_({ gecko: 104, ieWebkit: 104 }, '8'), + NUM_NINE: newKey_({ gecko: 105, ieWebkit: 105 }, '9'), + NUM_MULTIPLY: newKey_({ gecko: 106, ieWebkit: 106 }, '*'), + NUM_PLUS: newKey_({ gecko: 107, ieWebkit: 107 }, '+'), + NUM_MINUS: newKey_({ gecko: 109, ieWebkit: 109 }, '-'), + NUM_PERIOD: newKey_({ gecko: 110, ieWebkit: 110 }, '.'), + NUM_DIVISION: newKey_({ gecko: 111, ieWebkit: 111 }, '/'), + NUM_LOCK: newKey_(144), + + // Function keys + F1: newKey_(112), + F2: newKey_(113), + F3: newKey_(114), + F4: newKey_(115), + F5: newKey_(116), + F6: newKey_(117), + F7: newKey_(118), + F8: newKey_(119), + F9: newKey_(120), + F10: newKey_(121), + F11: newKey_(122), + F12: newKey_(123), + + // Punctuation keys + EQUALS: newKey_({ gecko: 107, ieWebkit: 187 }, '=', '+'), + SEPARATOR: newKey_(108, ','), + HYPHEN: newKey_({ gecko: 109, ieWebkit: 189 }, '-', '_'), + COMMA: newKey_(188, ',', '<'), + PERIOD: newKey_(190, '.', '>'), + SLASH: newKey_(191, '/', '?'), + BACKTICK: newKey_(192, '`', '~'), + OPEN_BRACKET: newKey_(219, '[', '{'), + BACKSLASH: newKey_(220, '\\', '|'), + CLOSE_BRACKET: newKey_(221, ']', '}'), + SEMICOLON: newKey_({ gecko: 59, ieWebkit: 186 }, ';', ':'), + APOSTROPHE: newKey_(222, "'", '"'), +} as const; + +export type KeysType = typeof Keys; + +/** + * Array of modifier keys. + */ +export const MODIFIERS: Key[] = [Keys.ALT, Keys.CONTROL, Keys.META, Keys.SHIFT]; + +/** + * Map of modifier to key. + */ +const MODIFIER_TO_KEY_MAP_: Map = new Map([ + [Modifier.SHIFT, Keys.SHIFT], + [Modifier.CONTROL, Keys.CONTROL], + [Modifier.ALT, Keys.ALT], + [Modifier.META, Keys.META], +]); + +/** + * Map of key code to modifier. + */ +const KEY_TO_MODIFIER_: Map = new Map(); +MODIFIER_TO_KEY_MAP_.forEach((key, modifier) => { + if (key.code !== null) { + KEY_TO_MODIFIER_.set(key.code, modifier); + } +}); + +/** + * The value used for newlines in the current browser/OS combination. + */ +const NEW_LINE_ = IE ? '\r\n' : '\n'; + +/** + * Whether firing a keypress event causes text to be edited without any + * additional logic to surgically apply the edit. + */ +const KEYPRESS_EDITS_TEXT_ = GECKO && !isEngineVersion(12); + +// ============================================================================ +// Selection helpers (replacing goog.dom.selection) +// ============================================================================ + +function useSelectionProperties_( + element: HTMLInputElement | HTMLTextAreaElement +): boolean { + try { + return typeof element.selectionStart === 'number'; + } catch { + return false; + } +} + +function getSelectionStart(element: HTMLInputElement | HTMLTextAreaElement): number { + try { + return element.selectionStart ?? 0; + } catch { + return 0; + } +} + +function getSelectionEnd(element: HTMLInputElement | HTMLTextAreaElement): number { + try { + return element.selectionEnd ?? 0; + } catch { + return 0; + } +} + +function getEndPoints( + element: HTMLInputElement | HTMLTextAreaElement +): [number, number] { + return [getSelectionStart(element), getSelectionEnd(element)]; +} + +function setSelectionStart( + element: HTMLInputElement | HTMLTextAreaElement, + start: number +): void { + if (useSelectionProperties_(element)) { + element.selectionStart = start; + } +} + +function setSelectionEnd( + element: HTMLInputElement | HTMLTextAreaElement, + end: number +): void { + if (useSelectionProperties_(element)) { + element.selectionEnd = end; + } +} + +function setCursorPosition( + element: HTMLInputElement | HTMLTextAreaElement, + pos: number +): void { + if (useSelectionProperties_(element)) { + element.selectionStart = pos; + element.selectionEnd = pos; + } +} + +function setSelectedText( + element: HTMLInputElement | HTMLTextAreaElement, + text: string +): void { + if (useSelectionProperties_(element)) { + const start = getSelectionStart(element); + const end = getSelectionEnd(element); + const value = element.value; + element.value = value.substring(0, start) + text + value.substring(end); + element.selectionStart = start; + element.selectionEnd = start + text.length; + } +} + +// ============================================================================ +// Keyboard State +// ============================================================================ + +/** + * Describes the current state of a keyboard. + */ +export interface KeyboardState { + pressed: Key[]; + currentPos: number; +} + +// ============================================================================ +// Keyboard Class +// ============================================================================ + +/** + * A keyboard that provides atomic typing actions. + */ +export class Keyboard extends Device { + private editable_: boolean; + private currentPos_: number = 0; + private pressed_: Set = new Set(); + + constructor(opt_state?: KeyboardState) { + super(); + + this.editable_ = isEditable(this.getElement()); + + if (opt_state) { + if (opt_state.pressed) { + opt_state.pressed.forEach((key) => { + this.setKeyPressed_(key, true); + }); + } + this.currentPos_ = opt_state.currentPos || 0; + } + } + + /** + * Set the modifier state if the provided key is one, otherwise just add + * to the list of pressed keys. + */ + private setKeyPressed_(key: Key, isPressed: boolean): void { + if (MODIFIERS.includes(key)) { + const modifier = KEY_TO_MODIFIER_.get(key.code!); + if (modifier !== undefined) { + this.modifiersState.setPressed(modifier, isPressed); + } + } + + if (isPressed) { + this.pressed_.add(key); + } else { + this.pressed_.delete(key); + } + } + + /** + * Returns whether the key is currently pressed. + */ + isPressed(key: Key): boolean { + return this.pressed_.has(key); + } + + /** + * Presses the given key on the keyboard. + */ + pressKey(key: Key): void { + if (MODIFIERS.includes(key) && this.isPressed(key)) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Cannot press a modifier key that is already pressed.' + ); + } + + const performDefault = + key.code !== null && this.fireKeyEvent_(EventType.KEYDOWN, key); + + if (performDefault || GECKO) { + if ( + !this.requiresKeyPress_(key) || + this.fireKeyEvent_(EventType.KEYPRESS, key, !performDefault) + ) { + if (performDefault) { + this.maybeSubmitForm_(key); + if (this.editable_) { + this.maybeEditText_(key); + } + } + } + } + + this.setKeyPressed_(key, true); + } + + /** + * Whether the given key currently requires a keypress. + */ + private requiresKeyPress_(key: Key): boolean { + if (key.character || key === Keys.ENTER) { + return true; + } else if (WEBKIT || EDGE) { + return false; + } else if (IE) { + return key === Keys.ESC; + } else { + // Gecko + switch (key) { + case Keys.SHIFT: + case Keys.CONTROL: + case Keys.ALT: + return false; + case Keys.META: + case Keys.META_RIGHT: + case Keys.CONTEXT_MENU: + return GECKO; + default: + return true; + } + } + } + + /** + * Maybe submit a form if the ENTER key is released. + */ + private maybeSubmitForm_(key: Key): void { + if (key !== Keys.ENTER) { + return; + } + if ((GECKO && !isEngineVersion(93)) || !isElement(this.getElement(), 'INPUT')) { + return; + } + + const form = findAncestorForm(this.getElement()); + if (form) { + const inputs = form.getElementsByTagName('input'); + const hasSubmit = Array.from(inputs).some((e) => isFormSubmitElement(e)); + if (hasSubmit || inputs.length === 1 || (WEBKIT && !isEngineVersion(534))) { + this.submitForm(form); + } + } + } + + /** + * Maybe edit text when a key is pressed in an editable form. + */ + private maybeEditText_(key: Key): void { + if (key.character) { + this.updateOnCharacter_(key); + } else { + switch (key) { + case Keys.ENTER: + this.updateOnEnter_(); + break; + case Keys.BACKSPACE: + case Keys.DELETE: + this.updateOnBackspaceOrDelete_(key); + break; + case Keys.LEFT: + case Keys.RIGHT: + this.updateOnLeftOrRight_(key); + break; + case Keys.HOME: + case Keys.END: + this.updateOnHomeOrEnd_(key); + break; + } + } + } + + /** + * Releases the given key on the keyboard. + */ + releaseKey(key: Key): void { + if (!this.isPressed(key)) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Cannot release a key that is not pressed. (' + key.code + ')' + ); + } + if (key.code !== null) { + this.fireKeyEvent_(EventType.KEYUP, key); + } + + this.setKeyPressed_(key, false); + } + + /** + * Given the current state of the SHIFT and CAPS_LOCK key, returns the + * character that will be typed if the specified key is pressed. + */ + private getChar_(key: Key): string { + if (!key.character) { + throw new BotError(ErrorCode.UNKNOWN_ERROR, 'not a character key'); + } + const shiftPressed = this.isPressed(Keys.SHIFT); + return (shiftPressed ? key.shiftChar : key.character) as string; + } + + /** + * Updates text when a character key is pressed. + */ + private updateOnCharacter_(key: Key): void { + if (KEYPRESS_EDITS_TEXT_) { + return; + } + + const element = this.getElement() as HTMLInputElement | HTMLTextAreaElement; + const character = this.getChar_(key); + const newPos = getSelectionStart(element) + 1; + if (supportsSelection(element)) { + setSelectedText(element, character); + setSelectionStart(element, newPos); + } else { + element.value += character; + } + if (WEBKIT) { + this.fireHtmlEvent(EventType.TEXTINPUT); + } + if (!IE_DOC_PRE9) { + this.fireHtmlEvent(EventType.INPUT); + } + this.updateCurrentPos_(newPos); + } + + /** + * Updates text when ENTER is pressed. + */ + private updateOnEnter_(): void { + if (KEYPRESS_EDITS_TEXT_) { + return; + } + + if (WEBKIT) { + this.fireHtmlEvent(EventType.TEXTINPUT); + } + if (isElement(this.getElement(), 'TEXTAREA')) { + const element = this.getElement() as HTMLTextAreaElement; + const newPos = getSelectionStart(element) + NEW_LINE_.length; + if (supportsSelection(element)) { + setSelectedText(element, NEW_LINE_); + setSelectionStart(element, newPos); + } else { + element.value += NEW_LINE_; + } + if (!IE) { + this.fireHtmlEvent(EventType.INPUT); + } + this.updateCurrentPos_(newPos); + } + } + + /** + * Updates text when BACKSPACE or DELETE is pressed. + */ + private updateOnBackspaceOrDelete_(key: Key): void { + if (KEYPRESS_EDITS_TEXT_) { + return; + } + + const element = this.getElement() as HTMLInputElement | HTMLTextAreaElement; + checkCanUpdateSelection_(element); + let endpoints = getEndPoints(element); + if (endpoints[0] === endpoints[1]) { + if (key === Keys.BACKSPACE) { + setSelectionStart(element, endpoints[1] - 1); + setSelectionEnd(element, endpoints[1]); + } else { + setSelectionEnd(element, endpoints[1] + 1); + } + } + + endpoints = getEndPoints(element); + const textChanged = !( + endpoints[0] === element.value.length || endpoints[1] === 0 + ); + setSelectedText(element, ''); + + if ((!IE && textChanged) || (GECKO && key === Keys.BACKSPACE)) { + this.fireHtmlEvent(EventType.INPUT); + } + + endpoints = getEndPoints(element); + this.updateCurrentPos_(endpoints[1]); + } + + /** + * Updates cursor position when LEFT or RIGHT is pressed. + */ + private updateOnLeftOrRight_(key: Key): void { + const element = this.getElement() as HTMLInputElement | HTMLTextAreaElement; + checkCanUpdateSelection_(element); + const start = getSelectionStart(element); + const end = getSelectionEnd(element); + + let newPos: number; + let startPos = 0; + let endPos = 0; + + if (key === Keys.LEFT) { + if (this.isPressed(Keys.SHIFT)) { + if (this.currentPos_ === start) { + startPos = Math.max(start - 1, 0); + endPos = end; + newPos = startPos; + } else { + startPos = start; + endPos = end - 1; + newPos = endPos; + } + } else { + newPos = start === end ? Math.max(start - 1, 0) : start; + } + } else { + if (this.isPressed(Keys.SHIFT)) { + if (this.currentPos_ === end) { + startPos = start; + endPos = Math.min(end + 1, element.value.length); + newPos = endPos; + } else { + startPos = start + 1; + endPos = end; + newPos = startPos; + } + } else { + newPos = start === end ? Math.min(end + 1, element.value.length) : end; + } + } + + if (this.isPressed(Keys.SHIFT)) { + setSelectionStart(element, startPos); + setSelectionEnd(element, endPos); + } else { + setCursorPosition(element, newPos); + } + this.updateCurrentPos_(newPos); + } + + /** + * Updates cursor position when HOME or END is pressed. + */ + private updateOnHomeOrEnd_(key: Key): void { + const element = this.getElement() as HTMLInputElement | HTMLTextAreaElement; + checkCanUpdateSelection_(element); + const start = getSelectionStart(element); + const end = getSelectionEnd(element); + + if (key === Keys.HOME) { + if (this.isPressed(Keys.SHIFT)) { + setSelectionStart(element, 0); + const endPos = this.currentPos_ === start ? end : start; + setSelectionEnd(element, endPos); + } else { + setCursorPosition(element, 0); + } + this.updateCurrentPos_(0); + } else { + if (this.isPressed(Keys.SHIFT)) { + if (this.currentPos_ === start) { + setSelectionStart(element, end); + } + setSelectionEnd(element, element.value.length); + } else { + setCursorPosition(element, element.value.length); + } + this.updateCurrentPos_(element.value.length); + } + } + + /** + * Updates the current cursor position. + */ + private updateCurrentPos_(pos: number): void { + this.currentPos_ = pos; + } + + /** + * Fires a keyboard event. + */ + private fireKeyEvent_( + type: EventFactory, + key: Key, + opt_preventDefault?: boolean + ): boolean { + if (key.code === null) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Key must have a keycode to be fired.' + ); + } + + const args = { + altKey: this.isPressed(Keys.ALT), + ctrlKey: this.isPressed(Keys.CONTROL), + metaKey: this.isPressed(Keys.META), + shiftKey: this.isPressed(Keys.SHIFT), + keyCode: key.code, + charCode: + key.character && type === EventType.KEYPRESS + ? this.getChar_(key).charCodeAt(0) + : 0, + preventDefault: !!opt_preventDefault, + }; + + return this.fireKeyboardEvent(type, args); + } + + /** + * Sets focus to the element. If the element does not have focus, place cursor + * at the end of the text in the element. + */ + moveCursor(element: Element): void { + this.setElement(element); + this.editable_ = isEditable(element); + + const focusChanged = this.focusOnElement(); + if (this.editable_ && focusChanged) { + const inputElement = element as HTMLInputElement | HTMLTextAreaElement; + setCursorPosition(inputElement, inputElement.value.length); + this.updateCurrentPos_(inputElement.value.length); + } + } + + /** + * Serialize the current state of the keyboard. + */ + getState(): KeyboardState { + return { + pressed: Array.from(this.pressed_), + currentPos: this.currentPos_, + }; + } + + /** + * Returns the state of the modifier keys, to be shared with other input devices. + */ + getModifiersState(): ModifiersState { + return this.modifiersState; + } +} + +// ============================================================================ +// Static helpers +// ============================================================================ + +/** + * Checks that the cursor position can be updated for the given element. + */ +function checkCanUpdateSelection_( + element: HTMLInputElement | HTMLTextAreaElement +): void { + try { + if (typeof element.selectionStart === 'number') { + return; + } + } catch (ex) { + if ((ex as Error).message.indexOf('does not support selection.') !== -1) { + throw Error( + (ex as Error).message + + ' (For more information, see ' + + 'https://code.google.com/p/chromium/issues/detail?id=330456)' + ); + } + throw ex; + } + throw Error('Element does not support selection'); +} + +/** + * Returns whether the given element supports the input element selection API. + */ +export function supportsSelection( + element: HTMLInputElement | HTMLTextAreaElement +): boolean { + try { + checkCanUpdateSelection_(element); + } catch { + return false; + } + return true; +} diff --git a/javascript/atoms/locators/classname.js b/javascript/atoms/locators/classname.js deleted file mode 100644 index 6c96fafc4b7a4..0000000000000 --- a/javascript/atoms/locators/classname.js +++ /dev/null @@ -1,107 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -goog.provide('bot.locators.className'); - -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('goog.dom'); -goog.require('goog.string'); - - -/** - * Tests whether the standardized W3C Selectors API are available on an - * element. - * @param {!(Document|Element)} root The document or element to test for CSS - * selector support. - * @return {boolean} Whether or not the root supports query selector APIs. - * @see http://www.w3.org/TR/selectors-api/ - * @private - */ -bot.locators.className.canUseQuerySelector_ = function (root) { - return !!(root.querySelectorAll && root.querySelector); -}; - - -/** - * Find an element by its class name. - * @param {string} target The class name to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {Element} The first matching element found in the DOM, or null if no - * such element could be found. - */ -bot.locators.className.single = function (target, root) { - if (!target) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'No class name specified'); - } - - target = goog.string.trim(target); - if (target.indexOf(' ') !== -1) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'Compound class names not permitted'); - } - - // Closure will not properly escape class names that contain a '.' when using - // the native selectors API, so we have to handle this ourselves. - if (bot.locators.className.canUseQuerySelector_(root)) { - try { - return root.querySelector('.' + target.replace(/\./g, '\\.')) || null; - } catch (e) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'An invalid or illegal class name was specified'); - } - } - var elements = goog.dom.getDomHelper(root).getElementsByTagNameAndClass( - /*tagName=*/'*', /*className=*/target, root); - return elements.length ? elements[0] : null; -}; - - -/** - * Find an element by its class name. - * @param {string} target The class name to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {!IArrayLike} All matching elements, or an empty list. - */ -bot.locators.className.many = function (target, root) { - if (!target) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'No class name specified'); - } - - target = goog.string.trim(target); - if (target.indexOf(' ') !== -1) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'Compound class names not permitted'); - } - - // Closure will not properly escape class names that contain a '.' when using - // the native selectors API, so we have to handle this ourselves. - if (bot.locators.className.canUseQuerySelector_(root)) { - try { - return root.querySelectorAll('.' + target.replace(/\./g, '\\.')); - } catch (e) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'An invalid or illegal class name was specified'); - } - } - return goog.dom.getDomHelper(root).getElementsByTagNameAndClass( - /*tagName=*/'*', /*className=*/target, root); -}; diff --git a/javascript/atoms/locators/classname.ts b/javascript/atoms/locators/classname.ts new file mode 100644 index 0000000000000..e66ff108fbb0c --- /dev/null +++ b/javascript/atoms/locators/classname.ts @@ -0,0 +1,99 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Locator functions for finding elements by class name. + */ + +import { BotError, ErrorCode } from '../error'; + +/** + * Tests whether the standardized W3C Selectors API are available on an element. + */ +function canUseQuerySelector(root: Document | Element): boolean { + return ( + typeof root.querySelectorAll === 'function' && typeof root.querySelector === 'function' + ); +} + +/** + * Find an element by its class name. + * + * @param target The class name to search for. + * @param root The document or element to perform the search under. + * @return The first matching element found in the DOM, or null if no + * such element could be found. + */ +export function single(target: string, root: Document | Element): Element | null { + if (!target) { + throw new BotError(ErrorCode.INVALID_SELECTOR_ERROR, 'No class name specified'); + } + + target = target.trim(); + if (target.indexOf(' ') !== -1) { + throw new BotError(ErrorCode.INVALID_SELECTOR_ERROR, 'Compound class names not permitted'); + } + + // Closure will not properly escape class names that contain a '.' when using + // the native selectors API, so we have to handle this ourselves. + if (canUseQuerySelector(root)) { + try { + return root.querySelector('.' + target.replace(/\./g, '\\.')) || null; + } catch (e) { + throw new BotError( + ErrorCode.INVALID_SELECTOR_ERROR, + 'An invalid or illegal class name was specified' + ); + } + } + + const elements = root.getElementsByClassName(target); + return elements.length ? elements[0] : null; +} + +/** + * Find all elements by class name. + * + * @param target The class name to search for. + * @param root The document or element to perform the search under. + * @return All matching elements, or an empty list. + */ +export function many(target: string, root: Document | Element): Element[] | NodeListOf { + if (!target) { + throw new BotError(ErrorCode.INVALID_SELECTOR_ERROR, 'No class name specified'); + } + + target = target.trim(); + if (target.indexOf(' ') !== -1) { + throw new BotError(ErrorCode.INVALID_SELECTOR_ERROR, 'Compound class names not permitted'); + } + + // Closure will not properly escape class names that contain a '.' when using + // the native selectors API, so we have to handle this ourselves. + if (canUseQuerySelector(root)) { + try { + return root.querySelectorAll('.' + target.replace(/\./g, '\\.')); + } catch (e) { + throw new BotError( + ErrorCode.INVALID_SELECTOR_ERROR, + 'An invalid or illegal class name was specified' + ); + } + } + + return Array.from(root.getElementsByClassName(target)); +} diff --git a/javascript/atoms/locators/css.js b/javascript/atoms/locators/css.js deleted file mode 100644 index 36dc759743754..0000000000000 --- a/javascript/atoms/locators/css.js +++ /dev/null @@ -1,97 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -// TODO: Add support for using sizzle to locate elements - -goog.provide('bot.locators.css'); - -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.userAgent'); -goog.require('goog.dom.NodeType'); -goog.require('goog.string'); -goog.require('goog.userAgent'); -goog.require('goog.utils'); - - -/** - * Find an element by using a CSS selector - * - * @param {string} target The selector to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {Element} The first matching element found in the DOM, or null if no - * such element could be found. - */ -bot.locators.css.single = function (target, root) { - if (typeof root['querySelector'] !== 'function' && - // IE8 in non-compatibility mode reports querySelector as an object. - goog.userAgent.IE && bot.userAgent.isEngineVersion(8) && - !goog.utils.isObject(root['querySelector'])) { - throw Error('CSS selection is not supported'); - } - - if (!target) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'No selector specified'); - } - - target = goog.string.trim(target); - - var element; - try { - element = root.querySelector(target); - } catch (e) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'An invalid or illegal selector was specified'); - } - - return element && element.nodeType == goog.dom.NodeType.ELEMENT ? - /**@type {Element}*/ (element) : null; -}; - - -/** - * Find all elements matching a CSS selector. - * - * @param {string} target The selector to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {!IArrayLike} All matching elements, or an empty list. - */ -bot.locators.css.many = function (target, root) { - if (typeof root['querySelectorAll'] !== 'function' && - // IE8 in non-compatibility mode reports querySelector as an object. - goog.userAgent.IE && bot.userAgent.isEngineVersion(8) && - !goog.utils.isObject(root['querySelector'])) { - throw Error('CSS selection is not supported'); - } - - if (!target) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'No selector specified'); - } - - target = goog.string.trim(target); - - try { - return root.querySelectorAll(target); - } catch (e) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'An invalid or illegal selector was specified'); - } -}; diff --git a/javascript/atoms/locators/css.ts b/javascript/atoms/locators/css.ts new file mode 100644 index 0000000000000..a936f06d1a548 --- /dev/null +++ b/javascript/atoms/locators/css.ts @@ -0,0 +1,82 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview CSS selector locator functions. + */ + +import { BotError, ErrorCode } from '../error'; + +const NODE_TYPE_ELEMENT = 1; + +/** + * Find an element by using a CSS selector. + * + * @param target The selector to search for. + * @param root The document or element to perform the search under. + * @return The first matching element found in the DOM, or null if no + * such element could be found. + */ +export function single(target: string, root: Document | Element): Element | null { + if (typeof (root as unknown as Record)['querySelector'] !== 'function') { + throw Error('CSS selection is not supported'); + } + + if (!target) { + throw new BotError(ErrorCode.INVALID_SELECTOR_ERROR, + 'No selector specified'); + } + + target = target.trim(); + + let element: Element | null; + try { + element = root.querySelector(target); + } catch (e) { + throw new BotError(ErrorCode.INVALID_SELECTOR_ERROR, + 'An invalid or illegal selector was specified'); + } + + return element && element.nodeType === NODE_TYPE_ELEMENT ? element : null; +} + +/** + * Find all elements matching a CSS selector. + * + * @param target The selector to search for. + * @param root The document or element to perform the search under. + * @return All matching elements, or an empty list. + */ +export function many(target: string, root: Document | Element): NodeListOf { + if (typeof (root as unknown as Record)['querySelectorAll'] !== 'function') { + throw Error('CSS selection is not supported'); + } + + if (!target) { + throw new BotError(ErrorCode.INVALID_SELECTOR_ERROR, + 'No selector specified'); + } + + target = target.trim(); + + try { + return root.querySelectorAll(target); + } catch (e) { + throw new BotError(ErrorCode.INVALID_SELECTOR_ERROR, + 'An invalid or illegal selector was specified'); + } +} diff --git a/javascript/atoms/locators/id.js b/javascript/atoms/locators/id.js deleted file mode 100644 index 49d1a1cd1c076..0000000000000 --- a/javascript/atoms/locators/id.js +++ /dev/null @@ -1,116 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -goog.provide('bot.locators.id'); - -goog.require('bot.dom'); -goog.require('goog.array'); -goog.require('goog.dom'); - - -/** - * Tests whether the standardized W3C Selectors API are available on an - * element and the target locator meets CSS requirements. - * @param {!(Document|Element)} root The document or element to test for CSS - * selector support. - * @param {string} target The id to search for. - * @return {boolean} Whether or not the root supports query selector APIs. - * @see http://www.w3.org/TR/selectors-api/ - * @private - */ -bot.locators.id.canUseQuerySelector_ = function (root, target) { - return !!(root.querySelectorAll && root.querySelector) && !/^\d.*/.test(target); -}; - - -/** - * Find an element by using the value of the ID attribute. - * @param {string} target The id to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {Element} The first matching element found in the DOM, or null if no - * such element could be found. - */ -bot.locators.id.single = function (target, root) { - var dom = goog.dom.getDomHelper(root); - - var e = dom.getElement(target); - if (!e) { - return null; - } - - // On IE getting by ID returns the first match by id _or_ name. - if (bot.dom.getAttribute(e, 'id') == target && - root != e && goog.dom.contains(root, e)) { - return e; - } - - var elements = dom.getElementsByTagNameAndClass('*'); - var element = goog.array.find(elements, function (element) { - return bot.dom.getAttribute(element, 'id') == target && - root != element && goog.dom.contains(root, element); - }); - return /**@type{Element}*/ (element); -}; - - -/** - * Find many elements by using the value of the ID attribute. - * @param {string} target The id to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {!IArrayLike} All matching elements, or an empty list. - */ -bot.locators.id.many = function (target, root) { - if (!target) { - return []; - } - if (bot.locators.id.canUseQuerySelector_(root, target)) { - try { - // Need to escape the ID for use in a CSS selector. - return root.querySelectorAll('#' + bot.locators.id.cssEscape_(target)); - } catch (e) { - return []; - } - } - var dom = goog.dom.getDomHelper(root); - var elements = dom.getElementsByTagNameAndClass('*', null, root); - return goog.array.filter(elements, function (e) { - return bot.dom.getAttribute(e, 'id') == target; - }); -}; - -/** - * Given a string, escapes all the characters that have special meaning in CSS. - * https://mathiasbynens.be/notes/css-escapes - * - * An ID can contain anything but spaces, but we also escape whitespace because - * some webpages use spaces, and getElementById allows spaces in every browser. - * http://www.w3.org/TR/html5/dom.html#the-id-attribute - * - * This could be further improved, perhaps by using - * http://dev.w3.org/csswg/cssom/#the-css.escape()-method , where implemented, - * or a polyfill such as https://github.com/mathiasbynens/CSS.escape. - * - * @param {!string} s String to escape CSS meaningful characters in. - * @return {!string} Escaped string. - * @private - */ -bot.locators.id.cssEscape_ = function (s) { - // One backslash escapes things in a regex statement; we need two in a string. - return s.replace(/([\s'"\\#.:;,!?+<>=~*^$|%&@`{}\-\/\[\]\(\)])/g, '\\$1'); -}; diff --git a/javascript/atoms/locators/id.ts b/javascript/atoms/locators/id.ts new file mode 100644 index 0000000000000..0fb70f3de1fc5 --- /dev/null +++ b/javascript/atoms/locators/id.ts @@ -0,0 +1,101 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Locator functions for finding elements by ID. + */ + +import { getAttribute } from '../domcore'; + +/** + * Tests whether the standardized W3C Selectors API are available on an + * element and the target locator meets CSS requirements. + */ +function canUseQuerySelector(root: Document | Element, target: string): boolean { + return ( + typeof root.querySelectorAll === 'function' && + typeof root.querySelector === 'function' && + !/^\d.*/.test(target) + ); +} + +/** + * Given a string, escapes all the characters that have special meaning in CSS. + * https://mathiasbynens.be/notes/css-escapes + * + * An ID can contain anything but spaces, but we also escape whitespace because + * some webpages use spaces, and getElementById allows spaces in every browser. + * http://www.w3.org/TR/html5/dom.html#the-id-attribute + */ +function cssEscape(s: string): string { + return s.replace(/([\s'"\\#.:;,!?+<>=~*^$|%&@`{}\-\/\[\]\(\)])/g, '\\$1'); +} + +/** + * Find an element by using the value of the ID attribute. + * + * @param target The id to search for. + * @param root The document or element to perform the search under. + * @return The first matching element found in the DOM, or null if no + * such element could be found. + */ +export function single(target: string, root: Document | Element): Element | null { + const doc = root.ownerDocument || (root as Document); + const e = doc.getElementById(target); + + if (!e) { + return null; + } + + // On IE getting by ID returns the first match by id _or_ name. + if (getAttribute(e, 'id') === target && root !== e && root.contains(e)) { + return e; + } + + const elements = root.getElementsByTagName('*'); + const found = Array.from(elements).find((element) => { + return getAttribute(element, 'id') === target && root !== element && root.contains(element); + }); + + return found || null; +} + +/** + * Find many elements by using the value of the ID attribute. + * + * @param target The id to search for. + * @param root The document or element to perform the search under. + * @return All matching elements, or an empty list. + */ +export function many(target: string, root: Document | Element): Element[] { + if (!target) { + return []; + } + + if (canUseQuerySelector(root, target)) { + try { + return Array.from(root.querySelectorAll('#' + cssEscape(target))); + } catch (e) { + return []; + } + } + + const elements = root.getElementsByTagName('*'); + return Array.from(elements).filter((e) => { + return getAttribute(e, 'id') === target; + }); +} diff --git a/javascript/atoms/locators/link_text.js b/javascript/atoms/locators/link_text.js deleted file mode 100644 index 3ec169d3ad0bd..0000000000000 --- a/javascript/atoms/locators/link_text.js +++ /dev/null @@ -1,144 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -goog.provide('bot.locators.linkText'); -goog.provide('bot.locators.partialLinkText'); - -goog.require('bot'); -goog.require('bot.dom'); -goog.require('bot.locators.css'); -goog.require('goog.array'); -goog.require('goog.dom'); -goog.require('goog.dom.TagName'); - - -/** - * Find an element by using the text value of a link - * @param {string} target The link text to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @param {boolean=} opt_isPartial Whether the link text needs to be matched - * only partially. - * @return {Element} The first matching element found in the DOM, or null if no - * such element could be found. - * @private - */ -bot.locators.linkText.single_ = function (target, root, opt_isPartial) { - var elements; - try { - elements = bot.locators.css.many('a', root); - } catch (e) { - // Old versions of browsers don't support CSS. They won't have XHTML - // support. Sorry. - elements = goog.dom.getDomHelper(root).getElementsByTagNameAndClass( - goog.dom.TagName.A, /*className=*/null, root); - } - - var element = goog.array.find(elements, function (element) { - var text = bot.dom.getVisibleText(element); - // getVisibleText replaces non-breaking spaces with plain - // spaces, so if these are present at the beginning or end - // of the link text, we need to trim the regular spaces off - // to be spec compliant for matching on link text. - text = text.replace(/^[\s]+|[\s]+$/g, ''); - return (opt_isPartial && text.indexOf(target) != -1) || text == target; - }); - return /**@type{Element}*/ (element); -}; - - -/** - * Find many elements by using the value of the link text - * @param {string} target The link text to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @param {boolean=} opt_isPartial Whether the link text needs to be matched - * only partially. - * @return {!IArrayLike} All matching elements, or an empty list. - * @private - */ -bot.locators.linkText.many_ = function (target, root, opt_isPartial) { - var elements; - try { - elements = bot.locators.css.many('a', root); - } catch (e) { - // Old versions of browsers don't support CSS. They won't have XHTML - // support. Sorry. - elements = goog.dom.getDomHelper(root).getElementsByTagNameAndClass( - goog.dom.TagName.A, /*className=*/null, root); - } - - return goog.array.filter(elements, function (element) { - var text = bot.dom.getVisibleText(element); - // getVisibleText replaces non-breaking spaces with plain - // spaces, so if these are present at the beginning or end - // of the link text, we need to trim the regular spaces off - // to be spec compliant for matching on link text. - text = text.replace(/^[\s]+|[\s]+$/g, ''); - return (opt_isPartial && text.indexOf(target) != -1) || text == target; - }); -}; - - -/** - * Find an element by using the text value of a link - * @param {string} target The link text to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {Element} The first matching element found in the DOM, or null if no - * such element could be found. - */ -bot.locators.linkText.single = function (target, root) { - return bot.locators.linkText.single_(target, root, false); -}; - - -/** - * Find many elements by using the value of the link text - * @param {string} target The link text to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {IArrayLike} All matching elements, or an empty list. - */ -bot.locators.linkText.many = function (target, root) { - return bot.locators.linkText.many_(target, root, false); -}; - - -/** - * Find an element by using part of the text value of a link. - * @param {string} target The link text to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {Element} The first matching element found in the DOM, or null if no - * such element could be found. - */ -bot.locators.partialLinkText.single = function (target, root) { - return bot.locators.linkText.single_(target, root, true); -}; - - -/** - * Find many elements by using part of the value of the link text. - * @param {string} target The link text to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {IArrayLike} All matching elements, or an empty list. - */ -bot.locators.partialLinkText.many = function (target, root) { - return bot.locators.linkText.many_(target, root, true); -}; diff --git a/javascript/atoms/locators/link_text.ts b/javascript/atoms/locators/link_text.ts new file mode 100644 index 0000000000000..4cd5fc7103d8d --- /dev/null +++ b/javascript/atoms/locators/link_text.ts @@ -0,0 +1,137 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Locator functions for finding elements by link text. + */ + +import { getVisibleText } from '../dom'; +import { many as cssMany } from './css'; + +/** + * Find an element by using the text value of a link. + * + * @param target The link text to search for. + * @param root The document or element to perform the search under. + * @param isPartial Whether the link text needs to be matched only partially. + * @return The first matching element found in the DOM, or null if no + * such element could be found. + */ +function singleImpl( + target: string, + root: Document | Element, + isPartial: boolean +): Element | null { + let elements: ArrayLike; + try { + elements = cssMany('a', root); + } catch (e) { + // Old versions of browsers don't support CSS. They won't have XHTML + // support. Sorry. + elements = root.getElementsByTagName('a'); + } + + const found = Array.from(elements).find((element) => { + let text = getVisibleText(element); + // getVisibleText replaces non-breaking spaces with plain + // spaces, so if these are present at the beginning or end + // of the link text, we need to trim the regular spaces off + // to be spec compliant for matching on link text. + text = text.replace(/^[\s]+|[\s]+$/g, ''); + return (isPartial && text.indexOf(target) !== -1) || text === target; + }); + + return found || null; +} + +/** + * Find many elements by using the value of the link text. + * + * @param target The link text to search for. + * @param root The document or element to perform the search under. + * @param isPartial Whether the link text needs to be matched only partially. + * @return All matching elements, or an empty list. + */ +function manyImpl(target: string, root: Document | Element, isPartial: boolean): Element[] { + let elements: ArrayLike; + try { + elements = cssMany('a', root); + } catch (e) { + // Old versions of browsers don't support CSS. They won't have XHTML + // support. Sorry. + elements = root.getElementsByTagName('a'); + } + + return Array.from(elements).filter((element) => { + let text = getVisibleText(element); + // getVisibleText replaces non-breaking spaces with plain + // spaces, so if these are present at the beginning or end + // of the link text, we need to trim the regular spaces off + // to be spec compliant for matching on link text. + text = text.replace(/^[\s]+|[\s]+$/g, ''); + return (isPartial && text.indexOf(target) !== -1) || text === target; + }); +} + +/** + * Find an element by using the text value of a link. + * + * @param target The link text to search for. + * @param root The document or element to perform the search under. + * @return The first matching element found in the DOM, or null if no + * such element could be found. + */ +export function single(target: string, root: Document | Element): Element | null { + return singleImpl(target, root, false); +} + +/** + * Find many elements by using the value of the link text. + * + * @param target The link text to search for. + * @param root The document or element to perform the search under. + * @return All matching elements, or an empty list. + */ +export function many(target: string, root: Document | Element): Element[] { + return manyImpl(target, root, false); +} + +// Partial link text locator functions +export const partialLinkText = { + /** + * Find an element by using part of the text value of a link. + * + * @param target The link text to search for. + * @param root The document or element to perform the search under. + * @return The first matching element found in the DOM, or null if no + * such element could be found. + */ + single(target: string, root: Document | Element): Element | null { + return singleImpl(target, root, true); + }, + + /** + * Find many elements by using part of the value of the link text. + * + * @param target The link text to search for. + * @param root The document or element to perform the search under. + * @return All matching elements, or an empty list. + */ + many(target: string, root: Document | Element): Element[] { + return manyImpl(target, root, true); + }, +}; diff --git a/javascript/atoms/locators/locators.js b/javascript/atoms/locators/locators.js deleted file mode 100644 index dddcc8d932613..0000000000000 --- a/javascript/atoms/locators/locators.js +++ /dev/null @@ -1,164 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Element locator functions. - */ - - -goog.provide('bot.locators'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.locators.className'); -goog.require('bot.locators.css'); -goog.require('bot.locators.id'); -goog.require('bot.locators.linkText'); -goog.require('bot.locators.name'); -goog.require('bot.locators.partialLinkText'); -goog.require('bot.locators.relative'); -goog.require('bot.locators.tagName'); -goog.require('bot.locators.xpath'); - - -/** - * @typedef {{single:function(string,!(Document|Element)):Element, - * many:function(string,!(Document|Element)):!IArrayLike}} - */ -bot.locators.strategy; - - -/** - * Known element location strategies. The returned objects have two - * methods on them, "single" and "many", for locating a single element - * or multiple elements, respectively. - * - * Note that the versions with spaces are synonyms for those without spaces, - * and are specified at: - * https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol - * @private {Object.} - * @const - */ -bot.locators.STRATEGIES_ = { - 'className': bot.locators.className, - 'class name': bot.locators.className, - - 'css': bot.locators.css, - 'css selector': bot.locators.css, - - 'relative': bot.locators.relative, - - 'id': bot.locators.id, - - 'linkText': bot.locators.linkText, - 'link text': bot.locators.linkText, - - 'name': bot.locators.name, - - 'partialLinkText': bot.locators.partialLinkText, - 'partial link text': bot.locators.partialLinkText, - - 'tagName': bot.locators.tagName, - 'tag name': bot.locators.tagName, - - 'xpath': bot.locators.xpath -}; - - -/** - * Add or override an existing strategy for locating elements. - * - * @param {string} name The name of the strategy. - * @param {!bot.locators.strategy} strategy The strategy to use. - */ -bot.locators.add = function (name, strategy) { - bot.locators.STRATEGIES_[name] = strategy; -}; - - -/** - * Returns one key from the object map that is not present in the - * Object.prototype, if any exists. - * - * @param {Object} target The object to pick a key from. - * @return {?string} The key or null if the object is empty. - */ -bot.locators.getOnlyKey = function (target) { - for (var k in target) { - if (target.hasOwnProperty(k)) { - return k; - } - } - return null; -}; - - -/** - * Find the first element in the DOM matching the target. The target - * object should have a single key, the name of which determines the - * locator strategy and the value of which gives the value to be - * searched for. For example {id: 'foo'} indicates that the first - * element on the DOM with the ID 'foo' should be returned. - * - * @param {!Object} target The selector to search for. - * @param {(Document|Element)=} opt_root The node from which to start the - * search. If not specified, will use `document` as the root. - * @return {Element} The first matching element found in the DOM, or null if no - * such element could be found. - */ -bot.locators.findElement = function (target, opt_root) { - var key = bot.locators.getOnlyKey(target); - - if (key) { - var strategy = bot.locators.STRATEGIES_[key]; - if (strategy && typeof strategy.single === 'function') { - var root = opt_root || bot.getDocument(); - return strategy.single(target[key], root); - } - } - throw new bot.Error(bot.ErrorCode.INVALID_ARGUMENT, - 'Unsupported locator strategy: ' + key); -}; - - -/** - * Find all elements in the DOM matching the target. The target object - * should have a single key, the name of which determines the locator - * strategy and the value of which gives the value to be searched - * for. For example {name: 'foo'} indicates that all elements with the - * 'name' attribute equal to 'foo' should be returned. - * - * @param {!Object} target The selector to search for. - * @param {(Document|Element)=} opt_root The node from which to start the - * search. If not specified, will use `document` as the root. - * @return {!IArrayLike.} All matching elements found in the - * DOM. - */ -bot.locators.findElements = function (target, opt_root) { - var key = bot.locators.getOnlyKey(target); - - if (key) { - var strategy = bot.locators.STRATEGIES_[key]; - if (strategy && typeof strategy.many === 'function') { - var root = opt_root || bot.getDocument(); - return strategy.many(target[key], root); - } - } - throw new bot.Error(bot.ErrorCode.INVALID_ARGUMENT, - 'Unsupported locator strategy: ' + key); -}; diff --git a/javascript/atoms/locators/locators.ts b/javascript/atoms/locators/locators.ts new file mode 100644 index 0000000000000..a4bc65203832b --- /dev/null +++ b/javascript/atoms/locators/locators.ts @@ -0,0 +1,166 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Element locator functions. + */ + +import { BotError, ErrorCode } from '../error'; +import * as className from './classname'; +import * as css from './css'; +import * as id from './id'; +import * as linkText from './link_text'; +import { partialLinkText } from './link_text'; +import * as name from './name'; +import * as relative from './relative'; +import * as tagName from './tag_name'; +import * as xpath from './xpath'; + +/** + * Locator strategy interface. + */ +export interface Strategy { + single: (target: unknown, root: Document | Element) => Element | null; + many: (target: unknown, root: Document | Element) => ArrayLike; +} + +/** + * Known element location strategies. The returned objects have two + * methods on them, "single" and "many", for locating a single element + * or multiple elements, respectively. + */ +const STRATEGIES: Record = { + className: className as Strategy, + 'class name': className as Strategy, + + css: css as Strategy, + 'css selector': css as Strategy, + + relative: relative as unknown as Strategy, + + id: id as Strategy, + + linkText: linkText as Strategy, + 'link text': linkText as Strategy, + + name: name as Strategy, + + partialLinkText: partialLinkText as Strategy, + 'partial link text': partialLinkText as Strategy, + + tagName: tagName as Strategy, + 'tag name': tagName as Strategy, + + xpath: xpath as Strategy, +}; + +/** + * Add or override an existing strategy for locating elements. + * + * @param strategyName The name of the strategy. + * @param strategy The strategy to use. + */ +export function add(strategyName: string, strategy: Strategy): void { + STRATEGIES[strategyName] = strategy; +} + +/** + * Returns one key from the object map that is not present in the + * Object.prototype, if any exists. + * + * @param target The object to pick a key from. + * @return The key or null if the object is empty. + */ +export function getOnlyKey(target: Record): string | null { + for (const k in target) { + if (Object.prototype.hasOwnProperty.call(target, k)) { + return k; + } + } + return null; +} + +/** + * Gets the current document. + */ +function getDocument(): Document { + return document; +} + +/** + * Find the first element in the DOM matching the target. The target + * object should have a single key, the name of which determines the + * locator strategy and the value of which gives the value to be + * searched for. For example {id: 'foo'} indicates that the first + * element on the DOM with the ID 'foo' should be returned. + * + * @param target The selector to search for. + * @param optRoot The node from which to start the search. If not specified, + * will use `document` as the root. + * @return The first matching element found in the DOM, or null if no + * such element could be found. + */ +export function findElement( + target: Record, + optRoot?: Document | Element +): Element | null { + const key = getOnlyKey(target); + + if (key) { + const strategy = STRATEGIES[key]; + if (strategy && typeof strategy.single === 'function') { + const root = optRoot || getDocument(); + return strategy.single(target[key] as string | object, root); + } + } + throw new BotError(ErrorCode.INVALID_ARGUMENT, 'Unsupported locator strategy: ' + key); +} + +/** + * Find all elements in the DOM matching the target. The target object + * should have a single key, the name of which determines the locator + * strategy and the value of which gives the value to be searched + * for. For example {name: 'foo'} indicates that all elements with the + * 'name' attribute equal to 'foo' should be returned. + * + * @param target The selector to search for. + * @param optRoot The node from which to start the search. If not specified, + * will use `document` as the root. + * @return All matching elements found in the DOM. + */ +export function findElements( + target: Record, + optRoot?: Document | Element +): ArrayLike { + const key = getOnlyKey(target); + + if (key) { + const strategy = STRATEGIES[key]; + if (strategy && typeof strategy.many === 'function') { + const root = optRoot || getDocument(); + return strategy.many(target[key] as string | object, root); + } + } + throw new BotError(ErrorCode.INVALID_ARGUMENT, 'Unsupported locator strategy: ' + key); +} + +// Wire up the relative locator with find functions to avoid circular dependency +relative.setFindElement(findElement); +relative.setFindElements(findElements); + +// Re-export individual locator modules for direct access +export { className, css, id, linkText, name, partialLinkText, relative, tagName, xpath }; diff --git a/javascript/atoms/locators/name.js b/javascript/atoms/locators/name.js deleted file mode 100644 index d31f4303438dc..0000000000000 --- a/javascript/atoms/locators/name.js +++ /dev/null @@ -1,58 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -goog.provide('bot.locators.name'); - -goog.require('bot.dom'); -goog.require('goog.array'); -goog.require('goog.dom'); - - -/** - * Find an element by the value of the name attribute - * - * @param {string} target The name to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {Element} The first matching element found in the DOM, or null if no - * such element could be found. - */ -bot.locators.name.single = function (target, root) { - var dom = goog.dom.getDomHelper(root); - var allElements = dom.getElementsByTagNameAndClass('*', null, root); - var element = goog.array.find(allElements, function (element) { - return bot.dom.getAttribute(element, 'name') == target; - }); - return /**@type{Element}*/ (element); -}; - - -/** - * Find all elements by the value of the name attribute - * - * @param {string} target The name to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {!IArrayLike} All matching elements, or an empty list. - */ -bot.locators.name.many = function (target, root) { - var dom = goog.dom.getDomHelper(root); - var allElements = dom.getElementsByTagNameAndClass('*', null, root); - return goog.array.filter(allElements, function (element) { - return bot.dom.getAttribute(element, 'name') == target; - }); -}; diff --git a/javascript/atoms/locators/name.ts b/javascript/atoms/locators/name.ts new file mode 100644 index 0000000000000..8ee2d1fd76957 --- /dev/null +++ b/javascript/atoms/locators/name.ts @@ -0,0 +1,52 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Locator functions for finding elements by name attribute. + */ + +import { getAttribute } from '../domcore'; + +/** + * Find an element by the value of the name attribute. + * + * @param target The name to search for. + * @param root The document or element to perform the search under. + * @return The first matching element found in the DOM, or null if no + * such element could be found. + */ +export function single(target: string, root: Document | Element): Element | null { + const allElements = root.getElementsByTagName('*'); + const found = Array.from(allElements).find((element) => { + return getAttribute(element, 'name') === target; + }); + return found || null; +} + +/** + * Find all elements by the value of the name attribute. + * + * @param target The name to search for. + * @param root The document or element to perform the search under. + * @return All matching elements, or an empty list. + */ +export function many(target: string, root: Document | Element): Element[] { + const allElements = root.getElementsByTagName('*'); + return Array.from(allElements).filter((element) => { + return getAttribute(element, 'name') === target; + }); +} diff --git a/javascript/atoms/locators/relative.js b/javascript/atoms/locators/relative.js deleted file mode 100644 index 5110821d5c041..0000000000000 --- a/javascript/atoms/locators/relative.js +++ /dev/null @@ -1,459 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -goog.provide('bot.locators.relative'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.dom'); -goog.require('bot.locators'); -goog.require('goog.array'); -goog.require('goog.dom'); -goog.require('goog.math.Rect'); -goog.require('goog.utils'); - - -/** - * @typedef {function(!Element):!boolean} - */ -var Filter; - -/** - * @param {!Element|function():!Element|!Object} selector Mechanism to be used - * to find the element. - * @param {!function(!goog.math.Rect, !goog.math.Rect):boolean} proximity - * @return {!Filter} A function that determines whether the - * selector matches the proximity function. - * @private - */ -bot.locators.relative.proximity_ = function (selector, proximity) { - /** - * Assigning to a temporary variable to keep the closure compiler happy. - * @todo Inline this. - * - * @type {!function(!Element):boolean} - */ - var toReturn = function (compareTo) { - var element = bot.locators.relative.resolve_(selector); - - var rect1 = bot.dom.getClientRect(element); - var rect2 = bot.dom.getClientRect(compareTo); - - return proximity.call(null, rect1, rect2); - }; - - return toReturn; -}; - - -/** - * Relative locator to find elements that are above the expected one. "Above" - * is defined as where the bottom of the element found by `selector` is above - * the top of an element we're comparing to. - * - * @param {!Element|function():!Element|!Object} selector Mechanism to be used to find the element. - * @return {!Filter} A function that determines whether the selector is above the given element. - * @private - */ -bot.locators.relative.above_ = function (selector) { - return bot.locators.relative.proximity_( - selector, - function (expected, toFind) { - return toFind.top + toFind.height <= expected.top; - }); -}; - - -/** - * Relative locator to find elements that are below the expected one. "Below" - * is defined as where the top of the element found by `selector` is below the - * bottom of an element we're comparing to. - * - * @param {!Element|function():!Element|!Object} selector Mechanism to be used to find the element. - * @return {!Filter} A function that determines whether the selector is below the given element. - * @private - */ -bot.locators.relative.below_ = function (selector) { - return bot.locators.relative.proximity_( - selector, - function (expected, toFind) { - return toFind.top >= expected.top + expected.height; - }); -}; - - -/** - * Relative locator to find elements that are to the left of the expected one. - * - * @param {!Element|function():!Element|!Object} selector Mechanism to be used to find the element. - * @return {!Filter} A function that determines whether the selector is left of the given element. - * @private - */ -bot.locators.relative.leftOf_ = function (selector) { - return bot.locators.relative.proximity_( - selector, - function (expected, toFind) { - return toFind.left + toFind.width <= expected.left; - }); -}; - - -/** -* Relative locator to find elements that are to the left of the expected one. -* -* @param {!Element|function():!Element|!Object} selector Mechanism to be used to find the element. -* @return {!Filter} A function that determines whether the selector is right of the given element. -* @private -*/ -bot.locators.relative.rightOf_ = function (selector) { - return bot.locators.relative.proximity_( - selector, - function (expected, toFind) { - return toFind.left >= expected.left + expected.width; - }); -}; - - -/** - * Relative locator to find elements that are above the expected one. "Above" - * is defined as where the bottom of the element found by `selector` is above - * the top of an element we're comparing to. - * - * @param {!Element|function():!Element|!Object} selector Mechanism to be used to find the element. - * @return {!Filter} A function that determines whether the selector is above the given element. - * @private - */ -bot.locators.relative.straightAbove_ = function (selector) { - return bot.locators.relative.proximity_( - selector, - function (expected, toFind) { - return toFind.left < expected.left + expected.width - && toFind.left + toFind.width > expected.left - && toFind.top + toFind.height <= expected.top; - }); -}; - - -/** - * Relative locator to find elements that are below the expected one. "Below" - * is defined as where the top of the element found by `selector` is below the - * bottom of an element we're comparing to. - * - * @param {!Element|function():!Element|!Object} selector Mechanism to be used to find the element. - * @return {!Filter} A function that determines whether the selector is below the given element. - * @private - */ -bot.locators.relative.straightBelow_ = function (selector) { - return bot.locators.relative.proximity_( - selector, - function (expected, toFind) { - return toFind.left < expected.left + expected.width - && toFind.left + toFind.width > expected.left - && toFind.top >= expected.top + expected.height; - }); -}; - - -/** - * Relative locator to find elements that are to the left of the expected one. - * - * @param {!Element|function():!Element|!Object} selector Mechanism to be used to find the element. - * @return {!Filter} A function that determines whether the selector is left of the given element. - * @private - */ -bot.locators.relative.straightLeftOf_ = function (selector) { - return bot.locators.relative.proximity_( - selector, - function (expected, toFind) { - return toFind.top < expected.top + expected.height - && toFind.top + toFind.height > expected.top - && toFind.left + toFind.width <= expected.left; - }); -}; - - -/** - * Relative locator to find elements that are to the left of the expected one. - * - * @param {!Element|function():!Element|!Object} selector Mechanism to be used to find the element. - * @return {!Filter} A function that determines whether the selector is right of the given element. - * @private - */ -bot.locators.relative.straightRightOf_ = function (selector) { - return bot.locators.relative.proximity_( - selector, - function (expected, toFind) { - return toFind.top < expected.top + expected.height - && toFind.top + toFind.height > expected.top - && toFind.left >= expected.left + expected.width; - }); -}; - - -/** - * Find elements within (by default) 50 pixels of the selected element. An - * element is not near itself. - * - * @param {!Element|function():!Element|!Object} selector Mechanism to be used to find the element. - * @param {number=} opt_distance Optional distance in pixels to count as "near" (defaults to 50 pixels). - * @return {!Filter} A function that determines whether the selector is near the given element. - * @private - */ -bot.locators.relative.near_ = function (selector, opt_distance) { - var distance; - if (opt_distance) { - distance = opt_distance; - } else if (typeof selector['distance'] === 'number') { - distance = /** @type {number} */ (selector['distance']); - // delete selector['distance']; - } - - if (!distance) { - distance = 50; - } - - /** - * @param {!Element} compareTo - * @return {boolean} - */ - var func = function (compareTo) { - var element = bot.locators.relative.resolve_(selector); - - if (element === compareTo) { - return false; - } - - var rect1 = bot.dom.getClientRect(element); - var rect2 = bot.dom.getClientRect(compareTo); - - var rect1_bigger = new goog.math.Rect( - rect1.left-distance, - rect1.top-distance, - rect1.width+distance*2, - rect1.height+distance*2 - ); - - return rect1_bigger.intersects(rect2); - }; - - return func; -}; - - -/** - * @param {!Element|function():!Element|!Object} selector Mechanism to be used to find the element. - * @returns {!Element} A single element. - * @private - */ -bot.locators.relative.resolve_ = function (selector) { - if (goog.dom.isElement(selector)) { - return /** @type {!Element} */ (selector); - } - - if (typeof selector === 'function') { - var func = /** @type {function():!Element} */ (selector); - return bot.locators.relative.resolve_(func.call(null)); - } - - if (goog.utils.isObject(selector)) { - var element = bot.locators.findElement(selector); - if (!element) { - throw new bot.Error( - bot.ErrorCode.NO_SUCH_ELEMENT, - "No element has been found by " + JSON.stringify(selector)); - } - return element; - } - - throw new bot.Error( - bot.ErrorCode.INVALID_ARGUMENT, - "Selector is of wrong type: " + JSON.stringify(selector)); -}; - - -/** - * @type {!Object} - * @private - * @const - */ -bot.locators.relative.STRATEGIES_ = { - 'above': bot.locators.relative.above_, - 'below': bot.locators.relative.below_, - 'left': bot.locators.relative.leftOf_, - 'near': bot.locators.relative.near_, - 'right': bot.locators.relative.rightOf_, - 'straightAbove': bot.locators.relative.straightAbove_, - 'straightBelow': bot.locators.relative.straightBelow_, - 'straightLeft': bot.locators.relative.straightLeftOf_, - 'straightRight': bot.locators.relative.straightRightOf_, -}; - -bot.locators.relative.RESOLVERS_ = { - 'above': bot.locators.relative.resolve_, - 'below': bot.locators.relative.resolve_, - 'left': bot.locators.relative.resolve_, - 'near': bot.locators.relative.resolve_, - 'right': bot.locators.relative.resolve_, - 'straightAbove': bot.locators.relative.resolve_, - 'straightBelow': bot.locators.relative.resolve_, - 'straightLeft': bot.locators.relative.resolve_, - 'straightRight': bot.locators.relative.resolve_, -}; - -/** - * @param {!IArrayLike} allElements - * @param {!IArrayLike}filters - * @return {!Array} - * @private - */ -bot.locators.relative.filterElements_ = function (allElements, filters) { - var toReturn = []; - goog.array.forEach( - allElements, - function (element) { - if (!!!element) { - return; - } - - var include = goog.array.every( - filters, - function (filter) { - // Look up the filter function by name - var name = filter["kind"]; - var strategy = bot.locators.relative.STRATEGIES_[name]; - - if (!!!strategy) { - throw new bot.Error( - bot.ErrorCode.INVALID_ARGUMENT, - "Cannot find filter suitable for " + name); - } - - // Call it with args. - var filterFunc = strategy.apply(null, filter["args"]); - return filterFunc(/** @type {!Element} */(element)); - }, - null); - - if (include) { - toReturn.push(element); - } - }, - null); - - // We want to sort the returned elements by proximity to the last "anchor" - // element in the filters. - var finalFilter = goog.array.last(filters); - var name = finalFilter ? finalFilter["kind"] : "unknown"; - var resolver = bot.locators.relative.RESOLVERS_[name]; - if (!!!resolver) { - return toReturn; - } - var lastAnchor = resolver.apply(null, finalFilter["args"]); - if (!!!lastAnchor) { - return toReturn; - } - - return bot.locators.relative.sortByProximity_(lastAnchor, toReturn); -}; - - -/** - * @param {!Element} anchor - * @param {!Array} elements - * @return {!Array} - * @private - */ -bot.locators.relative.sortByProximity_ = function (anchor, elements) { - var anchorRect = bot.dom.getClientRect(anchor); - var anchorCenter = { - x: anchorRect.left + (Math.max(1, anchorRect.width) / 2), - y: anchorRect.top + (Math.max(1, anchorRect.height) / 2) - }; - - var distance = function (e) { - var rect = bot.dom.getClientRect(e); - var center = { - x: rect.left + (Math.max(1, rect.width) / 2), - y: rect.top + (Math.max(1, rect.height) / 2) - }; - - var x = Math.pow(anchorCenter.x - center.x, 2); - var y = Math.pow(anchorCenter.y - center.y, 2); - - return Math.sqrt(x + y); - }; - - goog.array.sort(elements, function (left, right) { - return distance(left) - distance(right); - }); - - return elements; -}; - - -/** - * Find an element by using a relative locator. - * - * @param {!Object} target The search criteria. - * @param {!(Document|Element)} ignored_root The document or element to perform - * the search under, which is ignored. - * @return {Element} The first matching element, or null if no such element - * could be found. - */ -bot.locators.relative.single = function (target, ignored_root) { - var matches = bot.locators.relative.many(target, ignored_root); - if (goog.array.isEmpty(matches)) { - return null; - } - return matches[0]; -}; - - -/** - * Find many elements by using the value of the ID attribute. - * @param {!Object} target The search criteria. - * @param {!(Document|Element)} root The document or element to perform - * the search under, which is ignored. - * @return {!IArrayLike} All matching elements, or an empty list. - */ -bot.locators.relative.many = function (target, root) { - if (!target.hasOwnProperty("root") || !target.hasOwnProperty("filters")) { - throw new bot.Error( - bot.ErrorCode.INVALID_ARGUMENT, - "Locator not suitable for relative locators: " + JSON.stringify(target)); - } - if (!goog.utils.isArrayLike(target["filters"])) { - throw new bot.Error( - bot.ErrorCode.INVALID_ARGUMENT, - "Targets should be an array: " + JSON.stringify(target)); - } - - var elements; - if (bot.dom.isElement(target["root"])) { - elements = [ /** @type {!Element} */ (target["root"])]; - } else { - elements = bot.locators.findElements(target["root"], root); - } - - if (goog.array.isEmpty(elements)) { - return []; - } - - var filters = target["filters"]; - return bot.locators.relative.filterElements_(elements, filters); -}; diff --git a/javascript/atoms/locators/relative.ts b/javascript/atoms/locators/relative.ts new file mode 100644 index 0000000000000..5ab1068a70927 --- /dev/null +++ b/javascript/atoms/locators/relative.ts @@ -0,0 +1,435 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Relative locator functions for finding elements by spatial relationships. + */ + +import { BotError, ErrorCode } from '../error'; +import { getClientRect } from '../dom'; + +interface Rect { + left: number; + top: number; + width: number; + height: number; +} + +// Forward declaration - will be set by locators.ts to avoid circular dependency +let findElementFn: + | ((target: Record, root?: Document | Element) => Element | null) + | null = null; +let findElementsFn: + | ((target: Record, root?: Document | Element) => ArrayLike) + | null = null; + +/** + * Sets the findElement function reference. Called by locators.ts to avoid circular imports. + */ +export function setFindElement( + fn: (target: Record, root?: Document | Element) => Element | null +): void { + findElementFn = fn; +} + +/** + * Sets the findElements function reference. Called by locators.ts to avoid circular imports. + */ +export function setFindElements( + fn: (target: Record, root?: Document | Element) => ArrayLike +): void { + findElementsFn = fn; +} + +type Filter = (element: Element) => boolean; + +type ProximityFn = (expected: Rect, toFind: Rect) => boolean; + +type SelectorLike = Element | (() => Element) | Record; + +interface FilterDescriptor { + kind: string; + args: unknown[]; +} + +interface RelativeTarget { + root: Element | Record; + filters: FilterDescriptor[]; +} + +/** + * Helper to check if a value is an object (but not null). + */ +function isObject(value: unknown): value is object { + return typeof value === 'object' && value !== null; +} + +/** + * Helper to check if something looks array-like. + */ +function isArrayLike(value: unknown): value is ArrayLike { + return ( + Array.isArray(value) || + (isObject(value) && typeof (value as ArrayLike).length === 'number') + ); +} + +/** + * Creates a proximity filter function. + */ +function proximity(selector: SelectorLike, proximityFn: ProximityFn): Filter { + return function (compareTo: Element): boolean { + const element = resolve(selector); + const rect1 = getClientRect(element); + const rect2 = getClientRect(compareTo); + return proximityFn(rect1, rect2); + }; +} + +/** + * Relative locator to find elements that are above the expected one. + */ +function above(selector: SelectorLike): Filter { + return proximity(selector, function (expected, toFind) { + return toFind.top + toFind.height <= expected.top; + }); +} + +/** + * Relative locator to find elements that are below the expected one. + */ +function below(selector: SelectorLike): Filter { + return proximity(selector, function (expected, toFind) { + return toFind.top >= expected.top + expected.height; + }); +} + +/** + * Relative locator to find elements that are to the left of the expected one. + */ +function leftOf(selector: SelectorLike): Filter { + return proximity(selector, function (expected, toFind) { + return toFind.left + toFind.width <= expected.left; + }); +} + +/** + * Relative locator to find elements that are to the right of the expected one. + */ +function rightOf(selector: SelectorLike): Filter { + return proximity(selector, function (expected, toFind) { + return toFind.left >= expected.left + expected.width; + }); +} + +/** + * Relative locator to find elements that are directly above the expected one. + */ +function straightAbove(selector: SelectorLike): Filter { + return proximity(selector, function (expected, toFind) { + return ( + toFind.left < expected.left + expected.width && + toFind.left + toFind.width > expected.left && + toFind.top + toFind.height <= expected.top + ); + }); +} + +/** + * Relative locator to find elements that are directly below the expected one. + */ +function straightBelow(selector: SelectorLike): Filter { + return proximity(selector, function (expected, toFind) { + return ( + toFind.left < expected.left + expected.width && + toFind.left + toFind.width > expected.left && + toFind.top >= expected.top + expected.height + ); + }); +} + +/** + * Relative locator to find elements that are directly to the left of the expected one. + */ +function straightLeftOf(selector: SelectorLike): Filter { + return proximity(selector, function (expected, toFind) { + return ( + toFind.top < expected.top + expected.height && + toFind.top + toFind.height > expected.top && + toFind.left + toFind.width <= expected.left + ); + }); +} + +/** + * Relative locator to find elements that are directly to the right of the expected one. + */ +function straightRightOf(selector: SelectorLike): Filter { + return proximity(selector, function (expected, toFind) { + return ( + toFind.top < expected.top + expected.height && + toFind.top + toFind.height > expected.top && + toFind.left >= expected.left + expected.width + ); + }); +} + +/** + * Find elements within (by default) 50 pixels of the selected element. + * An element is not near itself. + */ +function near(selector: SelectorLike, optDistance?: number): Filter { + let distance: number; + if (optDistance) { + distance = optDistance; + } else if ( + typeof selector === 'object' && + typeof (selector as Record)['distance'] === 'number' + ) { + distance = (selector as Record)['distance'] as number; + } else { + distance = 50; + } + + return function (compareTo: Element): boolean { + const element = resolve(selector); + + if (element === compareTo) { + return false; + } + + const rect1 = getClientRect(element); + const rect2 = getClientRect(compareTo); + + // Create an expanded rectangle around rect1 + const rect1Bigger: Rect = { + left: rect1.left - distance, + top: rect1.top - distance, + width: rect1.width + distance * 2, + height: rect1.height + distance * 2, + }; + + // Check if rectangles intersect + return !( + rect1Bigger.left > rect2.left + rect2.width || + rect1Bigger.left + rect1Bigger.width < rect2.left || + rect1Bigger.top > rect2.top + rect2.height || + rect1Bigger.top + rect1Bigger.height < rect2.top + ); + }; +} + +/** + * Checks if a value is a DOM Element. + */ +function isDomElement(value: unknown): value is Element { + return ( + value instanceof Element || + (typeof value === 'object' && + value !== null && + (value as Node).nodeType === 1) + ); +} + +/** + * Resolves a selector to an element. + */ +function resolve(selector: SelectorLike): Element { + if (isDomElement(selector)) { + return selector; + } + + if (typeof selector === 'function') { + return resolve(selector()); + } + + if (isObject(selector) && findElementFn) { + const element = findElementFn(selector as Record); + if (!element) { + throw new BotError( + ErrorCode.NO_SUCH_ELEMENT, + 'No element has been found by ' + JSON.stringify(selector) + ); + } + return element; + } + + throw new BotError( + ErrorCode.INVALID_ARGUMENT, + 'Selector is of wrong type: ' + JSON.stringify(selector) + ); +} + +type StrategyFn = (selector: SelectorLike, ...args: unknown[]) => Filter; + +/** + * Strategy functions for different relative locator types. + */ +const STRATEGIES: Record = { + above: above as StrategyFn, + below: below as StrategyFn, + left: leftOf as StrategyFn, + near: near as StrategyFn, + right: rightOf as StrategyFn, + straightAbove: straightAbove as StrategyFn, + straightBelow: straightBelow as StrategyFn, + straightLeft: straightLeftOf as StrategyFn, + straightRight: straightRightOf as StrategyFn, +}; + +/** + * Resolver functions for extracting anchor elements from filters. + */ +const RESOLVERS: Record Element> = { + above: resolve, + below: resolve, + left: resolve, + near: resolve, + right: resolve, + straightAbove: resolve, + straightBelow: resolve, + straightLeft: resolve, + straightRight: resolve, +}; + +/** + * Filters elements based on relative locator criteria. + */ +function filterElements(allElements: ArrayLike, filters: FilterDescriptor[]): Element[] { + const toReturn: Element[] = []; + + Array.from(allElements).forEach((element) => { + if (!element) { + return; + } + + const include = Array.from(filters).every((filter) => { + const name = filter['kind']; + const strategy = STRATEGIES[name]; + + if (!strategy) { + throw new BotError(ErrorCode.INVALID_ARGUMENT, 'Cannot find filter suitable for ' + name); + } + + const filterFunc = strategy.apply(null, filter['args'] as [SelectorLike, ...unknown[]]); + return filterFunc(element); + }); + + if (include) { + toReturn.push(element); + } + }); + + // Sort by proximity to the last anchor element + const finalFilter = filters[filters.length - 1]; + const name = finalFilter ? finalFilter['kind'] : 'unknown'; + const resolver = RESOLVERS[name]; + if (!resolver) { + return toReturn; + } + + const lastAnchor = resolver.apply(null, finalFilter['args'] as [SelectorLike]); + if (!lastAnchor) { + return toReturn; + } + + return sortByProximity(lastAnchor, toReturn); +} + +/** + * Sorts elements by proximity to an anchor element. + */ +function sortByProximity(anchor: Element, elements: Element[]): Element[] { + const anchorRect = getClientRect(anchor); + const anchorCenter = { + x: anchorRect.left + Math.max(1, anchorRect.width) / 2, + y: anchorRect.top + Math.max(1, anchorRect.height) / 2, + }; + + const distance = function (e: Element): number { + const rect = getClientRect(e); + const center = { + x: rect.left + Math.max(1, rect.width) / 2, + y: rect.top + Math.max(1, rect.height) / 2, + }; + + const x = Math.pow(anchorCenter.x - center.x, 2); + const y = Math.pow(anchorCenter.y - center.y, 2); + + return Math.sqrt(x + y); + }; + + elements.sort(function (left, right) { + return distance(left) - distance(right); + }); + + return elements; +} + +/** + * Find an element by using a relative locator. + * + * @param target The search criteria. + * @param _root The document or element to perform the search under (ignored). + * @return The first matching element, or null if no such element could be found. + */ +export function single(target: RelativeTarget, _root: Document | Element): Element | null { + const matches = many(target, _root); + if (matches.length === 0) { + return null; + } + return matches[0]; +} + +/** + * Find many elements by using a relative locator. + * + * @param target The search criteria. + * @param root The document or element to perform the search under. + * @return All matching elements, or an empty list. + */ +export function many(target: RelativeTarget, root: Document | Element): Element[] { + if (!target.hasOwnProperty('root') || !target.hasOwnProperty('filters')) { + throw new BotError( + ErrorCode.INVALID_ARGUMENT, + 'Locator not suitable for relative locators: ' + JSON.stringify(target) + ); + } + + if (!isArrayLike(target['filters'])) { + throw new BotError( + ErrorCode.INVALID_ARGUMENT, + 'Targets should be an array: ' + JSON.stringify(target) + ); + } + + let elements: Element[]; + if (isDomElement(target['root'])) { + elements = [target['root'] as Element]; + } else if (findElementsFn) { + elements = Array.from(findElementsFn(target['root'] as Record, root)); + } else { + elements = []; + } + + if (elements.length === 0) { + return []; + } + + const filters = target['filters']; + return filterElements(elements, filters as FilterDescriptor[]); +} diff --git a/javascript/atoms/locators/tag_name.js b/javascript/atoms/locators/tag_name.ts similarity index 52% rename from javascript/atoms/locators/tag_name.js rename to javascript/atoms/locators/tag_name.ts index 6c98495deeaf7..e361bb4a44cbd 100644 --- a/javascript/atoms/locators/tag_name.js +++ b/javascript/atoms/locators/tag_name.ts @@ -15,40 +15,43 @@ // specific language governing permissions and limitations // under the License. -goog.provide('bot.locators.tagName'); - -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); +/** + * @fileoverview Locator functions for finding elements by tag name. + */ +import { BotError, ErrorCode } from '../error'; /** * Find an element by its tag name. - * @param {string} target The tag name to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {Element} The first matching element found in the DOM, or null if no + * + * @param target The tag name to search for. + * @param root The document or element to perform the search under. + * @return The first matching element found in the DOM, or null if no * such element could be found. */ -bot.locators.tagName.single = function (target, root) { - if (target === "") { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'Unable to locate an element with the tagName ""'); +export function single(target: string, root: Document | Element): Element | null { + if (target === '') { + throw new BotError( + ErrorCode.INVALID_SELECTOR_ERROR, + 'Unable to locate an element with the tagName ""' + ); } return root.getElementsByTagName(target)[0] || null; -}; - +} /** * Find all elements with a given tag name. - * @param {string} target The tag name to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {!IArrayLike} All matching elements, or an empty list. + * + * @param target The tag name to search for. + * @param root The document or element to perform the search under. + * @return All matching elements, or an empty list. */ -bot.locators.tagName.many = function (target, root) { - if (target === "") { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'Unable to locate an element with the tagName ""'); +export function many(target: string, root: Document | Element): HTMLCollectionOf { + if (target === '') { + throw new BotError( + ErrorCode.INVALID_SELECTOR_ERROR, + 'Unable to locate an element with the tagName ""' + ); } return root.getElementsByTagName(target); -}; +} diff --git a/javascript/atoms/locators/xpath.js b/javascript/atoms/locators/xpath.js deleted file mode 100644 index 3aa119d5b7678..0000000000000 --- a/javascript/atoms/locators/xpath.js +++ /dev/null @@ -1,248 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Functions to locate elements by XPath. - * - *

The locator implementations below differ from the Closure functions - * goog.dom.xml.{selectSingleNode,selectNodes} in three important ways: - *

    - *
  1. they do not refer to "document" which is undefined in the context of a - * Firefox extension; - *
  2. they use a default NsResolver for browsers that do not provide - * document.createNSResolver (e.g. Android); and - *
  3. they prefer document.evaluate to node.{selectSingleNode,selectNodes} - * because the latter silently return nothing when the xpath resolves to a - * non-Node type, limiting the error-checking the implementation can provide. - *
- */ - -goog.provide('bot.locators.xpath'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.locators'); -goog.require('goog.array'); -goog.require('goog.dom'); -goog.require('goog.dom.NodeType'); -goog.require('goog.userAgent'); -goog.require('goog.userAgent.product'); - -/** - * XPathResult enum values. These are defined separately since - * the context running this script may not support the XPathResult - * type. - * @enum {number} - * @see http://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathResult - * @private - */ -// TODO: Move this enum back to bot.locators.xpath namespace. -// The problem is that we alias bot.locators.xpath in locators.js, while -// we set the flag --collapse_properties (http://goo.gl/5W6cP). -// The compiler should have thrown the error anyways, it's a bug that it fails -// only when introducing this enum. -// Solution: remove --collapse_properties from the js_binary rule or -// use goog.exportSymbol to export the public methods and get rid of the alias. -bot.locators.XPathResult_ = { - ORDERED_NODE_SNAPSHOT_TYPE: 7, - FIRST_ORDERED_NODE_TYPE: 9 -}; - - -/** - * Default XPath namespace resolver. - * @private - */ -bot.locators.xpath.DEFAULT_RESOLVER_ = (function () { - var namespaces = { svg: 'http://www.w3.org/2000/svg' }; - return function (prefix) { - return namespaces[prefix] || null; - }; -})(); - - -/** - * Evaluates an XPath expression using a W3 XPathEvaluator. - * @param {!(Document|Element)} node The document or element to perform the - * search under. - * @param {string} path The xpath to search for. - * @param {!bot.locators.XPathResult_} resultType The desired result type. - * @return {XPathResult} The XPathResult or null if the root's ownerDocument - * does not support XPathEvaluators. - * @private - * @see http://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathEvaluator-evaluate - */ -bot.locators.xpath.evaluate_ = function (node, path, resultType) { - var doc = goog.dom.getOwnerDocument(node); - - if (!doc.documentElement) { - // document is not loaded yet - return null; - } - - try { - var resolver = doc.createNSResolver ? - doc.createNSResolver(doc.documentElement) : - bot.locators.xpath.DEFAULT_RESOLVER_; - - if (goog.userAgent.IE && !goog.userAgent.isVersionOrHigher(7)) { - // IE6, and only IE6, has an issue where calling a custom function - // directly attached to the document object does not correctly propagate - // thrown errors. So in that case *only* we will use apply(). - return doc.evaluate.call(doc, path, node, resolver, resultType, null); - - } else { - if (!goog.userAgent.IE || goog.userAgent.isDocumentModeOrHigher(9)) { - var reversedNamespaces = {}; - var allNodes = doc.getElementsByTagName("*"); - for (var i = 0; i < allNodes.length; ++i) { - var n = allNodes[i]; - var ns = n.namespaceURI; - if (ns && !reversedNamespaces[ns]) { - var prefix = n.lookupPrefix(ns); - if (!prefix) { - var m = ns.match('.*/(\\w+)/?$'); - if (m) { - prefix = m[1]; - } else { - prefix = 'xhtml'; - } - } - reversedNamespaces[ns] = prefix; - } - } - var namespaces = {}; - for (var key in reversedNamespaces) { - namespaces[reversedNamespaces[key]] = key; - } - resolver = function (prefix) { - return namespaces[prefix] || null; - }; - } - - try { - return doc.evaluate(path, node, resolver, resultType, null); - } catch (te) { - if (te.name === 'TypeError') { - // fallback to simplified implementation - resolver = doc.createNSResolver ? - doc.createNSResolver(doc.documentElement) : - bot.locators.xpath.DEFAULT_RESOLVER_; - return doc.evaluate(path, node, resolver, resultType, null); - } else { - throw te; - } - } - } - } catch (ex) { - // The Firefox XPath evaluator can throw an exception if the document is - // queried while it's in the midst of reloading, so we ignore it. In all - // other cases, we assume an invalid xpath has caused the exception. - if (!(goog.userAgent.GECKO && ex.name == 'NS_ERROR_ILLEGAL_VALUE')) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'Unable to locate an element with the xpath expression ' + path + - ' because of the following error:\n' + ex); - } - } -}; - - -/** - * @param {Node|undefined} node Node to check whether it is an Element. - * @param {string} path XPath expression to include in the error message. - * @private - */ -bot.locators.xpath.checkElement_ = function (node, path) { - if (!node || node.nodeType != goog.dom.NodeType.ELEMENT) { - throw new bot.Error(bot.ErrorCode.INVALID_SELECTOR_ERROR, - 'The result of the xpath expression "' + path + - '" is: ' + node + '. It should be an element.'); - } -}; - - -/** - * Find an element by using an xpath expression - * @param {string} target The xpath to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {Element} The first matching element found in the DOM, or null if no - * such element could be found. - */ -bot.locators.xpath.single = function (target, root) { - - function selectSingleNode() { - var result = bot.locators.xpath.evaluate_(root, target, - bot.locators.XPathResult_.FIRST_ORDERED_NODE_TYPE); - - if (result) { - var node = result.singleNodeValue; - return node || null; - } else if (root.selectSingleNode) { - var doc = goog.dom.getOwnerDocument(root); - if (doc.setProperty) { - doc.setProperty('SelectionLanguage', 'XPath'); - } - return root.selectSingleNode(target); - } - return null; - } - - var node = selectSingleNode(); - if (node !== null) { - bot.locators.xpath.checkElement_(node, target); - } - return /** @type {Element} */ (node); -}; - - -/** - * Find elements by using an xpath expression - * @param {string} target The xpath to search for. - * @param {!(Document|Element)} root The document or element to perform the - * search under. - * @return {!IArrayLike} All matching elements, or an empty list. - */ -bot.locators.xpath.many = function (target, root) { - - function selectNodes() { - var result = bot.locators.xpath.evaluate_(root, target, - bot.locators.XPathResult_.ORDERED_NODE_SNAPSHOT_TYPE); - if (result) { - var count = result.snapshotLength; - var results = []; - for (var i = 0; i < count; ++i) { - results.push(result.snapshotItem(i)); - } - return results; - } else if (root.selectNodes) { - var doc = goog.dom.getOwnerDocument(root); - if (doc.setProperty) { - doc.setProperty('SelectionLanguage', 'XPath'); - } - return root.selectNodes(target); - } - return []; - } - - var nodes = selectNodes(); - goog.array.forEach(nodes, function (n) { - bot.locators.xpath.checkElement_(n, target); - }); - return /** @type {!IArrayLike} */ (nodes); -}; diff --git a/javascript/atoms/locators/xpath.ts b/javascript/atoms/locators/xpath.ts new file mode 100644 index 0000000000000..7682becd11e5a --- /dev/null +++ b/javascript/atoms/locators/xpath.ts @@ -0,0 +1,245 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Functions to locate elements by XPath. + * + * The locator implementations below differ from the Closure functions + * goog.dom.xml.{selectSingleNode,selectNodes} in three important ways: + * 1. they do not refer to "document" which is undefined in the context of a + * Firefox extension; + * 2. they use a default NsResolver for browsers that do not provide + * document.createNSResolver (e.g. Android); and + * 3. they prefer document.evaluate to node.{selectSingleNode,selectNodes} + * because the latter silently return nothing when the xpath resolves to a + * non-Node type, limiting the error-checking the implementation can provide. + */ + +import { BotError, ErrorCode } from '../error'; + +const NODE_TYPE_ELEMENT = 1; + +/** + * XPathResult enum values. These are defined separately since + * the context running this script may not support the XPathResult type. + */ +const XPathResultType = { + ORDERED_NODE_SNAPSHOT_TYPE: 7, + FIRST_ORDERED_NODE_TYPE: 9, +} as const; + +/** + * Default XPath namespace resolver. + */ +const DEFAULT_RESOLVER: XPathNSResolver = { + lookupNamespaceURI(prefix: string | null): string | null { + const namespaces: Record = { svg: 'http://www.w3.org/2000/svg' }; + return prefix ? namespaces[prefix] || null : null; + }, +}; + +/** + * Gets the owner document for a node. + */ +function getOwnerDocument(node: Node): Document { + return node.nodeType === 9 ? (node as Document) : node.ownerDocument || document; +} + +/** + * Evaluates an XPath expression using a W3 XPathEvaluator. + * + * @param node The document or element to perform the search under. + * @param path The xpath to search for. + * @param resultType The desired result type. + * @return The XPathResult or null if the root's ownerDocument + * does not support XPathEvaluators. + */ +function evaluate( + node: Document | Element, + path: string, + resultType: number +): XPathResult | null { + const doc = getOwnerDocument(node); + + if (!doc.documentElement) { + return null; + } + + try { + let resolver: XPathNSResolver = doc.createNSResolver + ? doc.createNSResolver(doc.documentElement) + : DEFAULT_RESOLVER; + + // Build dynamic namespace resolver for modern browsers + const reversedNamespaces: Record = {}; + const allNodes = doc.getElementsByTagName('*'); + for (let i = 0; i < allNodes.length; ++i) { + const n = allNodes[i]; + const ns = n.namespaceURI; + if (ns && !reversedNamespaces[ns]) { + let prefix = n.lookupPrefix(ns); + if (!prefix) { + const m = ns.match('.*/(\\w+)/?$'); + if (m) { + prefix = m[1]; + } else { + prefix = 'xhtml'; + } + } + reversedNamespaces[ns] = prefix; + } + } + const namespaces: Record = {}; + for (const key in reversedNamespaces) { + namespaces[reversedNamespaces[key]] = key; + } + resolver = { + lookupNamespaceURI(prefix: string | null): string | null { + return prefix ? namespaces[prefix] || null : null; + }, + }; + + try { + return doc.evaluate(path, node, resolver, resultType, null); + } catch (te) { + if ((te as Error).name === 'TypeError') { + // fallback to simplified implementation + resolver = doc.createNSResolver + ? doc.createNSResolver(doc.documentElement) + : DEFAULT_RESOLVER; + return doc.evaluate(path, node, resolver, resultType, null); + } else { + throw te; + } + } + } catch (ex) { + // The Firefox XPath evaluator can throw an exception if the document is + // queried while it's in the midst of reloading, so we ignore it. In all + // other cases, we assume an invalid xpath has caused the exception. + if (!((ex as Error).name === 'NS_ERROR_ILLEGAL_VALUE')) { + throw new BotError( + ErrorCode.INVALID_SELECTOR_ERROR, + 'Unable to locate an element with the xpath expression ' + + path + + ' because of the following error:\n' + + ex + ); + } + } + + return null; +} + +/** + * Checks whether a node is an element. + * + * @param node Node to check whether it is an Element. + * @param path XPath expression to include in the error message. + */ +function checkElement(node: Node | undefined, path: string): void { + if (!node || node.nodeType !== NODE_TYPE_ELEMENT) { + throw new BotError( + ErrorCode.INVALID_SELECTOR_ERROR, + 'The result of the xpath expression "' + + path + + '" is: ' + + node + + '. It should be an element.' + ); + } +} + +interface NodeWithSelectSingleNode extends Node { + selectSingleNode?: (xpath: string) => Node | null; +} + +interface NodeWithSelectNodes extends Node { + selectNodes?: (xpath: string) => NodeList; +} + +interface DocumentWithSetProperty extends Document { + setProperty?: (name: string, value: string) => void; +} + +/** + * Find an element by using an xpath expression. + * + * @param target The xpath to search for. + * @param root The document or element to perform the search under. + * @return The first matching element found in the DOM, or null if no + * such element could be found. + */ +export function single(target: string, root: Document | Element): Element | null { + function selectSingleNode(): Node | null { + const result = evaluate(root, target, XPathResultType.FIRST_ORDERED_NODE_TYPE); + + if (result) { + const node = result.singleNodeValue; + return node || null; + } else if ((root as NodeWithSelectSingleNode).selectSingleNode) { + const doc = getOwnerDocument(root) as DocumentWithSetProperty; + if (doc.setProperty) { + doc.setProperty('SelectionLanguage', 'XPath'); + } + return (root as NodeWithSelectSingleNode).selectSingleNode!(target); + } + return null; + } + + const node = selectSingleNode(); + if (node !== null) { + checkElement(node, target); + } + return node as Element | null; +} + +/** + * Find elements by using an xpath expression. + * + * @param target The xpath to search for. + * @param root The document or element to perform the search under. + * @return All matching elements, or an empty list. + */ +export function many(target: string, root: Document | Element): Element[] { + function selectNodes(): Node[] { + const result = evaluate(root, target, XPathResultType.ORDERED_NODE_SNAPSHOT_TYPE); + if (result) { + const count = result.snapshotLength; + const results: Node[] = []; + for (let i = 0; i < count; ++i) { + const item = result.snapshotItem(i); + if (item) { + results.push(item); + } + } + return results; + } else if ((root as NodeWithSelectNodes).selectNodes) { + const doc = getOwnerDocument(root) as DocumentWithSetProperty; + if (doc.setProperty) { + doc.setProperty('SelectionLanguage', 'XPath'); + } + return Array.from((root as NodeWithSelectNodes).selectNodes!(target)); + } + return []; + } + + const nodes = selectNodes(); + nodes.forEach((n) => { + checkElement(n, target); + }); + return nodes as Element[]; +} diff --git a/javascript/atoms/mouse.js b/javascript/atoms/mouse.js deleted file mode 100644 index 6003df47d81f3..0000000000000 --- a/javascript/atoms/mouse.js +++ /dev/null @@ -1,516 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview The file contains an abstraction of a mouse for - * simulating the mouse actions. - */ - -goog.provide('bot.Mouse'); -goog.provide('bot.Mouse.Button'); - -goog.require('bot'); -goog.require('bot.Device'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.dom'); -goog.require('bot.events'); -goog.require('bot.userAgent'); -goog.require('goog.dom'); -goog.require('goog.dom.TagName'); -goog.require('goog.math.Coordinate'); -goog.require('goog.userAgent'); -goog.require('goog.utils'); - - - -/** - * A mouse that provides atomic mouse actions. This mouse currently only - * supports having one button pressed at a time. - * @param {bot.Mouse.State=} opt_state The mouse's initial state. - * @param {bot.Device.ModifiersState=} opt_modifiersState State of the keyboard. - * @param {bot.Device.EventEmitter=} opt_eventEmitter An object that should be - * used to fire events. - * @constructor - * @extends {bot.Device} - */ -bot.Mouse = function (opt_state, opt_modifiersState, opt_eventEmitter) { - bot.Device.call(this, opt_modifiersState, opt_eventEmitter); - - /** @private {?bot.Mouse.Button} */ - this.buttonPressed_ = null; - - /** @private {Element} */ - this.elementPressed_ = null; - - /** @private {!goog.math.Coordinate} */ - this.clientXY_ = new goog.math.Coordinate(0, 0); - - /** @private {boolean} */ - this.nextClickIsDoubleClick_ = false; - - /** - * Whether this Mouse has ever explicitly interacted with any element. - * @private {boolean} - */ - this.hasEverInteracted_ = false; - - if (opt_state) { - if (typeof opt_state['buttonPressed'] === 'number') { - this.buttonPressed_ = opt_state['buttonPressed']; - } - - try { - if (bot.dom.isElement(opt_state['elementPressed'])) { - this.elementPressed_ = opt_state['elementPressed']; - } - } catch (ignored) { - this.buttonPressed_ = null; - } - - this.clientXY_ = new goog.math.Coordinate( - opt_state['clientXY']['x'], - opt_state['clientXY']['y']); - - this.nextClickIsDoubleClick_ = !!opt_state['nextClickIsDoubleClick']; - this.hasEverInteracted_ = !!opt_state['hasEverInteracted']; - - try { - if (opt_state['element'] && bot.dom.isElement(opt_state['element'])) { - this.setElement(/** @type {!Element} */(opt_state['element'])); - } - } catch (ignored) { - this.buttonPressed_ = null; - } - } -}; -goog.utils.inherits(bot.Mouse, bot.Device); - - -/** - * Describes the state of the mouse. This type should be treated as a - * dictionary with all properties accessed using array notation to - * ensure properties are not renamed by the compiler. - * @typedef {{buttonPressed: ?bot.Mouse.Button, - * elementPressed: Element, - * clientXY: {x: number, y: number}, - * nextClickIsDoubleClick: boolean, - * hasEverInteracted: boolean, - * element: Element}} - */ -bot.Mouse.State; - - -/** - * Enumeration of mouse buttons that can be pressed. - * - * @enum {number} - */ -bot.Mouse.Button = { - LEFT: 0, - MIDDLE: 1, - RIGHT: 2 -}; - - -/** - * Index to indicate no button pressed in bot.Mouse.MOUSE_BUTTON_VALUE_MAP_. - * @private {number} - * @const - */ -bot.Mouse.NO_BUTTON_VALUE_INDEX_ = 3; - - -/** - * Maps mouse events to an array of button argument value for each mouse button. - * The array is indexed by the bot.Mouse.Button values. It encodes this table, - * where each cell contains the (left/middle/right/none) button values. - *
- *               click/    mouseup/   mouseout/  mousemove  contextmenu
- *               dblclick  mousedown  mouseover
- * IE_DOC_PRE9   0 0 0 X   1 4 2 X    0 0 0 0    1 4 2 0    X X 0 X
- * WEBKIT/IE9    0 1 2 X   0 1 2 X    0 1 2 0    0 1 2 0    X X 2 X
- * GECKO         0 1 2 X   0 1 2 X    0 0 0 0    0 0 0 0    X X 2 X
- * 
- * @private {!Object>} - * @const - */ -bot.Mouse.MOUSE_BUTTON_VALUE_MAP_ = (function () { - // EventTypes can safely be used as keys without collisions in a JS Object, - // because its toString method returns a unique string (the event type name). - var buttonValueMap = {}; - if (bot.userAgent.IE_DOC_PRE9) { - buttonValueMap[bot.events.EventType.CLICK] = [0, 0, 0, null]; - buttonValueMap[bot.events.EventType.CONTEXTMENU] = [null, null, 0, null]; - buttonValueMap[bot.events.EventType.MOUSEUP] = [1, 4, 2, null]; - buttonValueMap[bot.events.EventType.MOUSEOUT] = [0, 0, 0, 0]; - buttonValueMap[bot.events.EventType.MOUSEMOVE] = [1, 4, 2, 0]; - } else if (goog.userAgent.WEBKIT || bot.userAgent.IE_DOC_9) { - buttonValueMap[bot.events.EventType.CLICK] = [0, 1, 2, null]; - buttonValueMap[bot.events.EventType.CONTEXTMENU] = [null, null, 2, null]; - buttonValueMap[bot.events.EventType.MOUSEUP] = [0, 1, 2, null]; - buttonValueMap[bot.events.EventType.MOUSEOUT] = [0, 1, 2, 0]; - buttonValueMap[bot.events.EventType.MOUSEMOVE] = [0, 1, 2, 0]; - } else { - buttonValueMap[bot.events.EventType.CLICK] = [0, 1, 2, null]; - buttonValueMap[bot.events.EventType.CONTEXTMENU] = [null, null, 2, null]; - buttonValueMap[bot.events.EventType.MOUSEUP] = [0, 1, 2, null]; - buttonValueMap[bot.events.EventType.MOUSEOUT] = [0, 0, 0, 0]; - buttonValueMap[bot.events.EventType.MOUSEMOVE] = [0, 0, 0, 0]; - } - - if (bot.userAgent.IE_DOC_10) { - buttonValueMap[bot.events.EventType.MSPOINTERDOWN] = - buttonValueMap[bot.events.EventType.MOUSEUP]; - buttonValueMap[bot.events.EventType.MSPOINTERUP] = - buttonValueMap[bot.events.EventType.MOUSEUP]; - buttonValueMap[bot.events.EventType.MSPOINTERMOVE] = [-1, -1, -1, -1]; - buttonValueMap[bot.events.EventType.MSPOINTEROUT] = - buttonValueMap[bot.events.EventType.MSPOINTERMOVE]; - buttonValueMap[bot.events.EventType.MSPOINTEROVER] = - buttonValueMap[bot.events.EventType.MSPOINTERMOVE]; - } - - buttonValueMap[bot.events.EventType.DBLCLICK] = - buttonValueMap[bot.events.EventType.CLICK]; - buttonValueMap[bot.events.EventType.MOUSEDOWN] = - buttonValueMap[bot.events.EventType.MOUSEUP]; - buttonValueMap[bot.events.EventType.MOUSEOVER] = - buttonValueMap[bot.events.EventType.MOUSEOUT]; - return buttonValueMap; -})(); - - -/** - * Maps mouse events to corresponding MSPointer event. - * @private {!Object} - */ -bot.Mouse.MOUSE_EVENT_MAP_ = (function () { - var map = {}; - map[bot.events.EventType.MOUSEDOWN] = bot.events.EventType.MSPOINTERDOWN; - map[bot.events.EventType.MOUSEMOVE] = bot.events.EventType.MSPOINTERMOVE; - map[bot.events.EventType.MOUSEOUT] = bot.events.EventType.MSPOINTEROUT; - map[bot.events.EventType.MOUSEOVER] = bot.events.EventType.MSPOINTEROVER; - map[bot.events.EventType.MOUSEUP] = bot.events.EventType.MSPOINTERUP; - return map; -})(); - - -/** - * Attempts to fire a mousedown event and then returns whether or not the - * element should receive focus as a result of the mousedown. - * - * @param {?number=} opt_count Number of clicks that have been performed. - * @return {boolean} Whether to focus on the element after the mousedown. - * @private - */ -bot.Mouse.prototype.fireMousedown_ = function (opt_count) { - // On some browsers, a mouse down event on an OPTION or SELECT element cause - // the SELECT to open, blocking further JS execution. This is undesirable, - // and so needs to be detected. We always focus in this case. - // TODO: This is a nasty way to avoid locking the browser - var isFirefox3 = goog.userAgent.GECKO && !bot.userAgent.isProductVersion(4); - var blocksOnMousedown = (goog.userAgent.WEBKIT || isFirefox3) && - (bot.dom.isElement(this.getElement(), goog.dom.TagName.OPTION) || - bot.dom.isElement(this.getElement(), goog.dom.TagName.SELECT)); - if (blocksOnMousedown) { - return true; - } - - // On some browsers, if the mousedown event handler makes a focus() call to - // change the active element, this preempts the focus that would happen by - // default on the mousedown, so we should not explicitly focus in this case. - var beforeActiveElement; - var mousedownCanPreemptFocus = goog.userAgent.GECKO || goog.userAgent.IE; - if (mousedownCanPreemptFocus) { - beforeActiveElement = bot.dom.getActiveElement(this.getElement()); - } - var performFocus = this.fireMouseEvent_(bot.events.EventType.MOUSEDOWN, null, null, false, opt_count); - if (performFocus && mousedownCanPreemptFocus && - beforeActiveElement != bot.dom.getActiveElement(this.getElement())) { - return false; - } - return performFocus; -}; - - -/** - * Press a mouse button on an element that the mouse is interacting with. - * - * @param {!bot.Mouse.Button} button Button. - * @param {?number=} opt_count Number of clicks that have been performed. -*/ -bot.Mouse.prototype.pressButton = function (button, opt_count) { - if (this.buttonPressed_ !== null) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Cannot press more than one button or an already pressed button.'); - } - this.buttonPressed_ = button; - this.elementPressed_ = this.getElement(); - - var performFocus = this.fireMousedown_(opt_count); - if (performFocus) { - if (bot.userAgent.IE_DOC_10 && - this.buttonPressed_ == bot.Mouse.Button.LEFT && - bot.dom.isElement(this.elementPressed_, goog.dom.TagName.OPTION)) { - this.fireMSPointerEvent(bot.events.EventType.MSGOTPOINTERCAPTURE, - this.clientXY_, 0, bot.Device.MOUSE_MS_POINTER_ID, - MSPointerEvent.MSPOINTER_TYPE_MOUSE, true); - } - this.focusOnElement(); - } -}; - - -/** - * Releases the pressed mouse button. Throws exception if no button pressed. - * - * @param {boolean=} opt_force Whether the event should be fired even if the - * element is not interactable. - * @param {?number=} opt_count Number of clicks that have been performed. - */ -bot.Mouse.prototype.releaseButton = function (opt_force, opt_count) { - if (this.buttonPressed_ === null) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Cannot release a button when no button is pressed.'); - } - - this.maybeToggleOption(); - - // If a mouseup event is dispatched to an interactable event, and that mouseup - // would complete a click, then the click event must be dispatched even if the - // element becomes non-interactable after the mouseup. - var elementInteractableBeforeMouseup = - bot.dom.isInteractable(this.getElement()); - this.fireMouseEvent_(bot.events.EventType.MOUSEUP, null, null, opt_force, opt_count); - - try { // https://github.com/SeleniumHQ/selenium/issues/1509 - // TODO: Middle button can also trigger click. - if (this.buttonPressed_ == bot.Mouse.Button.LEFT && - this.getElement() == this.elementPressed_) { - if (!(bot.userAgent.WINDOWS_PHONE && - bot.dom.isElement(this.elementPressed_, goog.dom.TagName.OPTION))) { - this.clickElement(this.clientXY_, - this.getButtonValue_(bot.events.EventType.CLICK), - /* opt_force */ elementInteractableBeforeMouseup); - } - this.maybeDoubleClickElement_(); - if (bot.userAgent.IE_DOC_10 && - this.buttonPressed_ == bot.Mouse.Button.LEFT && - bot.dom.isElement(this.elementPressed_, goog.dom.TagName.OPTION)) { - this.fireMSPointerEvent(bot.events.EventType.MSLOSTPOINTERCAPTURE, - new goog.math.Coordinate(0, 0), 0, bot.Device.MOUSE_MS_POINTER_ID, - MSPointerEvent.MSPOINTER_TYPE_MOUSE, false); - } - // TODO: In Linux, this fires after mousedown event. - } else if (this.buttonPressed_ == bot.Mouse.Button.RIGHT) { - this.fireMouseEvent_(bot.events.EventType.CONTEXTMENU); - } - } catch (ignored) { - } - bot.Device.clearPointerMap(); - this.buttonPressed_ = null; - this.elementPressed_ = null; -}; - - -/** - * A helper function to fire mouse double click events. - * - * @private - */ -bot.Mouse.prototype.maybeDoubleClickElement_ = function () { - // Trigger an additional double click event if it is the second click. - if (this.nextClickIsDoubleClick_) { - this.fireMouseEvent_(bot.events.EventType.DBLCLICK); - } - this.nextClickIsDoubleClick_ = !this.nextClickIsDoubleClick_; -}; - - -/** - * Given a coordinates (x,y) related to an element, move mouse to (x,y) of the - * element. The top-left point of the element is (0,0). - * - * @param {!Element} element The destination element. - * @param {!goog.math.Coordinate} coords Mouse position related to the target. - */ -bot.Mouse.prototype.move = function (element, coords) { - // If the element is interactable at the start of the move, it receives the - // full event sequence, even if hidden by an element mid sequence. - var toElemWasInteractable = bot.dom.isInteractable(element); - - var rect = bot.dom.getClientRect(element); - this.clientXY_.x = coords.x + rect.left; - this.clientXY_.y = coords.y + rect.top; - var fromElement = this.getElement(); - - if (element != fromElement) { - // If the window of fromElement is closed, set fromElement to null as a flag - // to skip the mouseout event and so relatedTarget of the mouseover is null. - try { - if (goog.dom.getWindow(goog.dom.getOwnerDocument(fromElement)).closed) { - fromElement = null; - } - } catch (ignore) { - // Sometimes accessing a window that no longer exists causes an error. - fromElement = null; - } - - if (fromElement) { - // For the first mouse interaction on a page, if the mouse was over the - // browser window, the browser will pass null as the relatedTarget for the - // mouseover event. For subsequent interactions, it will pass the - // last-focused element. Unfortunately, we don't have anywhere to keep the - // state of which elements have been focused across Mouse instances, so we - // treat every Mouse initially positioned over the documentElement or body - // as if it's on a new page. Accordingly, for complex actions (e.g. - // drag-and-drop), a single Mouse instance should be used for the whole - // action, to ensure the correct relatedTargets are fired for any events. - var isRoot = fromElement === bot.getDocument().documentElement || - fromElement === bot.getDocument().body; - fromElement = (!this.hasEverInteracted_ && isRoot) ? null : fromElement; - this.fireMouseEvent_(bot.events.EventType.MOUSEOUT, element); - } - this.setElement(element); - - // All browsers except IE fire the mouseover before the mousemove. - if (!goog.userAgent.IE) { - this.fireMouseEvent_(bot.events.EventType.MOUSEOVER, fromElement, null, - toElemWasInteractable); - } - } - - this.fireMouseEvent_(bot.events.EventType.MOUSEMOVE, null, null, - toElemWasInteractable); - - // IE fires the mouseover event after the mousemove. - if (goog.userAgent.IE && element != fromElement) { - this.fireMouseEvent_(bot.events.EventType.MOUSEOVER, fromElement, null, - toElemWasInteractable); - } - - this.nextClickIsDoubleClick_ = false; -}; - - -/** - * Scrolls the wheel of the mouse by the given number of ticks, where a positive - * number indicates a downward scroll and a negative is upward scroll. - * - * @param {number} ticks Number of ticks to scroll the mouse wheel. - */ -bot.Mouse.prototype.scroll = function (ticks) { - if (ticks == 0) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Must scroll a non-zero number of ticks.'); - } - - // The wheelDelta value for a single up-tick of the mouse wheel is 120, and - // a single down-tick is -120. The deltas in pixels (which is only relevant - // for Firefox) appears to be -57 and 57, respectively. - var wheelDelta = ticks > 0 ? -120 : 120; - var pixelDelta = ticks > 0 ? 57 : -57; - - // Browsers fire a separate event (or pair of events in Gecko) for each tick. - for (var i = 0; i < Math.abs(ticks); i++) { - this.fireMouseEvent_(bot.events.EventType.MOUSEWHEEL, null, wheelDelta); - if (goog.userAgent.GECKO) { - this.fireMouseEvent_(bot.events.EventType.MOUSEPIXELSCROLL, null, - pixelDelta); - } - } -}; - - -/** - * A helper function to fire mouse events. - * - * @param {!bot.events.EventFactory_} type Event type. - * @param {Element=} opt_related The related element of this event. - * @param {?number=} opt_wheelDelta The wheel delta value for the event. - * @param {boolean=} opt_force Whether the event should be fired even if the - * element is not interactable. - * @param {?number=} opt_count Number of clicks that have been performed. - * @return {boolean} Whether the event fired successfully or was cancelled. - * @private - */ -bot.Mouse.prototype.fireMouseEvent_ = function (type, opt_related, - opt_wheelDelta, opt_force, opt_count) { - this.hasEverInteracted_ = true; - if (bot.userAgent.IE_DOC_10) { - var msPointerEvent = bot.Mouse.MOUSE_EVENT_MAP_[type]; - if (msPointerEvent) { - // The pointerId for mouse events is always 1 and the mouse event is never - // fired if the MSPointer event fails. - if (!this.fireMSPointerEvent(msPointerEvent, this.clientXY_, - this.getButtonValue_(msPointerEvent), bot.Device.MOUSE_MS_POINTER_ID, - MSPointerEvent.MSPOINTER_TYPE_MOUSE, /* isPrimary */ true, - opt_related, opt_force)) { - return false; - } - } - } - return this.fireMouseEvent(type, this.clientXY_, - this.getButtonValue_(type), opt_related, opt_wheelDelta, opt_force, null, opt_count); -}; - - -/** - * Given an event type and a mouse button, sets the mouse button value used - * for that event on the current browser. The mouse button value is 0 for any - * event not covered by bot.Mouse.MOUSE_BUTTON_VALUE_MAP_. - * - * @param {!bot.events.EventFactory_} eventType Type of mouse event. - * @return {number} The mouse button ID value to the current browser. - * @private -*/ -bot.Mouse.prototype.getButtonValue_ = function (eventType) { - if (!(eventType in bot.Mouse.MOUSE_BUTTON_VALUE_MAP_)) { - return 0; - } - - var buttonIndex = this.buttonPressed_ === null ? - bot.Mouse.NO_BUTTON_VALUE_INDEX_ : this.buttonPressed_; - var buttonValue = bot.Mouse.MOUSE_BUTTON_VALUE_MAP_[eventType][buttonIndex]; - if (buttonValue === null) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Event does not permit the specified mouse button.'); - } - return buttonValue; -}; - - -/** - * Serialize the current state of the mouse. - * @return {!bot.Mouse.State} The current mouse state. - */ -bot.Mouse.prototype.getState = function () { - // Need to use quoted literals here, so the compiler will not rename the - // properties of the emitted object. When the object is created via the - // "constructor", we will look for these *specific* properties. Everywhere - // else internally, we use the dot-notation, so it's okay if the compiler - // renames the internal variable name. - return { - 'buttonPressed': this.buttonPressed_, - 'elementPressed': this.elementPressed_, - 'clientXY': { 'x': this.clientXY_.x, 'y': this.clientXY_.y }, - 'nextClickIsDoubleClick': this.nextClickIsDoubleClick_, - 'hasEverInteracted': this.hasEverInteracted_, - 'element': this.getElement() - }; -}; diff --git a/javascript/atoms/mouse.ts b/javascript/atoms/mouse.ts new file mode 100644 index 0000000000000..76844d54be0f2 --- /dev/null +++ b/javascript/atoms/mouse.ts @@ -0,0 +1,506 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview The file contains an abstraction of a mouse for + * simulating the mouse actions. + */ + +import { BotError, ErrorCode } from './error'; +import { + Device, + ModifiersState, + EventEmitter, + Coordinate, + MOUSE_MS_POINTER_ID, + clearPointerMap, +} from './device'; +import { + isElement, + isInteractable, + getActiveElement, + getClientRect, +} from './dom'; +import { EventType, EventFactory } from './events'; +import { + GECKO, + WEBKIT, + IE, + isProductVersion, + IE_DOC_PRE9, + IE_DOC_9, + IE_DOC_10, + WINDOWS_PHONE, +} from './userAgent'; +import { getDocument } from './bot'; + +// Browser detection +const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''; +const IS_IE = /MSIE|Trident/.test(userAgent); + +// ============================================================================ +// Mouse Button Enum +// ============================================================================ + +/** + * Enumeration of mouse buttons that can be pressed. + */ +export enum Button { + LEFT = 0, + MIDDLE = 1, + RIGHT = 2, +} + +// ============================================================================ +// Mouse State +// ============================================================================ + +/** + * Describes the state of the mouse. + */ +export interface MouseState { + buttonPressed: Button | null; + elementPressed: Element | null; + clientXY: { x: number; y: number }; + nextClickIsDoubleClick: boolean; + hasEverInteracted: boolean; + element: Element | null; +} + +// ============================================================================ +// Button Value Mapping +// ============================================================================ + +/** + * Index to indicate no button pressed in MOUSE_BUTTON_VALUE_MAP_. + */ +const NO_BUTTON_VALUE_INDEX_ = 3; + +/** + * Maps mouse events to an array of button argument value for each mouse button. + */ +const MOUSE_BUTTON_VALUE_MAP_: Map = (function () { + const buttonValueMap = new Map(); + + if (IE_DOC_PRE9) { + buttonValueMap.set(EventType.CLICK, [0, 0, 0, null]); + buttonValueMap.set(EventType.CONTEXTMENU, [null, null, 0, null]); + buttonValueMap.set(EventType.MOUSEUP, [1, 4, 2, null]); + buttonValueMap.set(EventType.MOUSEOUT, [0, 0, 0, 0]); + buttonValueMap.set(EventType.MOUSEMOVE, [1, 4, 2, 0]); + } else if (WEBKIT || IE_DOC_9) { + buttonValueMap.set(EventType.CLICK, [0, 1, 2, null]); + buttonValueMap.set(EventType.CONTEXTMENU, [null, null, 2, null]); + buttonValueMap.set(EventType.MOUSEUP, [0, 1, 2, null]); + buttonValueMap.set(EventType.MOUSEOUT, [0, 1, 2, 0]); + buttonValueMap.set(EventType.MOUSEMOVE, [0, 1, 2, 0]); + } else { + buttonValueMap.set(EventType.CLICK, [0, 1, 2, null]); + buttonValueMap.set(EventType.CONTEXTMENU, [null, null, 2, null]); + buttonValueMap.set(EventType.MOUSEUP, [0, 1, 2, null]); + buttonValueMap.set(EventType.MOUSEOUT, [0, 0, 0, 0]); + buttonValueMap.set(EventType.MOUSEMOVE, [0, 0, 0, 0]); + } + + if (IE_DOC_10) { + buttonValueMap.set( + EventType.MSPOINTERDOWN, + buttonValueMap.get(EventType.MOUSEUP)! + ); + buttonValueMap.set( + EventType.MSPOINTERUP, + buttonValueMap.get(EventType.MOUSEUP)! + ); + buttonValueMap.set(EventType.MSPOINTERMOVE, [-1, -1, -1, -1]); + buttonValueMap.set( + EventType.MSPOINTEROUT, + buttonValueMap.get(EventType.MSPOINTERMOVE)! + ); + buttonValueMap.set( + EventType.MSPOINTEROVER, + buttonValueMap.get(EventType.MSPOINTERMOVE)! + ); + } + + buttonValueMap.set(EventType.DBLCLICK, buttonValueMap.get(EventType.CLICK)!); + buttonValueMap.set(EventType.MOUSEDOWN, buttonValueMap.get(EventType.MOUSEUP)!); + buttonValueMap.set(EventType.MOUSEOVER, buttonValueMap.get(EventType.MOUSEOUT)!); + + return buttonValueMap; +})(); + +/** + * Maps mouse events to corresponding MSPointer event. + */ +const MOUSE_EVENT_MAP_: Map = new Map([ + [EventType.MOUSEDOWN, EventType.MSPOINTERDOWN], + [EventType.MOUSEMOVE, EventType.MSPOINTERMOVE], + [EventType.MOUSEOUT, EventType.MSPOINTEROUT], + [EventType.MOUSEOVER, EventType.MSPOINTEROVER], + [EventType.MOUSEUP, EventType.MSPOINTERUP], +]); + +// ============================================================================ +// Helper functions +// ============================================================================ + +function getWindow(doc: Document): Window { + return doc.defaultView || (doc as Document & { parentWindow?: Window }).parentWindow || window; +} + +function getOwnerDocument(node: Node): Document { + return node.ownerDocument || (node as Document); +} + +// ============================================================================ +// Mouse Class +// ============================================================================ + +/** + * A mouse that provides atomic mouse actions. + */ +export class Mouse extends Device { + private buttonPressed_: Button | null = null; + private elementPressed_: Element | null = null; + private clientXY_: Coordinate = { x: 0, y: 0 }; + private nextClickIsDoubleClick_: boolean = false; + private hasEverInteracted_: boolean = false; + + constructor( + opt_state?: MouseState, + opt_modifiersState?: ModifiersState, + opt_eventEmitter?: EventEmitter + ) { + super(opt_modifiersState, opt_eventEmitter); + + if (opt_state) { + if (typeof opt_state.buttonPressed === 'number') { + this.buttonPressed_ = opt_state.buttonPressed; + } + + try { + if (opt_state.elementPressed && isElement(opt_state.elementPressed)) { + this.elementPressed_ = opt_state.elementPressed; + } + } catch { + this.buttonPressed_ = null; + } + + this.clientXY_ = { + x: opt_state.clientXY.x, + y: opt_state.clientXY.y, + }; + + this.nextClickIsDoubleClick_ = !!opt_state.nextClickIsDoubleClick; + this.hasEverInteracted_ = !!opt_state.hasEverInteracted; + + try { + if (opt_state.element && isElement(opt_state.element)) { + this.setElement(opt_state.element); + } + } catch { + this.buttonPressed_ = null; + } + } + } + + /** + * Attempts to fire a mousedown event and then returns whether or not the + * element should receive focus as a result of the mousedown. + */ + private fireMousedown_(opt_count?: number): boolean { + const isFirefox3 = GECKO && !isProductVersion(4); + const blocksOnMousedown = + (WEBKIT || isFirefox3) && + (isElement(this.getElement(), 'OPTION') || + isElement(this.getElement(), 'SELECT')); + if (blocksOnMousedown) { + return true; + } + + let beforeActiveElement: Element | null = null; + const mousedownCanPreemptFocus = GECKO || IS_IE; + if (mousedownCanPreemptFocus) { + beforeActiveElement = getActiveElement(this.getElement()); + } + const performFocus = this.fireMouseEvent_( + EventType.MOUSEDOWN, + null, + null, + false, + opt_count + ); + if ( + performFocus && + mousedownCanPreemptFocus && + beforeActiveElement !== getActiveElement(this.getElement()) + ) { + return false; + } + return performFocus; + } + + /** + * Presses the given mouse button on the current element. + */ + pressButton(button: Button, opt_count?: number): void { + if (this.buttonPressed_ !== null) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Cannot press more than one button or an already pressed button.' + ); + } + this.buttonPressed_ = button; + this.elementPressed_ = this.getElement(); + + if (this.fireMousedown_(opt_count)) { + if ( + IE_DOC_10 && + this.buttonPressed_ === Button.LEFT && + isElement(this.elementPressed_, 'OPTION') + ) { + this.fireMSPointerEvent( + EventType.MSGOTPOINTERCAPTURE, + this.clientXY_, + 0, + MOUSE_MS_POINTER_ID, + (window as Window & { MSPointerEvent?: { MSPOINTER_TYPE_MOUSE?: number } }).MSPointerEvent?.MSPOINTER_TYPE_MOUSE ?? 4, + true + ); + } + this.focusOnElement(); + } + } + + /** + * Releases the pressed mouse button. + */ + releaseButton(opt_force?: boolean, opt_count?: number): void { + if (this.buttonPressed_ === null) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Cannot release a button when no button is pressed.' + ); + } + + this.maybeToggleOption(); + + const elementInteractableBeforeMouseup = isInteractable(this.getElement()); + this.fireMouseEvent_(EventType.MOUSEUP, null, null, opt_force, opt_count); + + try { + if ( + this.buttonPressed_ === Button.LEFT && + this.getElement() === this.elementPressed_ + ) { + if ( + !(WINDOWS_PHONE && isElement(this.elementPressed_, 'OPTION')) + ) { + this.clickElement( + this.clientXY_, + this.getButtonValue_(EventType.CLICK), + elementInteractableBeforeMouseup + ); + } + this.maybeDoubleClickElement_(); + if ( + IE_DOC_10 && + this.buttonPressed_ === Button.LEFT && + isElement(this.elementPressed_, 'OPTION') + ) { + this.fireMSPointerEvent( + EventType.MSLOSTPOINTERCAPTURE, + { x: 0, y: 0 }, + 0, + MOUSE_MS_POINTER_ID, + (window as Window & { MSPointerEvent?: { MSPOINTER_TYPE_MOUSE?: number } }).MSPointerEvent?.MSPOINTER_TYPE_MOUSE ?? 4, + false + ); + } + } else if (this.buttonPressed_ === Button.RIGHT) { + this.fireMouseEvent_(EventType.CONTEXTMENU); + } + } catch { + // Ignore errors per original implementation + } + clearPointerMap(); + this.buttonPressed_ = null; + this.elementPressed_ = null; + } + + /** + * A helper function to fire mouse double click events. + */ + private maybeDoubleClickElement_(): void { + if (this.nextClickIsDoubleClick_) { + this.fireMouseEvent_(EventType.DBLCLICK); + } + this.nextClickIsDoubleClick_ = !this.nextClickIsDoubleClick_; + } + + /** + * Given coordinates (x,y) related to an element, move mouse to (x,y) of the element. + */ + move(element: Element, coords: Coordinate): void { + const toElemWasInteractable = isInteractable(element); + + const rect = getClientRect(element); + this.clientXY_.x = coords.x + rect.left; + this.clientXY_.y = coords.y + rect.top; + let fromElement: Element | null = this.getElement(); + + if (element !== fromElement) { + try { + if (getWindow(getOwnerDocument(fromElement)).closed) { + fromElement = null; + } + } catch { + fromElement = null; + } + + if (fromElement) { + const isRoot = + fromElement === getDocument().documentElement || + fromElement === getDocument().body; + fromElement = !this.hasEverInteracted_ && isRoot ? null : fromElement; + this.fireMouseEvent_(EventType.MOUSEOUT, element); + } + this.setElement(element); + + if (!IS_IE) { + this.fireMouseEvent_( + EventType.MOUSEOVER, + fromElement, + null, + toElemWasInteractable + ); + } + } + + this.fireMouseEvent_(EventType.MOUSEMOVE, null, null, toElemWasInteractable); + + if (IS_IE && element !== fromElement) { + this.fireMouseEvent_( + EventType.MOUSEOVER, + fromElement, + null, + toElemWasInteractable + ); + } + + this.nextClickIsDoubleClick_ = false; + } + + /** + * Scrolls the wheel of the mouse by the given number of ticks. + */ + scroll(ticks: number): void { + if (ticks === 0) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Must scroll a non-zero number of ticks.' + ); + } + + const wheelDelta = ticks > 0 ? -120 : 120; + const pixelDelta = ticks > 0 ? 57 : -57; + + for (let i = 0; i < Math.abs(ticks); i++) { + this.fireMouseEvent_(EventType.MOUSEWHEEL, null, wheelDelta); + if (GECKO) { + this.fireMouseEvent_(EventType.MOUSEPIXELSCROLL, null, pixelDelta); + } + } + } + + /** + * A helper function to fire mouse events. + */ + private fireMouseEvent_( + type: EventFactory, + opt_related?: Element | null, + opt_wheelDelta?: number | null, + opt_force?: boolean, + opt_count?: number + ): boolean { + this.hasEverInteracted_ = true; + if (IE_DOC_10) { + const msPointerEvent = MOUSE_EVENT_MAP_.get(type); + if (msPointerEvent) { + if ( + !this.fireMSPointerEvent( + msPointerEvent, + this.clientXY_, + this.getButtonValue_(msPointerEvent), + MOUSE_MS_POINTER_ID, + (window as Window & { MSPointerEvent?: { MSPOINTER_TYPE_MOUSE?: number } }).MSPointerEvent?.MSPOINTER_TYPE_MOUSE ?? 4, + true, + opt_related ?? undefined, + opt_force + ) + ) { + return false; + } + } + } + return this.fireMouseEvent( + type, + this.clientXY_, + this.getButtonValue_(type), + opt_related ?? undefined, + opt_wheelDelta ?? undefined, + opt_force, + undefined, + opt_count + ); + } + + /** + * Given an event type and a mouse button, returns the mouse button value. + */ + private getButtonValue_(eventType: EventFactory): number { + const buttonValues = MOUSE_BUTTON_VALUE_MAP_.get(eventType); + if (!buttonValues) { + return 0; + } + + const buttonIndex = + this.buttonPressed_ === null ? NO_BUTTON_VALUE_INDEX_ : this.buttonPressed_; + const buttonValue = buttonValues[buttonIndex]; + if (buttonValue === null) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Event does not permit the specified mouse button.' + ); + } + return buttonValue; + } + + /** + * Serialize the current state of the mouse. + */ + getState(): MouseState { + return { + buttonPressed: this.buttonPressed_, + elementPressed: this.elementPressed_, + clientXY: { x: this.clientXY_.x, y: this.clientXY_.y }, + nextClickIsDoubleClick: this.nextClickIsDoubleClick_, + hasEverInteracted: this.hasEverInteracted_, + element: this.getElement(), + }; + } +} diff --git a/javascript/atoms/package.json b/javascript/atoms/package.json index b7f7d7dcbea01..90d5a568151a2 100644 --- a/javascript/atoms/package.json +++ b/javascript/atoms/package.json @@ -4,6 +4,7 @@ "private": true, "description": "Build tools for Selenium Browser Automation Atoms", "license": "Apache-2.0", + "sideEffects": false, "repository": { "type": "git", "url": "https://github.com/SeleniumHQ/selenium.git" diff --git a/javascript/atoms/response.js b/javascript/atoms/response.js deleted file mode 100644 index 9c6f410db9a4b..0000000000000 --- a/javascript/atoms/response.js +++ /dev/null @@ -1,111 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Utilities for working with WebDriver response objects. - * @see: https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#responses - */ - -goog.provide('bot.response'); -goog.provide('bot.response.ResponseObject'); - -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('goog.utils'); - - -/** - * Type definition for a response object, as defined by the JSON wire protocol. - * @typedef {{status: bot.ErrorCode, value: (*|{message: string})}} - * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#responses - */ -bot.response.ResponseObject; - - -/** - * @param {*} value The value to test. - * @return {boolean} Whether the given value is a response object. - */ -bot.response.isResponseObject = function (value) { - return goog.utils.isObject(value) && goog.utils.isNumber(value['status']); -}; - - -/** - * Creates a new success response object with the provided value. - * @param {*} value The response value. - * @return {!bot.response.ResponseObject} The new response object. - */ -bot.response.createResponse = function (value) { - if (bot.response.isResponseObject(value)) { - return /** @type {!bot.response.ResponseObject} */ (value); - } - return { - 'status': bot.ErrorCode.SUCCESS, - 'value': value - }; -}; - - -/** - * Converts an error value into its JSON representation as defined by the - * WebDriver wire protocol. - * @param {(bot.Error|Error|*)} error The error value to convert. - * @return {!bot.response.ResponseObject} The new response object. - */ -bot.response.createErrorResponse = function (error) { - if (bot.response.isResponseObject(error)) { - return /** @type {!bot.response.ResponseObject} */ (error); - } - - var statusCode = error && goog.utils.isNumber(error.code) ? error.code : - bot.ErrorCode.UNKNOWN_ERROR; - return { - 'status': /** @type {bot.ErrorCode} */ (statusCode), - 'value': { - 'message': (error && error.message || error) + '' - } - }; -}; - - -/** - * Checks that a response object does not specify an error as defined by the - * WebDriver wire protocol. If the response object defines an error, it will - * be thrown. Otherwise, the response will be returned as is. - * @param {!bot.response.ResponseObject} responseObj The response object to - * check. - * @return {!bot.response.ResponseObject} The checked response object. - * @throws {bot.Error} If the response describes an error. - * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#failed-commands - */ -bot.response.checkResponse = function (responseObj) { - var status = responseObj['status']; - if (status == bot.ErrorCode.SUCCESS) { - return responseObj; - } - - // If status is not defined, assume an unknown error. - status = status || bot.ErrorCode.UNKNOWN_ERROR; - - var value = responseObj['value']; - if (!value || !goog.utils.isObject(value)) { - throw new bot.Error(status, value + ''); - } - - throw new bot.Error(status, value['message'] + ''); -}; diff --git a/javascript/atoms/response.ts b/javascript/atoms/response.ts new file mode 100644 index 0000000000000..ef978d06aa153 --- /dev/null +++ b/javascript/atoms/response.ts @@ -0,0 +1,115 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Utilities for working with WebDriver response objects. + * @see: https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#responses + */ + +import { BotError, ErrorCode } from './error'; + +/** + * Type definition for a response object, as defined by the JSON wire protocol. + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#responses + */ +export interface ResponseObject { + status: ErrorCode; + value: unknown | { message: string }; +} + +/** + * Checks if the given value is a response object. + * @param value The value to test. + * @return Whether the given value is a response object. + */ +export function isResponseObject(value: unknown): value is ResponseObject { + return ( + typeof value === 'object' && + value !== null && + typeof (value as ResponseObject)['status'] === 'number' + ); +} + +/** + * Creates a new success response object with the provided value. + * @param value The response value. + * @return The new response object. + */ +export function createResponse(value: unknown): ResponseObject { + if (isResponseObject(value)) { + return value; + } + return { + status: ErrorCode.SUCCESS, + value: value, + }; +} + +/** + * Converts an error value into its JSON representation as defined by the + * WebDriver wire protocol. + * @param error The error value to convert. + * @return The new response object. + */ +export function createErrorResponse( + error: BotError | Error | unknown +): ResponseObject { + if (isResponseObject(error)) { + return error; + } + + const errorObj = error as { code?: number; message?: string }; + const statusCode = + errorObj && typeof errorObj.code === 'number' + ? errorObj.code + : ErrorCode.UNKNOWN_ERROR; + + return { + status: statusCode as ErrorCode, + value: { + message: ((errorObj && errorObj.message) || error) + '', + }, + }; +} + +/** + * Checks that a response object does not specify an error as defined by the + * WebDriver wire protocol. If the response object defines an error, it will + * be thrown. Otherwise, the response will be returned as is. + * @param responseObj The response object to check. + * @return The checked response object. + * @throws BotError If the response describes an error. + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#failed-commands + */ +export function checkResponse(responseObj: ResponseObject): ResponseObject { + const status = responseObj['status']; + if (status === ErrorCode.SUCCESS) { + return responseObj; + } + + const statusCode = status || ErrorCode.UNKNOWN_ERROR; + const value = responseObj['value']; + + if (!value || typeof value !== 'object') { + throw new BotError(statusCode, value + ''); + } + + throw new BotError( + statusCode, + (value as { message?: string })['message'] + '' + ); +} diff --git a/javascript/atoms/scripts/generate-shim.js b/javascript/atoms/scripts/generate-shim.js new file mode 100755 index 0000000000000..7a4ca6d7f9d97 --- /dev/null +++ b/javascript/atoms/scripts/generate-shim.js @@ -0,0 +1,1816 @@ +#!/usr/bin/env node +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Generates a Closure-compatible shim from a TypeScript module. + * + * This script parses a TypeScript file to extract its exports and generates + * a JavaScript shim that exposes those exports via goog.provide, allowing + * existing Closure-based tests to continue working during the migration. + * + * The generated shim uses proper Closure JSDoc annotations so that the + * Closure Compiler understands the types without needing suppressions. + * + * Usage: node generate-shim.js + * Example: node generate-shim.js bot.ts bot ../dist/bot.js + */ + +const fs = require('fs'); +const path = require('path'); + +// Namespace mapping: TypeScript file basename -> Closure namespace +const NAMESPACE_MAP = { + bot: 'bot', + dom: 'bot.dom', + domcore: 'bot.dom.core', + css: 'bot.locators.css', + action: 'bot.action', + mouse: 'bot.Mouse', + keyboard: 'bot.Keyboard', + touchscreen: 'bot.Touchscreen', + device: 'bot.Device', + color: 'bot.color', + error: 'bot', + response: 'bot.response', + events: 'bot.events', + userAgent: 'bot.userAgent', + json: 'bot.json', + inject: 'bot.inject', + frame: 'bot.frame', + window: 'bot.window', + // HTML5 modules + html5: 'bot.html5', + appcache: 'bot.appcache', + database: 'bot.storage.database', + location: 'bot.geolocation', + storage: 'bot.storage', + // Locator modules + id: 'bot.locators.id', + name: 'bot.locators.name', + classname: 'bot.locators.className', + tag_name: 'bot.locators.tagName', + link_text: 'bot.locators.linkText', + xpath: 'bot.locators.xpath', + relative: 'bot.locators.relative', + locators: 'bot.locators', +}; + +// Dependencies mapping by file (not namespace) - which Closure modules each file requires +const FILE_DEPS_MAP = { + 'bot': [], + 'error': ['goog.utils'], // error.ts uses goog.utils.inherits + 'response': ['bot.Error', 'bot.ErrorCode'], + 'color': [], + 'userAgent': [ + 'goog.string', + 'goog.userAgent', + 'goog.userAgent.product', + 'goog.userAgent.product.isVersion', + ], + 'json': [], + 'domcore': ['bot.Error', 'bot.ErrorCode', 'bot.userAgent'], + 'css': ['bot.Error', 'bot.ErrorCode'], + 'dom': ['bot.dom.core', 'bot.color', 'bot.userAgent', 'bot.locators.css'], + 'action': ['bot', 'bot.dom', 'bot.Error', 'bot.events'], + 'events': ['bot', 'bot.Error', 'bot.ErrorCode', 'bot.userAgent', 'goog.userAgent', 'goog.userAgent.product'], + 'device': ['bot', 'bot.Error', 'bot.ErrorCode', 'bot.dom', 'bot.events', 'bot.locators', 'bot.userAgent', 'goog.userAgent', 'goog.userAgent.product'], + 'keyboard': ['bot', 'bot.Device', 'bot.Error', 'bot.ErrorCode', 'bot.dom', 'bot.events', 'bot.userAgent', 'goog.userAgent'], + 'mouse': ['bot', 'bot.Device', 'bot.Error', 'bot.ErrorCode', 'bot.dom', 'bot.events', 'bot.userAgent', 'goog.userAgent'], + 'touchscreen': ['bot', 'bot.Device', 'bot.Error', 'bot.ErrorCode', 'bot.dom', 'bot.events', 'bot.userAgent'], + 'action': [ + 'bot', + 'bot.Device', + 'bot.Error', + 'bot.ErrorCode', + 'bot.Keyboard', + 'bot.Mouse', + 'bot.Touchscreen', + 'bot.dom', + 'bot.events', + 'bot.userAgent', + 'goog.userAgent', + 'goog.userAgent.product', + 'goog.utils', + ], + 'frame': ['bot', 'bot.Error', 'bot.ErrorCode', 'bot.dom', 'bot.locators'], + 'window': ['bot', 'bot.Error', 'bot.ErrorCode', 'bot.events', 'bot.userAgent'], + // Locator modules + 'id': ['bot.dom.core'], + 'name': ['bot.dom.core'], + 'classname': ['bot.Error', 'bot.ErrorCode'], + 'tag_name': ['bot.Error', 'bot.ErrorCode'], + 'link_text': ['bot.dom', 'bot.locators.css'], + 'xpath': ['bot.Error', 'bot.ErrorCode'], + 'relative': ['bot.Error', 'bot.ErrorCode', 'bot.dom'], + 'locators': ['bot', 'bot.Error', 'bot.ErrorCode', 'bot.locators.className', 'bot.locators.css', 'bot.locators.id', 'bot.locators.linkText', 'bot.locators.name', 'bot.locators.partialLinkText', 'bot.locators.relative', 'bot.locators.tagName', 'bot.locators.xpath'], + 'inject': ['bot.Error', 'bot.ErrorCode', 'bot.json'], + // HTML5 modules + 'html5': ['bot', 'bot.Error', 'bot.ErrorCode', 'bot.userAgent'], + 'appcache': ['bot', 'bot.Error', 'bot.ErrorCode', 'bot.html5'], + 'database': ['bot', 'bot.Error', 'bot.ErrorCode'], + 'location': ['bot', 'bot.Error', 'bot.ErrorCode', 'bot.html5'], + 'storage': ['bot', 'bot.Error', 'bot.ErrorCode', 'bot.html5'], +}; + +// Export rename mapping: TypeScript export name -> Closure export name +const EXPORT_RENAME_MAP = { + 'error': { + 'BotError': 'Error', + 'State': 'Error.State', + }, + 'storage': { + 'StorageWrapper': 'Storage', + }, +}; + +// Additional provides for files that need to provide multiple namespaces +const ADDITIONAL_PROVIDES_MAP = { + 'error': ['bot.Error', 'bot.ErrorCode'], + 'response': ['bot.response', 'bot.response.ResponseObject'], + 'inject': ['bot.inject', 'bot.inject.cache'], + 'events': [ + 'bot.events', + 'bot.events.EventType', + 'bot.events.EventArgs', + 'bot.events.MouseArgs', + 'bot.events.KeyboardArgs', + 'bot.events.TouchArgs', + 'bot.events.Touch', + 'bot.events.MSGestureArgs', + 'bot.events.MSPointerArgs', + ], + 'device': [ + 'bot.Device', + 'bot.Device.EventEmitter', + 'bot.Device.ModifiersState', + 'bot.Device.Modifier', + ], + 'mouse': [ + 'bot.Mouse', + 'bot.Mouse.Button', + 'bot.Mouse.State', + ], + 'keyboard': [ + 'bot.Keyboard', + 'bot.Keyboard.Key', + 'bot.Keyboard.Keys', + 'bot.Keyboard.State', + ], + 'touchscreen': ['bot.Touchscreen'], + 'action': ['bot.action'], + 'frame': ['bot.frame'], + 'window': ['bot.window', 'bot.window.Orientation'], + // HTML5 modules + 'html5': ['bot.html5', 'bot.html5.API'], + 'appcache': ['bot.appcache'], + 'database': ['bot.storage.database', 'bot.storage.database.ResultSet'], + 'location': ['bot.geolocation'], + 'storage': ['bot.storage', 'bot.storage.Storage'], +}; + +// Import alias mapping: maps TypeScript import names to their Closure equivalents +const IMPORT_ALIAS_MAP = { + 'response': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + }, +}; + +// Symbol replacement mapping for each file: local symbol -> Closure namespace symbol +// Applied when extracting code from compiled JS +const SYMBOL_REPLACEMENTS = { + 'error': { + 'State': 'bot.Error.State', + 'ErrorCode': 'bot.ErrorCode', + 'CODE_TO_STATE': 'bot.Error.CODE_TO_STATE_', + }, + 'response': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'isResponseObject': 'bot.response.isResponseObject', + }, + 'domcore': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'IE_DOC_PRE8': 'bot.userAgent.IE_DOC_PRE8', + 'IE_DOC_PRE9': 'bot.userAgent.IE_DOC_PRE9', + }, + 'css': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + }, + 'dom': { + 'isElement': 'bot.dom.core.isElement', + 'isSelectable': 'bot.dom.core.isSelectable', + 'isSelected': 'bot.dom.core.isSelected', + 'getAttribute': 'bot.dom.core.getAttribute', + 'getProperty': 'bot.dom.core.getProperty', + 'standardizeColor': 'bot.color.standardizeColor', + 'IE_DOC_PRE9': 'bot.userAgent.IE_DOC_PRE9', + 'isEngineVersion': 'bot.userAgent.isEngineVersion', + 'cssSingle': 'bot.locators.css.single', + }, + // Locator modules + 'id': { + 'getAttribute': 'bot.dom.core.getAttribute', + }, + 'name': { + 'getAttribute': 'bot.dom.core.getAttribute', + }, + 'classname': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + }, + 'tag_name': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + }, + 'link_text': { + 'getVisibleText': 'bot.dom.getVisibleText', + 'cssMany': 'bot.locators.css.many', + }, + 'xpath': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + }, + 'relative': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'getClientRect': 'bot.dom.getClientRect', + }, + 'locators': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + }, + 'inject': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'stringify': 'bot.json.stringify', + }, + 'events': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'IE': 'goog.userAgent.IE', + 'GECKO': 'goog.userAgent.GECKO', + 'WEBKIT': 'goog.userAgent.WEBKIT', + 'EDGE': 'goog.userAgent.EDGE', + 'ANDROID': 'goog.userAgent.product.ANDROID', + 'IOS': 'bot.userAgent.IOS', + 'isEngineVersion': 'bot.userAgent.isEngineVersion', + 'isProductVersion': 'bot.userAgent.isProductVersion', + 'getWindow': 'bot.getWindow', + }, + 'device': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'isElement': 'bot.dom.isElement', + 'isSelectable': 'bot.dom.isSelectable', + 'isSelected': 'bot.dom.isSelected', + 'isInteractable': 'bot.dom.isInteractable', + 'isFocusable': 'bot.dom.isFocusable', + 'getActiveElement': 'bot.dom.getActiveElement', + 'getClientRect': 'bot.dom.getClientRect', + 'fire': 'bot.events.fire', + 'EventType': 'bot.events.EventType', + 'EventFactory': 'bot.events.EventFactory_', + 'IE': 'goog.userAgent.IE', + 'GECKO': 'goog.userAgent.GECKO', + 'WEBKIT': 'goog.userAgent.WEBKIT', + 'isEngineVersion': 'bot.userAgent.isEngineVersion', + 'isProductVersion': 'bot.userAgent.isProductVersion', + 'WEBEXTENSION': 'bot.userAgent.WEBEXTENSION', + 'getDocument': 'bot.getDocument', + }, + 'touchscreen': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'Device': 'bot.Device', + 'Coordinate': 'goog.math.Coordinate', + 'clearPointerMap': 'bot.Device.clearPointerMap', + 'isInteractable': 'bot.dom.isInteractable', + 'isElement': 'bot.dom.isElement', + 'isSelectable': 'bot.dom.isSelectable', + 'getClientRect': 'bot.dom.getClientRect', + 'getEffectiveStyle': 'bot.dom.getEffectiveStyle', + 'getParentElement': 'bot.dom.getParentElement', + 'EventType': 'bot.events.EventType', + 'IE_DOC_10': 'bot.userAgent.IE_DOC_10', + 'IOS': 'bot.userAgent.IOS', + 'WINDOWS_PHONE': 'bot.userAgent.WINDOWS_PHONE', + }, + 'mouse': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'Device': 'bot.Device', + 'ModifiersState': 'bot.Device.ModifiersState', + 'EventEmitter': 'bot.Device.EventEmitter', + 'Coordinate': 'goog.math.Coordinate', + 'MOUSE_MS_POINTER_ID': 'bot.Device.MOUSE_MS_POINTER_ID', + 'clearPointerMap': 'bot.Device.clearPointerMap', + 'isElement': 'bot.dom.isElement', + 'isInteractable': 'bot.dom.isInteractable', + 'getActiveElement': 'bot.dom.getActiveElement', + 'getClientRect': 'bot.dom.getClientRect', + 'EventType': 'bot.events.EventType', + 'EventFactory': 'bot.events.EventFactory_', + 'IE': 'goog.userAgent.IE', + 'GECKO': 'goog.userAgent.GECKO', + 'WEBKIT': 'goog.userAgent.WEBKIT', + 'isProductVersion': 'bot.userAgent.isProductVersion', + 'IE_DOC_PRE9': 'bot.userAgent.IE_DOC_PRE9', + 'IE_DOC_9': 'bot.userAgent.IE_DOC_9', + 'IE_DOC_10': 'bot.userAgent.IE_DOC_10', + 'WINDOWS_PHONE': 'bot.userAgent.WINDOWS_PHONE', + 'getDocument': 'bot.getDocument', + }, + 'keyboard': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'Device': 'bot.Device', + 'ModifiersState': 'bot.Device.ModifiersState', + 'Modifier': 'bot.Device.Modifier', + 'isFormSubmitElement': 'bot.Device.isFormSubmitElement', + 'findAncestorForm': 'bot.Device.findAncestorForm', + 'isEditable': 'bot.dom.isEditable', + 'isElement': 'bot.dom.isElement', + 'EventType': 'bot.events.EventType', + 'EventFactory': 'bot.events.EventFactory_', + 'IE': 'goog.userAgent.IE', + 'GECKO': 'goog.userAgent.GECKO', + 'WEBKIT': 'goog.userAgent.WEBKIT', + 'EDGE': 'goog.userAgent.EDGE', + 'IE_DOC_PRE9': 'bot.userAgent.IE_DOC_PRE9', + 'isEngineVersion': 'bot.userAgent.isEngineVersion', + }, + 'action': { + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'Device': 'bot.Device', + 'findAncestorForm': 'bot.Device.findAncestorForm', + 'Keyboard': 'bot.Keyboard', + 'Key': 'bot.Keyboard.Key', + 'Keys': 'bot.Keyboard.Keys', + 'MODIFIERS': 'bot.Keyboard.MODIFIERS', + 'Mouse': 'bot.Mouse', + 'Button': 'bot.Mouse.Button', + 'Touchscreen': 'bot.Touchscreen', + 'isShown': 'bot.dom.isShown', + 'isInteractable': 'bot.dom.isInteractable', + 'isEditable': 'bot.dom.isEditable', + 'isElement': 'bot.dom.isElement', + 'isContentEditable': 'bot.dom.isContentEditable', + 'isInputType': 'bot.dom.isInputType', + 'getActiveElement': 'bot.dom.getActiveElement', + 'getOverflowState': 'bot.dom.getOverflowState', + 'getClientRect': 'bot.dom.getClientRect', + 'getClientRegion': 'bot.dom.getClientRegion', + 'getParentElement': 'bot.dom.getParentElement', + 'OverflowState': 'bot.dom.OverflowState', + 'fire': 'bot.events.fire', + 'EventType': 'bot.events.EventType', + 'GECKO': 'goog.userAgent.GECKO', + 'IE': 'goog.userAgent.IE', + 'getDocument': 'bot.getDocument', + }, + 'frame': { + 'getWindow': 'bot.getWindow', + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'isElement': 'bot.dom.isElement', + 'findElements': 'bot.locators.findElements', + }, + 'window': { + 'getWindow': 'bot.getWindow', + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'fire': 'bot.events.fire', + 'EventType': 'bot.events.EventType', + 'isEngineVersion': 'bot.userAgent.isEngineVersion', + 'ANDROID_PRE_ICECREAMSANDWICH': 'bot.userAgent.ANDROID_PRE_ICECREAMSANDWICH', + }, + // HTML5 modules + 'html5': { + 'getWindow': 'bot.getWindow', + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'isEngineVersion': 'bot.userAgent.isEngineVersion', + 'isProductVersion': 'bot.userAgent.isProductVersion', + }, + 'appcache': { + 'getWindow': 'bot.getWindow', + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'API': 'bot.html5.API', + 'isSupported': 'bot.html5.isSupported', + }, + 'database': { + 'getWindow': 'bot.getWindow', + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + }, + 'location': { + 'getWindow': 'bot.getWindow', + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'API': 'bot.html5.API', + 'isSupported': 'bot.html5.isSupported', + }, + 'storage': { + 'getWindow': 'bot.getWindow', + 'BotError': 'bot.Error', + 'ErrorCode': 'bot.ErrorCode', + 'API': 'bot.html5.API', + 'isSupported': 'bot.html5.isSupported', + }, +}; + +// Defines which exports are "nested" under another export +const NESTED_EXPORTS_MAP = { + 'error': { + 'State': 'Error', + }, +}; + +// Private constants that need to be generated for each file +const PRIVATE_CONSTANTS_MAP = { + 'error': ['CODE_TO_STATE'], +}; + +// Module-level initialization code that should be included in the shim +// This handles variables and initialization blocks that functions depend on +const MODULE_INIT_MAP = { + 'bot': ` +/** @type {!Window} */ +var currentWindow; +try { + currentWindow = window; +} catch (ignored) { + currentWindow = /** @type {!Window} */ (globalThis); +} +`, +}; + +// Additional exports needed for specific files (exports not in the main exports list) +const ADDITIONAL_EXPORTS_MAP = { + 'relative': ['setFindElement', 'setFindElements'], +}; + +// Files that need completely custom shims because they orchestrate other modules +const ORCHESTRATOR_FILES = ['locators']; + +// Namespaces that need special handling due to complex exports +// Keyed by namespace, not basename +const SPECIAL_NAMESPACE_HANDLERS = { + // The partialLinkText shim needs to export the partialLinkText object's methods + // instead of the regular single/many exports from link_text.ts + 'bot.locators.partialLinkText': function(shimHeader, compiledJs) { + let shim = shimHeader; + shim += `(function() { + /** + * Find an element by using the text value of a link. + */ + function singleImpl(target, root, isPartial) { + let elements; + try { + elements = bot.locators.css.many('a', root); + } + catch (e) { + elements = root.getElementsByTagName('a'); + } + const found = Array.from(elements).find(function(element) { + var text = bot.dom.getVisibleText(element); + text = text.replace(/^[\\s]+|[\\s]+$/g, ''); + return (isPartial && text.indexOf(target) !== -1) || text === target; + }); + return found || null; + } + /** + * Find many elements by using the value of the link text. + */ + function manyImpl(target, root, isPartial) { + let elements; + try { + elements = bot.locators.css.many('a', root); + } + catch (e) { + elements = root.getElementsByTagName('a'); + } + return Array.from(elements).filter(function(element) { + var text = bot.dom.getVisibleText(element); + text = text.replace(/^[\\s]+|[\\s]+$/g, ''); + return (isPartial && text.indexOf(target) !== -1) || text === target; + }); + } + /** + * @param {string} target + * @param {!(Document|Element)} root + * @return {Element} + */ + bot.locators.partialLinkText.single = function(target, root) { + return singleImpl(target, root, true); + }; + /** + * @param {string} target + * @param {!(Document|Element)} root + * @return {!IArrayLike} + */ + bot.locators.partialLinkText.many = function(target, root) { + return manyImpl(target, root, true); + }; +})(); +`; + return shim; + } +}; + +const BUNDLE_MODE_FILES = [ + 'color', 'userAgent', 'json', 'domcore', 'css', 'dom', + // Locator modules (except locators.ts which needs special handling) + 'id', 'name', 'classname', 'tag_name', 'link_text', 'xpath', 'relative', + 'inject', + 'events', + 'device', + 'keyboard', + 'mouse', + 'touchscreen', + 'action', + 'frame', + 'window', + // HTML5 modules + 'html5', + 'appcache', + 'database', + 'location', + 'storage', +]; + +/** + * Parses TypeScript to extract detailed export information. + */ +function parseExports(tsContent) { + const exports = { + functions: [], + constants: [], + enums: [], + classes: [], + interfaces: [], + privateConstants: [], + }; + + // Parse enums (including const enum) + const enumRegex = /export\s+(?:const\s+)?enum\s+(\w+)\s*\{([^}]+)\}/g; + let match; + while ((match = enumRegex.exec(tsContent)) !== null) { + const enumName = match[1]; + const enumBody = match[2]; + const hasStringValues = enumBody.includes("'") || enumBody.includes('"'); + const members = []; + + const memberRegex = /(\w+)\s*=\s*([^,\n]+)/g; + let memberMatch; + while ((memberMatch = memberRegex.exec(enumBody)) !== null) { + members.push({ + name: memberMatch[1], + value: memberMatch[2].trim(), + }); + } + + exports.enums.push({ + name: enumName, + type: hasStringValues ? 'string' : 'number', + members: members, + }); + } + + // Parse classes + const classRegex = /export\s+class\s+(\w+)(?:\s+extends\s+(\w+))?\s*\{/g; + while ((match = classRegex.exec(tsContent)) !== null) { + const className = match[1]; + const extendsClass = match[2] || null; + const classStart = match.index; + const classBody = extractBracedBlock(tsContent, classStart); + const constructorParams = parseConstructorParams(classBody); + const classProperties = parseClassProperties(classBody); + + exports.classes.push({ + name: className, + extends: extendsClass, + constructorParams: constructorParams, + properties: classProperties, + }); + } + + // Parse functions + const functionRegex = + /export\s+function\s+(\w+)\s*(?:<[^>]*>)?\s*\(([^)]*)\)\s*(?::\s*([^{]+))?\s*\{/g; + while ((match = functionRegex.exec(tsContent)) !== null) { + exports.functions.push({ + name: match[1], + params: parseParameters(match[2]), + returnType: match[3] ? match[3].trim() : 'void', + }); + } + + // Parse exported constants + const constRegex = /export\s+const\s+(\w+)\s*(?::\s*([^=]+))?\s*=/g; + while ((match = constRegex.exec(tsContent)) !== null) { + exports.constants.push({ + name: match[1], + type: match[2] ? match[2].trim() : null, + }); + } + + // Parse private (non-exported) constants + const privateConstRegex = /^const\s+(\w+)\s*(?::\s*([^=]+))?\s*=\s*(\{[^}]+\}|[^;]+);/gm; + while ((match = privateConstRegex.exec(tsContent)) !== null) { + // Skip if it's an export const + if (tsContent.substring(match.index - 10, match.index).includes('export')) { + continue; + } + exports.privateConstants.push({ + name: match[1], + type: match[2] ? match[2].trim() : null, + value: match[3].trim(), + }); + } + + // Parse interfaces + const interfaceRegex = /export\s+interface\s+(\w+)\s*\{([^}]+)\}/g; + while ((match = interfaceRegex.exec(tsContent)) !== null) { + exports.interfaces.push({ + name: match[1], + fields: parseInterfaceFields(match[2]), + }); + } + + return exports; +} + +/** + * Extracts a braced block starting from the given position. + */ +function extractBracedBlock(content, startPos) { + let braceCount = 0; + let started = false; + let blockStart = startPos; + + for (let i = startPos; i < content.length; i++) { + if (content[i] === '{') { + if (!started) { + blockStart = i; + started = true; + } + braceCount++; + } else if (content[i] === '}') { + braceCount--; + if (started && braceCount === 0) { + return content.substring(blockStart, i + 1); + } + } + } + return content.substring(blockStart); +} + +/** + * Parses constructor parameters from a class body. + */ +function parseConstructorParams(classBody) { + const constructorMatch = classBody.match(/constructor\s*\(([^)]*)\)/); + if (!constructorMatch) { + return []; + } + return parseParameters(constructorMatch[1]); +} + +/** + * Parses class properties from a class body. + */ +function parseClassProperties(classBody) { + const properties = []; + const propRegex = /^\s*(\w+)\s*:\s*([^;=]+);/gm; + let match; + while ((match = propRegex.exec(classBody)) !== null) { + properties.push({ + name: match[1], + type: match[2].trim(), + }); + } + return properties; +} + +/** + * Parses a parameter list string into structured data. + */ +function parseParameters(paramsStr) { + if (!paramsStr.trim()) { + return []; + } + + const params = []; + const paramParts = splitParameters(paramsStr); + + for (const part of paramParts) { + const trimmed = part.trim(); + if (!trimmed) continue; + + const paramMatch = trimmed.match(/(\w+)(\?)?(?:\s*:\s*(.+))?/); + if (paramMatch) { + params.push({ + name: paramMatch[1], + optional: !!paramMatch[2], + type: paramMatch[3] ? paramMatch[3].trim() : 'any', + }); + } + } + return params; +} + +/** + * Splits parameter string respecting nested brackets. + */ +function splitParameters(paramsStr) { + const result = []; + let current = ''; + let depth = 0; + + for (const char of paramsStr) { + if (char === '<' || char === '(' || char === '{' || char === '[') { + depth++; + current += char; + } else if (char === '>' || char === ')' || char === '}' || char === ']') { + depth--; + current += char; + } else if (char === ',' && depth === 0) { + result.push(current); + current = ''; + } else { + current += char; + } + } + if (current.trim()) { + result.push(current); + } + return result; +} + +/** + * Parses interface fields. + */ +function parseInterfaceFields(interfaceBody) { + const fields = []; + // Match fields with or without trailing semicolons + const fieldRegex = /(\w+)(\?)?:\s*([^;\n}]+);?/g; + let match; + while ((match = fieldRegex.exec(interfaceBody)) !== null) { + const fieldType = match[3].trim(); + if (fieldType) { + fields.push({ + name: match[1], + optional: !!match[2], + type: fieldType, + }); + } + } + return fields; +} + +/** + * Converts a TypeScript type to a Closure type. + */ +function tsTypeToClosureType(tsType, nullable = false) { + if (!tsType) return '*'; + + // Handle TypeScript type predicates (e.g., "value is ResponseObject" -> boolean) + if (tsType.includes(' is ')) { + return 'boolean'; + } + + // Handle inline object types like { message: string } - just use Object + if (tsType.includes('{') && tsType.includes('}')) { + return '*'; + } + + // Handle union types with undefined/null + if (tsType.includes('|')) { + const parts = tsType.split('|').map((p) => p.trim()); + const nonNullParts = parts.filter((p) => p !== 'undefined' && p !== 'null'); + if (parts.length !== nonNullParts.length) { + if (nonNullParts.length === 1) { + return '?' + tsTypeToClosureType(nonNullParts[0]); + } + } + // For complex union types, just use * + if (parts.length > 1) { + return '*'; + } + } + + const typeMap = { + 'string': 'string', + 'number': 'number', + 'boolean': 'boolean', + 'void': 'void', + 'undefined': 'undefined', + 'null': 'null', + 'any': '*', + 'unknown': '*', + 'object': '!Object', + 'Object': '!Object', + 'Error': '!Error', + 'ErrorCode': 'bot.ErrorCode', + 'BotError': '!bot.Error', + 'State': 'bot.Error.State', + 'ResponseObject': 'bot.response.ResponseObject', + }; + + const mapped = typeMap[tsType]; + if (mapped) { + return nullable ? '?' + mapped : mapped; + } + + if (tsType.endsWith('[]')) { + const elementType = tsType.slice(0, -2); + return '!Array<' + tsTypeToClosureType(elementType) + '>'; + } + + if (tsType.startsWith('Record<')) { + return '!Object'; + } + + return nullable ? '?' + tsType : '!' + tsType; +} + +/** + * Applies symbol replacements to code. + */ +function applySymbolReplacements(code, replacements) { + let result = code; + for (const [symbol, replacement] of Object.entries(replacements)) { + // Use word boundary to avoid partial matches + result = result.replace(new RegExp(`\\b${symbol}\\b`, 'g'), replacement); + } + return result; +} + +/** + * Generates the dom module shim with proper JSDoc annotations for Closure Compiler. + * This is special because the dom module has many functions that need type info. + */ +function generateDomModuleShim(shimHeader, namespace, exports, moduleCode) { + let shim = shimHeader; + + // Define a private namespace for the implementation + const implNamespace = 'bot.dom.impl_'; + + // Wrap the implementation in an IIFE that attaches to a private namespace + shim += `/** @private */\n`; + shim += `${implNamespace} = {};\n\n`; + shim += `(function() {\n`; + + const lines = moduleCode.split('\n'); + lines.forEach((line) => { + shim += ` ${line}\n`; + }); + + shim += '\n'; + + // Assign functions to the private implementation namespace + exports.functions.forEach((fn) => { + shim += ` ${implNamespace}${fn.name} = ${fn.name};\n`; + }); + + // Assign constants to the private implementation namespace + exports.constants.forEach((c) => { + shim += ` ${implNamespace}${c.name} = ${c.name};\n`; + }); + + // Also export OverflowState enum + shim += ` ${implNamespace}OverflowState = OverflowState;\n`; + + shim += `})();\n\n`; + + // Now generate properly-typed wrapper functions + // Each function gets full JSDoc and delegates to the implementation + + // Type mappings for dom functions + const domFunctionTypes = { + getActiveElement: { + params: [{ name: 'nodeOrWindow', type: '(!Node|!Window)' }], + returns: '?Element', + }, + isInteractable: { + params: [{ name: 'element', type: '!Element' }], + returns: 'boolean', + }, + isFocusable: { + params: [{ name: 'element', type: '!Element' }], + returns: 'boolean', + }, + isEnabled: { + params: [{ name: 'el', type: '!Element' }], + returns: 'boolean', + }, + isTextual: { + params: [{ name: 'element', type: '!Element' }], + returns: 'boolean', + }, + isFileInput: { + params: [{ name: 'element', type: '!Element' }], + returns: 'boolean', + }, + isInputType: { + params: [{ name: 'element', type: '!Element' }, { name: 'inputType', type: 'string' }], + returns: 'boolean', + }, + isContentEditable: { + params: [{ name: 'element', type: '!Element' }], + returns: 'boolean', + }, + isEditable: { + params: [{ name: 'element', type: '!Element' }], + returns: 'boolean', + }, + getParentElement: { + params: [{ name: 'node', type: '!Node' }], + returns: '?Element', + }, + getInlineStyle: { + params: [{ name: 'elem', type: '!Element' }, { name: 'styleName', type: 'string' }], + returns: 'string', + }, + getEffectiveStyle: { + params: [{ name: 'elem', type: '!Element' }, { name: 'propertyName', type: 'string' }], + returns: '?string', + }, + isShown: { + params: [{ name: 'elem', type: '!Element' }, { name: 'opt_ignoreOpacity', type: 'boolean=', optional: true }], + returns: 'boolean', + }, + getOverflowState: { + params: [{ name: 'elem', type: '!Element' }, { name: 'opt_region', type: '(goog.math.Coordinate|goog.math.Rect)=', optional: true }], + returns: 'bot.dom.OverflowState', + }, + getClientRect: { + params: [{ name: 'elem', type: '!Element' }], + returns: '!goog.math.Rect', + }, + getClientRegion: { + params: [{ name: 'elem', type: '!Element' }, { name: 'opt_region', type: '(goog.math.Coordinate|goog.math.Rect)=', optional: true }], + returns: '!goog.math.Box', + }, + getVisibleText: { + params: [{ name: 'elem', type: '!Element' }], + returns: 'string', + }, + getOpacity: { + params: [{ name: 'elem', type: '!Element' }], + returns: 'number', + }, + getParentNodeInComposedDom: { + params: [{ name: 'node', type: '!Node' }], + returns: '?Node', + }, + isNodeDistributedIntoShadowDom: { + params: [{ name: 'node', type: '!Node' }], + returns: 'boolean', + }, + }; + + // Generate wrapper functions with JSDoc + for (const fn of exports.functions) { + const typeInfo = domFunctionTypes[fn.name]; + if (!typeInfo) { + // Unknown function, just assign directly + shim += `${namespace}.${fn.name} = ${implNamespace}${fn.name};\n`; + continue; + } + + shim += `/**\n`; + for (const param of typeInfo.params) { + shim += ` * @param {${param.type}} ${param.name}\n`; + } + shim += ` * @return {${typeInfo.returns}}\n`; + shim += ` */\n`; + + const paramNames = typeInfo.params.map((p) => p.name).join(', '); + shim += `${namespace}.${fn.name} = function(${paramNames}) {\n`; + shim += ` return ${implNamespace}${fn.name}(${paramNames});\n`; + shim += `};\n\n`; + } + + // Generate constants + for (const c of exports.constants) { + shim += `/** @const */\n`; + shim += `${namespace}.${c.name} = ${implNamespace}${c.name};\n\n`; + } + + // Generate OverflowState enum + shim += `/**\n`; + shim += ` * @enum {string}\n`; + shim += ` */\n`; + shim += `${namespace}.OverflowState = ${implNamespace}OverflowState;\n\n`; + + // Re-export functions from bot.dom.core for backward compatibility + shim += `// Re-export functions from bot.dom.core for backward compatibility\n`; + shim += `/** @const */\n`; + shim += `bot.dom.isElement = bot.dom.core.isElement;\n\n`; + shim += `/** @const */\n`; + shim += `bot.dom.isSelectable = bot.dom.core.isSelectable;\n\n`; + shim += `/** @const */\n`; + shim += `bot.dom.isSelected = bot.dom.core.isSelected;\n\n`; + shim += `/** @const */\n`; + shim += `bot.dom.getAttribute = bot.dom.core.getAttribute;\n\n`; + shim += `/** @const */\n`; + shim += `bot.dom.getProperty = bot.dom.core.getProperty;\n`; + + return shim; +} + +/** + * Generates a bundle-mode shim that includes the entire compiled JS + * wrapped in an IIFE, with exports assigned to the namespace. + */ +function generateBundleModeShim(shimHeader, namespace, exports, compiledJs, basename) { + let shim = shimHeader; + + // Get symbol replacements for this file + const symbolReplacements = SYMBOL_REPLACEMENTS[basename] || {}; + + // Strip the ES module import/export and source map comment from compiled JS + let moduleCode = compiledJs + .replace(/^import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"];?\s*$/gm, '') // Remove import statements + .replace(/^export\s+\{[^}]+\};?\s*$/gm, '') // Remove re-export statements + .replace(/^export\s+/gm, '') + .replace(/\/\/# sourceMappingURL=.*$/m, '') + .trim(); + + // Apply symbol replacements for imported symbols + moduleCode = applySymbolReplacements(moduleCode, symbolReplacements); + + // For 'dom' module, use a different strategy: create namespace wrapper functions + // with JSDoc that delegate to the internal implementation + if (basename === 'dom') { + return generateDomModuleShim(shim, namespace, exports, moduleCode); + } + + // Special handling for device: insert early assignment of Modifier enum + // after its definition so it can be used in ModifiersState class methods + if (basename === 'device') { + moduleCode = moduleCode.replace( + /\}\)\(Modifier \|\| \(Modifier = \{\}\)\);/, + '})(Modifier || (Modifier = {}));\n // Early assignment for Closure Compiler\n bot.Device.Modifier = Modifier;' + ); + } + + // Wrap in IIFE to create a scope for the private symbols + shim += `(function() {\n`; + + // Include the module code (constants, helper functions, etc.) + const lines = moduleCode.split('\n'); + lines.forEach((line) => { + shim += ` ${line}\n`; + }); + + shim += '\n'; + + // Special handling for inject: assign to both bot.inject and bot.inject.cache namespaces + if (basename === 'inject') { + shim += ` + // Assign cache functions to bot.inject.cache namespace + bot.inject.cache.CACHE_KEY_ = CACHE_KEY; + bot.inject.cache.ELEMENT_KEY_PREFIX = ELEMENT_KEY_PREFIX; + bot.inject.cache.addElement = addElement; + bot.inject.cache.getElement = getElement; + + // Assign main functions to bot.inject namespace + bot.inject.ELEMENT_KEY = ELEMENT_KEY; + bot.inject.WINDOW_KEY = WINDOW_KEY; + bot.inject.wrapValue = wrapValue; + bot.inject.unwrapValue = unwrapValue; + bot.inject.wrapResponse = wrapResponse; + bot.inject.wrapError = wrapError; + bot.inject.executeScript = executeScript; + bot.inject.executeAsyncScript = executeAsyncScript; +`; + } else if (basename !== 'device') { + // Skip for device since it uses special handling where Device class IS the namespace + // Assign exported functions to the namespace + exports.functions.forEach((fn) => { + shim += ` ${namespace}.${fn.name} = ${fn.name};\n`; + }); + + // Assign exported constants to the namespace + exports.constants.forEach((c) => { + shim += ` ${namespace}.${c.name} = ${c.name};\n`; + }); + + // Assign exported enums to the namespace + exports.enums.forEach((e) => { + shim += ` ${namespace}.${e.name} = ${e.name};\n`; + }); + } + + // Assign additional exports if defined for this file (skip for device) + if (basename !== 'device') { + const additionalExports = ADDITIONAL_EXPORTS_MAP[basename] || []; + additionalExports.forEach((exportName) => { + shim += ` ${namespace}.${exportName} = ${exportName};\n`; + }); + } + + // Assign exported classes to the namespace + // Skip for device, touchscreen, mouse, keyboard since they use special handling where the class IS the namespace + if (basename !== 'device' && basename !== 'touchscreen' && basename !== 'mouse' && basename !== 'keyboard') { + exports.classes.forEach((cls) => { + shim += ` ${namespace}.${cls.name} = ${cls.name};\n`; + }); + } + + // Special handling for events: also need to export isSynthetic and factory classes + // with underscore suffix for backward compatibility with device.js + if (basename === 'events') { + shim += ` ${namespace}.isSynthetic = isSynthetic;\n`; + shim += ` ${namespace}.EventFactory_ = EventFactory;\n`; + shim += ` ${namespace}.MouseEventFactory_ = MouseEventFactory;\n`; + shim += ` ${namespace}.KeyboardEventFactory_ = KeyboardEventFactory;\n`; + shim += ` ${namespace}.TouchEventFactory_ = TouchEventFactory;\n`; + shim += ` ${namespace}.MSGestureEventFactory_ = MSGestureEventFactory;\n`; + shim += ` ${namespace}.MSPointerEventFactory_ = MSPointerEventFactory;\n`; + shim += ` ${namespace}.BROKEN_TOUCH_API_ = BROKEN_TOUCH_API;\n`; + } + + // Special handling for touchscreen: The Touchscreen class IS the namespace (bot.Touchscreen) + // and extends bot.Device + if (basename === 'touchscreen') { + shim += ` + // ES2015 class can be used directly as a constructor with 'new' + bot.Touchscreen = Touchscreen; + // Set up Closure-style inheritance for type checking + goog.utils.inherits(bot.Touchscreen, bot.Device); +`; + } + + // Special handling for keyboard: The Keyboard class IS the namespace (bot.Keyboard) + // and extends bot.Device. Also need to expose Key, Keys, and State. + if (basename === 'keyboard') { + shim += ` + // ES2015 class can be used directly as a constructor with 'new' + bot.Keyboard = Keyboard; + // Set up Closure-style inheritance for type checking + goog.utils.inherits(bot.Keyboard, bot.Device); + // Export the Key class, Keys object, and MODIFIERS as nested properties + bot.Keyboard.Key = Key; + bot.Keyboard.Keys = Keys; + bot.Keyboard.MODIFIERS = MODIFIERS; + bot.Keyboard.supportsSelection = supportsSelection; +`; + } + + // Special handling for mouse: The Mouse class IS the namespace (bot.Mouse) + // and extends bot.Device. Also need to expose Button enum and State typedef. + if (basename === 'mouse') { + shim += ` + // ES2015 class can be used directly as a constructor with 'new' + bot.Mouse = Mouse; + // Set up Closure-style inheritance for type checking + goog.utils.inherits(bot.Mouse, bot.Device); + // Export the Button enum as a nested property + bot.Mouse.Button = Button; +`; + } + + // Special handling for action: Create the LegacyDevice_ singleton class for compatibility + // with existing Closure code that uses bot.action.LegacyDevice_.focusOnElement(elem) + if (basename === 'action') { + shim += ` + // Create the LegacyDevice_ pseudo-class for backward compatibility + // Original code used bot.action.LegacyDevice_.focusOnElement(elem) etc. + bot.action.LegacyDevice_ = function() { + bot.Device.call(this); + }; + goog.utils.inherits(bot.action.LegacyDevice_, bot.Device); + goog.utils.addSingletonGetter(bot.action.LegacyDevice_); + + // Static methods on LegacyDevice_ that delegate to the internal functions + bot.action.LegacyDevice_.focusOnElement = function(element) { + return legacyDeviceFocusOnElement(element); + }; + bot.action.LegacyDevice_.submitForm = function(element, form) { + legacyDeviceSubmitForm(element, form); + }; + bot.action.LegacyDevice_.findAncestorForm = function(element) { + return legacyDeviceFindAncestorForm(element); + }; + + // Export all action functions + bot.action.clear = clear; + bot.action.focusOnElement = focusOnElement; + bot.action.type = type; + bot.action.submit = submit; + bot.action.moveMouse = moveMouse; + bot.action.click = click; + bot.action.rightClick = rightClick; + bot.action.doubleClick = doubleClick; + bot.action.doubleClick2 = doubleClick2; + bot.action.scrollMouse = scrollMouse; + bot.action.drag = drag; + bot.action.tap = tap; + bot.action.swipe = swipe; + bot.action.pinch = pinch; + bot.action.rotate = rotate; + bot.action.getInteractableSize = getInteractableSize; + bot.action.scrollIntoView = scrollIntoView; +`; + } + + // Special handling for device: The Device class IS the namespace (bot.Device) + // and inner classes are properties on it + if (basename === 'device') { + // Create a wrapper function that can be called with .call() for backward compatibility + // with Closure-style inheritance (e.g., bot.Device.call(this, ...)) + // while also supporting 'new bot.Device()' + shim += ` + // Compatibility wrapper: ES2015 classes can't be called with .call(), + // but Closure-style child classes do: bot.Device.call(this, ...) + // This wrapper function makes the class work both ways. + var DeviceWrapper = function(opt_modifiersState, opt_eventEmitter) { + // If called with 'new', this is a DeviceWrapper instance + // If called with .call(thisArg, ...), 'this' is the child class instance + if (this instanceof DeviceWrapper || this.constructor === DeviceWrapper) { + // Called with 'new DeviceWrapper()' - return Device instance + return Object.assign(this, new Device(opt_modifiersState, opt_eventEmitter)); + } + // Called with DeviceWrapper.call(thisArg, ...) - initialize 'this' + // Copy Device constructor logic here for Closure-style inheritance + this.element_ = bot.getDocument().documentElement; + this.select_ = null; + this.modifiersState = opt_modifiersState || new ModifiersState(); + this.eventEmitter = opt_eventEmitter || new EventEmitter(); + var activeElement = bot.dom.getActiveElement(this.element_); + if (activeElement) { + // Need to call setElement from the prototype + Device.prototype.setElement.call(this, activeElement); + } + }; + // Copy prototype methods from Device to DeviceWrapper + DeviceWrapper.prototype = Device.prototype; + DeviceWrapper.prototype.constructor = DeviceWrapper; + bot.Device = DeviceWrapper; +`; + // Then assign inner classes and static functions to bot.Device + shim += ` bot.Device.ModifiersState = ModifiersState;\n`; + shim += ` bot.Device.EventEmitter = EventEmitter;\n`; + shim += ` bot.Device.Modifier = Modifier;\n`; + shim += ` bot.Device.MOUSE_MS_POINTER_ID = MOUSE_MS_POINTER_ID;\n`; + shim += ` bot.Device.getPointerElement = getPointerElement;\n`; + shim += ` bot.Device.clearPointerMap = clearPointerMap;\n`; + shim += ` bot.Device.isFormSubmitElement = isFormSubmitElement;\n`; + shim += ` bot.Device.findAncestorForm = findAncestorForm;\n`; + } + + shim += `})();\n`; + + // Special handling for userAgent: delegate version functions to Closure + // This ensures consistency with goog.userAgent.product.VERSION used by tests + if (basename === 'userAgent') { + shim += ` +// Override version functions to use Closure's implementation for consistency +// with tests that compare against goog.userAgent.product.VERSION +/** + * @param {string|number} version + * @return {boolean} + */ +bot.userAgent.isEngineVersion = function(version) { + if (goog.userAgent.IE) { + return goog.string.compareVersions( + /** @type {number} */ (goog.userAgent.DOCUMENT_MODE), version) >= 0; + } + return goog.userAgent.isVersionOrHigher(version); +}; + +/** + * @param {string|number} version + * @return {boolean} + */ +bot.userAgent.isProductVersion = function(version) { + if (goog.userAgent.product.ANDROID) { + return goog.string.compareVersions( + bot.userAgent.ANDROID_VERSION_, version) >= 0; + } + return goog.userAgent.product.isVersion(version); +}; +`; + } + + return shim; +} + +/** + * Generates a shim for orchestrator modules (like locators.ts) that coordinate + * other modules. These don't include any implementation code, just wire up + * the strategies from the dependent modules. + */ +function generateOrchestratorShim(shimHeader, namespace, exports, basename) { + let shim = shimHeader; + + if (basename === 'locators') { + // Generate the locators orchestrator shim + shim += `/** + * @typedef {{single:function((string|!Object),!(Document|Element)):Element, + * many:function((string|!Object),!(Document|Element)):!IArrayLike}} + */ +bot.locators.strategy; + +/** + * Known element location strategies. + * @private {!Object} + * @const + */ +bot.locators.STRATEGIES_ = { + 'className': bot.locators.className, + 'class name': bot.locators.className, + 'css': bot.locators.css, + 'css selector': bot.locators.css, + 'relative': bot.locators.relative, + 'id': bot.locators.id, + 'linkText': bot.locators.linkText, + 'link text': bot.locators.linkText, + 'name': bot.locators.name, + 'partialLinkText': bot.locators.partialLinkText, + 'partial link text': bot.locators.partialLinkText, + 'tagName': bot.locators.tagName, + 'tag name': bot.locators.tagName, + 'xpath': bot.locators.xpath +}; + +/** + * Add or override an existing strategy for locating elements. + * @param {string} name The name of the strategy. + * @param {!bot.locators.strategy} strategy The strategy to use. + */ +bot.locators.add = function(name, strategy) { + bot.locators.STRATEGIES_[name] = strategy; +}; + +/** + * Returns one key from the object map that is not present in the + * Object.prototype, if any exists. + * @param {!Object} target The object to pick a key from. + * @return {?string} The key or null if the object is empty. + */ +bot.locators.getOnlyKey = function(target) { + for (var k in target) { + if (target.hasOwnProperty(k)) { + return k; + } + } + return null; +}; + +/** + * Find the first element in the DOM matching the target. + * @param {!Object} target The selector to search for. + * @param {(Document|Element)=} opt_root The node from which to start the search. + * @return {Element} The first matching element found in the DOM, or null. + */ +bot.locators.findElement = function(target, opt_root) { + var key = bot.locators.getOnlyKey(target); + if (key) { + var strategy = bot.locators.STRATEGIES_[key]; + if (strategy && typeof strategy.single === 'function') { + var root = opt_root || bot.getDocument(); + return strategy.single(target[key], root); + } + } + throw new bot.Error(bot.ErrorCode.INVALID_ARGUMENT, + 'Unsupported locator strategy: ' + key); +}; + +/** + * Find all elements in the DOM matching the target. + * @param {!Object} target The selector to search for. + * @param {(Document|Element)=} opt_root The node from which to start the search. + * @return {!IArrayLike} All matching elements found in the DOM. + */ +bot.locators.findElements = function(target, opt_root) { + var key = bot.locators.getOnlyKey(target); + if (key) { + var strategy = bot.locators.STRATEGIES_[key]; + if (strategy && typeof strategy.many === 'function') { + var root = opt_root || bot.getDocument(); + return strategy.many(target[key], root); + } + } + throw new bot.Error(bot.ErrorCode.INVALID_ARGUMENT, + 'Unsupported locator strategy: ' + key); +}; + +// Wire up relative locator with findElement/findElements +// These functions are exposed by the TypeScript implementation +/** @suppress {missingProperties} */ +(function() { + if (bot.locators.relative.setFindElement) { + bot.locators.relative.setFindElement(bot.locators.findElement); + } + if (bot.locators.relative.setFindElements) { + bot.locators.relative.setFindElements(bot.locators.findElements); + } +})(); +`; + } + + return shim; +} + +function generateShim(tsFile, namespace, compiledJsPath) { + const tsContent = fs.readFileSync(tsFile, 'utf-8'); + const exports = parseExports(tsContent); + const basename = path.basename(tsFile, '.ts'); + + const exportRenames = EXPORT_RENAME_MAP[basename] || {}; + const symbolReplacements = SYMBOL_REPLACEMENTS[basename] || {}; + const nestedExports = NESTED_EXPORTS_MAP[basename] || {}; + const privateConstantNames = PRIVATE_CONSTANTS_MAP[basename] || []; + + // Separate nested enums from top-level enums + const topLevelEnums = []; + const nestedEnums = []; + exports.enums.forEach((e) => { + if (nestedExports[e.name]) { + nestedEnums.push(e); + } else { + topLevelEnums.push(e); + } + }); + + // Generate provides + const additionalProvides = ADDITIONAL_PROVIDES_MAP[basename] || []; + const provides = + additionalProvides.length > 0 ? [...additionalProvides] : [namespace]; + + topLevelEnums.forEach((e) => { + const renamed = exportRenames[e.name]; + if (!renamed) { + const enumProvide = `${namespace}.${e.name}`; + if (!provides.includes(enumProvide)) { + provides.push(enumProvide); + } + } + }); + + const requires = FILE_DEPS_MAP[basename] || []; + + let shim = `// Auto-generated Closure-compatible shim for ${namespace} +// Source: ${path.basename(tsFile)} +// DO NOT EDIT - This file is generated by scripts/generate-shim.js + +`; + + provides.forEach((p) => { + shim += `goog.provide('${p}');\n`; + }); + shim += '\n'; + + if (requires.length > 0) { + requires.forEach((req) => { + shim += `goog.require('${req}');\n`; + }); + shim += '\n'; + } + + const compiledJs = readCompiledJs(compiledJsPath); + + // Special namespace handling: namespaces that need custom shim generation + if (SPECIAL_NAMESPACE_HANDLERS[namespace]) { + return SPECIAL_NAMESPACE_HANDLERS[namespace](shim, compiledJs); + } + + // Orchestrator mode: these files coordinate other modules and need custom shims + if (ORCHESTRATOR_FILES.includes(basename)) { + return generateOrchestratorShim(shim, namespace, exports, basename); + } + + // Bundle mode: include entire compiled JS and assign exports to namespace + if (BUNDLE_MODE_FILES.includes(basename)) { + return generateBundleModeShim(shim, namespace, exports, compiledJs, basename); + } + + // 0. Include module-level initialization code if needed + const moduleInit = MODULE_INIT_MAP[basename]; + if (moduleInit) { + shim += moduleInit.trim() + '\n\n'; + } + + // 1. Generate top-level enums first + topLevelEnums.forEach((e) => { + const closureName = exportRenames[e.name] || e.name; + const fullName = `${namespace}.${closureName}`; + + shim += `/**\n * @enum {${e.type}}\n */\n`; + shim += `${fullName} = {\n`; + e.members.forEach((m, i) => { + const comma = i < e.members.length - 1 ? ',' : ''; + shim += ` ${m.name}: ${m.value}${comma}\n`; + }); + shim += `};\n\n`; + }); + + // 2. Generate classes + exports.classes.forEach((cls) => { + const closureName = exportRenames[cls.name] || cls.name; + const fullName = `${namespace}.${closureName}`; + + shim += `/**\n`; + cls.constructorParams.forEach((p) => { + const closureType = tsTypeToClosureType(p.type, p.optional); + const paramName = p.optional ? 'opt_' + p.name : p.name; + shim += ` * @param {${closureType}} ${paramName}\n`; + }); + shim += ` * @constructor\n`; + if (cls.extends) { + shim += ` * @extends {${cls.extends}}\n`; + } + shim += ` */\n`; + + const paramNames = cls.constructorParams.map((p) => + p.optional ? 'opt_' + p.name : p.name + ); + shim += `${fullName} = function(${paramNames.join(', ')}) {\n`; + + // Generate constructor body + const constructorBody = extractClassConstructorBody(cls.name, compiledJs, symbolReplacements, paramNames); + shim += constructorBody; + + shim += `};\n`; + + if (cls.extends) { + shim += `goog.utils.inherits(${fullName}, ${cls.extends});\n`; + } + shim += '\n'; + }); + + // 3. Generate nested enums AFTER the class + nestedEnums.forEach((e) => { + const closureName = exportRenames[e.name] || e.name; + const fullName = `${namespace}.${closureName}`; + + shim += `/**\n * @enum {${e.type}}\n */\n`; + shim += `${fullName} = {\n`; + e.members.forEach((m, i) => { + const comma = i < e.members.length - 1 ? ',' : ''; + shim += ` ${m.name}: ${m.value}${comma}\n`; + }); + shim += `};\n\n`; + }); + + // 4. Generate private constants needed by the class (after both class and nested enums) + const neededPrivateConstants = exports.privateConstants.filter( + (c) => privateConstantNames.includes(c.name) + ); + neededPrivateConstants.forEach((c) => { + const closureName = symbolReplacements[c.name] || `${namespace}.${c.name}_`; + shim += `/**\n * @private {!Object}\n */\n`; + + // Extract the constant value from compiled JS and apply replacements + const value = extractConstant(c.name, compiledJs); + const processedValue = applySymbolReplacements(value, symbolReplacements); + shim += `${closureName} = ${processedValue};\n\n`; + }); + + // 5. Generate interfaces as @record types + exports.interfaces.forEach((iface) => { + const fullName = `${namespace}.${iface.name}`; + shim += `/**\n * @record\n */\n`; + shim += `${fullName} = function() {};\n`; + iface.fields.forEach((f) => { + const closureType = tsTypeToClosureType(f.type, f.optional); + shim += `/** @type {${closureType}} */\n`; + shim += `${fullName}.prototype.${f.name};\n`; + }); + shim += '\n'; + }); + + // 6. Generate functions + exports.functions.forEach((fn) => { + const closureName = exportRenames[fn.name] || fn.name; + const fullName = `${namespace}.${closureName}`; + + shim += `/**\n`; + fn.params.forEach((p) => { + const closureType = tsTypeToClosureType(p.type, p.optional); + const paramName = p.optional ? 'opt_' + p.name : p.name; + shim += ` * @param {${closureType}} ${paramName}\n`; + }); + const returnType = tsTypeToClosureType(fn.returnType); + shim += ` * @return {${returnType}}\n`; + shim += ` */\n`; + + const funcBody = extractFunctionBody(fn.name, compiledJs, symbolReplacements); + shim += `${fullName} = function${funcBody};\n\n`; + }); + + // 7. Generate constants + exports.constants.forEach((c) => { + const closureName = exportRenames[c.name] || c.name; + const fullName = `${namespace}.${closureName}`; + const closureType = c.type ? tsTypeToClosureType(c.type) : '*'; + + shim += `/** @const {${closureType}} */\n`; + const value = extractConstant(c.name, compiledJs); + shim += `${fullName} = ${applySymbolReplacements(value, symbolReplacements)};\n\n`; + }); + + return shim; +} + +/** + * Reads the compiled JavaScript file. + */ +function readCompiledJs(compiledJsPath) { + let resolvedPath = compiledJsPath; + + if (resolvedPath.startsWith('bazel-out/')) { + const match = resolvedPath.match(/bazel-out\/[^\/]+\/bin\/(.*)/); + if (match) { + resolvedPath = match[1]; + } + } + + const possiblePaths = [ + resolvedPath, + compiledJsPath, + path.join(process.cwd(), resolvedPath), + ]; + + for (const tryPath of possiblePaths) { + if (fs.existsSync(tryPath)) { + return fs.readFileSync(tryPath, 'utf-8'); + } + } + + console.error('Warning: Compiled JS file not found:', compiledJsPath); + return ''; +} + +/** + * Extracts class constructor body from compiled JS. + */ +function extractClassConstructorBody(className, compiledJs, replacements, paramNames) { + const classRegex = new RegExp(`class\\s+${className}[^{]*\\{`, 'g'); + const classMatch = classRegex.exec(compiledJs); + + if (!classMatch) { + return ' // Constructor body not found\n'; + } + + const classBody = extractBracedBlock(compiledJs, classMatch.index); + const constructorMatch = classBody.match(/constructor\s*\([^)]*\)\s*\{/); + + if (!constructorMatch) { + return ' // No constructor found\n'; + } + + const constructorStart = classBody.indexOf(constructorMatch[0]); + const constructorBlock = extractBracedBlock(classBody, constructorStart); + + const bodyStart = constructorBlock.indexOf('{') + 1; + const bodyEnd = constructorBlock.lastIndexOf('}'); + let body = constructorBlock.substring(bodyStart, bodyEnd); + + // Replace 'super(' with parent class call using proper parameter name + // Also add explicit this.message assignment since Error.call doesn't set it in all browsers + const messageParam = paramNames.find((p) => p.includes('message')) || "''"; + body = body.replace( + /super\s*\(\s*message\s*\|\|\s*''\s*\)/g, + `Error.call(this, ${messageParam} || '');\n /** @override */\n this.message = ${messageParam} || ''` + ); + body = body.replace(/super\s*\(/g, 'Error.call(this, '); + + // Apply symbol replacements + body = applySymbolReplacements(body, replacements); + + // Clean up and indent + const lines = body + .split('\n') + .map((line) => ' ' + line.trimEnd()) + .filter((line) => line.trim() !== ''); + + return lines.join('\n') + '\n'; +} + +/** + * Extracts a function body (params and block) from compiled JS. + */ +function extractFunctionBody(funcName, compiledJs, replacements) { + const funcRegex = new RegExp( + `function\\s+${funcName}\\s*(\\([^)]*\\))\\s*\\{`, + 'g' + ); + const match = funcRegex.exec(compiledJs); + + if (match) { + const params = match[1]; + const startIndex = match.index + match[0].length - 1; // Position of the opening brace + const block = extractBracedBlock(compiledJs, startIndex - 1); + + // Get just the block part (the { ... }) + const braceStart = match[0].lastIndexOf('{'); + const fullBlock = extractBracedBlock(compiledJs, match.index + braceStart); + + let processed = applySymbolReplacements(fullBlock, replacements); + return params + ' ' + processed; + } + + return `() { throw new Error('Function ${funcName} not found'); }`; +} + +/** + * Extracts a constant value from compiled JS. + */ +function extractConstant(constName, compiledJs) { + // Handle multi-line object constants + const constStartRegex = new RegExp(`(?:const|var)\\s+${constName}\\s*=\\s*`); + const match = constStartRegex.exec(compiledJs); + + if (match) { + const valueStart = match.index + match[0].length; + const firstChar = compiledJs[valueStart]; + + if (firstChar === '{') { + // It's an object, extract the full block + const block = extractBracedBlock(compiledJs, valueStart); + return block; + } else if (firstChar === '[') { + // It's an array, extract the full block + let depth = 0; + let end = valueStart; + for (let i = valueStart; i < compiledJs.length; i++) { + if (compiledJs[i] === '[') depth++; + else if (compiledJs[i] === ']') { + depth--; + if (depth === 0) { + end = i + 1; + break; + } + } + } + return compiledJs.substring(valueStart, end); + } else { + // Simple value, find the semicolon + const semicolon = compiledJs.indexOf(';', valueStart); + return compiledJs.substring(valueStart, semicolon).trim(); + } + } + + return 'undefined'; +} + +/** + * Infers the Closure namespace from a TypeScript filename. + */ +function inferNamespace(tsFile) { + const basename = path.basename(tsFile, '.ts'); + return NAMESPACE_MAP[basename] || `bot.${basename}`; +} + +// Main execution +function main() { + const args = process.argv.slice(2); + + if (args.length < 1) { + console.error( + 'Usage: node generate-shim.js [closure-namespace] [compiled-js-path]' + ); + console.error('Example: node generate-shim.js bot.ts bot ./bot.js'); + process.exit(1); + } + + const tsFile = args[0]; + const namespace = args[1] || inferNamespace(tsFile); + const compiledJsPath = args[2] || `./${path.basename(tsFile, '.ts')}.js`; + + if (!fs.existsSync(tsFile)) { + console.error(`Error: TypeScript file not found: ${tsFile}`); + process.exit(1); + } + + const shim = generateShim(tsFile, namespace, compiledJsPath); + console.log(shim); +} + +main(); diff --git a/javascript/atoms/test/overflow_test.html b/javascript/atoms/test/overflow_test.html index 6c98b4cd04d9a..4cafba71ae574 100644 --- a/javascript/atoms/test/overflow_test.html +++ b/javascript/atoms/test/overflow_test.html @@ -8,6 +8,7 @@ goog.require('bot.userAgent'); goog.require('goog.array'); goog.require('goog.dom'); + goog.require('goog.math.Rect'); goog.require('goog.testing.jsunit'); goog.require('goog.userAgent'); diff --git a/javascript/atoms/touchscreen.js b/javascript/atoms/touchscreen.js deleted file mode 100644 index 099c4c4d66bab..0000000000000 --- a/javascript/atoms/touchscreen.js +++ /dev/null @@ -1,421 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview The file contains an abstraction of a touch screen - * for simulating atomic touchscreen actions. - */ - -goog.provide('bot.Touchscreen'); - -goog.require('bot'); -goog.require('bot.Device'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.dom'); -goog.require('bot.events'); -goog.require('bot.userAgent'); -goog.require('goog.dom.TagName'); -goog.require('goog.math.Coordinate'); -goog.require('goog.userAgent.product'); -goog.require('goog.utils'); - - - -/** - * A TouchScreen that provides atomic touch actions. The metaphor - * for this abstraction is a finger moving above the touchscreen that - * can press and then release the touchscreen when specified. - * - * The touchscreen supports three actions: press, release, and move. - * - * @constructor - * @extends {bot.Device} - */ -bot.Touchscreen = function () { - bot.Device.call(this); - - /** @private {!goog.math.Coordinate} */ - this.clientXY_ = new goog.math.Coordinate(0, 0); - - /** @private {!goog.math.Coordinate} */ - this.clientXY2_ = new goog.math.Coordinate(0, 0); -}; -goog.utils.inherits(bot.Touchscreen, bot.Device); - - -/** @private {boolean} */ -bot.Touchscreen.prototype.fireMouseEventsOnRelease_ = true; - - -/** @private {boolean} */ -bot.Touchscreen.prototype.cancelled_ = false; - - -/** @private {number} */ -bot.Touchscreen.prototype.touchIdentifier_ = 0; - - -/** @private {number} */ -bot.Touchscreen.prototype.touchIdentifier2_ = 0; - - -/** @private {number} */ -bot.Touchscreen.prototype.touchCounter_ = 2; - - -/** - * Press the touch screen. Pressing before moving results in an exception. - * Pressing while already pressed also results in an exception. - * - * @param {boolean=} opt_press2 Whether or not press the second finger during - * the press. If not defined or false, only the primary finger will be - * pressed. - */ -bot.Touchscreen.prototype.press = function (opt_press2) { - if (this.isPressed()) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Cannot press touchscreen when already pressed.'); - } - - this.touchIdentifier_ = this.touchCounter_++; - if (opt_press2) { - this.touchIdentifier2_ = this.touchCounter_++; - } - - if (bot.userAgent.IE_DOC_10) { - this.fireMouseEventsOnRelease_ = true; - this.firePointerEvents_(bot.Touchscreen.fireSinglePressPointer_); - } else { - this.fireMouseEventsOnRelease_ = this.fireTouchEvent_( - bot.events.EventType.TOUCHSTART); - } -}; - - -/** - * Releases an element on a touchscreen. Releasing an element that is not - * pressed results in an exception. - */ -bot.Touchscreen.prototype.release = function () { - if (!this.isPressed()) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Cannot release touchscreen when not already pressed.'); - } - - if (!bot.userAgent.IE_DOC_10) { - this.fireTouchReleaseEvents_(); - } else if (!this.cancelled_) { - this.firePointerEvents_(bot.Touchscreen.fireSingleReleasePointer_); - } - bot.Device.clearPointerMap(); - this.touchIdentifier_ = 0; - this.touchIdentifier2_ = 0; - this.cancelled_ = false; -}; - - -/** - * Moves finger along the touchscreen. - * - * @param {!Element} element Element that is being pressed. - * @param {!goog.math.Coordinate} coords Coordinates relative to - * currentElement. - * @param {goog.math.Coordinate=} opt_coords2 Coordinates relative to - * currentElement. - */ -bot.Touchscreen.prototype.move = function (element, coords, opt_coords2) { - // The target element for touch actions is the original element. Hence, the - // element is set only when the touchscreen is not currently being pressed. - // The exception is IE10 which fire events on the moved to element. - var originalElement = this.getElement(); - if (!this.isPressed() || bot.userAgent.IE_DOC_10) { - this.setElement(element); - } - - var rect = bot.dom.getClientRect(element); - this.clientXY_.x = coords.x + rect.left; - this.clientXY_.y = coords.y + rect.top; - - if (opt_coords2 !== undefined) { - this.clientXY2_.x = opt_coords2.x + rect.left; - this.clientXY2_.y = opt_coords2.y + rect.top; - } - - if (this.isPressed()) { - if (!bot.userAgent.IE_DOC_10) { - this.fireMouseEventsOnRelease_ = false; - this.fireTouchEvent_(bot.events.EventType.TOUCHMOVE); - } else if (!this.cancelled_) { - if (element != originalElement) { - this.fireMouseEventsOnRelease_ = false; - } - if (bot.Touchscreen.hasMsTouchActionsEnabled_(element)) { - this.firePointerEvents_(bot.Touchscreen.fireSingleMovePointer_); - } else { - this.fireMSPointerEvent(bot.events.EventType.MSPOINTEROUT, coords, -1, - this.touchIdentifier_, MSPointerEvent.MSPOINTER_TYPE_TOUCH, true); - this.fireMouseEvent(bot.events.EventType.MOUSEOUT, coords, 0); - this.fireMSPointerEvent(bot.events.EventType.MSPOINTERCANCEL, coords, 0, - this.touchIdentifier_, MSPointerEvent.MSPOINTER_TYPE_TOUCH, true); - this.cancelled_ = true; - bot.Device.clearPointerMap(); - } - } - } -}; - - -/** - * Returns whether the touchscreen is currently pressed. - * - * @return {boolean} Whether the touchscreen is pressed. - */ -bot.Touchscreen.prototype.isPressed = function () { - return !!this.touchIdentifier_; -}; - - -/** - * A helper function to fire touch events. - * - * @param {!bot.events.EventFactory_} type Event type. - * @return {boolean} Whether the event fired successfully or was cancelled. - * @private - */ -bot.Touchscreen.prototype.fireTouchEvent_ = function (type) { - if (!this.isPressed()) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'Should never fire event when touchscreen is not pressed.'); - } - var touchIdentifier2; - var coords2; - if (this.touchIdentifier2_) { - touchIdentifier2 = this.touchIdentifier2_; - coords2 = this.clientXY2_; - } - return this.fireTouchEvent(type, this.touchIdentifier_, this.clientXY_, - touchIdentifier2, coords2); -}; - - -/** - * A helper function to fire touch events that occur on a release. - * - * @private - */ -bot.Touchscreen.prototype.fireTouchReleaseEvents_ = function () { - var touchendSuccess = this.fireTouchEvent_(bot.events.EventType.TOUCHEND); - - // In general, TouchScreen.Release will fire the legacy mouse events: - // mousemove, mousedown, mouseup, and click after the touch events have been - // fired. The click button should be zero and only one mousemove should fire. - // Under the following cases, mouse events should not be fired: - // 1. Movement has occurred since press. - // 2. Any event handler for touchstart has called preventDefault(). - // 3. Any event handler for touchend has called preventDefault(), and browser - // is Mobile Safari or Chrome. - var fireMouseEvents = - this.fireMouseEventsOnRelease_ && - (touchendSuccess || !(bot.userAgent.IOS || - goog.userAgent.product.CHROME)); - - if (fireMouseEvents) { - this.fireMouseEvent(bot.events.EventType.MOUSEMOVE, this.clientXY_, 0); - var performFocus = this.fireMouseEvent(bot.events.EventType.MOUSEDOWN, - this.clientXY_, 0); - // Element gets focus after the mousedown event only if the mousedown was - // not cancelled. - if (performFocus) { - this.focusOnElement(); - } - this.maybeToggleOption(); - - // If a mouseup event is dispatched to an interactable event, and that - // mouseup would complete a click, then the click event must be dispatched - // even if the element becomes non-interactable after the mouseup. - var elementInteractableBeforeMouseup = - bot.dom.isInteractable(this.getElement()); - this.fireMouseEvent(bot.events.EventType.MOUSEUP, this.clientXY_, 0); - - // Special click logic to follow links and to perform form actions. - if (!(bot.userAgent.WINDOWS_PHONE && - bot.dom.isElement(this.getElement(), goog.dom.TagName.OPTION))) { - this.clickElement(this.clientXY_, - /* button */ 0, - /* opt_force */ elementInteractableBeforeMouseup); - } - } -}; - - -/** - * A helper function to fire a sequence of Pointer events. - * @param {function(!bot.Touchscreen, !Element, !goog.math.Coordinate, number, - * boolean)} fireSinglePointer A function that fires a set of events for one - * finger. - * @private - */ -bot.Touchscreen.prototype.firePointerEvents_ = function (fireSinglePointer) { - fireSinglePointer(this, this.getElement(), this.clientXY_, - this.touchIdentifier_, true); - if (this.touchIdentifier2_ && - bot.Touchscreen.hasMsTouchActionsEnabled_(this.getElement())) { - fireSinglePointer(this, this.getElement(), - this.clientXY2_, this.touchIdentifier2_, false); - } -}; - - -/** - * A helper function to fire Pointer events related to a press. - * - * @param {!bot.Touchscreen} ts A touchscreen object. - * @param {!Element} element Element that is being pressed. - * @param {!goog.math.Coordinate} coords Coordinates relative to - * currentElement. - * @param {number} id The touch identifier. - * @param {boolean} isPrimary Whether the pointer represents the primary point - * of contact. - * @private - */ -bot.Touchscreen.fireSinglePressPointer_ = function (ts, element, coords, id, - isPrimary) { - // Fire a mousemove event. - ts.fireMouseEvent(bot.events.EventType.MOUSEMOVE, coords, 0); - - // Fire a MSPointerOver and mouseover events. - ts.fireMSPointerEvent(bot.events.EventType.MSPOINTEROVER, coords, 0, id, - MSPointerEvent.MSPOINTER_TYPE_TOUCH, isPrimary); - ts.fireMouseEvent(bot.events.EventType.MOUSEOVER, coords, 0); - - // Fire a MSPointerDown and mousedown events. - ts.fireMSPointerEvent(bot.events.EventType.MSPOINTERDOWN, coords, 0, id, - MSPointerEvent.MSPOINTER_TYPE_TOUCH, isPrimary); - - // Element gets focus after the mousedown event. - if (ts.fireMouseEvent(bot.events.EventType.MOUSEDOWN, coords, 0)) { - // For selectable elements, IE 10 fires a MSGotPointerCapture event. - if (bot.dom.isSelectable(element)) { - ts.fireMSPointerEvent(bot.events.EventType.MSGOTPOINTERCAPTURE, coords, 0, - id, MSPointerEvent.MSPOINTER_TYPE_TOUCH, isPrimary); - } - ts.focusOnElement(); - } -}; - - -/** - * A helper function to fire Pointer events related to a release. - * - * @param {!bot.Touchscreen} ts A touchscreen object. - * @param {!Element} element Element that is being released. - * @param {!goog.math.Coordinate} coords Coordinates relative to - * currentElement. - * @param {number} id The touch identifier. - * @param {boolean} isPrimary Whether the pointer represents the primary point - * of contact. - * @private - */ -bot.Touchscreen.fireSingleReleasePointer_ = function (ts, element, coords, id, - isPrimary) { - // Fire a MSPointerUp and mouseup events. - ts.fireMSPointerEvent(bot.events.EventType.MSPOINTERUP, coords, 0, id, - MSPointerEvent.MSPOINTER_TYPE_TOUCH, isPrimary); - - // If a mouseup event is dispatched to an interactable event, and that mouseup - // would complete a click, then the click event must be dispatched even if the - // element becomes non-interactable after the mouseup. - var elementInteractableBeforeMouseup = - bot.dom.isInteractable(ts.getElement()); - ts.fireMouseEvent(bot.events.EventType.MOUSEUP, coords, 0, null, 0, false, - id); - - // Fire a click. - if (ts.fireMouseEventsOnRelease_) { - ts.maybeToggleOption(); - if (!(bot.userAgent.WINDOWS_PHONE && - bot.dom.isElement(element, goog.dom.TagName.OPTION))) { - ts.clickElement(ts.clientXY_, - /* button */ 0, - /* opt_force */ elementInteractableBeforeMouseup, - id); - } - } - - if (bot.dom.isSelectable(element)) { - // For selectable elements, IE 10 fires a MSLostPointerCapture event. - ts.fireMSPointerEvent(bot.events.EventType.MSLOSTPOINTERCAPTURE, - new goog.math.Coordinate(0, 0), 0, id, - MSPointerEvent.MSPOINTER_TYPE_TOUCH, false); - } - - // Fire a MSPointerOut and mouseout events. - ts.fireMSPointerEvent(bot.events.EventType.MSPOINTEROUT, coords, -1, id, - MSPointerEvent.MSPOINTER_TYPE_TOUCH, isPrimary); - ts.fireMouseEvent(bot.events.EventType.MOUSEOUT, coords, 0, null, 0, false, - id); -}; - - -/** - * A helper function to fire Pointer events related to a move. - * - * @param {!bot.Touchscreen} ts A touchscreen object. - * @param {!Element} element Element that is being moved. - * @param {!goog.math.Coordinate} coords Coordinates relative to - * currentElement. - * @param {number} id The touch identifier. - * @param {boolean} isPrimary Whether the pointer represents the primary point - * of contact. - * @private - */ -bot.Touchscreen.fireSingleMovePointer_ = function (ts, element, coords, id, - isPrimary) { - // Fire a MSPointerMove and mousemove events. - ts.fireMSPointerEvent(bot.events.EventType.MSPOINTERMOVE, coords, -1, id, - MSPointerEvent.MSPOINTER_TYPE_TOUCH, isPrimary); - ts.fireMouseEvent(bot.events.EventType.MOUSEMOVE, coords, 0, null, 0, false, - id); -}; - - -/** - * A function that determines whether an element can be manipulated by the user. - * The msTouchAction style is queried and an element can be manipulated if the - * style value is none. If an element cannot be manipulated, then move gestures - * will result in a cancellation and multi-touch events will be prevented. Tap - * gestures will still be allowed. If not on IE 10, the function returns true. - * - * @param {!Element} element The element being manipulated. - * @return {boolean} Whether the element can be manipulated. - * @private - */ -bot.Touchscreen.hasMsTouchActionsEnabled_ = function (element) { - if (!bot.userAgent.IE_DOC_10) { - throw new Error('hasMsTouchActionsEnable should only be called from IE 10'); - } - - // Although this particular element may have a style indicating that it cannot - // receive javascript events, its parent may indicate otherwise. - if (bot.dom.getEffectiveStyle(element, 'ms-touch-action') == 'none') { - return true; - } else { - var parent = bot.dom.getParentElement(element); - return !!parent && bot.Touchscreen.hasMsTouchActionsEnabled_(parent); - } -}; diff --git a/javascript/atoms/touchscreen.ts b/javascript/atoms/touchscreen.ts new file mode 100644 index 0000000000000..9c5f061cddf82 --- /dev/null +++ b/javascript/atoms/touchscreen.ts @@ -0,0 +1,401 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview The file contains an abstraction of a touch screen + * for simulating atomic touchscreen actions. + */ + +import { BotError, ErrorCode } from './error'; +import { + Device, + Coordinate, + clearPointerMap, +} from './device'; +import { + isElement, + isInteractable, + isSelectable, + getClientRect, + getEffectiveStyle, + getParentElement, +} from './dom'; +import { EventType } from './events'; +import { IE_DOC_10, WINDOWS_PHONE, IOS } from './userAgent'; + +// Browser detection +const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''; +const IS_CHROME = /Chrome\//.test(userAgent) && !/Chromium\//.test(userAgent); + +// MSPointerEvent types for IE10 +declare const MSPointerEvent: { + MSPOINTER_TYPE_TOUCH: number; +}; + +// ============================================================================ +// Touchscreen Class +// ============================================================================ + +/** + * A TouchScreen that provides atomic touch actions. + */ +export class Touchscreen extends Device { + /** @internal */ + clientXY_: Coordinate = { x: 0, y: 0 }; + private clientXY2_: Coordinate = { x: 0, y: 0 }; + /** @internal */ + fireMouseEventsOnRelease_: boolean = true; + private cancelled_: boolean = false; + private touchIdentifier_: number = 0; + private touchIdentifier2_: number = 0; + private touchCounter_: number = 2; + + constructor() { + super(); + } + + /** + * Press the touch screen. + */ + press(opt_press2?: boolean): void { + if (this.isPressed()) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Cannot press touchscreen when already pressed.' + ); + } + + this.touchIdentifier_ = this.touchCounter_++; + if (opt_press2) { + this.touchIdentifier2_ = this.touchCounter_++; + } + + if (IE_DOC_10) { + this.fireMouseEventsOnRelease_ = true; + this.firePointerEvents_(fireSinglePressPointer_); + } else { + this.fireMouseEventsOnRelease_ = this.fireTouchEvent_(EventType.TOUCHSTART); + } + } + + /** + * Releases an element on a touchscreen. + */ + release(): void { + if (!this.isPressed()) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Cannot release touchscreen when not already pressed.' + ); + } + + if (!IE_DOC_10) { + this.fireTouchReleaseEvents_(); + } else if (!this.cancelled_) { + this.firePointerEvents_(fireSingleReleasePointer_); + } + clearPointerMap(); + this.touchIdentifier_ = 0; + this.touchIdentifier2_ = 0; + this.cancelled_ = false; + } + + /** + * Moves finger along the touchscreen. + */ + move(element: Element, coords: Coordinate, opt_coords2?: Coordinate): void { + const originalElement = this.getElement(); + if (!this.isPressed() || IE_DOC_10) { + this.setElement(element); + } + + const rect = getClientRect(element); + this.clientXY_.x = coords.x + rect.left; + this.clientXY_.y = coords.y + rect.top; + + if (opt_coords2 !== undefined) { + this.clientXY2_.x = opt_coords2.x + rect.left; + this.clientXY2_.y = opt_coords2.y + rect.top; + } + + if (this.isPressed()) { + if (!IE_DOC_10) { + this.fireMouseEventsOnRelease_ = false; + this.fireTouchEvent_(EventType.TOUCHMOVE); + } else if (!this.cancelled_) { + if (element !== originalElement) { + this.fireMouseEventsOnRelease_ = false; + } + if (hasMsTouchActionsEnabled_(element)) { + this.firePointerEvents_(fireSingleMovePointer_); + } else { + this.fireMSPointerEvent( + EventType.MSPOINTEROUT, + coords, + -1, + this.touchIdentifier_, + MSPointerEvent.MSPOINTER_TYPE_TOUCH, + true + ); + this.fireMouseEvent(EventType.MOUSEOUT, coords, 0); + this.fireMSPointerEvent( + EventType.MSPOINTERCANCEL, + coords, + 0, + this.touchIdentifier_, + MSPointerEvent.MSPOINTER_TYPE_TOUCH, + true + ); + this.cancelled_ = true; + clearPointerMap(); + } + } + } + } + + /** + * Returns whether the touchscreen is currently pressed. + */ + isPressed(): boolean { + return !!this.touchIdentifier_; + } + + /** + * A helper function to fire touch events. + */ + private fireTouchEvent_(type: typeof EventType.TOUCHSTART | typeof EventType.TOUCHMOVE | typeof EventType.TOUCHEND): boolean { + if (!this.isPressed()) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'Should never fire event when touchscreen is not pressed.' + ); + } + let touchIdentifier2: number | undefined; + let coords2: Coordinate | undefined; + if (this.touchIdentifier2_) { + touchIdentifier2 = this.touchIdentifier2_; + coords2 = this.clientXY2_; + } + return this.fireTouchEvent( + type, + this.touchIdentifier_, + this.clientXY_, + touchIdentifier2, + coords2 + ); + } + + /** + * A helper function to fire touch events that occur on a release. + */ + private fireTouchReleaseEvents_(): void { + const touchendSuccess = this.fireTouchEvent_(EventType.TOUCHEND); + + const fireMouseEvents = + this.fireMouseEventsOnRelease_ && (touchendSuccess || !(IOS || IS_CHROME)); + + if (fireMouseEvents) { + this.fireMouseEvent(EventType.MOUSEMOVE, this.clientXY_, 0); + const performFocus = this.fireMouseEvent( + EventType.MOUSEDOWN, + this.clientXY_, + 0 + ); + if (performFocus) { + this.focusOnElement(); + } + this.maybeToggleOption(); + + const elementInteractableBeforeMouseup = isInteractable(this.getElement()); + this.fireMouseEvent(EventType.MOUSEUP, this.clientXY_, 0); + + if (!(WINDOWS_PHONE && isElement(this.getElement(), 'OPTION'))) { + this.clickElement(this.clientXY_, 0, elementInteractableBeforeMouseup); + } + } + } + + /** + * A helper function to fire a sequence of Pointer events. + */ + private firePointerEvents_( + fireSinglePointer: ( + ts: Touchscreen, + element: Element, + coords: Coordinate, + id: number, + isPrimary: boolean + ) => void + ): void { + fireSinglePointer( + this, + this.getElement(), + this.clientXY_, + this.touchIdentifier_, + true + ); + if (this.touchIdentifier2_ && hasMsTouchActionsEnabled_(this.getElement())) { + fireSinglePointer( + this, + this.getElement(), + this.clientXY2_, + this.touchIdentifier2_, + false + ); + } + } +} + +// ============================================================================ +// Static Helper Functions +// ============================================================================ + +/** + * A helper function to fire Pointer events related to a press. + */ +function fireSinglePressPointer_( + ts: Touchscreen, + element: Element, + coords: Coordinate, + id: number, + isPrimary: boolean +): void { + ts.fireMouseEvent(EventType.MOUSEMOVE, coords, 0); + + ts.fireMSPointerEvent( + EventType.MSPOINTEROVER, + coords, + 0, + id, + MSPointerEvent.MSPOINTER_TYPE_TOUCH, + isPrimary + ); + ts.fireMouseEvent(EventType.MOUSEOVER, coords, 0); + + ts.fireMSPointerEvent( + EventType.MSPOINTERDOWN, + coords, + 0, + id, + MSPointerEvent.MSPOINTER_TYPE_TOUCH, + isPrimary + ); + + if (ts.fireMouseEvent(EventType.MOUSEDOWN, coords, 0)) { + if (isSelectable(element)) { + ts.fireMSPointerEvent( + EventType.MSGOTPOINTERCAPTURE, + coords, + 0, + id, + MSPointerEvent.MSPOINTER_TYPE_TOUCH, + isPrimary + ); + } + ts.focusOnElement(); + } +} + +/** + * A helper function to fire Pointer events related to a release. + */ +function fireSingleReleasePointer_( + ts: Touchscreen, + element: Element, + coords: Coordinate, + id: number, + isPrimary: boolean +): void { + ts.fireMSPointerEvent( + EventType.MSPOINTERUP, + coords, + 0, + id, + MSPointerEvent.MSPOINTER_TYPE_TOUCH, + isPrimary + ); + + const elementInteractableBeforeMouseup = isInteractable(ts.getElement()); + ts.fireMouseEvent(EventType.MOUSEUP, coords, 0, undefined, 0, false, id); + + if (ts.fireMouseEventsOnRelease_) { + ts.maybeToggleOption(); + if (!(WINDOWS_PHONE && isElement(element, 'OPTION'))) { + ts.clickElement(ts.clientXY_, 0, elementInteractableBeforeMouseup, id); + } + } + + if (isSelectable(element)) { + ts.fireMSPointerEvent( + EventType.MSLOSTPOINTERCAPTURE, + { x: 0, y: 0 }, + 0, + id, + MSPointerEvent.MSPOINTER_TYPE_TOUCH, + false + ); + } + + ts.fireMSPointerEvent( + EventType.MSPOINTEROUT, + coords, + -1, + id, + MSPointerEvent.MSPOINTER_TYPE_TOUCH, + isPrimary + ); + ts.fireMouseEvent(EventType.MOUSEOUT, coords, 0, undefined, 0, false, id); +} + +/** + * A helper function to fire Pointer events related to a move. + */ +function fireSingleMovePointer_( + ts: Touchscreen, + _element: Element, + coords: Coordinate, + id: number, + isPrimary: boolean +): void { + ts.fireMSPointerEvent( + EventType.MSPOINTERMOVE, + coords, + -1, + id, + MSPointerEvent.MSPOINTER_TYPE_TOUCH, + isPrimary + ); + ts.fireMouseEvent(EventType.MOUSEMOVE, coords, 0, undefined, 0, false, id); +} + +/** + * A function that determines whether an element can be manipulated by the user. + */ +function hasMsTouchActionsEnabled_(element: Element): boolean { + if (!IE_DOC_10) { + throw new Error('hasMsTouchActionsEnable should only be called from IE 10'); + } + + if (getEffectiveStyle(element, 'ms-touch-action') === 'none') { + return true; + } else { + const parent = getParentElement(element); + return !!parent && hasMsTouchActionsEnabled_(parent); + } +} + + diff --git a/javascript/atoms/ts-fragments/BUILD.bazel b/javascript/atoms/ts-fragments/BUILD.bazel new file mode 100644 index 0000000000000..1268773023159 --- /dev/null +++ b/javascript/atoms/ts-fragments/BUILD.bazel @@ -0,0 +1,197 @@ +load("//javascript/private:ts_fragment.bzl", "ts_fragment") + +package(default_visibility = ["//visibility:public"]) + +# Example TypeScript-based fragment using the modern esbuild approach. +# This demonstrates the migration path away from Closure Compiler. + +ts_fragment( + name = "standardize-color", + entry_point = "standardize-color.ts", + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "get-effective-style", + entry_point = "get-effective-style.ts", + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "get-text", + entry_point = "get-text.ts", + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "is-displayed", + entry_point = "is-displayed.ts", + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "is-editable", + entry_point = "is-editable.ts", + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "is-enabled", + entry_point = "is-enabled.ts", + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "is-focusable", + entry_point = "is-focusable.ts", + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "is-interactable", + entry_point = "is-interactable.ts", + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "clear", + entry_point = "clear.ts", + visibility = [ + "//javascript/chrome-driver:__pkg__", + "//javascript/ie-driver:__pkg__", + ], + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "click", + entry_point = "click.ts", + visibility = [ + "//javascript/chrome-driver:__pkg__", + "//javascript/ie-driver:__pkg__", + ], + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "submit", + entry_point = "submit.ts", + visibility = [ + "//javascript/chrome-driver:__pkg__", + "//javascript/ie-driver:__pkg__", + ], + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "execute-script", + entry_point = "execute-script.ts", + visibility = [ + "//java/test/org/openqa/selenium/atoms:__pkg__", + "//javascript/android-atoms:__pkg__", + "//javascript/chrome-driver:__pkg__", + ], + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "execute-async-script", + entry_point = "execute-async-script.ts", + visibility = [ + "//javascript/android-atoms:__pkg__", + "//javascript/chrome-driver:__pkg__", + ], + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "get-element-from-cache", + entry_point = "get-element-from-cache.ts", + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "execute-sql", + entry_point = "execute-sql.ts", + visibility = [ + "//javascript/chrome-driver:__pkg__", + ], + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "get-location", + entry_point = "get-location.ts", + visibility = [ + "//javascript/chrome-driver:__pkg__", + ], + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "get-size", + entry_point = "get-size.ts", + visibility = [ + "//javascript/chrome-driver:__pkg__", + ], +) + +ts_fragment( + name = "find-element", + entry_point = "find-element.ts", + visibility = [ + "//javascript/chrome-driver:__pkg__", + "//javascript/ie-driver:__pkg__", + ], + deps = [ + "//javascript/atoms:errors_ts", + ], +) + +ts_fragment( + name = "find-elements", + entry_point = "find-elements.ts", + visibility = [ + "//dotnet/src/webdriver:__pkg__", + "//java/src/org/openqa/selenium/support/locators:__pkg__", + "//javascript/chrome-driver:__pkg__", + "//javascript/selenium-webdriver/lib/atoms:__pkg__", + "//py:__pkg__", + "//rb/lib/selenium/webdriver/atoms:__pkg__", + ], + deps = [ + "//javascript/atoms:errors_ts", + ], +) diff --git a/javascript/atoms/ts-fragments/clear.ts b/javascript/atoms/ts-fragments/clear.ts new file mode 100644 index 0000000000000..7c1dd5087a57d --- /dev/null +++ b/javascript/atoms/ts-fragments/clear.ts @@ -0,0 +1,25 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for bot.action.clear. + * This file is the entry point for esbuild bundling. + */ + +import { clear } from '../dist/action'; + +(globalThis as unknown as { __fragment__: typeof clear }).__fragment__ = clear; diff --git a/javascript/atoms/ts-fragments/click.ts b/javascript/atoms/ts-fragments/click.ts new file mode 100644 index 0000000000000..c8b8eda9f1bc0 --- /dev/null +++ b/javascript/atoms/ts-fragments/click.ts @@ -0,0 +1,25 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for bot.action.click. + * This file is the entry point for esbuild bundling. + */ + +import { click } from '../dist/action'; + +(globalThis as unknown as { __fragment__: typeof click }).__fragment__ = click; diff --git a/javascript/atoms/ts-fragments/execute-async-script.ts b/javascript/atoms/ts-fragments/execute-async-script.ts new file mode 100644 index 0000000000000..bc89f7b89201a --- /dev/null +++ b/javascript/atoms/ts-fragments/execute-async-script.ts @@ -0,0 +1,25 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for bot.inject.executeAsyncScript. + * This file is the entry point for esbuild bundling. + */ + +import { executeAsyncScript } from '../dist/inject'; + +(globalThis as unknown as { __fragment__: typeof executeAsyncScript }).__fragment__ = executeAsyncScript; diff --git a/javascript/atoms/ts-fragments/execute-script.ts b/javascript/atoms/ts-fragments/execute-script.ts new file mode 100644 index 0000000000000..50539ebc48af1 --- /dev/null +++ b/javascript/atoms/ts-fragments/execute-script.ts @@ -0,0 +1,25 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for bot.inject.executeScript. + * This file is the entry point for esbuild bundling. + */ + +import { executeScript } from '../dist/inject'; + +(globalThis as unknown as { __fragment__: typeof executeScript }).__fragment__ = executeScript; diff --git a/javascript/atoms/ts-fragments/execute-sql.ts b/javascript/atoms/ts-fragments/execute-sql.ts new file mode 100644 index 0000000000000..c0e879ac9a015 --- /dev/null +++ b/javascript/atoms/ts-fragments/execute-sql.ts @@ -0,0 +1,25 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for bot.storage.database.executeSql. + * This file is the entry point for esbuild bundling. + */ + +import { executeSql } from '../dist/html5/database'; + +(globalThis as unknown as { __fragment__: typeof executeSql }).__fragment__ = executeSql; diff --git a/javascript/atoms/ts-fragments/find-element.ts b/javascript/atoms/ts-fragments/find-element.ts new file mode 100644 index 0000000000000..dd730b1b7a087 --- /dev/null +++ b/javascript/atoms/ts-fragments/find-element.ts @@ -0,0 +1,25 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for bot.locators.findElement. + * This file is the entry point for esbuild bundling. + */ + +import { findElement } from '../dist/locators/locators'; + +(globalThis as unknown as { __fragment__: typeof findElement }).__fragment__ = findElement; diff --git a/javascript/atoms/ts-fragments/find-elements.ts b/javascript/atoms/ts-fragments/find-elements.ts new file mode 100644 index 0000000000000..68e1dbf4ec7a3 --- /dev/null +++ b/javascript/atoms/ts-fragments/find-elements.ts @@ -0,0 +1,25 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for bot.locators.findElements. + * This file is the entry point for esbuild bundling. + */ + +import { findElements } from '../dist/locators/locators'; + +(globalThis as unknown as { __fragment__: typeof findElements }).__fragment__ = findElements; diff --git a/javascript/atoms/ts-fragments/get-effective-style.ts b/javascript/atoms/ts-fragments/get-effective-style.ts new file mode 100644 index 0000000000000..69b4e45c70a68 --- /dev/null +++ b/javascript/atoms/ts-fragments/get-effective-style.ts @@ -0,0 +1,26 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for getEffectiveStyle. + * This file is the entry point for esbuild bundling. + */ + +import { getEffectiveStyle } from '../dist/dom'; + +// Assign to a global variable that the wrapper can reference +(globalThis as unknown as { __fragment__: typeof getEffectiveStyle }).__fragment__ = getEffectiveStyle; diff --git a/javascript/atoms/ts-fragments/get-element-from-cache.ts b/javascript/atoms/ts-fragments/get-element-from-cache.ts new file mode 100644 index 0000000000000..190c9a9e56ee7 --- /dev/null +++ b/javascript/atoms/ts-fragments/get-element-from-cache.ts @@ -0,0 +1,25 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for bot.inject.cache.getElement. + * This file is the entry point for esbuild bundling. + */ + +import { getElement } from '../dist/inject'; + +(globalThis as unknown as { __fragment__: typeof getElement }).__fragment__ = getElement; diff --git a/javascript/atoms/ts-fragments/get-location.ts b/javascript/atoms/ts-fragments/get-location.ts new file mode 100644 index 0000000000000..707d28a22d61d --- /dev/null +++ b/javascript/atoms/ts-fragments/get-location.ts @@ -0,0 +1,25 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for bot.geolocation.getCurrentPosition. + * This file is the entry point for esbuild bundling. + */ + +import { getCurrentPosition } from '../dist/html5/location'; + +(globalThis as unknown as { __fragment__: typeof getCurrentPosition }).__fragment__ = getCurrentPosition; diff --git a/javascript/atoms/ts-fragments/get-size.ts b/javascript/atoms/ts-fragments/get-size.ts new file mode 100644 index 0000000000000..7a6472df89d0f --- /dev/null +++ b/javascript/atoms/ts-fragments/get-size.ts @@ -0,0 +1,42 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for getting element size. + * This is a fresh TypeScript implementation replacing goog.style.getSize. + */ + +/** + * Returns the size of an element, including padding but not border or margin. + * This is equivalent to the element's offsetWidth and offsetHeight, but uses + * getBoundingClientRect for more accurate floating-point values. + * + * @param element The element to get the size of. + * @returns An object with width and height properties. + */ +function getSize(element: Element): { width: number; height: number } { + // Use getBoundingClientRect for accurate dimensions + // This includes padding but accounts for CSS transforms + const rect = element.getBoundingClientRect(); + + return { + width: rect.width, + height: rect.height, + }; +} + +(globalThis as unknown as { __fragment__: typeof getSize }).__fragment__ = getSize; diff --git a/javascript/atoms/ts-fragments/get-text.ts b/javascript/atoms/ts-fragments/get-text.ts new file mode 100644 index 0000000000000..bfc83826f5644 --- /dev/null +++ b/javascript/atoms/ts-fragments/get-text.ts @@ -0,0 +1,26 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for getVisibleText (get-text). + * This file is the entry point for esbuild bundling. + */ + +import { getVisibleText } from '../dist/dom'; + +// Assign to a global variable that the wrapper can reference +(globalThis as unknown as { __fragment__: typeof getVisibleText }).__fragment__ = getVisibleText; diff --git a/javascript/atoms/ts-fragments/is-displayed.ts b/javascript/atoms/ts-fragments/is-displayed.ts new file mode 100644 index 0000000000000..f94d9986c0f6c --- /dev/null +++ b/javascript/atoms/ts-fragments/is-displayed.ts @@ -0,0 +1,26 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for isShown (is-displayed). + * This file is the entry point for esbuild bundling. + */ + +import { isShown } from '../dist/dom'; + +// Assign to a global variable that the wrapper can reference +(globalThis as unknown as { __fragment__: typeof isShown }).__fragment__ = isShown; diff --git a/javascript/atoms/ts-fragments/is-editable.ts b/javascript/atoms/ts-fragments/is-editable.ts new file mode 100644 index 0000000000000..03e9ba871f1fe --- /dev/null +++ b/javascript/atoms/ts-fragments/is-editable.ts @@ -0,0 +1,26 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for isEditable. + * This file is the entry point for esbuild bundling. + */ + +import { isEditable } from '../dist/dom'; + +// Assign to a global variable that the wrapper can reference +(globalThis as unknown as { __fragment__: typeof isEditable }).__fragment__ = isEditable; diff --git a/javascript/atoms/ts-fragments/is-enabled.ts b/javascript/atoms/ts-fragments/is-enabled.ts new file mode 100644 index 0000000000000..db522ac1ce56e --- /dev/null +++ b/javascript/atoms/ts-fragments/is-enabled.ts @@ -0,0 +1,26 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for isEnabled. + * This file is the entry point for esbuild bundling. + */ + +import { isEnabled } from '../dist/dom'; + +// Assign to a global variable that the wrapper can reference +(globalThis as unknown as { __fragment__: typeof isEnabled }).__fragment__ = isEnabled; diff --git a/javascript/atoms/ts-fragments/is-focusable.ts b/javascript/atoms/ts-fragments/is-focusable.ts new file mode 100644 index 0000000000000..c62d6fb4cd3d9 --- /dev/null +++ b/javascript/atoms/ts-fragments/is-focusable.ts @@ -0,0 +1,26 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for isFocusable. + * This file is the entry point for esbuild bundling. + */ + +import { isFocusable } from '../dist/dom'; + +// Assign to a global variable that the wrapper can reference +(globalThis as unknown as { __fragment__: typeof isFocusable }).__fragment__ = isFocusable; diff --git a/javascript/atoms/ts-fragments/is-interactable.ts b/javascript/atoms/ts-fragments/is-interactable.ts new file mode 100644 index 0000000000000..98495b6d5709c --- /dev/null +++ b/javascript/atoms/ts-fragments/is-interactable.ts @@ -0,0 +1,26 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for isInteractable. + * This file is the entry point for esbuild bundling. + */ + +import { isInteractable } from '../dist/dom'; + +// Assign to a global variable that the wrapper can reference +(globalThis as unknown as { __fragment__: typeof isInteractable }).__fragment__ = isInteractable; diff --git a/javascript/atoms/ts-fragments/standardize-color.ts b/javascript/atoms/ts-fragments/standardize-color.ts new file mode 100644 index 0000000000000..0d20ef68031de --- /dev/null +++ b/javascript/atoms/ts-fragments/standardize-color.ts @@ -0,0 +1,26 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for standardizeColor. + * This file is the entry point for esbuild bundling. + */ + +import { standardizeColor } from '../dist/color'; + +// Assign to a global variable that the wrapper can reference +(globalThis as unknown as { __fragment__: typeof standardizeColor }).__fragment__ = standardizeColor; diff --git a/javascript/atoms/ts-fragments/submit.ts b/javascript/atoms/ts-fragments/submit.ts new file mode 100644 index 0000000000000..c44cae37f1902 --- /dev/null +++ b/javascript/atoms/ts-fragments/submit.ts @@ -0,0 +1,25 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Fragment entry point for bot.action.submit. + * This file is the entry point for esbuild bundling. + */ + +import { submit } from '../dist/action'; + +(globalThis as unknown as { __fragment__: typeof submit }).__fragment__ = submit; diff --git a/javascript/atoms/tsconfig.json b/javascript/atoms/tsconfig.json new file mode 100644 index 0000000000000..cb27113f4c9b0 --- /dev/null +++ b/javascript/atoms/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "target": "ES2015", + "module": "ES2015", + "lib": ["ES2017", "DOM"], + + "outDir": "./dist", + "rootDir": ".", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true + }, + "include": [ + "*.ts", + "locators/*.ts", + "html5/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "test", + "bazel-*" + ] +} diff --git a/javascript/atoms/userAgent.js b/javascript/atoms/userAgent.js deleted file mode 100644 index 65510769ae142..0000000000000 --- a/javascript/atoms/userAgent.js +++ /dev/null @@ -1,205 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Similar to goog.userAgent.isVersion, but with support for - * getting the version information when running in a firefox extension. - */ -goog.provide('bot.userAgent'); - -goog.require('goog.string'); -goog.require('goog.userAgent'); -goog.require('goog.userAgent.product'); -goog.require('goog.userAgent.product.isVersion'); - - -/** - * Whether the rendering engine version of the current browser is equal to or - * greater than the given version. This implementation differs from - * goog.userAgent.isVersion in the following ways: - *
    - *
  1. in a Firefox extension, tests the engine version through the XUL version - * comparator service, because no window.navigator object is available - *
  2. in IE, compares the given version to the current documentMode - *
- * - * @param {string|number} version The version number to check. - * @return {boolean} Whether the browser engine version is the same or higher - * than the given version. - */ -bot.userAgent.isEngineVersion = function (version) { - if (goog.userAgent.IE) { - return goog.string.compareVersions( - /** @type {number} */(goog.userAgent.DOCUMENT_MODE), version) >= 0; - } else { - return goog.userAgent.isVersionOrHigher(version); - } -}; - - -/** - * Whether the product version of the current browser is equal to or greater - * than the given version. This implementation differs from - * goog.userAgent.product.isVersion in the following ways: - *
    - *
  1. in a Firefox extension, tests the product version through the XUL version - * comparator service, because no window.navigator object is available - *
  2. on Android, always compares to the version to the OS version - *
- * - * @param {string|number} version The version number to check. - * @return {boolean} Whether the browser product version is the same or higher - * than the given version. - */ -bot.userAgent.isProductVersion = function (version) { - if (goog.userAgent.product.ANDROID) { - return goog.string.compareVersions( - bot.userAgent.ANDROID_VERSION_, version) >= 0; - } else { - return goog.userAgent.product.isVersion(version); - } -}; - - -/** - * Whether we are a WebExtension. - * - * @const - * @type {boolean} - */ -bot.userAgent.WEBEXTENSION = (function () { - // The content script global object is different than it's window - // Which requires accessing the chrome and browser objects through this - try { - return !!((goog.global.chrome || goog.global.browser)['extension']); - } catch (e) { - return false; - } -})(); - -/** - * Whether we are on IOS. - * - * @const - * @type {boolean} - */ -bot.userAgent.IOS = goog.userAgent.product.IPAD || - goog.userAgent.product.IPHONE; - - -/** - * Whether we are on a mobile browser. - * - * @const - * @type {boolean} - */ -bot.userAgent.MOBILE = bot.userAgent.IOS || goog.userAgent.product.ANDROID; - - -/** - * Android Operating System Version. - * @private {string} - * @const - */ -bot.userAgent.ANDROID_VERSION_ = (function () { - if (goog.userAgent.product.ANDROID) { - var userAgentString = goog.userAgent.getUserAgentString(); - var match = /Android\s+([0-9\.]+)/.exec(userAgentString); - return match ? match[1] : '0'; - } else { - return '0'; - } -})(); - - -/** - * Whether the current document is IE in a documentMode older than 8. - * @type {boolean} - * @const - */ -bot.userAgent.IE_DOC_PRE8 = goog.userAgent.IE && - !goog.userAgent.isDocumentModeOrHigher(8); - - -/** - * Whether the current document is IE in IE9 (or newer) standards mode. - * @type {boolean} - * @const - */ -bot.userAgent.IE_DOC_9 = goog.userAgent.isDocumentModeOrHigher(9); - - -/** - * Whether the current document is IE in a documentMode older than 9. - * @type {boolean} - * @const - */ -bot.userAgent.IE_DOC_PRE9 = goog.userAgent.IE && - !goog.userAgent.isDocumentModeOrHigher(9); - - -/** - * Whether the current document is IE in IE10 (or newer) standards mode. - * @type {boolean} - * @const - */ -bot.userAgent.IE_DOC_10 = goog.userAgent.isDocumentModeOrHigher(10); - - -/** - * Whether the current document is IE in a documentMode older than 10. - * @type {boolean} - * @const - */ -bot.userAgent.IE_DOC_PRE10 = goog.userAgent.IE && - !goog.userAgent.isDocumentModeOrHigher(10); - - -/** - * Whether the current browser is Android pre-gingerbread. - * @type {boolean} - * @const - */ -bot.userAgent.ANDROID_PRE_GINGERBREAD = goog.userAgent.product.ANDROID && - !bot.userAgent.isProductVersion(2.3); - - -/** - * Whether the current browser is Android pre-icecreamsandwich - * @type {boolean} - * @const - */ -bot.userAgent.ANDROID_PRE_ICECREAMSANDWICH = goog.userAgent.product.ANDROID && - !bot.userAgent.isProductVersion(4); - - -/** - * Whether the current browser is Safari 6. - * @type {boolean} - * @const - */ -bot.userAgent.SAFARI_6 = goog.userAgent.product.SAFARI && - bot.userAgent.isProductVersion(6); - - -/** - * Whether the current browser is Windows Phone. - * @type {boolean} - * @const - */ -bot.userAgent.WINDOWS_PHONE = goog.userAgent.IE && - goog.userAgent.getUserAgentString().indexOf('IEMobile') != -1; diff --git a/javascript/atoms/userAgent.ts b/javascript/atoms/userAgent.ts new file mode 100644 index 0000000000000..5577ae2fb339b --- /dev/null +++ b/javascript/atoms/userAgent.ts @@ -0,0 +1,358 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview User agent detection utilities for Selenium atoms. + * Browser detection patterns based on ua-parser-js (MIT license). + */ + +declare const globalThis: typeof window; + +/** + * Returns the user agent string. + */ +function getUserAgentString(): string { + if (typeof navigator !== 'undefined' && navigator.userAgent) { + return navigator.userAgent; + } + return ''; +} + +const userAgent = getUserAgentString(); + +/** + * Compares two version numbers. + * @param version1 Version of first item. + * @param version2 Version of second item. + * @return 1 if version1 is higher, 0 if equal, -1 if version2 is higher. + */ +function compareVersions( + version1: string | number, + version2: string | number +): number { + let order = 0; + const v1Subs = String(version1).trim().split('.'); + const v2Subs = String(version2).trim().split('.'); + const subCount = Math.max(v1Subs.length, v2Subs.length); + + for (let subIdx = 0; order === 0 && subIdx < subCount; subIdx++) { + let v1Sub = v1Subs[subIdx] || ''; + let v2Sub = v2Subs[subIdx] || ''; + + do { + const v1Comp = /(\d*)(\D*)(.*)/.exec(v1Sub) || ['', '', '', '']; + const v2Comp = /(\d*)(\D*)(.*)/.exec(v2Sub) || ['', '', '', '']; + + if (v1Comp[0].length === 0 && v2Comp[0].length === 0) { + break; + } + + const v1CompNum = v1Comp[1].length === 0 ? 0 : parseInt(v1Comp[1], 10); + const v2CompNum = v2Comp[1].length === 0 ? 0 : parseInt(v2Comp[1], 10); + + order = + compareElements(v1CompNum, v2CompNum) || + compareElements(v1Comp[2].length === 0, v2Comp[2].length === 0) || + compareElements(v1Comp[2], v2Comp[2]); + + v1Sub = v1Comp[3]; + v2Sub = v2Comp[3]; + } while (order === 0); + } + + return order; +} + +/** + * Compares elements of a version number. + */ +function compareElements( + left: string | number | boolean, + right: string | number | boolean +): number { + if (left < right) { + return -1; + } else if (left > right) { + return 1; + } + return 0; +} + +// ============================================================================ +// Browser Detection (patterns from ua-parser-js, MIT license) +// ============================================================================ + +interface BrowserInfo { + name: string; + version: string; +} + +interface OSInfo { + name: string; + version: string; +} + +interface EngineInfo { + name: string; + version: string; +} + +function detectBrowser(): BrowserInfo { + let name = ''; + let version = ''; + + // Order matters - check more specific patterns first + if (/Opera|OPR\//.test(userAgent)) { + name = 'Opera'; + const match = /(?:Opera|OPR)[\/\s](\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/Edg\//.test(userAgent)) { + name = 'Edge'; + const match = /Edg\/(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/Edge\//.test(userAgent)) { + name = 'Edge'; + const match = /Edge\/(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/Firefox\//.test(userAgent) && !/Seamonkey\//.test(userAgent)) { + name = 'Firefox'; + const match = /Firefox\/(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/MSIE|Trident/.test(userAgent)) { + name = 'IE'; + const match = /(?:MSIE |rv:)(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/Chrome\//.test(userAgent) && !/Chromium\//.test(userAgent)) { + name = 'Chrome'; + const match = /Chrome\/(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/Chromium\//.test(userAgent)) { + name = 'Chromium'; + const match = /Chromium\/(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/Safari\//.test(userAgent) && !/Chrome\//.test(userAgent)) { + name = 'Safari'; + const match = /Version\/(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/Android/.test(userAgent) && !/Chrome\//.test(userAgent)) { + name = 'Android Browser'; + const match = /Version\/(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } + + return { name, version }; +} + +function detectOS(): OSInfo { + let name = ''; + let version = ''; + + if (/Windows/.test(userAgent)) { + name = 'Windows'; + const match = /Windows NT (\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/Android/.test(userAgent)) { + name = 'Android'; + const match = /Android (\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/iPhone|iPad|iPod/.test(userAgent)) { + name = 'iOS'; + const match = /OS (\d+[_\d]*)/.exec(userAgent); + version = match ? match[1].replace(/_/g, '.') : ''; + } else if (/Mac OS X/.test(userAgent)) { + name = 'Mac OS'; + const match = /Mac OS X (\d+[_\d]*)/.exec(userAgent); + version = match ? match[1].replace(/_/g, '.') : ''; + } else if (/Linux/.test(userAgent)) { + name = 'Linux'; + } + + return { name, version }; +} + +function detectEngine(): EngineInfo { + let name = ''; + let version = ''; + + if (/Trident/.test(userAgent)) { + name = 'Trident'; + const match = /Trident\/(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/Gecko\//.test(userAgent) && !/like Gecko/.test(userAgent)) { + name = 'Gecko'; + const match = /rv:(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/AppleWebKit\//.test(userAgent)) { + name = 'WebKit'; + const match = /AppleWebKit\/(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } else if (/Presto\//.test(userAgent)) { + name = 'Presto'; + const match = /Presto\/(\d+(?:\.\d+)*)/.exec(userAgent); + version = match ? match[1] : ''; + } + + return { name, version }; +} + +// Perform detection once at load time +const browser = detectBrowser(); +const os = detectOS(); +const engine = detectEngine(); + +// Browser/engine constants - these are exported for use by events.ts and others +export const IE: boolean = browser.name === 'IE'; +export const GECKO: boolean = engine.name === 'Gecko'; +export const WEBKIT: boolean = engine.name === 'WebKit'; +export const EDGE: boolean = browser.name === 'Edge'; +export const ANDROID: boolean = os.name === 'Android'; + +const DOCUMENT_MODE: number | undefined = (function () { + if (typeof document !== 'undefined' && IE) { + return (document as { documentMode?: number }).documentMode; + } + return undefined; +})(); + +function isVersionOrHigher(version: string | number): boolean { + return compareVersions(engine.version, version) >= 0; +} + +function isDocumentModeOrHigher(documentMode: number): boolean { + return Number(DOCUMENT_MODE) >= documentMode; +} + +// Product detection +const isAndroidOS = os.name === 'Android'; +const isAndroidBrowser = browser.name === 'Android Browser'; +const IPHONE: boolean = /iPhone|iPod/.test(userAgent); +const IPAD: boolean = /iPad/.test(userAgent); +const SAFARI: boolean = browser.name === 'Safari' && !IPHONE && !IPAD; +const CHROME: boolean = browser.name === 'Chrome' || browser.name === 'Chromium'; +const FIREFOX: boolean = browser.name === 'Firefox'; + +/** + * Android Operating System Version. + */ +export const ANDROID_VERSION_: string = isAndroidOS ? os.version || '0' : '0'; + +/** + * Product version. + */ +const PRODUCT_VERSION: string = browser.version; + +function isProductVersionOrHigher(version: string | number): boolean { + return compareVersions(PRODUCT_VERSION, version) >= 0; +} + +// ============================================================================ +// Exported API - matches original bot.userAgent interface +// ============================================================================ + +/** + * Whether the rendering engine version of the current browser is equal to or + * greater than the given version. + */ +export function isEngineVersion(version: string | number): boolean { + if (IE) { + return compareVersions(DOCUMENT_MODE ?? 0, version) >= 0; + } + return isVersionOrHigher(version); +} + +/** + * Whether the product version of the current browser is equal to or greater + * than the given version. + */ +export function isProductVersion(version: string | number): boolean { + if (isAndroidBrowser) { + return compareVersions(ANDROID_VERSION_, version) >= 0; + } + return isProductVersionOrHigher(version); +} + +/** + * Whether we are a WebExtension. + */ +export const WEBEXTENSION: boolean = (function () { + try { + const global = typeof globalThis !== 'undefined' ? globalThis : window; + const chrome = (global as { chrome?: { extension?: unknown } }).chrome; + const browserObj = (global as { browser?: { extension?: unknown } }).browser; + return !!((chrome || browserObj)?.extension); + } catch (e) { + return false; + } +})(); + +/** + * Whether we are on iOS. + */ +export const IOS: boolean = IPAD || IPHONE; + +/** + * Whether we are on a mobile browser. + */ +export const MOBILE: boolean = IOS || isAndroidOS; + +/** + * Whether the current document is IE in a documentMode older than 8. + */ +export const IE_DOC_PRE8: boolean = IE && !isDocumentModeOrHigher(8); + +/** + * Whether the current document is IE in IE9 (or newer) standards mode. + */ +export const IE_DOC_9: boolean = isDocumentModeOrHigher(9); + +/** + * Whether the current document is IE in a documentMode older than 9. + */ +export const IE_DOC_PRE9: boolean = IE && !isDocumentModeOrHigher(9); + +/** + * Whether the current document is IE in IE10 (or newer) standards mode. + */ +export const IE_DOC_10: boolean = isDocumentModeOrHigher(10); + +/** + * Whether the current document is IE in a documentMode older than 10. + */ +export const IE_DOC_PRE10: boolean = IE && !isDocumentModeOrHigher(10); + +/** + * Whether the current browser is Android pre-gingerbread. + */ +export const ANDROID_PRE_GINGERBREAD: boolean = + isAndroidOS && !isProductVersion(2.3); + +/** + * Whether the current browser is Android pre-icecreamsandwich. + */ +export const ANDROID_PRE_ICECREAMSANDWICH: boolean = + isAndroidOS && !isProductVersion(4); + +/** + * Whether the current browser is Safari 6. + */ +export const SAFARI_6: boolean = SAFARI && isProductVersion(6); + +/** + * Whether the current browser is Windows Phone. + */ +export const WINDOWS_PHONE: boolean = + IE && userAgent.indexOf('IEMobile') !== -1; diff --git a/javascript/atoms/window.js b/javascript/atoms/window.js deleted file mode 100644 index 86ea0f155aed5..0000000000000 --- a/javascript/atoms/window.js +++ /dev/null @@ -1,436 +0,0 @@ -// Licensed to the Software Freedom Conservancy (SFC) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The SFC licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * @fileoverview Atoms for simulating user actions against the browser window. - */ - -goog.provide('bot.window'); - -goog.require('bot'); -goog.require('bot.Error'); -goog.require('bot.ErrorCode'); -goog.require('bot.events'); -goog.require('bot.userAgent'); -goog.require('goog.dom'); -goog.require('goog.dom.DomHelper'); -goog.require('goog.math.Coordinate'); -goog.require('goog.math.Size'); -goog.require('goog.style'); -goog.require('goog.userAgent'); -goog.require('goog.userAgent.product'); - - -/** - * Whether the value of history.length includes a newly loaded page. If not, - * after a new page load history.length is the number of pages that have loaded, - * minus 1, but becomes the total number of pages on a subsequent back() call. - * @private {boolean} - * @const - */ -bot.window.HISTORY_LENGTH_INCLUDES_NEW_PAGE_ = !goog.userAgent.IE; - - -/** - * Whether value of history.length includes the pages ahead of the current one - * in the history. If not, history.length equals the number of prior pages. - * Here is the WebKit bug for this behavior that was fixed by version 533: - * https://bugs.webkit.org/show_bug.cgi?id=24472 - * @private {boolean} - * @const - */ -bot.window.HISTORY_LENGTH_INCLUDES_FORWARD_PAGES_ = - !goog.userAgent.WEBKIT || bot.userAgent.isEngineVersion('533'); - - -/** - * Screen orientation values. From the draft W3C spec at: - * http://www.w3.org/TR/2012/WD-screen-orientation-20120522 - * - * @enum {string} - */ -bot.window.Orientation = { - PORTRAIT: 'portrait-primary', - PORTRAIT_SECONDARY: 'portrait-secondary', - LANDSCAPE: 'landscape-primary', - LANDSCAPE_SECONDARY: 'landscape-secondary' -}; - - -/** - * Returns the degrees corresponding to the orientation input. - * - * @param {!bot.window.Orientation} orientation The orientation. - * @return {number} The orientation degrees. - * @private - */ -bot.window.getOrientationDegrees_ = (function () { - var orientationMap; - return function (orientation) { - if (!orientationMap) { - orientationMap = {}; - if (goog.userAgent.MOBILE) { - // The iPhone and Android phones do not change orientation event when - // held upside down. Hence, PORTRAIT_SECONDARY is not set. - orientationMap[bot.window.Orientation.PORTRAIT] = 0; - orientationMap[bot.window.Orientation.LANDSCAPE] = 90; - orientationMap[bot.window.Orientation.LANDSCAPE_SECONDARY] = -90; - if (goog.userAgent.product.IPAD) { - orientationMap[bot.window.Orientation.PORTRAIT_SECONDARY] = 180; - } - } else if (goog.userAgent.product.ANDROID) { - // Unlike the iPad, Android tablets treat landscape orientation as the - // default, i.e., having window.orientation = 0. - orientationMap[bot.window.Orientation.PORTRAIT] = -90; - orientationMap[bot.window.Orientation.LANDSCAPE] = 0; - orientationMap[bot.window.Orientation.PORTRAIT_SECONDARY] = 90; - orientationMap[bot.window.Orientation.LANDSCAPE_SECONDARY] = 180; - } - } - return orientationMap[orientation]; - }; -})(); - - -/** - * Go back in the browser history. The number of pages to go back can - * optionally be specified and defaults to 1. - * - * @param {number=} opt_numPages Number of pages to go back. - */ -bot.window.back = function (opt_numPages) { - // Relax the upper bound by one for browsers that do not count - // newly loaded pages towards the value of window.history.length. - var maxPages = bot.window.HISTORY_LENGTH_INCLUDES_NEW_PAGE_ ? - bot.getWindow().history.length - 1 : bot.getWindow().history.length; - var numPages = bot.window.checkNumPages_(maxPages, opt_numPages); - bot.getWindow().history.go(-numPages); -}; - - -/** - * Go forward in the browser history. The number of pages to go forward can - * optionally be specified and defaults to 1. - * - * @param {number=} opt_numPages Number of pages to go forward. - */ -bot.window.forward = function (opt_numPages) { - // Do not check the upper bound (use null for infinity) for browsers that - // do not count forward pages towards the value of window.history.length. - var maxPages = bot.window.HISTORY_LENGTH_INCLUDES_FORWARD_PAGES_ ? - bot.getWindow().history.length - 1 : null; - var numPages = bot.window.checkNumPages_(maxPages, opt_numPages); - bot.getWindow().history.go(numPages); -}; - - -/** - * @param {?number} maxPages Upper bound on number of pages; null for infinity. - * @param {number=} opt_numPages Number of pages to move in history. - * @return {number} Correct number of pages to move in history. - * @private - */ -bot.window.checkNumPages_ = function (maxPages, opt_numPages) { - var numPages = opt_numPages !== undefined ? opt_numPages : 1; - if (numPages <= 0) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'number of pages must be positive'); - } - if (maxPages !== null && numPages > maxPages) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'number of pages must be less than the length of the browser history'); - } - return numPages; -}; - - -/** - * Determine the size of the window that a user could interact with. This will - * be the greatest of document.body.(width|scrollWidth), the same for - * document.documentElement or the size of the viewport. - * - * @param {!Window=} opt_win Window to determine the size of. Defaults to - * bot.getWindow(). - * @return {!goog.math.Size} The calculated size. - */ -bot.window.getInteractableSize = function (opt_win) { - var win = opt_win || bot.getWindow(); - var doc = win.document; - var elem = doc.documentElement; - var body = doc.body; - if (!body) { - throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, - 'No BODY element present'); - } - - var widths = [ - elem.clientWidth, elem.scrollWidth, elem.offsetWidth, - body.scrollWidth, body.offsetWidth - ]; - var heights = [ - elem.clientHeight, elem.scrollHeight, elem.offsetHeight, - body.scrollHeight, body.offsetHeight - ]; - - var width = Math.max.apply(null, widths); - var height = Math.max.apply(null, heights); - - return new goog.math.Size(width, height); -}; - - -/** - * Gets the frame element. - * - * @param {!Window} win Window of the frame. Defaults to bot.getWindow(). - * @return {Element} The frame element if it exists, null otherwise. - * @private - */ -bot.window.getFrame_ = function (win) { - try { - // On IE, accessing the frameElement of a popup window results in a "No - // Such interface" exception. - return win.frameElement; - } catch (e) { - return null; - } -}; - - -/** - * Determine the outer size of the window. - * - * @param {!Window=} opt_win Window to determine the size of. Defaults to - * bot.getWindow(). - * @return {!goog.math.Size} The calculated size. - */ -bot.window.getSize = function (opt_win) { - var win = opt_win || bot.getWindow(); - var frame = bot.window.getFrame_(win); - if (bot.userAgent.ANDROID_PRE_ICECREAMSANDWICH) { - if (frame) { - // Early Android browsers do not account for border width. - var box = goog.style.getBorderBox(frame); - return new goog.math.Size(frame.clientWidth - box.left - box.right, - frame.clientHeight); - } else { - // A fixed popup size. - return new goog.math.Size(320, 240); - } - } else if (frame) { - return new goog.math.Size(frame.clientWidth, frame.clientHeight); - } else { - var docElem = win.document.documentElement; - var body = win.document.body; - var width = win.outerWidth || (docElem && docElem.clientWidth) || - (body && body.clientWidth) || 0; - var height = win.outerHeight || (docElem && docElem.clientHeight) || - (body && body.clientHeight) || 0; - return new goog.math.Size(width, height); - } -}; - - -/** - * Set the outer size of the window. - * - * @param {!goog.math.Size} size The new window size. - * @param {!Window=} opt_win Window to determine the size of. Defaults to - * bot.getWindow(). - */ -bot.window.setSize = function (size, opt_win) { - var win = opt_win || bot.getWindow(); - var frame = bot.window.getFrame_(win); - if (frame) { - // minHeight and minWidth are altered because many browsers will not change - // height or width if it is less than a specified minHeight or minWidth. - frame.style.minHeight = '0px'; - frame.style.minWidth = '0px'; - frame.width = size.width + 'px'; - frame.style.width = size.width + 'px'; - frame.height = size.height + 'px'; - frame.style.height = size.height + 'px'; - } else { - win.resizeTo(size.width, size.height); - } -}; - - -/** - * Determine the scroll position of the window. - * - * @param {!Window=} opt_win Window to determine the scroll position of. - * Defaults to bot.getWindow(). - * @return {!goog.math.Coordinate} The scroll position. - */ -bot.window.getScroll = function (opt_win) { - var win = opt_win || bot.getWindow(); - return new goog.dom.DomHelper(win.document).getDocumentScroll(); -}; - - -/** - * Set the scroll position of the window. - * - * @param {!goog.math.Coordinate} position The new scroll position. - * @param {!Window=} opt_win Window to apply position to. Defaults to - * bot.getWindow(). - */ -bot.window.setScroll = function (position, opt_win) { - var win = opt_win || bot.getWindow(); - win.scrollTo(position.x, position.y); -}; - - -/** - * Get the position of the window. - * - * @param {!Window=} opt_win Window to determine the position of. Defaults to - * bot.getWindow(). - * @return {!goog.math.Coordinate} The position of the window. - */ -bot.window.getPosition = function (opt_win) { - var win = opt_win || bot.getWindow(); - var x, y; - - if (goog.userAgent.IE) { - x = win.screenLeft; - y = win.screenTop; - } else { - x = win.screenX; - y = win.screenY; - } - - return new goog.math.Coordinate(x, y); -}; - - -/** - * Set the position of the window. - * - * @param {!goog.math.Coordinate} position The target position. - * @param {!Window=} opt_win Window to set the position of. Defaults to - * bot.getWindow(). - */ -bot.window.setPosition = function (position, opt_win) { - var win = opt_win || bot.getWindow(); - win.moveTo(position.x, position.y); -}; - - -/** - * Scrolls the given position into the viewport, using the minimal amount of - * scrolling necessary to being the coordinate into view. - * - * @param {!goog.math.Coordinate} position The position to scroll into view. - * @param {!Window=} opt_win Window to apply position to. Defaults to - * bot.getWindow(). - */ -bot.window.scrollIntoView = function (position, opt_win) { - var win = opt_win || bot.getWindow(); - var viewport = goog.dom.getViewportSize(win); - var scroll = bot.window.getScroll(win); - - // Scroll the minimal amount to bring the position into view. - var targetScroll = new goog.math.Coordinate( - newScrollDim(position.x, scroll.x, viewport.width), - newScrollDim(position.y, scroll.y, viewport.height)); - if (!goog.math.Coordinate.equals(targetScroll, scroll)) { - bot.window.setScroll(targetScroll, win); - } - - // It is difficult to determine the size of the web page in some browsers. - // We check if the scrolling we intended to do really happened. If not we - // assume that the target location is not on the web page. - if (!goog.math.Coordinate.equals(targetScroll, bot.window.getScroll(win))) { - throw new bot.Error(bot.ErrorCode.MOVE_TARGET_OUT_OF_BOUNDS, - 'The target scroll location ' + targetScroll + ' is not on the page.'); - } - - function newScrollDim(positionDim, scrollDim, viewportDim) { - if (positionDim < scrollDim) { - return positionDim; - } else if (positionDim >= scrollDim + viewportDim) { - return positionDim - viewportDim + 1; - } else { - return scrollDim; - } - } -}; - - -/** - * @return {number} The current window orientation degrees. - * window. - * @private - */ -bot.window.getCurrentOrientationDegrees_ = function () { - var win = bot.getWindow(); - if (win.orientation === undefined) { - // If window.orientation is not defined, assume a default orientation of 0. - // A value of 0 indicates a portrait orientation except for android tablets - // where 0 indicates a landscape orientation. - win.orientation = 0; - } - return win.orientation; -}; - - -/** - * Changes window orientation. - * - * @param {!bot.window.Orientation} orientation The new orientation of the - * window. - */ -bot.window.changeOrientation = function (orientation) { - var win = bot.getWindow(); - var currentOrientationDegrees = bot.window.getCurrentOrientationDegrees_(); - var newOrientationDegrees = bot.window.getOrientationDegrees_(orientation); - if (currentOrientationDegrees == newOrientationDegrees || - newOrientationDegrees === undefined) { - return; - } - - // If possible, try to override the window's orientation value. - // On some older version of Android, it's not possible to change - // the window's orientation value. - if (Object.getOwnPropertyDescriptor && Object.defineProperty) { - var descriptor = Object.getOwnPropertyDescriptor(win, 'orientation'); - if (descriptor && descriptor.configurable) { - Object.defineProperty(win, 'orientation', { - configurable: true, - get: function () { - return newOrientationDegrees; - } - }); - } - } - bot.events.fire(win, bot.events.EventType.ORIENTATIONCHANGE); - - // Change the window size to reflect the new orientation. - if (Math.abs(currentOrientationDegrees - newOrientationDegrees) % 180 != 0) { - var size = bot.window.getSize(); - var shorter = size.getShortest(); - var longer = size.getLongest(); - if (orientation == bot.window.Orientation.PORTRAIT || - orientation == bot.window.Orientation.PORTRAIT_SECONDARY) { - bot.window.setSize(new goog.math.Size(shorter, longer)); - } else { - bot.window.setSize(new goog.math.Size(longer, shorter)); - } - } -}; diff --git a/javascript/atoms/window.ts b/javascript/atoms/window.ts new file mode 100644 index 0000000000000..a6ca5cea02d49 --- /dev/null +++ b/javascript/atoms/window.ts @@ -0,0 +1,451 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Atoms for simulating user actions against the browser window. + */ + +import { getWindow } from './bot'; +import { BotError, ErrorCode } from './error'; +import { fire, EventType } from './events'; +import { isEngineVersion, ANDROID_PRE_ICECREAMSANDWICH } from './userAgent'; + +// Browser detection +const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''; +const IS_IE = /MSIE|Trident/.test(userAgent); +const IS_WEBKIT = /AppleWebKit/.test(userAgent); +const IS_MOBILE = /Mobile/.test(userAgent); +const IS_ANDROID = /Android/.test(userAgent); +const IS_IPAD = /iPad/.test(userAgent); + +// ============================================================================ +// Size and Coordinate types +// ============================================================================ + +export interface Size { + width: number; + height: number; +} + +export interface Coordinate { + x: number; + y: number; +} + +/** + * Creates a Size object with getShortest/getLongest methods for compatibility. + */ +function createSize(width: number, height: number): Size & { getShortest(): number; getLongest(): number } { + return { + width, + height, + getShortest() { + return Math.min(this.width, this.height); + }, + getLongest() { + return Math.max(this.width, this.height); + } + }; +} + +// ============================================================================ +// Private constants +// ============================================================================ + +/** + * Whether the value of history.length includes a newly loaded page. + */ +const HISTORY_LENGTH_INCLUDES_NEW_PAGE_ = !IS_IE; + +/** + * Whether value of history.length includes the pages ahead of the current one + * in the history. + */ +const HISTORY_LENGTH_INCLUDES_FORWARD_PAGES_ = + !IS_WEBKIT || isEngineVersion('533'); + +// ============================================================================ +// Orientation enum +// ============================================================================ + +/** + * Screen orientation values. From the draft W3C spec. + */ +export enum Orientation { + PORTRAIT = 'portrait-primary', + PORTRAIT_SECONDARY = 'portrait-secondary', + LANDSCAPE = 'landscape-primary', + LANDSCAPE_SECONDARY = 'landscape-secondary', +} + +/** + * Returns the degrees corresponding to the orientation input. + */ +const getOrientationDegrees_ = (function () { + let orientationMap: Record | undefined; + return function (orientation: Orientation): number | undefined { + if (!orientationMap) { + orientationMap = {}; + if (IS_MOBILE) { + orientationMap[Orientation.PORTRAIT] = 0; + orientationMap[Orientation.LANDSCAPE] = 90; + orientationMap[Orientation.LANDSCAPE_SECONDARY] = -90; + if (IS_IPAD) { + orientationMap[Orientation.PORTRAIT_SECONDARY] = 180; + } + } else if (IS_ANDROID) { + orientationMap[Orientation.PORTRAIT] = -90; + orientationMap[Orientation.LANDSCAPE] = 0; + orientationMap[Orientation.PORTRAIT_SECONDARY] = 90; + orientationMap[Orientation.LANDSCAPE_SECONDARY] = 180; + } + } + return orientationMap[orientation]; + }; +})(); + +// ============================================================================ +// Private helpers +// ============================================================================ + +/** + * Gets the frame element for a window. + */ +function getFrame_(win: Window): HTMLElement | null { + try { + return win.frameElement as HTMLElement | null; + } catch (e) { + return null; + } +} + +/** + * Gets the border box of an element. + */ +function getBorderBox(elem: Element): { + top: number; + right: number; + bottom: number; + left: number; +} { + const style = window.getComputedStyle(elem); + return { + top: parseFloat(style.borderTopWidth) || 0, + right: parseFloat(style.borderRightWidth) || 0, + bottom: parseFloat(style.borderBottomWidth) || 0, + left: parseFloat(style.borderLeftWidth) || 0, + }; +} + +/** + * Gets the viewport size of a window. + */ +function getViewportSize(win: Window): Size { + const doc = win.document; + const docEl = doc.documentElement; + return { + width: docEl.clientWidth || win.innerWidth, + height: docEl.clientHeight || win.innerHeight, + }; +} + +/** + * Checks the number of pages to navigate in history. + */ +function checkNumPages_(maxPages: number | null, opt_numPages?: number): number { + const numPages = opt_numPages !== undefined ? opt_numPages : 1; + if (numPages <= 0) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'number of pages must be positive' + ); + } + if (maxPages !== null && numPages > maxPages) { + throw new BotError( + ErrorCode.UNKNOWN_ERROR, + 'number of pages must be less than the length of the browser history' + ); + } + return numPages; +} + +/** + * Gets the current window orientation in degrees. + */ +function getCurrentOrientationDegrees_(): number { + const win = getWindow() as Window & { orientation?: number }; + if (win.orientation === undefined) { + win.orientation = 0; + } + return win.orientation; +} + +// ============================================================================ +// Public functions +// ============================================================================ + +/** + * Go back in the browser history. + */ +export function back(opt_numPages?: number): void { + const maxPages = HISTORY_LENGTH_INCLUDES_NEW_PAGE_ + ? getWindow().history.length - 1 + : getWindow().history.length; + const numPages = checkNumPages_(maxPages, opt_numPages); + getWindow().history.go(-numPages); +} + +/** + * Go forward in the browser history. + */ +export function forward(opt_numPages?: number): void { + const maxPages = HISTORY_LENGTH_INCLUDES_FORWARD_PAGES_ + ? getWindow().history.length - 1 + : null; + const numPages = checkNumPages_(maxPages, opt_numPages); + getWindow().history.go(numPages); +} + +/** + * Determine the size of the window that a user could interact with. + */ +export function getInteractableSize(opt_win?: Window): Size { + const win = opt_win || getWindow(); + const doc = win.document; + const elem = doc.documentElement; + const body = doc.body; + if (!body) { + throw new BotError(ErrorCode.UNKNOWN_ERROR, 'No BODY element present'); + } + + const widths = [ + elem.clientWidth, + elem.scrollWidth, + elem.offsetWidth, + body.scrollWidth, + body.offsetWidth, + ]; + const heights = [ + elem.clientHeight, + elem.scrollHeight, + elem.offsetHeight, + body.scrollHeight, + body.offsetHeight, + ]; + + const width = Math.max.apply(null, widths); + const height = Math.max.apply(null, heights); + + return { width, height }; +} + +/** + * Determine the outer size of the window. + */ +export function getSize(opt_win?: Window): Size & { getShortest(): number; getLongest(): number } { + const win = opt_win || getWindow(); + const frame = getFrame_(win); + if (ANDROID_PRE_ICECREAMSANDWICH) { + if (frame) { + const box = getBorderBox(frame); + return createSize( + frame.clientWidth - box.left - box.right, + frame.clientHeight + ); + } else { + return createSize(320, 240); + } + } else if (frame) { + return createSize(frame.clientWidth, frame.clientHeight); + } else { + const docElem = win.document.documentElement; + const body = win.document.body; + const width = + win.outerWidth || + (docElem && docElem.clientWidth) || + (body && body.clientWidth) || + 0; + const height = + win.outerHeight || + (docElem && docElem.clientHeight) || + (body && body.clientHeight) || + 0; + return createSize(width, height); + } +} + +/** + * Set the outer size of the window. + */ +export function setSize(size: Size, opt_win?: Window): void { + const win = opt_win || getWindow(); + const frame = getFrame_(win) as HTMLFrameElement | HTMLIFrameElement | null; + if (frame) { + frame.style.minHeight = '0px'; + frame.style.minWidth = '0px'; + (frame as HTMLIFrameElement).width = size.width + 'px'; + frame.style.width = size.width + 'px'; + (frame as HTMLIFrameElement).height = size.height + 'px'; + frame.style.height = size.height + 'px'; + } else { + win.resizeTo(size.width, size.height); + } +} + +/** + * Determine the scroll position of the window. + */ +export function getScroll(opt_win?: Window): Coordinate { + const win = opt_win || getWindow(); + const doc = win.document; + const docEl = doc.documentElement; + const body = doc.body; + + // Standard way for modern browsers + if (typeof win.pageXOffset === 'number') { + return { x: win.pageXOffset, y: win.pageYOffset }; + } + + // For older IE + if (docEl && (docEl.scrollLeft || docEl.scrollTop)) { + return { x: docEl.scrollLeft, y: docEl.scrollTop }; + } + + // For quirks mode + if (body) { + return { x: body.scrollLeft, y: body.scrollTop }; + } + + return { x: 0, y: 0 }; +} + +/** + * Set the scroll position of the window. + */ +export function setScroll(position: Coordinate, opt_win?: Window): void { + const win = opt_win || getWindow(); + win.scrollTo(position.x, position.y); +} + +/** + * Get the position of the window. + */ +export function getPosition(opt_win?: Window): Coordinate { + const win = opt_win || getWindow(); + let x: number; + let y: number; + + if (IS_IE) { + x = (win as Window & { screenLeft?: number }).screenLeft || 0; + y = (win as Window & { screenTop?: number }).screenTop || 0; + } else { + x = win.screenX; + y = win.screenY; + } + + return { x, y }; +} + +/** + * Set the position of the window. + */ +export function setPosition(position: Coordinate, opt_win?: Window): void { + const win = opt_win || getWindow(); + win.moveTo(position.x, position.y); +} + +/** + * Scrolls the given position into the viewport. + */ +export function scrollIntoView(position: Coordinate, opt_win?: Window): void { + const win = opt_win || getWindow(); + const viewport = getViewportSize(win); + const scroll = getScroll(win); + + function newScrollDim( + positionDim: number, + scrollDim: number, + viewportDim: number + ): number { + if (positionDim < scrollDim) { + return positionDim; + } else if (positionDim >= scrollDim + viewportDim) { + return positionDim - viewportDim + 1; + } else { + return scrollDim; + } + } + + const targetScroll: Coordinate = { + x: newScrollDim(position.x, scroll.x, viewport.width), + y: newScrollDim(position.y, scroll.y, viewport.height), + }; + + if (targetScroll.x !== scroll.x || targetScroll.y !== scroll.y) { + setScroll(targetScroll, win); + } + + const newScroll = getScroll(win); + if (targetScroll.x !== newScroll.x || targetScroll.y !== newScroll.y) { + throw new BotError( + ErrorCode.MOVE_TARGET_OUT_OF_BOUNDS, + `The target scroll location (${targetScroll.x}, ${targetScroll.y}) is not on the page.` + ); + } +} + +/** + * Changes window orientation. + */ +export function changeOrientation(orientation: Orientation): void { + const win = getWindow() as Window & { orientation?: number }; + const currentOrientationDegrees = getCurrentOrientationDegrees_(); + const newOrientationDegrees = getOrientationDegrees_(orientation); + if ( + currentOrientationDegrees === newOrientationDegrees || + newOrientationDegrees === undefined + ) { + return; + } + + if (Object.getOwnPropertyDescriptor && Object.defineProperty) { + const descriptor = Object.getOwnPropertyDescriptor(win, 'orientation'); + if (descriptor && descriptor.configurable) { + Object.defineProperty(win, 'orientation', { + configurable: true, + get: function () { + return newOrientationDegrees; + }, + }); + } + } + fire(win as unknown as Element, EventType.ORIENTATIONCHANGE); + + if (Math.abs(currentOrientationDegrees - newOrientationDegrees) % 180 !== 0) { + const size = getSize(); + const shorter = size.getShortest(); + const longer = size.getLongest(); + if ( + orientation === Orientation.PORTRAIT || + orientation === Orientation.PORTRAIT_SECONDARY + ) { + setSize({ width: shorter, height: longer }); + } else { + setSize({ width: longer, height: shorter }); + } + } +} diff --git a/javascript/private/BUILD.bazel b/javascript/private/BUILD.bazel index d9f243e2b2a18..65ac20b2591b0 100644 --- a/javascript/private/BUILD.bazel +++ b/javascript/private/BUILD.bazel @@ -35,3 +35,11 @@ closure_bin.closure_make_deps_binary( "//third_party/closure/goog:__pkg__", ], ) + +js_binary( + name = "fragment_wrapper", + entry_point = ":fragment_wrapper.js", + visibility = [ + "//javascript:__subpackages__", + ], +) diff --git a/javascript/private/closure_make_deps_wrapper.js b/javascript/private/closure_make_deps_wrapper.js index 87bd4b8f37ba2..27d4181cf8ffd 100644 --- a/javascript/private/closure_make_deps_wrapper.js +++ b/javascript/private/closure_make_deps_wrapper.js @@ -56,7 +56,16 @@ async function main() { } if (result.text) { - fs.writeFileSync(outputPath, result.text); + // Post-process the deps.js to fix paths for generated files. + // Generated files in bazel-out/.../bin/path/to/file.js should be + // remapped to just path/to/file.js so that the debug server can find them. + // The debug server serves files under /filez/_main/path/to/file.js + // and can find both source files and generated files at that path. + const fixedText = result.text.replace( + /bazel-out\/[^/]+\/bin\//g, + '' + ); + fs.writeFileSync(outputPath, fixedText); } else { process.exit(1); } diff --git a/javascript/private/fragment.bzl b/javascript/private/fragment.bzl index f32d197caa167..f5eda0e3597d9 100644 --- a/javascript/private/fragment.bzl +++ b/javascript/private/fragment.bzl @@ -49,6 +49,10 @@ def closure_fragment( name = exports_lib_name, srcs = [exports_file_name], deps = kwargs.get("deps", []), + suppress = [ + "JSC_UNKNOWN_EXPR_TYPE", + "reportUnknownTypes", + ], ) # Wrap the output in two functions. The outer function ensures the diff --git a/javascript/private/fragment_wrapper.js b/javascript/private/fragment_wrapper.js new file mode 100644 index 0000000000000..0c70c592d64a8 --- /dev/null +++ b/javascript/private/fragment_wrapper.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * @fileoverview Wraps an esbuild bundle in the Selenium fragment pattern. + * + * The wrapper pattern ensures: + * 1. The fragment never pollutes the global scope by using its own scope + * 2. We import window.navigator into this scope since the code may need it + * 3. The exported function is returned and can be called with arguments + * + * Input: An esbuild IIFE bundle that assigns to globalThis.__fragment__ + * Output: A wrapped function that can be embedded in browser automation code + * + * Usage: node fragment_wrapper.js + */ + +const fs = require('fs'); + +// Find the .js file among the arguments (esbuild may output .js + .js.map) +const args = process.argv.slice(2); +const bundlePath = args.find(arg => arg.endsWith('.js') && !arg.endsWith('.map.js')); + +if (!bundlePath) { + console.error('Usage: node fragment_wrapper.js [bundle.js.map]'); + console.error('Received args:', args); + process.exit(1); +} + +const bundle = fs.readFileSync(bundlePath, 'utf-8'); + +// The original Closure wrapper pattern: +// function(){ +// return (function(){ +// %output%; +// return this.se_exportedFunctionSymbol.apply(null,arguments); +// }).apply(window, arguments); +// } + +// Our modern equivalent: +// 1. The bundle runs and assigns the function to this.__fragment__ +// 2. We call that function with the provided arguments +// 3. The inner function runs with `this === window` for navigator/document access + +// Strip sourcemap comment for cleaner output +const bundleClean = bundle.replace(/\/\/# sourceMappingURL=.*$/gm, '').trim(); + +const wrapped = `function(){return(function(){${bundleClean};return this.__fragment__.apply(null,arguments)}).apply(window,arguments)}`; + +console.log(wrapped); diff --git a/javascript/private/ts_fragment.bzl b/javascript/private/ts_fragment.bzl new file mode 100644 index 0000000000000..a1cae2db555a6 --- /dev/null +++ b/javascript/private/ts_fragment.bzl @@ -0,0 +1,91 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Modern TypeScript-based fragment generation using esbuild. + +This replaces the Closure Compiler-based fragment.bzl with a modern approach: +1. TypeScript source files are compiled and bundled with esbuild +2. Tree-shaking removes unused code +3. esbuild's minifier produces compact output +4. A wrapper script applies the IIFE wrapper pattern required by Selenium + +The wrapper pattern ensures: +- The fragment never pollutes the global scope +- The inner function runs with `this === window` so navigator/document are accessible +- The exported function is returned and can be called with arguments + +NOTE: esbuild fragments are currently ~50% larger than Closure Compiler fragments +due to less aggressive minification (no property renaming) and module-level +tree-shaking vs statement-level. This will be addressed when we: +1. Complete the migration away from Closure Compiler +2. Switch to const enums with isolatedModules: false +3. Optionally add terser as a post-processing step +""" + +load("@aspect_rules_esbuild//esbuild:defs.bzl", "esbuild") +load("@aspect_rules_js//js:defs.bzl", "js_run_binary") + +def ts_fragment( + name, + entry_point, + visibility = None, + deps = [], + **kwargs): + """ + Generates a minified JavaScript fragment from TypeScript source. + + Args: + name: Name of the fragment target + entry_point: TypeScript file that exports the fragment function as default + visibility: Bazel visibility + deps: Dependencies (ts_project targets) + **kwargs: Additional arguments passed to esbuild + """ + + # Step 1: Bundle with esbuild (tree-shaking + minification) + bundle_name = "_%s_bundle" % name + esbuild( + name = bundle_name, + srcs = deps + [entry_point], + entry_point = entry_point, + bundle = True, + minify = True, + format = "iife", + platform = "browser", + target = "es2015", + output = "%s_bundle.js" % name, + config = { + "treeShaking": True, + "ignoreAnnotations": False, + }, + # Disable sandbox plugin to allow resolving sources from other packages + bazel_sandbox_plugin = False, + **kwargs + ) + + # Step 2: Wrap the bundle in the Selenium fragment pattern + js_run_binary( + name = name, + srcs = [":%s" % bundle_name], + args = [ + "$(rootpaths :%s)" % bundle_name, + ], + stdout = "%s.js" % name, + tool = "//javascript/private:fragment_wrapper", + visibility = visibility, + ) diff --git a/scripts/update_copyright.py b/scripts/update_copyright.py index 5238168bb2117..4b8053a1f89ba 100755 --- a/scripts/update_copyright.py +++ b/scripts/update_copyright.py @@ -32,6 +32,14 @@ def update(self, files): with open(file, "r", encoding="utf-8-sig") as f: lines = f.readlines() + if not lines: + continue + + shebang = None + if lines[0].startswith("#!"): + shebang = lines[0] + lines = lines[1:] + index = -1 for i, line in enumerate(lines): if line.startswith( @@ -42,11 +50,11 @@ def update(self, files): break if index == -1: - self.write_update_notice(file, lines) + self.write_update_notice(file, lines, shebang) else: current = "".join(lines[: index + 1]) if current != self.copyright_notice(file): - self.write_update_notice(file, lines[index + 1 :]) + self.write_update_notice(file, lines[index + 1 :], shebang) def valid_copyright_notice_line(self, line, index, file): return index + 1 < len(self.copyright_notice_lines(file)) and line.startswith( @@ -76,9 +84,11 @@ def commented_notice_lines(self): for line in self.NOTICE.split("\n") ] - def write_update_notice(self, file, lines): + def write_update_notice(self, file, lines, shebang=None): print(f"Adding notice to {file}") with open(file, "w") as f: + if shebang: + f.write(shebang) f.write(self.copyright_notice(file) + "\n") if lines and lines[0] != "\n": f.write("\n")