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 or not interactable, exit.
- if (!this.select_ || !bot.dom.isInteractable(this.element_)) {
- return;
- }
- var select = /** @type {!Element} */ (this.select_);
- var wasSelected = bot.dom.isSelected(this.element_);
- // Cannot toggle off options in single-selects.
- if (wasSelected && !select.multiple) {
- return;
- }
-
- // TODO: In a multiselect, clicking an option without the ctrl key down
- // should deselect all other selected options. Right now multiselect click
- // works as ctrl+click should (and unit tests written so that they pass).
-
- this.element_.selected = !wasSelected;
- // Only WebKit fires the change event itself and only for multi-selects,
- // except for Android versions >= 4.0 and Chrome >= 28.
- if (!(goog.userAgent.WEBKIT && select.multiple) ||
- (goog.userAgent.product.CHROME && bot.userAgent.isProductVersion(28)) ||
- (goog.userAgent.product.ANDROID && bot.userAgent.isProductVersion(4))) {
- bot.events.fire(select, bot.events.EventType.CHANGE);
- }
-};
-
-
-/**
- * Toggles the checked state of a radio button or checkbox. This is a noop if
- * it is a radio button that is checked, because it can't be toggled off.
- *
- * @param {boolean} wasChecked Whether the element was originally checked.
- * @private
- */
-bot.Device.prototype.toggleRadioButtonOrCheckbox_ = function (wasChecked) {
- // Gecko and WebKit toggle the element as a result of a click.
- if (goog.userAgent.GECKO || goog.userAgent.WEBKIT) {
- return;
- }
- // Cannot toggle off radio buttons.
- if (wasChecked && this.element_.type.toLowerCase() == 'radio') {
- return;
- }
- this.element_.checked = !wasChecked;
-};
-
-
-/**
- * Find FORM element that is an ancestor of the passed in element.
- * @param {Node} node The node to find a FORM for.
- * @return {Element} The ancestor FORM element if it exists.
- * @protected
- */
-bot.Device.findAncestorForm = function (node) {
- return /** @type {Element} */ (goog.dom.getAncestor(
- node, bot.Device.isForm_, /*includeNode=*/true));
-};
-
-
-/**
- * @param {Node} node The node to test.
- * @return {boolean} Whether the node is a FORM element.
- * @private
- */
-bot.Device.isForm_ = function (node) {
- return bot.dom.isElement(node, goog.dom.TagName.FORM);
-};
-
-
-/**
- * Submits the specified form. Unlike the public function, it expects to be
- * given a form element and fails if it is not.
- * @param {!Element} form The form to submit.
- * @protected
- */
-bot.Device.prototype.submitForm = function (form) {
- if (!bot.Device.isForm_(form)) {
- throw new bot.Error(bot.ErrorCode.INVALID_ELEMENT_STATE,
- 'Element is not a form, so could not submit.');
- }
- if (bot.events.fire(form, bot.events.EventType.SUBMIT)) {
- // When a form has an element with an id or name exactly equal to "submit"
- // (not uncommon) it masks the form.submit function. We can avoid this by
- // calling the prototype's submit function, except in IE < 8, where DOM id
- // elements don't let you reference their prototypes. For IE < 8, can change
- // the id and names of the elements and revert them back, but they must be
- // reverted before the submit call, because the onsubmit handler might rely
- // on their being correct, and the HTTP request might otherwise be left with
- // incorrect value names. Fortunately, saving the submit function and
- // calling it after reverting the ids and names works! Oh, and goog.typeOf
- // (and thus goog.isFunction) doesn't work for form.submit in IE < 8.
- if (!bot.dom.isElement(form.submit)) {
- form.submit();
- } else if (!goog.userAgent.IE || bot.userAgent.isEngineVersion(8)) {
- /** @type {Function} */ (form.constructor.prototype['submit']).call(form);
- } else {
- var idMasks = bot.locators.findElements({ 'id': 'submit' }, form);
- var nameMasks = bot.locators.findElements({ 'name': 'submit' }, form);
- goog.array.forEach(idMasks, function (m) {
- m.removeAttribute('id');
- });
- goog.array.forEach(nameMasks, function (m) {
- m.removeAttribute('name');
- });
- var submitFunction = form.submit;
- goog.array.forEach(idMasks, function (m) {
- m.setAttribute('id', 'submit');
- });
- goog.array.forEach(nameMasks, function (m) {
- m.setAttribute('name', 'submit');
- });
- submitFunction();
- }
- }
-};
-
-
-/**
- * Regular expression for splitting up a URL into components.
- * @private {!RegExp}
- * @const
- */
-bot.Device.URL_REGEXP_ = new RegExp(
- '^' +
- '([^:/?#.]+:)?' + // protocol
- '(?://([^/]*))?' + // host
- '([^?#]+)?' + // pathname
- '(\\?[^#]*)?' + // search
- '(#.*)?' + // hash
- '$');
-
-
-/**
- * Resolves a potentially relative URL against a base location.
- * @param {!Location} base Base location against which to resolve.
- * @param {string} rel Url to resolve against the location.
- * @return {string} Resolution of url against base location.
- * @private
- */
-bot.Device.resolveUrl_ = function (base, rel) {
- var m = rel.match(bot.Device.URL_REGEXP_);
- if (!m) {
- return '';
- }
- var target = {
- protocol: m[1] || '',
- host: m[2] || '',
- pathname: m[3] || '',
- search: m[4] || '',
- hash: m[5] || ''
- };
-
- if (!target.protocol) {
- target.protocol = base.protocol;
- if (!target.host) {
- target.host = base.host;
- if (!target.pathname) {
- target.pathname = base.pathname;
- target.search = target.search || base.search;
- } else if (target.pathname.charAt(0) != '/') {
- var lastSlashIndex = base.pathname.lastIndexOf('/');
- if (lastSlashIndex != -1) {
- var directory = base.pathname.substr(0, lastSlashIndex + 1);
- target.pathname = directory + target.pathname;
- }
- }
- }
- }
-
- return target.protocol + '//' + target.host + target.pathname +
- target.search + target.hash;
-};
-
-
-
-/**
- * Stores the state of modifier keys
- *
- * @constructor
- */
-bot.Device.ModifiersState = function () {
- /**
- * State of the modifier keys.
- * @private {number}
- */
- this.pressedModifiers_ = 0;
-};
-
-
-/**
- * An enum for the various modifier keys (keycode-independent).
- * @enum {number}
- */
-bot.Device.Modifier = {
- SHIFT: 0x1,
- CONTROL: 0x2,
- ALT: 0x4,
- META: 0x8
-};
-
-
-/**
- * Checks whether a specific modifier is pressed
- * @param {!bot.Device.Modifier} modifier The modifier to check.
- * @return {boolean} Whether the modifier is pressed.
- */
-bot.Device.ModifiersState.prototype.isPressed = function (modifier) {
- return (this.pressedModifiers_ & modifier) != 0;
-};
-
-
-/**
- * Sets the state of a given modifier.
- * @param {!bot.Device.Modifier} modifier The modifier to set.
- * @param {boolean} isPressed whether the modifier is set or released.
- */
-bot.Device.ModifiersState.prototype.setPressed = function (
- modifier, isPressed) {
- if (isPressed) {
- this.pressedModifiers_ = this.pressedModifiers_ | modifier;
- } else {
- this.pressedModifiers_ = this.pressedModifiers_ & (~modifier);
- }
-};
-
-
-/**
- * @return {boolean} State of the Shift key.
- */
-bot.Device.ModifiersState.prototype.isShiftPressed = function () {
- return this.isPressed(bot.Device.Modifier.SHIFT);
-};
-
-
-/**
- * @return {boolean} State of the Control key.
- */
-bot.Device.ModifiersState.prototype.isControlPressed = function () {
- return this.isPressed(bot.Device.Modifier.CONTROL);
-};
-
-
-/**
- * @return {boolean} State of the Alt key.
- */
-bot.Device.ModifiersState.prototype.isAltPressed = function () {
- return this.isPressed(bot.Device.Modifier.ALT);
-};
-
-
-/**
- * @return {boolean} State of the Meta key.
- */
-bot.Device.ModifiersState.prototype.isMetaPressed = function () {
- return this.isPressed(bot.Device.Modifier.META);
-};
-
-
-/**
- * The pointer id used for MSPointer events initiated through a mouse device.
- * @type {number}
- * @const
- */
-bot.Device.MOUSE_MS_POINTER_ID = 1;
-
-
-/**
- * A map of pointer id to Elements.
- * @private {!Object.}
- */
-bot.Device.pointerElementMap_ = {};
-
-
-/**
- * Gets the element associated with a pointer id.
- * @param {number} pointerId The pointer Id.
- * @return {?Element} The element associated with the pointer id.
- * @protected
- */
-bot.Device.getPointerElement = function (pointerId) {
- return bot.Device.pointerElementMap_[pointerId];
-};
-
-
-/**
- * Clear the pointer map.
- * @protected
- */
-bot.Device.clearPointerMap = function () {
- bot.Device.pointerElementMap_ = {};
-};
-
-
-/**
- * Fires events, a driver can replace it with a custom implementation
- *
- * @constructor
- */
-bot.Device.EventEmitter = function () {
-};
-
-
-/**
- * Fires an HTML event given the state of the device.
- *
- * @param {!Element} target The element on which to fire the event.
- * @param {!bot.events.EventFactory_} type HTML Event type.
- * @return {boolean} Whether the event fired successfully; false if cancelled.
- * @protected
- */
-bot.Device.EventEmitter.prototype.fireHtmlEvent = function (target, type) {
- return bot.events.fire(target, type);
-};
-
-
-/**
- * Fires a keyboard event given the state of the device and the given arguments.
- *
- * @param {!Element} target The element on which to fire the event.
- * @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.EventEmitter.prototype.fireKeyboardEvent = function (
- target, type, args) {
- return bot.events.fire(target, type, args);
-};
-
-
-/**
- * Fires a mouse event given the state of the device and the given arguments.
- *
- * @param {!Element} target The element on which to fire the event.
- * @param {!bot.events.EventFactory_} type Mouse event type.
- * @param {bot.events.MouseArgs} args Mouse event arguments.
- * @return {boolean} Whether the event fired successfully; false if cancelled.
- * @protected
- */
-bot.Device.EventEmitter.prototype.fireMouseEvent = function (
- target, type, args) {
- return bot.events.fire(target, type, args);
-};
-
-
-/**
- * Fires a mouse event given the state of the device and the given arguments.
- *
- * @param {!Element} target The element on which to fire the event.
- * @param {!bot.events.EventFactory_} type Touch event type.
- * @param {bot.events.TouchArgs} args Touch event arguments.
- * @return {boolean} Whether the event fired successfully; false if cancelled.
- * @protected
- */
-bot.Device.EventEmitter.prototype.fireTouchEvent = function (
- target, type, args) {
- return bot.events.fire(target, type, args);
-};
-
-
-/**
- * Fires an MSPointer event given the state of the device and the given
- * arguments.
- *
- * @param {!Element} target The element on which to fire the event.
- * @param {!bot.events.EventFactory_} type MSPointer event type.
- * @param {bot.events.MSPointerArgs} args MSPointer event arguments.
- * @return {boolean} Whether the event fired successfully; false if cancelled.
- * @protected
- */
-bot.Device.EventEmitter.prototype.fireMSPointerEvent = function (
- target, type, args) {
- return bot.events.fire(target, type, args);
-};
diff --git a/javascript/atoms/device.ts b/javascript/atoms/device.ts
new file mode 100644
index 0000000000000..a5b033687b977
--- /dev/null
+++ b/javascript/atoms/device.ts
@@ -0,0 +1,971 @@
+// 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.
+ */
+
+import { BotError, ErrorCode } from './error';
+import {
+ isElement,
+ isSelectable,
+ isSelected,
+ isInteractable,
+ isFocusable,
+ getActiveElement,
+ getClientRect,
+} from './dom';
+import {
+ fire,
+ EventType,
+ EventFactory,
+ MouseArgs,
+ KeyboardArgs,
+ TouchArgs,
+ MSPointerArgs,
+ TouchInfo,
+} from './events';
+import {
+ IE,
+ GECKO,
+ WEBKIT,
+ isEngineVersion,
+ isProductVersion,
+ WEBEXTENSION,
+} from './userAgent';
+import { getDocument } from './bot';
+
+// Browser detection for inline checks
+const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
+const IS_IE = /MSIE|Trident/.test(userAgent);
+const IS_CHROME = /Chrome\//.test(userAgent) && !/Chromium\//.test(userAgent);
+const IS_ANDROID = /Android/.test(userAgent);
+
+// ============================================================================
+// Coordinate type
+// ============================================================================
+
+/**
+ * Simple Coordinate type to replace goog.math.Coordinate
+ */
+export interface Coordinate {
+ x: number;
+ y: number;
+}
+
+// ============================================================================
+// Modifier State
+// ============================================================================
+
+/**
+ * An enum for the various modifier keys (keycode-independent).
+ */
+export enum Modifier {
+ SHIFT = 0x1,
+ CONTROL = 0x2,
+ ALT = 0x4,
+ META = 0x8,
+}
+
+/**
+ * Stores the state of modifier keys.
+ */
+export class ModifiersState {
+ private pressedModifiers_ = 0;
+
+ /**
+ * Checks whether a specific modifier is pressed.
+ */
+ isPressed(modifier: Modifier): boolean {
+ return (this.pressedModifiers_ & modifier) !== 0;
+ }
+
+ /**
+ * Sets the state of a given modifier.
+ */
+ setPressed(modifier: Modifier, isPressed: boolean): void {
+ if (isPressed) {
+ this.pressedModifiers_ = this.pressedModifiers_ | modifier;
+ } else {
+ this.pressedModifiers_ = this.pressedModifiers_ & ~modifier;
+ }
+ }
+
+ /**
+ * @return State of the Shift key.
+ */
+ isShiftPressed(): boolean {
+ return this.isPressed(Modifier.SHIFT);
+ }
+
+ /**
+ * @return State of the Control key.
+ */
+ isControlPressed(): boolean {
+ return this.isPressed(Modifier.CONTROL);
+ }
+
+ /**
+ * @return State of the Alt key.
+ */
+ isAltPressed(): boolean {
+ return this.isPressed(Modifier.ALT);
+ }
+
+ /**
+ * @return State of the Meta key.
+ */
+ isMetaPressed(): boolean {
+ return this.isPressed(Modifier.META);
+ }
+}
+
+// ============================================================================
+// Event Emitter
+// ============================================================================
+
+/**
+ * Fires events. A driver can replace it with a custom implementation.
+ */
+export class EventEmitter {
+ /**
+ * Fires an HTML event given the state of the device.
+ */
+ fireHtmlEvent(target: Element, type: EventFactory): boolean {
+ return fire(target, type);
+ }
+
+ /**
+ * Fires a keyboard event given the state of the device and the given arguments.
+ */
+ fireKeyboardEvent(
+ target: Element,
+ type: EventFactory,
+ args: KeyboardArgs
+ ): boolean {
+ return fire(target, type, args);
+ }
+
+ /**
+ * Fires a mouse event given the state of the device and the given arguments.
+ */
+ fireMouseEvent(
+ target: Element,
+ type: EventFactory,
+ args: MouseArgs
+ ): boolean {
+ return fire(target, type, args);
+ }
+
+ /**
+ * Fires a touch event given the state of the device and the given arguments.
+ */
+ fireTouchEvent(
+ target: Element,
+ type: EventFactory,
+ args: TouchArgs
+ ): boolean {
+ return fire(target, type, args);
+ }
+
+ /**
+ * Fires an MSPointer event given the state of the device and the given arguments.
+ */
+ fireMSPointerEvent(
+ target: Element,
+ type: EventFactory,
+ args: MSPointerArgs
+ ): boolean {
+ return fire(target, type, args);
+ }
+}
+
+// ============================================================================
+// Pointer Element Map (static)
+// ============================================================================
+
+/**
+ * The pointer id used for MSPointer events initiated through a mouse device.
+ */
+export const MOUSE_MS_POINTER_ID = 1;
+
+/**
+ * A map of pointer id to Elements.
+ */
+let pointerElementMap_: Record = {};
+
+/**
+ * Gets the element associated with a pointer id.
+ */
+export function getPointerElement(pointerId: number): Element | undefined {
+ return pointerElementMap_[pointerId];
+}
+
+/**
+ * Sets the element associated with a pointer id.
+ */
+export function setPointerElement(pointerId: number, element: Element): void {
+ pointerElementMap_[pointerId] = element;
+}
+
+/**
+ * Clear the pointer map.
+ */
+export function clearPointerMap(): void {
+ pointerElementMap_ = {};
+}
+
+// ============================================================================
+// URL Utilities
+// ============================================================================
+
+/**
+ * Regular expression for splitting up a URL into components.
+ */
+const URL_REGEXP_ = new RegExp(
+ '^' +
+ '([^:/?#.]+:)?' + // protocol
+ '(?://([^/]*))?' + // host
+ '([^?#]+)?' + // pathname
+ '(\\?[^#]*)?' + // search
+ '(#.*)?' + // hash
+ '$'
+);
+
+/**
+ * Resolves a potentially relative URL against a base location.
+ */
+function resolveUrl_(base: Location, rel: string): string {
+ const m = rel.match(URL_REGEXP_);
+ if (!m) {
+ return '';
+ }
+ const target = {
+ protocol: m[1] || '',
+ host: m[2] || '',
+ pathname: m[3] || '',
+ search: m[4] || '',
+ hash: m[5] || '',
+ };
+
+ if (!target.protocol) {
+ target.protocol = base.protocol;
+ if (!target.host) {
+ target.host = base.host;
+ if (!target.pathname) {
+ target.pathname = base.pathname;
+ target.search = target.search || base.search;
+ } else if (target.pathname.charAt(0) !== '/') {
+ const lastSlashIndex = base.pathname.lastIndexOf('/');
+ if (lastSlashIndex !== -1) {
+ const directory = base.pathname.substr(0, lastSlashIndex + 1);
+ target.pathname = directory + target.pathname;
+ }
+ }
+ }
+ }
+
+ return (
+ target.protocol +
+ '//' +
+ target.host +
+ target.pathname +
+ target.search +
+ target.hash
+ );
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+/**
+ * Whether links must be manually followed when clicking (because firing click
+ * events doesn't follow them).
+ */
+const ALWAYS_FOLLOWS_LINKS_ON_CLICK_ = WEBKIT;
+
+/**
+ * Gets the window for a document.
+ */
+function getWindow_(doc: Document): Window {
+ return doc.defaultView || (doc as unknown as { parentWindow: Window }).parentWindow;
+}
+
+/**
+ * Gets the owner document of a node.
+ */
+function getOwnerDocument_(node: Node): Document {
+ return node.ownerDocument || (node as unknown as Document);
+}
+
+/**
+ * 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;
+}
+
+/**
+ * Gets the document scroll offset.
+ */
+function getDocumentScroll_(doc: Document): Coordinate {
+ const win = getWindow_(doc);
+ const docEl = doc.documentElement;
+ const body = doc.body;
+ return {
+ x:
+ win.pageXOffset ??
+ (docEl?.scrollLeft ?? 0) ??
+ (body?.scrollLeft ?? 0),
+ y:
+ win.pageYOffset ??
+ (docEl?.scrollTop ?? 0) ??
+ (body?.scrollTop ?? 0),
+ };
+}
+
+// ============================================================================
+// Device Class
+// ============================================================================
+
+/**
+ * A Device class that provides common functionality for input devices.
+ */
+export class Device {
+ /**
+ * Element being interacted with.
+ */
+ protected element_: Element;
+
+ /**
+ * If the element is an option, this is its parent select element.
+ */
+ protected select_: HTMLSelectElement | null = null;
+
+ /**
+ * State of modifier keys for this device.
+ */
+ protected modifiersState: ModifiersState;
+
+ /**
+ * Event emitter for this device.
+ */
+ protected eventEmitter: EventEmitter;
+
+ constructor(
+ opt_modifiersState?: ModifiersState,
+ opt_eventEmitter?: EventEmitter
+ ) {
+ this.element_ = getDocument().documentElement;
+ this.modifiersState = opt_modifiersState || new ModifiersState();
+ this.eventEmitter = opt_eventEmitter || new EventEmitter();
+
+ // If there is an active element, make that the current element instead.
+ const activeElement = getActiveElement(this.element_);
+ if (activeElement) {
+ this.setElement(activeElement);
+ }
+ }
+
+ /**
+ * Returns the element with which the device is interacting.
+ */
+ getElement(): Element {
+ return this.element_;
+ }
+
+ /**
+ * Sets the element with which the device is interacting.
+ */
+ setElement(element: Element): void {
+ this.element_ = element;
+ if (isElement(element, 'OPTION')) {
+ this.select_ = getAncestor_(
+ element,
+ (node) => isElement(node, 'SELECT'),
+ false
+ ) as HTMLSelectElement | null;
+ } else {
+ this.select_ = null;
+ }
+ }
+
+ /**
+ * Fires an HTML event given the state of the device.
+ */
+ protected fireHtmlEvent(type: EventFactory): boolean {
+ return this.eventEmitter.fireHtmlEvent(this.element_, type);
+ }
+
+ /**
+ * Fires a keyboard event given the state of the device and the given arguments.
+ */
+ protected fireKeyboardEvent(type: EventFactory, args: KeyboardArgs): boolean {
+ return this.eventEmitter.fireKeyboardEvent(this.element_, type, args);
+ }
+
+ /**
+ * Fires a mouse event given the state of the device and the given arguments.
+ */
+ fireMouseEvent(
+ type: EventFactory,
+ coord: Coordinate,
+ button: number,
+ opt_related?: Element | null,
+ opt_wheelDelta?: number | null,
+ opt_force?: boolean,
+ opt_pointerId?: number | null,
+ opt_count?: number | null
+ ): boolean {
+ if (!opt_force && !isInteractable(this.element_)) {
+ return false;
+ }
+
+ if (
+ opt_related &&
+ !(
+ EventType.MOUSEOVER === type ||
+ EventType.MOUSEOUT === type
+ )
+ ) {
+ throw new BotError(
+ ErrorCode.INVALID_ELEMENT_STATE,
+ 'Event type does not allow related target: ' + type
+ );
+ }
+
+ const args: MouseArgs = {
+ 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,
+ };
+
+ const pointerId = opt_pointerId ?? MOUSE_MS_POINTER_ID;
+
+ let target: Element | null = this.element_;
+ // On click and mousedown events, captured pointers are ignored and the
+ // event always fires on the original element.
+ if (
+ type !== EventType.CLICK &&
+ type !== EventType.MOUSEDOWN &&
+ pointerId in pointerElementMap_
+ ) {
+ target = 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.
+ */
+ protected fireTouchEvent(
+ type: EventFactory,
+ id: number,
+ coord: Coordinate,
+ opt_id2?: number,
+ opt_coord2?: Coordinate
+ ): boolean {
+ const pageOffset = getDocumentScroll_(getOwnerDocument_(this.element_));
+
+ const touches: TouchInfo[] = [];
+ const targetTouches: TouchInfo[] = [];
+ const changedTouches: TouchInfo[] = [];
+
+ function addTouch(identifier: number, coords: Coordinate) {
+ const touch: TouchInfo = {
+ identifier: identifier,
+ screenX: coords.x,
+ screenY: coords.y,
+ clientX: coords.x,
+ clientY: coords.y,
+ pageX: coords.x + pageOffset.x,
+ pageY: coords.y + pageOffset.y,
+ };
+
+ changedTouches.push(touch);
+ if (type === EventType.TOUCHSTART || type === EventType.TOUCHMOVE) {
+ touches.push(touch);
+ targetTouches.push(touch);
+ }
+ }
+
+ addTouch(id, coord);
+ if (opt_id2 !== undefined && opt_coord2) {
+ addTouch(opt_id2, opt_coord2);
+ }
+
+ const args: TouchArgs = {
+ touches: touches,
+ targetTouches: targetTouches,
+ changedTouches: changedTouches,
+ altKey: this.modifiersState.isAltPressed(),
+ ctrlKey: this.modifiersState.isControlPressed(),
+ shiftKey: this.modifiersState.isShiftPressed(),
+ metaKey: this.modifiersState.isMetaPressed(),
+ relatedTarget: null,
+ scale: 0,
+ rotation: 0,
+ };
+
+ return this.eventEmitter.fireTouchEvent(this.element_, type, args);
+ }
+
+ /**
+ * Fires a MSPointer event given the state of the device and the given arguments.
+ */
+ fireMSPointerEvent(
+ type: EventFactory,
+ coord: Coordinate,
+ button: number,
+ pointerId: number,
+ device: number,
+ isPrimary: boolean,
+ opt_related?: Element | null,
+ opt_force?: boolean
+ ): boolean {
+ if (!opt_force && !isInteractable(this.element_)) {
+ return false;
+ }
+
+ if (
+ opt_related &&
+ !(
+ EventType.MSPOINTEROVER === type ||
+ EventType.MSPOINTEROUT === type
+ )
+ ) {
+ throw new BotError(
+ ErrorCode.INVALID_ELEMENT_STATE,
+ 'Event type does not allow related target: ' + type
+ );
+ }
+
+ const args: MSPointerArgs = {
+ 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,
+ };
+
+ let target: Element | null = this.select_
+ ? this.getTargetOfOptionMouseEvent_(type)
+ : this.element_;
+ if (pointerElementMap_[pointerId]) {
+ target = pointerElementMap_[pointerId];
+ }
+
+ const owner = getWindow_(getOwnerDocument_(this.element_));
+ let originalMsSetPointerCapture: ((id: number) => void) | undefined;
+ if (owner && type === EventType.MSPOINTERDOWN) {
+ // Overwrite msSetPointerCapture on the Element's prototype
+ // because synthetic pointer events cause an access denied exception.
+ const elemProto = (owner as Window & { Element: { prototype: HTMLElement } }).Element?.prototype;
+ if (elemProto && 'msSetPointerCapture' in elemProto) {
+ originalMsSetPointerCapture = (elemProto as unknown as { msSetPointerCapture: (id: number) => void }).msSetPointerCapture;
+ (elemProto as unknown as { msSetPointerCapture: (id: number) => void }).msSetPointerCapture = function (this: Element, id: number) {
+ pointerElementMap_[id] = this;
+ };
+ }
+ }
+
+ const result = target
+ ? this.eventEmitter.fireMSPointerEvent(target, type, args)
+ : true;
+
+ if (originalMsSetPointerCapture) {
+ const elemProto = (owner as Window & { Element: { prototype: HTMLElement } }).Element?.prototype;
+ (elemProto as unknown as { msSetPointerCapture: (id: number) => void }).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.
+ */
+ private getTargetOfOptionMouseEvent_(type: EventFactory): Element | null {
+ // IE either fires the event on the parent select or not at all.
+ if (IS_IE) {
+ switch (type) {
+ case EventType.MOUSEOVER:
+ case EventType.MSPOINTEROVER:
+ return null;
+ case EventType.CONTEXTMENU:
+ case EventType.MOUSEMOVE:
+ case 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 (WEBKIT) {
+ switch (type) {
+ case EventType.CLICK:
+ case EventType.MOUSEUP:
+ return this.select_!.multiple ? this.element_ : this.select_;
+ default:
+ return this.select_!.multiple ? this.element_ : null;
+ }
+ }
+
+ // Firefox fires every event on the option element.
+ return this.element_;
+ }
+
+ /**
+ * A helper function to fire click events. This method is shared between
+ * the mouse and touchscreen devices.
+ */
+ clickElement(
+ coord: Coordinate,
+ button: number,
+ opt_force?: boolean,
+ opt_pointerId?: number | null
+ ): void {
+ if (!opt_force && !isInteractable(this.element_)) {
+ return;
+ }
+
+ // bot.events.fire(element, 'click') can trigger all onclick events, but may
+ // not follow links (FORM.action or A.href).
+ let targetLink: Element | null = null;
+ let targetButton: Element | null = null;
+ if (!ALWAYS_FOLLOWS_LINKS_ON_CLICK_) {
+ for (let e: Node | null = this.element_; e; e = e.parentNode) {
+ if (isElement(e, 'A')) {
+ targetLink = e as Element;
+ break;
+ } else if (isFormSubmitElement(e)) {
+ targetButton = e as Element;
+ 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.
+ const isRadioOrCheckbox = !this.select_ && isSelectable(this.element_);
+ const wasChecked = isRadioOrCheckbox && isSelected(this.element_);
+
+ // In IE, clicking a form submit button needs special handling.
+ if (IS_IE && targetButton) {
+ (targetButton as HTMLElement).click();
+ return;
+ }
+
+ const performDefault = this.fireMouseEvent(
+ EventType.CLICK,
+ coord,
+ button,
+ null,
+ 0,
+ opt_force,
+ opt_pointerId
+ );
+ if (!performDefault) {
+ return;
+ }
+
+ if (targetLink && shouldFollowHref_(targetLink as HTMLAnchorElement)) {
+ followHref_(targetLink as HTMLAnchorElement);
+ } 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.
+ */
+ focusOnElement(): boolean {
+ const elementToFocus = (getAncestor_(
+ this.element_,
+ (node) => {
+ return (
+ !!node &&
+ isElement(node) &&
+ isFocusable(node as Element)
+ );
+ },
+ true /* Return this.element_ if it is focusable. */
+ ) || this.element_) as Element;
+
+ const activeElement = getActiveElement(elementToFocus);
+ if (elementToFocus === activeElement) {
+ return false;
+ }
+
+ // If there is a currently active element, try to blur it.
+ if (
+ activeElement &&
+ (typeof (activeElement as HTMLElement).blur === 'function' ||
+ // IE reports native functions as being objects.
+ (IS_IE &&
+ typeof (activeElement as HTMLElement).blur === 'object' &&
+ (activeElement as HTMLElement).blur !== null))
+ ) {
+ if (!isElement(activeElement, 'BODY')) {
+ try {
+ (activeElement as HTMLElement).blur();
+ } catch (e) {
+ if (
+ !(IS_IE && (e as Error).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.
+ if (IS_IE && !isEngineVersion(8)) {
+ getWindow_(getOwnerDocument_(elementToFocus)).focus();
+ }
+ }
+
+ // Try to focus on the element.
+ if (
+ typeof (elementToFocus as HTMLElement).focus === 'function' ||
+ (IS_IE &&
+ typeof (elementToFocus as HTMLElement).focus === 'object' &&
+ (elementToFocus as HTMLElement).focus !== null)
+ ) {
+ ((elementToFocus as HTMLElement).focus as () => void).call(elementToFocus);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Toggles the selected state of the current element if it is an option.
+ */
+ maybeToggleOption(): void {
+ // If this is not an or not interactable, exit.
+ if (!this.select_ || !isInteractable(this.element_)) {
+ return;
+ }
+ const select = this.select_;
+ const wasSelected = isSelected(this.element_);
+ // Cannot toggle off options in single-selects.
+ if (wasSelected && !select.multiple) {
+ return;
+ }
+
+ (this.element_ as HTMLOptionElement).selected = !wasSelected;
+ // Only WebKit fires the change event itself and only for multi-selects,
+ // except for Android versions >= 4.0 and Chrome >= 28.
+ if (
+ !(WEBKIT && select.multiple) ||
+ (IS_CHROME && isProductVersion(28)) ||
+ (IS_ANDROID && isProductVersion(4))
+ ) {
+ fire(select, EventType.CHANGE);
+ }
+ }
+
+ /**
+ * Toggles the checked state of a radio button or checkbox.
+ */
+ private toggleRadioButtonOrCheckbox_(wasChecked: boolean): void {
+ // Gecko and WebKit toggle the element as a result of a click.
+ if (GECKO || WEBKIT) {
+ return;
+ }
+ // Cannot toggle off radio buttons.
+ if (
+ wasChecked &&
+ (this.element_ as HTMLInputElement).type.toLowerCase() === 'radio'
+ ) {
+ return;
+ }
+ (this.element_ as HTMLInputElement).checked = !wasChecked;
+ }
+
+ /**
+ * Submits the specified form.
+ */
+ submitForm(form: HTMLFormElement): void {
+ if (!isForm_(form)) {
+ throw new BotError(
+ ErrorCode.INVALID_ELEMENT_STATE,
+ 'Element is not a form, so could not submit.'
+ );
+ }
+ if (fire(form, EventType.SUBMIT)) {
+ // When a form has an element with an id or name exactly equal to "submit"
+ // it masks the form.submit function.
+ if (!isElement(form.submit as unknown as Node)) {
+ form.submit();
+ } else if (!IS_IE || isEngineVersion(8)) {
+ (
+ (form.constructor as { prototype: { submit: () => void } }).prototype
+ .submit as () => void
+ ).call(form);
+ } else {
+ // IE < 8 special handling for masked submit function
+ const idMasks = findElementsWithAttribute(form, 'id', 'submit');
+ const nameMasks = findElementsWithAttribute(form, 'name', 'submit');
+ idMasks.forEach((m) => m.removeAttribute('id'));
+ nameMasks.forEach((m) => m.removeAttribute('name'));
+ const submitFunction = form.submit;
+ idMasks.forEach((m) => m.setAttribute('id', 'submit'));
+ nameMasks.forEach((m) => m.setAttribute('name', 'submit'));
+ (submitFunction as () => void)();
+ }
+ }
+ }
+}
+
+// ============================================================================
+// Static Helper Functions
+// ============================================================================
+
+/**
+ * Checks if a node is a FORM element.
+ */
+function isForm_(node: Node): node is HTMLFormElement {
+ return isElement(node, 'FORM');
+}
+
+/**
+ * Checks if the element is a form submit element.
+ */
+export function isFormSubmitElement(element: Node): boolean {
+ if (isElement(element, 'INPUT')) {
+ const type = (element as HTMLInputElement).type.toLowerCase();
+ if (type === 'submit' || type === 'image') {
+ return true;
+ }
+ }
+
+ if (isElement(element, 'BUTTON')) {
+ const type = (element as HTMLButtonElement).type.toLowerCase();
+ if (type === 'submit') {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Find FORM element that is an ancestor of the passed in element.
+ */
+export function findAncestorForm(node: Node): HTMLFormElement | null {
+ return getAncestor_(node, isForm_, true) as HTMLFormElement | null;
+}
+
+/**
+ * Finds elements with a specific attribute value.
+ */
+function findElementsWithAttribute(
+ root: Element,
+ attrName: string,
+ attrValue: string
+): Element[] {
+ const result: Element[] = [];
+ const all = root.querySelectorAll(`[${attrName}="${attrValue}"]`);
+ for (let i = 0; i < all.length; i++) {
+ result.push(all[i]);
+ }
+ return result;
+}
+
+/**
+ * Indicates whether we should manually follow the href of the element we're clicking.
+ */
+function shouldFollowHref_(element: HTMLAnchorElement): boolean {
+ if (ALWAYS_FOLLOWS_LINKS_ON_CLICK_ || !element.href) {
+ return false;
+ }
+
+ if (!WEBEXTENSION) {
+ return true;
+ }
+
+ if (element.target || element.href.toLowerCase().indexOf('javascript') === 0) {
+ return false;
+ }
+
+ const owner = getWindow_(getOwnerDocument_(element));
+ const sourceUrl = owner.location.href;
+ const destinationUrl = resolveUrl_(owner.location, element.href);
+ const isOnlyHashChange =
+ sourceUrl.split('#')[0] === destinationUrl.split('#')[0];
+
+ return !isOnlyHashChange;
+}
+
+/**
+ * Explicitly follows the href of an anchor.
+ */
+function followHref_(anchorElement: HTMLAnchorElement): void {
+ let targetHref = anchorElement.href;
+ const owner = getWindow_(getOwnerDocument_(anchorElement));
+
+ // IE7 and earlier incorrectly resolve a relative href against the top window
+ // location instead of the window to which the href is assigned.
+ if (IS_IE && !isEngineVersion(8)) {
+ targetHref = resolveUrl_(owner.location, targetHref);
+ }
+
+ if (anchorElement.target) {
+ owner.open(targetHref, anchorElement.target);
+ } else {
+ owner.location.href = targetHref;
+ }
+}
diff --git a/javascript/atoms/dom.js b/javascript/atoms/dom.js
deleted file mode 100644
index d604d3c96dca4..0000000000000
--- a/javascript/atoms/dom.js
+++ /dev/null
@@ -1,1447 +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 DOM manipulation and querying routines.
- */
-
-goog.provide('bot.dom');
-
-goog.require('bot');
-goog.require('bot.color');
-goog.require('bot.dom.core');
-goog.require('bot.locators.css');
-goog.require('bot.userAgent');
-goog.require('goog.array');
-goog.require('goog.dom');
-goog.require('goog.dom.DomHelper');
-goog.require('goog.dom.NodeType');
-goog.require('goog.dom.TagName');
-goog.require('goog.math');
-goog.require('goog.math.Coordinate');
-goog.require('goog.math.Rect');
-goog.require('goog.string');
-goog.require('goog.style');
-goog.require('goog.userAgent');
-
-
-/**
- * Whether Shadow DOM operations are supported by the browser.
- * @const {boolean}
- */
-bot.dom.IS_SHADOW_DOM_ENABLED = (typeof ShadowRoot === 'function');
-
-
-/**
- * Retrieves the active element for a node's owner document.
- * @param {(!Node|!Window)} nodeOrWindow The node whose owner document to get
- * the active element for.
- * @return {?Element} The active element, if any.
- */
-bot.dom.getActiveElement = function (nodeOrWindow) {
- var active = goog.dom.getActiveElement(
- goog.dom.getOwnerDocument(nodeOrWindow));
- // IE has the habit of returning an empty object from
- // goog.dom.getActiveElement instead of null.
- if (goog.userAgent.IE &&
- active &&
- typeof active.nodeType === 'undefined') {
- return null;
- }
- return active;
-};
-
-
-/**
- * @const
- */
-bot.dom.isElement = bot.dom.core.isElement;
-
-
-/**
- * Returns whether an element is in an interactable state: whether it is shown
- * to the user, ignoring its opacity, and whether it is enabled.
- *
- * @param {!Element} element The element to check.
- * @return {boolean} Whether the element is interactable.
- * @see bot.dom.isShown.
- * @see bot.dom.isEnabled
- */
-bot.dom.isInteractable = function (element) {
- return bot.dom.isShown(element, /*ignoreOpacity=*/true) &&
- bot.dom.isEnabled(element) &&
- !bot.dom.hasPointerEventsDisabled_(element);
-};
-
-
-/**
- * @param {!Element} element Element.
- * @return {boolean} Whether element is set by the CSS pointer-events property
- * not to be interactable.
- * @private
- */
-bot.dom.hasPointerEventsDisabled_ = function (element) {
- if (goog.userAgent.IE ||
- (goog.userAgent.GECKO && !bot.userAgent.isEngineVersion('1.9.2'))) {
- // Don't support pointer events
- return false;
- }
- return bot.dom.getEffectiveStyle(element, 'pointer-events') == 'none';
-};
-
-
-/**
- * @const
- */
-bot.dom.isSelectable = bot.dom.core.isSelectable;
-
-
-/**
- * @const
- */
-bot.dom.isSelected = bot.dom.core.isSelected;
-
-
-/**
- * List of the focusable fields, according to
- * http://www.w3.org/TR/html401/interact/scripts.html#adef-onfocus
- * @private {!Array.}
- * @const
- */
-bot.dom.FOCUSABLE_FORM_FIELDS_ = [
- goog.dom.TagName.A,
- goog.dom.TagName.AREA,
- goog.dom.TagName.BUTTON,
- goog.dom.TagName.INPUT,
- goog.dom.TagName.LABEL,
- goog.dom.TagName.SELECT,
- goog.dom.TagName.TEXTAREA
-];
-
-
-/**
- * Returns whether a node is a focusable element. An element may receive focus
- * if it is a form field, has a non-negative tabindex, or is editable.
- * @param {!Element} element The node to test.
- * @return {boolean} Whether the node is focusable.
- */
-bot.dom.isFocusable = function (element) {
- return goog.array.some(bot.dom.FOCUSABLE_FORM_FIELDS_, tagNameMatches) ||
- (bot.dom.getAttribute(element, 'tabindex') != null &&
- Number(bot.dom.getProperty(element, 'tabIndex')) >= 0) ||
- bot.dom.isEditable(element);
-
- function tagNameMatches(tagName) {
- return bot.dom.isElement(element, tagName);
- }
-};
-
-
-/**
- * @const
- */
-bot.dom.getProperty = bot.dom.core.getProperty;
-
-
-/**
- * @const
- */
-bot.dom.getAttribute = bot.dom.core.getAttribute;
-
-
-/**
- * List of elements that support the "disabled" attribute, as defined by the
- * HTML 4.01 specification.
- * @private {!Array.}
- * @const
- * @see http://www.w3.org/TR/html401/interact/forms.html#h-17.12.1
- */
-bot.dom.DISABLED_ATTRIBUTE_SUPPORTED_ = [
- goog.dom.TagName.BUTTON,
- goog.dom.TagName.INPUT,
- goog.dom.TagName.OPTGROUP,
- goog.dom.TagName.OPTION,
- goog.dom.TagName.SELECT,
- goog.dom.TagName.TEXTAREA
-];
-
-
-/**
- * Determines if an element is enabled. An element is considered enabled if it
- * does not support the "disabled" attribute, or if it is not disabled.
- * @param {!Element} el The element to test.
- * @return {boolean} Whether the element is enabled.
- */
-bot.dom.isEnabled = function (el) {
- var isSupported = goog.array.some(
- bot.dom.DISABLED_ATTRIBUTE_SUPPORTED_,
- function (tagName) { return bot.dom.isElement(el, tagName); });
- if (!isSupported) {
- return true;
- }
-
- if (bot.dom.getProperty(el, 'disabled')) {
- return false;
- }
-
- // The element is not explicitly disabled, but if it is an OPTION or OPTGROUP,
- // we must test if it inherits its state from a parent.
- if (el.parentNode &&
- el.parentNode.nodeType == goog.dom.NodeType.ELEMENT &&
- bot.dom.isElement(el, goog.dom.TagName.OPTGROUP) ||
- bot.dom.isElement(el, goog.dom.TagName.OPTION)) {
- return bot.dom.isEnabled(/**@type{!Element}*/(el.parentNode));
- }
-
- // Is there an ancestor of the current element that is a disabled fieldset
- // and whose child is also an ancestor-or-self of the current element but is
- // not the first legend child of the fieldset. If so then the element is
- // disabled.
- return !goog.dom.getAncestor(el, function (e) {
- var parent = e.parentNode;
-
- if (parent &&
- bot.dom.isElement(parent, goog.dom.TagName.FIELDSET) &&
- bot.dom.getProperty(/** @type {!Element} */(parent), 'disabled')) {
- if (!bot.dom.isElement(e, goog.dom.TagName.LEGEND)) {
- return true;
- }
-
- var sibling = e;
- // Are there any previous legend siblings? If so then we are not the
- // first and the element is disabled
- while (sibling = goog.dom.getPreviousElementSibling(sibling)) {
- if (bot.dom.isElement(sibling, goog.dom.TagName.LEGEND)) {
- return true;
- }
- }
- }
- return false;
- }, true);
-};
-
-
-/**
- * List of input types that create text fields.
- * @private {!Array.}
- * @const
- * @see http://www.whatwg.org/specs/web-apps/current-work/multipage/the-input-element.html#attr-input-type
- */
-bot.dom.TEXTUAL_INPUT_TYPES_ = [
- 'text',
- 'search',
- 'tel',
- 'url',
- 'email',
- 'password',
- 'number'
-];
-
-
-/**
- * TODO: Add support for designMode elements.
- *
- * @param {!Element} element The element to check.
- * @return {boolean} Whether the element accepts user-typed text.
- */
-bot.dom.isTextual = function (element) {
- if (bot.dom.isElement(element, goog.dom.TagName.TEXTAREA)) {
- return true;
- }
-
- if (bot.dom.isElement(element, goog.dom.TagName.INPUT)) {
- var type = element.type.toLowerCase();
- return goog.array.contains(bot.dom.TEXTUAL_INPUT_TYPES_, type);
- }
-
- if (bot.dom.isContentEditable(element)) {
- return true;
- }
-
- return false;
-};
-
-
-/**
- * @param {!Element} element The element to check.
- * @return {boolean} Whether the element is a file input.
- */
-bot.dom.isFileInput = function (element) {
- if (bot.dom.isElement(element, goog.dom.TagName.INPUT)) {
- var type = element.type.toLowerCase();
- return type == 'file';
- }
-
- return false;
-};
-
-
-/**
- * @param {!Element} element The element to check.
- * @param {string} inputType The type of input to check.
- * @return {boolean} Whether the element is an input with specified type.
- */
-bot.dom.isInputType = function (element, inputType) {
- if (bot.dom.isElement(element, goog.dom.TagName.INPUT)) {
- var type = element.type.toLowerCase();
- return type == inputType;
- }
-
- return false;
-};
-
-
-/**
- * @param {!Element} element The element to check.
- * @return {boolean} Whether the element is contentEditable.
- */
-bot.dom.isContentEditable = function (element) {
- // Check if browser supports contentEditable.
- if (element['contentEditable'] === undefined) {
- return false;
- }
-
- // Checking the element's isContentEditable property is preferred except for
- // IE where that property is not reliable on IE versions 7, 8, and 9.
- if (!goog.userAgent.IE && element['isContentEditable'] !== undefined) {
- return element.isContentEditable;
- }
-
- // For IE and for browsers where contentEditable is supported but
- // isContentEditable is not, traverse up the ancestors:
- function legacyIsContentEditable(e) {
- if (e.contentEditable == 'inherit') {
- var parent = bot.dom.getParentElement(e);
- return parent ? legacyIsContentEditable(parent) : false;
- } else {
- return e.contentEditable == 'true';
- }
- }
- return legacyIsContentEditable(element);
-};
-
-
-/**
- * TODO: Merge isTextual into this function and move to bot.dom.
- * For Puppet, requires adding support to getVisibleText for grabbing
- * text from all textual elements.
- *
- * Whether the element may contain text the user can edit.
- *
- * @param {!Element} element The element to check.
- * @return {boolean} Whether the element accepts user-typed text.
- */
-bot.dom.isEditable = function (element) {
- return (bot.dom.isTextual(element) ||
- bot.dom.isFileInput(element) ||
- bot.dom.isInputType(element, 'range') ||
- bot.dom.isInputType(element, 'date') ||
- bot.dom.isInputType(element, 'month') ||
- bot.dom.isInputType(element, 'week') ||
- bot.dom.isInputType(element, 'time') ||
- bot.dom.isInputType(element, 'datetime-local') ||
- bot.dom.isInputType(element, 'color')) &&
- !bot.dom.getProperty(element, 'readOnly');
-};
-
-
-/**
- * Returns the parent element of the given node, or null. This is required
- * because the parent node may not be another element.
- *
- * @param {!Node} node The node who's parent is desired.
- * @return {Element} The parent element, if available, null otherwise.
- */
-bot.dom.getParentElement = function (node) {
- var elem = node.parentNode;
-
- while (elem &&
- elem.nodeType != goog.dom.NodeType.ELEMENT &&
- elem.nodeType != goog.dom.NodeType.DOCUMENT &&
- elem.nodeType != goog.dom.NodeType.DOCUMENT_FRAGMENT) {
- elem = elem.parentNode;
- }
- return /** @type {Element} */ (bot.dom.isElement(elem) ? elem : null);
-};
-
-
-/**
- * Retrieves an explicitly-set, inline style value of an element. This returns
- * '' if there isn't a style attribute on the element or if this style property
- * has not been explicitly set in script.
- *
- * @param {!Element} elem Element to get the style value from.
- * @param {string} styleName Name of the style property in selector-case.
- * @return {string} The value of the style property.
- */
-bot.dom.getInlineStyle = function (elem, styleName) {
- return goog.style.getStyle(elem, styleName);
-};
-
-
-/**
- * Retrieves the implicitly-set, effective style of an element, or null if it is
- * unknown. It returns the computed style where available; otherwise it looks
- * up the DOM tree for the first style value not equal to 'inherit,' using the
- * IE currentStyle of each node if available, and otherwise the inline style.
- * Since the computed, current, and inline styles can be different, the return
- * value of this function is not always consistent across browsers. See:
- * http://code.google.com/p/doctype/wiki/ArticleComputedStyleVsCascadedStyle
- *
- * @param {!Element} elem Element to get the style value from.
- * @param {string} propertyName Name of the CSS property.
- * @return {?string} The value of the style property, or null.
- */
-bot.dom.getEffectiveStyle = function (elem, propertyName) {
- var styleName = goog.string.toCamelCase(propertyName);
- if (styleName == 'float' ||
- styleName == 'cssFloat' ||
- styleName == 'styleFloat') {
- styleName = bot.userAgent.IE_DOC_PRE9 ? 'styleFloat' : 'cssFloat';
- }
- var style = goog.style.getComputedStyle(elem, styleName) ||
- bot.dom.getCascadedStyle_(elem, styleName);
- if (style === null) {
- return null;
- }
- return bot.color.standardizeColor(styleName, style);
-};
-
-
-/**
- * Looks up the DOM tree for the first style value not equal to 'inherit,' using
- * the currentStyle of each node if available, and otherwise the inline style.
- *
- * @param {!Element} elem Element to get the style value from.
- * @param {string} styleName CSS style property in camelCase.
- * @return {?string} The value of the style property, or null.
- * @private
- */
-bot.dom.getCascadedStyle_ = function (elem, styleName) {
- var style = elem.currentStyle || elem.style;
- var value = style[styleName];
- if (value === undefined && typeof style.getPropertyValue === 'function') {
- value = style.getPropertyValue(styleName);
- }
-
- if (value != 'inherit') {
- return value !== undefined ? value : null;
- }
- var parent = bot.dom.getParentElement(elem);
- return parent ? bot.dom.getCascadedStyle_(parent, styleName) : null;
-};
-
-
-/**
- * Extracted code from bot.dom.isShown.
- *
- * @param {!Element} elem The element to consider.
- * @param {boolean} ignoreOpacity Whether to ignore the element's opacity
- * when determining whether it is shown.
- * @param {function(!Element):boolean} displayedFn a function that's used
- * to tell if the chain of ancestors or descendants are all shown.
- * @return {boolean} Whether or not the element is visible.
- * @private
- */
-bot.dom.isShown_ = function (elem, ignoreOpacity, displayedFn) {
- if (!bot.dom.isElement(elem)) {
- throw new Error('Argument to isShown must be of type Element');
- }
-
- // By convention, BODY element is always shown: BODY represents the document
- // and even if there's nothing rendered in there, user can always see there's
- // the document.
- if (bot.dom.isElement(elem, goog.dom.TagName.BODY)) {
- return true;
- }
-
- // Option or optgroup is shown iff enclosing select is shown (ignoring the
- // select's opacity).
- if (bot.dom.isElement(elem, goog.dom.TagName.OPTION) ||
- bot.dom.isElement(elem, goog.dom.TagName.OPTGROUP)) {
- var select = /**@type {Element}*/ (goog.dom.getAncestor(elem, function (e) {
- return bot.dom.isElement(e, goog.dom.TagName.SELECT);
- }));
- return !!select && bot.dom.isShown_(select, true, displayedFn);
- }
-
- // Image map elements are shown if image that uses it is shown, and
- // the area of the element is positive.
- var imageMap = bot.dom.maybeFindImageMap_(elem);
- if (imageMap) {
- return !!imageMap.image &&
- imageMap.rect.width > 0 && imageMap.rect.height > 0 &&
- bot.dom.isShown_(
- imageMap.image, ignoreOpacity, displayedFn);
- }
-
- // Any hidden input is not shown.
- if (bot.dom.isElement(elem, goog.dom.TagName.INPUT) &&
- elem.type.toLowerCase() == 'hidden') {
- return false;
- }
-
- // Any NOSCRIPT element is not shown.
- if (bot.dom.isElement(elem, goog.dom.TagName.NOSCRIPT)) {
- return false;
- }
-
- // Any element with hidden/collapsed visibility is not shown.
- var visibility = bot.dom.getEffectiveStyle(elem, 'visibility');
- if (visibility == 'collapse' || visibility == 'hidden') {
- return false;
- }
-
- if (!displayedFn(elem)) {
- return false;
- }
-
- // Any transparent element is not shown.
- if (!ignoreOpacity && bot.dom.getOpacity(elem) == 0) {
- return false;
- }
-
- // Any element without positive size dimensions is not shown.
- function positiveSize(e) {
- var rect = bot.dom.getClientRect(e);
- if (rect.height > 0 && rect.width > 0) {
- return true;
- }
- // A vertical or horizontal SVG Path element will report zero width or
- // height but is "shown" if it has a positive stroke-width.
- if (bot.dom.isElement(e, 'PATH') && (rect.height > 0 || rect.width > 0)) {
- var strokeWidth = bot.dom.getEffectiveStyle(e, 'stroke-width');
- return !!strokeWidth && (parseInt(strokeWidth, 10) > 0);
- }
-
- // Any element with hidden/collapsed visibility is not shown.
- var visibility = bot.dom.getEffectiveStyle(e, 'visibility');
- if (visibility == 'collapse' || visibility == 'hidden') {
- return false;
- }
-
- if (!displayedFn(e)) {
- return false;
- }
- // Zero-sized elements should still be considered to have positive size
- // if they have a child element or text node with positive size, unless
- // the element has an 'overflow' style of 'hidden'.
- // Note: Text nodes containing only structural whitespace (with newlines
- // or tabs) are ignored as they are likely just HTML formatting, not
- // visible content.
- return bot.dom.getEffectiveStyle(e, 'overflow') != 'hidden' &&
- goog.array.some(e.childNodes, function (n) {
- if (n.nodeType == goog.dom.NodeType.TEXT) {
- var text = n.nodeValue;
- // Ignore text nodes that are purely structural whitespace
- // (contain newlines or tabs and nothing else besides spaces)
- if (/^[\s]*$/.test(text) && /[\n\r\t]/.test(text)) {
- return false;
- }
- return true;
- }
- return bot.dom.isElement(n) && positiveSize(n);
- });
- }
- if (!positiveSize(elem)) {
- return false;
- }
-
- // Elements that are hidden by overflow are not shown.
- function hiddenByOverflow(e) {
- return bot.dom.getOverflowState(e) == bot.dom.OverflowState.HIDDEN &&
- goog.array.every(e.childNodes, function (n) {
- return !bot.dom.isElement(n) || hiddenByOverflow(n) ||
- !positiveSize(n);
- });
- }
- return !hiddenByOverflow(elem);
-};
-
-
-/**
- * Determines whether an element is what a user would call "shown". This means
- * that the element is shown in the viewport of the browser, and only has
- * height and width greater than 0px, and that its visibility is not "hidden"
- * and its display property is not "none".
- * Options and Optgroup elements are treated as special cases: they are
- * considered shown iff they have a enclosing select element that is shown.
- *
- * Elements in Shadow DOMs with younger shadow roots are not visible, and
- * elements distributed into shadow DOMs check the visibility of the
- * ancestors in the Composed DOM, rather than their ancestors in the logical
- * DOM.
- *
- * @param {!Element} elem The element to consider.
- * @param {boolean=} opt_ignoreOpacity Whether to ignore the element's opacity
- * when determining whether it is shown; defaults to false.
- * @return {boolean} Whether or not the element is visible.
- */
-bot.dom.isShown = function (elem, opt_ignoreOpacity) {
- /**
- * Determines whether an element or its parents have `display: none` or similar CSS properties set
- * @param {!Node} e the element
- * @return {!boolean}
- */
- function displayed(e) {
- if (bot.dom.isElement(e)) {
- var elem = /** @type {!Element} */ (e);
- if ((bot.dom.getEffectiveStyle(elem, 'display') == 'none')
- || (bot.dom.getEffectiveStyle(elem, 'content-visibility') == 'hidden')) {
- return false;
- }
- }
-
- var parent = bot.dom.getParentNodeInComposedDom(e);
-
- if (bot.dom.IS_SHADOW_DOM_ENABLED && (parent instanceof ShadowRoot)) {
- if (parent.host.shadowRoot && parent.host.shadowRoot !== parent) {
- // There is a younger shadow root, which will take precedence over
- // the shadow this element is in, thus this element won't be
- // displayed.
- return false;
- } else {
- parent = parent.host;
- }
- }
-
- if (parent && (parent.nodeType == goog.dom.NodeType.DOCUMENT ||
- parent.nodeType == goog.dom.NodeType.DOCUMENT_FRAGMENT)) {
- return true;
- }
-
- // Child of DETAILS element is not shown unless the DETAILS element is open
- // or the child is a SUMMARY element.
- if (parent && bot.dom.isElement(parent, goog.dom.TagName.DETAILS) &&
- !parent.open && !bot.dom.isElement(e, goog.dom.TagName.SUMMARY)) {
- return false;
- }
-
- return !!parent && displayed(parent);
- }
-
- return bot.dom.isShown_(elem, !!opt_ignoreOpacity, displayed);
-};
-
-
-/**
- * The kind of overflow area in which an element may be located. NONE if it does
- * not overflow any ancestor element; HIDDEN if it overflows and cannot be
- * scrolled into view; SCROLL if it overflows but can be scrolled into view.
- *
- * @enum {string}
- */
-bot.dom.OverflowState = {
- NONE: 'none',
- HIDDEN: 'hidden',
- SCROLL: 'scroll'
-};
-
-
-/**
- * Returns the overflow state of the given element.
- *
- * If an optional coordinate or rectangle region is provided, returns the
- * overflow state of that region relative to the element. A coordinate is
- * treated as a 1x1 rectangle whose top-left corner is the coordinate.
- *
- * @param {!Element} elem Element.
- * @param {!(goog.math.Coordinate|goog.math.Rect)=} opt_region
- * Coordinate or rectangle relative to the top-left corner of the element.
- * @return {bot.dom.OverflowState} Overflow state of the element.
- */
-bot.dom.getOverflowState = function (elem, opt_region) {
- var region = bot.dom.getClientRegion(elem, opt_region);
- var ownerDoc = goog.dom.getOwnerDocument(elem);
- var htmlElem = ownerDoc.documentElement;
- var bodyElem = ownerDoc.body;
- var htmlOverflowStyle = bot.dom.getEffectiveStyle(htmlElem, 'overflow');
- var treatAsFixedPosition;
-
- // Return the closest ancestor that the given element may overflow.
- function getOverflowParent(e) {
- var position = bot.dom.getEffectiveStyle(e, 'position');
- if (position == 'fixed') {
- treatAsFixedPosition = true;
- // Fixed-position element may only overflow the viewport.
- return e == htmlElem ? null : htmlElem;
- } else {
- var parent = bot.dom.getParentElement(e);
- while (parent && !canBeOverflowed(parent)) {
- parent = bot.dom.getParentElement(parent);
- }
- return parent;
- }
-
- function canBeOverflowed(container) {
- // The HTML element can always be overflowed.
- if (container == htmlElem) {
- return true;
- }
- // An element cannot overflow an element with an inline or contents display style.
- var containerDisplay = /** @type {string} */ (
- bot.dom.getEffectiveStyle(container, 'display'));
- if (goog.string.startsWith(containerDisplay, 'inline') ||
- (containerDisplay == 'contents')) {
- return false;
- }
- // An absolute-positioned element cannot overflow a static-positioned one.
- if (position == 'absolute' &&
- bot.dom.getEffectiveStyle(container, 'position') == 'static') {
- return false;
- }
- return true;
- }
- }
-
- // Return the x and y overflow styles for the given element.
- function getOverflowStyles(e) {
- // When the element has an overflow style of 'visible', it assumes
- // the overflow style of the body, and the body is really overflow:visible.
- var overflowElem = e;
- if (htmlOverflowStyle == 'visible') {
- // Note: bodyElem will be null/undefined in SVG documents.
- if (e == htmlElem && bodyElem) {
- overflowElem = bodyElem;
- } else if (e == bodyElem) {
- return { x: 'visible', y: 'visible' };
- }
- }
- var overflow = {
- x: bot.dom.getEffectiveStyle(overflowElem, 'overflow-x'),
- y: bot.dom.getEffectiveStyle(overflowElem, 'overflow-y')
- };
- // The element cannot have a genuine 'visible' overflow style,
- // because the viewport can't expand; 'visible' is really 'auto'.
- if (e == htmlElem) {
- overflow.x = overflow.x == 'visible' ? 'auto' : overflow.x;
- overflow.y = overflow.y == 'visible' ? 'auto' : overflow.y;
- }
- return overflow;
- }
-
- // Returns the scroll offset of the given element.
- function getScroll(e) {
- if (e == htmlElem) {
- return new goog.dom.DomHelper(ownerDoc).getDocumentScroll();
- } else {
- return new goog.math.Coordinate(e.scrollLeft, e.scrollTop);
- }
- }
-
- // Check if the element overflows any ancestor element.
- for (var container = getOverflowParent(elem);
- !!container;
- container = getOverflowParent(container)) {
- var containerOverflow = getOverflowStyles(container);
-
- // If the container has overflow:visible, the element cannot overflow it.
- if (containerOverflow.x == 'visible' && containerOverflow.y == 'visible') {
- continue;
- }
-
- var containerRect = bot.dom.getClientRect(container);
-
- // Zero-sized containers without overflow:visible hide all descendants.
- if (containerRect.width == 0 || containerRect.height == 0) {
- return bot.dom.OverflowState.HIDDEN;
- }
-
- // Check "underflow": if an element is to the left or above the container
- var underflowsX = region.right < containerRect.left;
- var underflowsY = region.bottom < containerRect.top;
- if ((underflowsX && containerOverflow.x == 'hidden') ||
- (underflowsY && containerOverflow.y == 'hidden')) {
- return bot.dom.OverflowState.HIDDEN;
- } else if ((underflowsX && containerOverflow.x != 'visible') ||
- (underflowsY && containerOverflow.y != 'visible')) {
- // When the element is positioned to the left or above a container, we
- // have to distinguish between the element being completely outside the
- // container and merely scrolled out of view within the container.
- var containerScroll = getScroll(container);
- var unscrollableX = region.right < containerRect.left - containerScroll.x;
- var unscrollableY = region.bottom < containerRect.top - containerScroll.y;
- if ((unscrollableX && containerOverflow.x != 'visible') ||
- (unscrollableY && containerOverflow.x != 'visible')) {
- return bot.dom.OverflowState.HIDDEN;
- }
- var containerState = bot.dom.getOverflowState(container);
- return containerState == bot.dom.OverflowState.HIDDEN ?
- bot.dom.OverflowState.HIDDEN : bot.dom.OverflowState.SCROLL;
- }
-
- // Check "overflow": if an element is to the right or below a container
- var overflowsX = region.left >= containerRect.left + containerRect.width;
- var overflowsY = region.top >= containerRect.top + containerRect.height;
- if ((overflowsX && containerOverflow.x == 'hidden') ||
- (overflowsY && containerOverflow.y == 'hidden')) {
- return bot.dom.OverflowState.HIDDEN;
- } else if ((overflowsX && containerOverflow.x != 'visible') ||
- (overflowsY && containerOverflow.y != 'visible')) {
- // If the element has fixed position and falls outside the scrollable area
- // of the document, then it is hidden.
- if (treatAsFixedPosition) {
- var docScroll = getScroll(container);
- if ((region.left >= htmlElem.scrollWidth - docScroll.x) ||
- (region.right >= htmlElem.scrollHeight - docScroll.y)) {
- return bot.dom.OverflowState.HIDDEN;
- }
- }
- // If the element can be scrolled into view of the parent, it has a scroll
- // state; unless the parent itself is entirely hidden by overflow, in
- // which it is also hidden by overflow.
- var containerState = bot.dom.getOverflowState(container);
- return containerState == bot.dom.OverflowState.HIDDEN ?
- bot.dom.OverflowState.HIDDEN : bot.dom.OverflowState.SCROLL;
- }
- }
-
- // Does not overflow any ancestor.
- return bot.dom.OverflowState.NONE;
-};
-
-
-/**
- * A regular expression to match the CSS transform matrix syntax.
- * @private {!RegExp}
- * @const
- */
-bot.dom.CSS_TRANSFORM_MATRIX_REGEX_ =
- new RegExp('matrix\\(([\\d\\.\\-]+), ([\\d\\.\\-]+), ' +
- '([\\d\\.\\-]+), ([\\d\\.\\-]+), ' +
- '([\\d\\.\\-]+)(?:px)?, ([\\d\\.\\-]+)(?:px)?\\)');
-
-
-/**
- * Gets the client rectangle of the DOM element. It often returns the same value
- * as Element.getBoundingClientRect, but is "fixed" for various scenarios:
- * 1. Like goog.style.getClientPosition, it adjusts for the inset border in IE.
- * 2. Gets a rect for 's and 's relative to the image using them.
- * 3. Gets a rect for SVG elements representing their true bounding box.
- * 4. Defines the client rect of the element to be the window viewport.
- *
- * @param {!Element} elem The element to use.
- * @return {!goog.math.Rect} The interaction box of the element.
- */
-bot.dom.getClientRect = function (elem) {
- var imageMap = bot.dom.maybeFindImageMap_(elem);
- if (imageMap) {
- return imageMap.rect;
- } else if (bot.dom.isElement(elem, goog.dom.TagName.HTML)) {
- // Define the client rect of the element to be the viewport.
- var doc = goog.dom.getOwnerDocument(elem);
- var viewportSize = goog.dom.getViewportSize(goog.dom.getWindow(doc));
- return new goog.math.Rect(0, 0, viewportSize.width, viewportSize.height);
- } else {
- var nativeRect;
- try {
- // TODO: in IE and Firefox, getBoundingClientRect includes stroke width,
- // but getBBox does not.
- nativeRect = elem.getBoundingClientRect();
- } catch (e) {
- // On IE < 9, calling getBoundingClientRect on an orphan element raises
- // an "Unspecified Error". All other browsers return zeros.
- return new goog.math.Rect(0, 0, 0, 0);
- }
-
- var rect = new goog.math.Rect(nativeRect.left, nativeRect.top,
- nativeRect.right - nativeRect.left, nativeRect.bottom - nativeRect.top);
-
- // In IE, the element can additionally be offset by a border around the
- // documentElement or body element that we have to subtract.
- if (goog.userAgent.IE && elem.ownerDocument.body) {
- var doc = goog.dom.getOwnerDocument(elem);
- 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 client
- * rectangle of the element; otherwise returns null. The return value is an
- * object with 'image' and 'rect' properties. When no image uses the given
- * element, the returned rectangle is present but has zero size.
- *
- * @param {!Element} elem Element to test.
- * @return {?{image: Element, rect: !goog.math.Rect}} Image and rectangle.
- * @private
- */
-bot.dom.maybeFindImageMap_ = function (elem) {
- // If not a or , return null indicating so.
- var isMap = bot.dom.isElement(elem, goog.dom.TagName.MAP);
- if (!isMap && !bot.dom.isElement(elem, goog.dom.TagName.AREA)) {
- return null;
- }
-
- // Get the associated with this element, or null if none.
- var map = isMap ? elem :
- (bot.dom.isElement(elem.parentNode, goog.dom.TagName.MAP) ?
- elem.parentNode : null);
-
- var image = null, rect = null;
- if (map && map.name) {
- var mapDoc = goog.dom.getOwnerDocument(map);
-
- // TODO: Restrict to applet, img, input:image, and object nodes.
- var locator = '*[usemap="#' + map.name + '"]';
-
- // TODO: Break dependency of bot.locators on bot.dom,
- // so bot.locators.findElement can be called here instead.
- image = bot.locators.css.single(locator, mapDoc);
-
- if (image) {
- rect = bot.dom.getClientRect(image);
- if (!isMap && elem.shape.toLowerCase() != 'default') {
- // Shift and crop the relative area rectangle to the map.
- var relRect = bot.dom.getAreaRelativeRect_(elem);
- var relX = Math.min(Math.max(relRect.left, 0), rect.width);
- var relY = Math.min(Math.max(relRect.top, 0), rect.height);
- var w = Math.min(relRect.width, rect.width - relX);
- var h = Math.min(relRect.height, rect.height - relY);
- rect = new goog.math.Rect(relX + rect.left, relY + rect.top, w, h);
- }
- }
- }
-
- return { image: image, rect: rect || new goog.math.Rect(0, 0, 0, 0) };
-};
-
-
-/**
- * Returns the bounding box around an element relative to its enclosing
- * . Does not apply to elements with shape=='default'.
- *
- * @param {!Element} area Area element.
- * @return {!goog.math.Rect} Bounding box of the area element.
- * @private
- */
-bot.dom.getAreaRelativeRect_ = function (area) {
- var shape = area.shape.toLowerCase();
- var coords = area.coords.split(',');
- if (shape == 'rect' && coords.length == 4) {
- var x = coords[0], y = coords[1];
- return new goog.math.Rect(x, y, coords[2] - x, coords[3] - y);
- } else if (shape == 'circle' && coords.length == 3) {
- var centerX = coords[0], centerY = coords[1], radius = coords[2];
- return new goog.math.Rect(centerX - radius, centerY - radius,
- 2 * radius, 2 * radius);
- } else if (shape == 'poly' && coords.length > 2) {
- var minX = coords[0], minY = coords[1], maxX = minX, maxY = minY;
- for (var 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 goog.math.Rect(minX, minY, maxX - minX, maxY - minY);
- }
- return new goog.math.Rect(0, 0, 0, 0);
-};
-
-
-/**
- * Gets the element's client rectangle as a box, optionally clipped to the
- * given coordinate or rectangle relative to the client's position. A coordinate
- * is treated as a 1x1 rectangle whose top-left corner is the coordinate.
- *
- * @param {!Element} elem The element.
- * @param {!(goog.math.Coordinate|goog.math.Rect)=} opt_region
- * Coordinate or rectangle relative to the top-left corner of the element.
- * @return {!goog.math.Box} The client region box.
- */
-bot.dom.getClientRegion = function (elem, opt_region) {
- var region = bot.dom.getClientRect(elem).toBox();
-
- if (opt_region) {
- var rect = opt_region instanceof goog.math.Rect ? opt_region :
- new goog.math.Rect(opt_region.x, opt_region.y, 1, 1);
- region.left = goog.math.clamp(
- region.left + rect.left, region.left, region.right);
- region.top = goog.math.clamp(
- region.top + rect.top, region.top, region.bottom);
- region.right = goog.math.clamp(
- region.left + rect.width, region.left, region.right);
- region.bottom = goog.math.clamp(
- region.top + rect.height, region.top, region.bottom);
- }
-
- return region;
-};
-
-
-/**
- * Trims leading and trailing whitespace from strings, leaving non-breaking
- * space characters in place.
- *
- * @param {string} str The string to trim.
- * @return {string} str without any leading or trailing whitespace characters
- * except non-breaking spaces.
- * @private
- */
-bot.dom.trimExcludingNonBreakingSpaceCharacters_ = function (str) {
- return str.replace(/^[^\S\xa0]+|[^\S\xa0]+$/g, '');
-};
-
-
-/**
- * Helper function for getVisibleText[InDisplayedDom].
- * @param {!Array.} lines Accumulated visible lines of text.
- * @return {string} cleaned up concatenated lines
- * @private
- */
-bot.dom.concatenateCleanedLines_ = function (lines) {
- lines = goog.array.map(
- lines,
- bot.dom.trimExcludingNonBreakingSpaceCharacters_);
- var joined = lines.join('\n');
- var trimmed = bot.dom.trimExcludingNonBreakingSpaceCharacters_(joined);
-
- // Replace non-breakable spaces with regular ones.
- return trimmed.replace(/\xa0/g, ' ');
-};
-
-
-/**
- * @param {!Element} elem The element to consider.
- * @return {string} visible text.
- */
-bot.dom.getVisibleText = function (elem) {
- var lines = [];
-
- if (bot.dom.IS_SHADOW_DOM_ENABLED) {
- bot.dom.appendVisibleTextLinesFromElementInComposedDom_(elem, lines);
- } else {
- bot.dom.appendVisibleTextLinesFromElement_(elem, lines);
- }
- return bot.dom.concatenateCleanedLines_(lines);
-};
-
-
-/**
- * Helper function used by bot.dom.appendVisibleTextLinesFromElement_ and
- * bot.dom.appendVisibleTextLinesFromElementInComposedDom_
- * @param {!Element} elem Element.
- * @param {!Array.} lines Accumulated visible lines of text.
- * @param {function(!Element):boolean} isShownFn function to call to
- * tell if an element is shown
- * @param {function(!Node, !Array., boolean, ?string, ?string):void}
- * childNodeFn function to call to append lines from any child nodes
- * @private
- */
-bot.dom.appendVisibleTextLinesFromElementCommon_ = function (
- elem, lines, isShownFn, childNodeFn) {
- function currLine() {
- return /** @type {string|undefined} */ (goog.array.peek(lines)) || '';
- }
-
- // TODO: Add case here for textual form elements.
- if (bot.dom.isElement(elem, goog.dom.TagName.BR)) {
- lines.push('');
- } else {
- // TODO: properly handle display:run-in
- var isTD = bot.dom.isElement(elem, goog.dom.TagName.TD);
- var display = bot.dom.getEffectiveStyle(elem, 'display');
- // On some browsers, table cells incorrectly show up with block styles.
- var isBlock = !isTD &&
- !goog.array.contains(bot.dom.INLINE_DISPLAY_BOXES_, display);
-
- // Add a newline before block elems when there is text on the current line,
- // except when the previous sibling has a display: run-in.
- // Also, do not run-in the previous sibling if this element is floated.
-
- var previousElementSibling = goog.dom.getPreviousElementSibling(elem);
- var prevDisplay = (previousElementSibling) ?
- bot.dom.getEffectiveStyle(previousElementSibling, 'display') : '';
- // TODO: getEffectiveStyle should mask this for us
- var thisFloat = bot.dom.getEffectiveStyle(elem, 'float') ||
- bot.dom.getEffectiveStyle(elem, 'cssFloat') ||
- bot.dom.getEffectiveStyle(elem, 'styleFloat');
- var runIntoThis = prevDisplay == 'run-in' && thisFloat == 'none';
- if (isBlock && !runIntoThis &&
- !goog.string.isEmptyOrWhitespace(currLine())) {
- lines.push('');
- }
-
- // This element may be considered unshown, but have a child that is
- // explicitly shown (e.g. this element has "visibility:hidden").
- // Nevertheless, any text nodes that are direct descendants of this
- // element will not contribute to the visible text.
- var shown = isShownFn(elem);
-
- // All text nodes that are children of this element need to know the
- // effective "white-space" and "text-transform" styles to properly
- // compute their contribution to visible text. Compute these values once.
- var whitespace = null, textTransform = null;
- if (shown) {
- whitespace = bot.dom.getEffectiveStyle(elem, 'white-space');
- textTransform = bot.dom.getEffectiveStyle(elem, 'text-transform');
- }
-
- goog.array.forEach(elem.childNodes, function (node) {
- childNodeFn(node, lines, shown, whitespace, textTransform);
- });
-
- var line = currLine();
-
- // Here we differ from standard innerText implementations (if there were
- // such a thing). Usually, table cells are separated by a tab, but we
- // normalize tabs into single spaces.
- if ((isTD || display == 'table-cell') && line &&
- !goog.string.endsWith(line, ' ')) {
- lines[lines.length - 1] += ' ';
- }
-
- // Add a newline after block elems when there is text on the current line,
- // and the current element isn't marked as run-in.
- if (isBlock && display != 'run-in' &&
- !goog.string.isEmptyOrWhitespace(line)) {
- lines.push('');
- }
- }
-};
-
-
-/**
- * @param {!Element} elem Element.
- * @param {!Array.} lines Accumulated visible lines of text.
- * @private
- */
-bot.dom.appendVisibleTextLinesFromElement_ = function (elem, lines) {
- bot.dom.appendVisibleTextLinesFromElementCommon_(
- elem, lines, bot.dom.isShown,
- 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);
- bot.dom.appendVisibleTextLinesFromElement_(castElem, lines);
- }
- });
-};
-
-
-/**
- * Elements with one of these effective "display" styles are treated as inline
- * display boxes and have their visible text appended to the current line.
- * @private {!Array.}
- * @const
- */
-bot.dom.INLINE_DISPLAY_BOXES_ = [
- 'inline',
- 'inline-block',
- 'inline-table',
- 'none',
- 'table-cell',
- 'table-column',
- 'table-column-group'
-];
-
-
-/**
- * @param {!Text} textNode Text node.
- * @param {!Array.} lines Accumulated visible lines of text.
- * @param {?string} whitespace Parent element's "white-space" style.
- * @param {?string} textTransform Parent element's "text-transform" style.
- * @private
- */
-bot.dom.appendVisibleTextLinesFromTextNode_ = function (textNode, lines,
- whitespace, textTransform) {
-
- // First, remove zero-width characters. Do this before regularizing spaces as
- // the zero-width space is both zero-width and a space, but we do not want to
- // make it visible by converting it to a regular space.
- // The replaced characters are:
- // U+200B: Zero-width space
- // U+200E: Left-to-right mark
- // U+200F: Right-to-left mark
- var text = textNode.nodeValue.replace(/[\u200b\u200e\u200f]/g, '');
-
- // Canonicalize the new lines, and then collapse new lines
- // for the whitespace styles that collapse. See:
- // https://developer.mozilla.org/en/CSS/white-space
- text = goog.string.canonicalizeNewlines(text);
- if (whitespace == 'normal' || whitespace == 'nowrap') {
- text = text.replace(/\n/g, ' ');
- }
-
- // For pre and pre-wrap whitespace styles, convert all breaking spaces to be
- // non-breaking, otherwise, collapse all breaking spaces. Breaking spaces are
- // converted to regular spaces by getVisibleText().
- 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') {
- // 1) don't treat '_' as a separator (protects snake_case)
- var 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, function () {
- return arguments[1] + arguments[2].toUpperCase();
- });
-
- // 2) capitalize after opening "_" or "*"
- // Preceded by start or a non-word (so it won't fire for snake_case)
- re = /(^|[^'_0-9A-Za-z\u00C0-\u02AF\u1E00-\u1EFF\u24B6-\u24E9])([_*])([A-Za-z\u00C0-\u02AF\u1E00-\u1EFF\u24D0-\u24E9])/g;
- text = text.replace(re, function () {
- return arguments[1] + arguments[2] + arguments[3].toUpperCase();
- });
- } else if (textTransform == 'uppercase') {
- text = text.toUpperCase();
- } else if (textTransform == 'lowercase') {
- text = text.toLowerCase();
- }
-
- var currLine = lines.pop() || '';
- if (goog.string.endsWith(currLine, ' ') &&
- goog.string.startsWith(text, ' ')) {
- text = text.substr(1);
- }
- lines.push(currLine + text);
-};
-
-
-/**
- * Gets the opacity of a node (x-browser).
- * This gets the inline style opacity of the node and takes into account the
- * cascaded or the computed style for this node.
- *
- * @param {!Element} elem Element whose opacity has to be found.
- * @return {number} Opacity between 0 and 1.
- */
-bot.dom.getOpacity = function (elem) {
- // TODO: Does this need to deal with rgba colors?
- if (!bot.userAgent.IE_DOC_PRE9) {
- return bot.dom.getOpacityNonIE_(elem);
- } else {
- if (bot.dom.getEffectiveStyle(elem, 'position') == 'relative') {
- // Filter does not apply to non positioned elements.
- return 1;
- }
-
- var opacityStyle = bot.dom.getEffectiveStyle(elem, 'filter');
- var groups = opacityStyle.match(/^alpha\(opacity=(\d*)\)/) ||
- opacityStyle.match(
- /^progid:DXImageTransform.Microsoft.Alpha\(Opacity=(\d*)\)/);
-
- if (groups) {
- return Number(groups[1]) / 100;
- } else {
- return 1; // Opaque.
- }
- }
-};
-
-
-/**
- * Implementation of getOpacity for browsers that do support
- * the "opacity" style.
- *
- * @param {!Element} elem Element whose opacity has to be found.
- * @return {number} Opacity between 0 and 1.
- * @private
- */
-bot.dom.getOpacityNonIE_ = function (elem) {
- // By default the element is opaque.
- var elemOpacity = 1;
-
- var opacityStyle = bot.dom.getEffectiveStyle(elem, 'opacity');
- if (opacityStyle) {
- elemOpacity = Number(opacityStyle);
- }
-
- // Let's apply the parent opacity to the element.
- var parentElement = bot.dom.getParentElement(elem);
- if (parentElement) {
- elemOpacity = elemOpacity * bot.dom.getOpacityNonIE_(parentElement);
- }
- return elemOpacity;
-};
-
-
-/**
- * Returns the display parent element of the given node, or null. This method
- * differs from bot.dom.getParentElement in the presence of ShadowDOM and
- * <shadow> or <content> tags. For example if
- *
- * div A contains div B
- * div B has a css class .C
- * div A contains a Shadow DOM with a div D
- * div D contains a contents tag selecting all items of class .C
- *
- * 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
- // elements. This if-statement is sufficient if these cases are restricted
- // to boolean attributes whose reflected property names are all lowercase
- // (as attributeName is by this point), like "selected". We have not
- // found a boolean attribute for which this does not work.
- if (bot.userAgent.IE_DOC_PRE9 && element[attributeName] === true) {
- return String(element.getAttribute(attributeName));
- }
-
- // When the attribute is not present, either attr will be null or
- // attr.specified will be false.
- var attr = element.getAttributeNode(attributeName);
- return (attr && attr.specified) ? attr.value : null;
-};
-
-
-/**
- * Regex to split on semicolons, but not when enclosed in parens or quotes.
- * Helper for {@link bot.dom.core.standardizeStyleAttribute_}.
- * If the style attribute ends with a semicolon this will include an empty
- * string at the end of the array
- * @private {!RegExp}
- * @const
- */
-bot.dom.core.SPLIT_STYLE_ATTRIBUTE_ON_SEMICOLONS_REGEXP_ =
- new RegExp('[;]+' +
- '(?=(?:(?:[^"]*"){2})*[^"]*$)' +
- '(?=(?:(?:[^\']*\'){2})*[^\']*$)' +
- '(?=(?:[^()]*\\([^()]*\\))*[^()]*$)');
-
-
-/**
- * Standardize a style attribute value, which includes:
- * (1) converting all property names lowercase
- * (2) ensuring it ends in a trailing semicolon
- * @param {string} value The style attribute value.
- * @return {string} The identical value, with the formatting rules described
- * above applied.
- * @private
- */
-bot.dom.core.standardizeStyleAttribute_ = function (value) {
- var styleArray = value.split(
- bot.dom.core.SPLIT_STYLE_ATTRIBUTE_ON_SEMICOLONS_REGEXP_);
- var css = [];
- goog.array.forEach(styleArray, function (pair) {
- var i = pair.indexOf(':');
- if (i > 0) {
- var keyValue = [pair.slice(0, i), pair.slice(i + 1)];
- if (keyValue.length == 2) {
- css.push(keyValue[0].toLowerCase(), ':', keyValue[1], ';');
- }
- }
- });
- css = css.join('');
- css = css.charAt(css.length - 1) == ';' ? css : css + ';';
- return css;
-};
-
-
-/**
- * Looks up the given property (not to be confused with an attribute) on the
- * given element.
- *
- * @param {!Element} element The element to use.
- * @param {string} propertyName The name of the property.
- * @return {*} The value of the property.
- */
-bot.dom.core.getProperty = function (element, propertyName) {
- // When an 's value attribute is not set, its value property should be
- // its text content, but IE < 8 does not adhere to that behavior, so fix it.
- // http://www.w3.org/TR/1999/REC-html401-19991224/interact/forms.html#adef-value-OPTION
- if (bot.userAgent.IE_DOC_PRE8 && propertyName == 'value' &&
- bot.dom.core.isElement(element, goog.dom.TagName.OPTION) &&
- bot.dom.core.getAttribute(element, 'value') === null) {
- return goog.dom.getRawTextContent(element);
- }
- return element[propertyName];
-};
-
-
-
-/**
- * Returns whether the given node is an element and, optionally, whether it has
- * the given tag name. If the tag name is not provided, returns true if the node
- * is an element, regardless of the tag name.h
- *
- * @template T
- * @param {Node} node The node to test.
- * @param {(goog.dom.TagName|string)=} opt_tagName Tag name to test the node for.
- * @return {boolean} Whether the node is an element with the given tag name.
- */
-bot.dom.core.isElement = function (node, opt_tagName) {
- // because we call this with deprecated tags such as SHADOW
- if (opt_tagName && (typeof opt_tagName !== 'string')) {
- opt_tagName = opt_tagName.toString();
- }
- // because node.tagName.toUpperCase() fails when tagName is "tagName"
- if (node instanceof HTMLFormElement) {
- return !!node && node.nodeType == goog.dom.NodeType.ELEMENT &&
- (!opt_tagName || "FORM" == opt_tagName);
- }
- return !!node && node.nodeType == goog.dom.NodeType.ELEMENT &&
- (!opt_tagName || node.tagName.toUpperCase() == opt_tagName);
-};
-
-
-/**
- * Returns whether the element can be checked or selected.
- *
- * @param {!Element} element The element to check.
- * @return {boolean} Whether the element could be checked or selected.
- */
-bot.dom.core.isSelectable = function (element) {
- if (bot.dom.core.isElement(element, goog.dom.TagName.OPTION)) {
- return true;
- }
-
- if (bot.dom.core.isElement(element, goog.dom.TagName.INPUT)) {
- var type = element.type.toLowerCase();
- return type == 'checkbox' || type == 'radio';
- }
-
- return false;
-};
-
-
-/**
- * Returns whether the element is checked or selected.
- *
- * @param {!Element} element The element to check.
- * @return {boolean} Whether the element is checked or selected.
- */
-bot.dom.core.isSelected = function (element) {
- if (!bot.dom.core.isSelectable(element)) {
- throw new bot.Error(bot.ErrorCode.ELEMENT_NOT_SELECTABLE,
- 'Element is not selectable');
- }
-
- var propertyName = 'selected';
- var type = element.type && element.type.toLowerCase();
- if ('checkbox' == type || 'radio' == type) {
- propertyName = 'checked';
- }
-
- return !!bot.dom.core.getProperty(element, propertyName);
-};
diff --git a/javascript/atoms/domcore.ts b/javascript/atoms/domcore.ts
new file mode 100644
index 0000000000000..a95d239d120a9
--- /dev/null
+++ b/javascript/atoms/domcore.ts
@@ -0,0 +1,148 @@
+// 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.
+ */
+
+import { BotError, ErrorCode } from './error';
+import { IE_DOC_PRE8, IE_DOC_PRE9 } from './userAgent';
+
+const NODE_TYPE_ELEMENT = 1;
+
+const SPLIT_STYLE_ATTRIBUTE_ON_SEMICOLONS_REGEXP =
+ new RegExp('[;]+' +
+ '(?=(?:(?:[^"]*"){2})*[^"]*$)' +
+ '(?=(?:(?:[^\']*\'){2})*[^\']*$)' +
+ '(?=(?:[^()]*\\([^()]*\\))*[^()]*$)');
+
+/**
+ * Standardizes a style attribute value by lowercasing property names and
+ * ensuring it ends with a trailing semicolon.
+ * Note: Exported with underscore suffix for backward compatibility with tests.
+ */
+export function standardizeStyleAttribute_(value: string): string {
+ const styleArray = value.split(SPLIT_STYLE_ATTRIBUTE_ON_SEMICOLONS_REGEXP);
+ const css: string[] = [];
+ styleArray.forEach((pair) => {
+ const i = pair.indexOf(':');
+ if (i > 0) {
+ const keyValue = [pair.slice(0, i), pair.slice(i + 1)];
+ if (keyValue.length === 2) {
+ css.push(keyValue[0].toLowerCase(), ':', keyValue[1], ';');
+ }
+ }
+ });
+ let result = css.join('');
+ result = result.charAt(result.length - 1) === ';' ? result : result + ';';
+ return result;
+}
+
+/**
+ * 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 the style attribute, it standardizes the value by lower-casing the
+ * property names and always including a trailing semicolon.
+ */
+export function getAttribute(element: Element, attributeName: string): string | null {
+ attributeName = attributeName.toLowerCase();
+
+ if (attributeName === 'style') {
+ return standardizeStyleAttribute_((element as HTMLElement).style.cssText);
+ }
+
+ if (IE_DOC_PRE8 && attributeName === 'value' &&
+ isElement(element, 'INPUT')) {
+ return (element as HTMLInputElement).value;
+ }
+
+ if (IE_DOC_PRE9 && (element as unknown as Record)[attributeName] === true) {
+ return String(element.getAttribute(attributeName));
+ }
+
+ const attr = element.getAttributeNode(attributeName);
+ return (attr && attr.specified) ? attr.value : null;
+}
+
+/**
+ * Looks up the given property on the given element.
+ */
+export function getProperty(element: Element, propertyName: string): unknown {
+ if (IE_DOC_PRE8 && propertyName === 'value' &&
+ isElement(element, 'OPTION') &&
+ getAttribute(element, 'value') === null) {
+ return element.textContent || (element as HTMLElement).innerText || '';
+ }
+ return (element as unknown as Record)[propertyName];
+}
+
+/**
+ * Returns whether the given node is an element and, optionally, whether it has
+ * the given tag name.
+ */
+export function isElement(node: Node | null, tagName?: string): node is Element {
+ if (tagName && typeof tagName !== 'string') {
+ tagName = String(tagName);
+ }
+ if (node instanceof HTMLFormElement) {
+ return !!node && node.nodeType === NODE_TYPE_ELEMENT &&
+ (!tagName || 'FORM' === tagName);
+ }
+ return !!node && node.nodeType === NODE_TYPE_ELEMENT &&
+ (!tagName || (node as Element).tagName.toUpperCase() === tagName);
+}
+
+/**
+ * Returns whether the element can be checked or selected.
+ */
+export function isSelectable(element: Element): boolean {
+ if (isElement(element, 'OPTION')) {
+ return true;
+ }
+
+ if (isElement(element, 'INPUT')) {
+ const type = (element as HTMLInputElement).type.toLowerCase();
+ return type === 'checkbox' || type === 'radio';
+ }
+
+ return false;
+}
+
+/**
+ * Returns whether the element is checked or selected.
+ */
+export function isSelected(element: Element): boolean {
+ if (!isSelectable(element)) {
+ throw new BotError(ErrorCode.ELEMENT_NOT_SELECTABLE,
+ 'Element is not selectable');
+ }
+
+ let propertyName = 'selected';
+ const type = (element as HTMLInputElement).type &&
+ (element as HTMLInputElement).type.toLowerCase();
+ if (type === 'checkbox' || type === 'radio') {
+ propertyName = 'checked';
+ }
+
+ return !!getProperty(element, propertyName);
+}
diff --git a/javascript/atoms/error.js b/javascript/atoms/error.js
deleted file mode 100644
index dd5611ba18774..0000000000000
--- a/javascript/atoms/error.js
+++ /dev/null
@@ -1,209 +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 errors as defined by WebDriver's
- * wire protocol: https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol
- */
-
-goog.provide('bot.Error');
-goog.provide('bot.ErrorCode');
-
-goog.require('goog.utils');
-
-
-/**
- * Error codes from the Selenium WebDriver protocol:
- * https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#response-status-codes
- *
- * @enum {number}
- * @suppress {lintChecks}
- */
-bot.ErrorCode = {
- SUCCESS: 0, // Included for completeness
-
- NO_SUCH_ELEMENT: 7,
- NO_SUCH_FRAME: 8,
- UNKNOWN_COMMAND: 9,
- UNSUPPORTED_OPERATION: 9, // Alias.
- STALE_ELEMENT_REFERENCE: 10,
- ELEMENT_NOT_VISIBLE: 11,
- INVALID_ELEMENT_STATE: 12,
- UNKNOWN_ERROR: 13,
- ELEMENT_NOT_SELECTABLE: 15,
- JAVASCRIPT_ERROR: 17,
- XPATH_LOOKUP_ERROR: 19,
- TIMEOUT: 21,
- NO_SUCH_WINDOW: 23,
- INVALID_COOKIE_DOMAIN: 24,
- UNABLE_TO_SET_COOKIE: 25,
- UNEXPECTED_ALERT_OPEN: 26,
- NO_SUCH_ALERT: 27,
- SCRIPT_TIMEOUT: 28,
- INVALID_ELEMENT_COORDINATES: 29,
- IME_NOT_AVAILABLE: 30,
- IME_ENGINE_ACTIVATION_FAILED: 31,
- INVALID_SELECTOR_ERROR: 32,
- SESSION_NOT_CREATED: 33,
- MOVE_TARGET_OUT_OF_BOUNDS: 34,
- SQL_DATABASE_ERROR: 35,
- INVALID_XPATH_SELECTOR: 51,
- INVALID_XPATH_SELECTOR_RETURN_TYPE: 52,
- INVALID_ARGUMENT: 61,
- // The following error codes are derived straight from HTTP return codes.
- METHOD_NOT_ALLOWED: 405
-};
-
-
-/**
- * Represents an error returned from a WebDriver command request.
- *
- * @param {!bot.ErrorCode} code The error's status code.
- * @param {string=} opt_message Optional error message.
- * @constructor
- * @extends {Error}
- */
-bot.Error = function (code, opt_message) {
-
- /**
- * This error's status code.
- * @type {!bot.ErrorCode}
- */
- this.code = code;
-
- /** @type {string} */
- this.state =
- bot.Error.CODE_TO_STATE_[code] || bot.Error.State.UNKNOWN_ERROR;
-
- /** @override */
- this.message = opt_message || '';
-
- var name = this.state.replace(/((?:^|\s+)[a-z])/g, function (str) {
- // IE<9 does not support String#trim(). Also, IE does not include 0xa0
- // (the non-breaking-space) in the \s character class, so we have to
- // explicitly include it.
- return str.toUpperCase().replace(/^[\s\xa0]+/g, '');
- });
-
- var l = name.length - 'Error'.length;
- if (l < 0 || name.indexOf('Error', l) != l) {
- name += 'Error';
- }
-
- /** @override */
- this.name = name;
-
- // Generate a stacktrace for our custom error; ensure the error has our
- // custom name and message so the stack prints correctly in all browsers.
- var template = new Error(this.message);
- template.name = this.name;
-
- /** @override */
- this.stack = template.stack || '';
-};
-goog.utils.inherits(bot.Error, Error);
-
-
-/**
- * Status strings enumerated in the W3C WebDriver protocol.
- * @enum {string}
- * @see https://w3c.github.io/webdriver/webdriver-spec.html#handling-errors
- */
-bot.Error.State = {
- ELEMENT_NOT_SELECTABLE: 'element not selectable',
- ELEMENT_NOT_VISIBLE: 'element not visible',
- INVALID_ARGUMENT: 'invalid argument',
- INVALID_COOKIE_DOMAIN: 'invalid cookie domain',
- INVALID_ELEMENT_COORDINATES: 'invalid element coordinates',
- INVALID_ELEMENT_STATE: 'invalid element state',
- INVALID_SELECTOR: 'invalid selector',
- INVALID_SESSION_ID: 'invalid session id',
- JAVASCRIPT_ERROR: 'javascript error',
- MOVE_TARGET_OUT_OF_BOUNDS: 'move target out of bounds',
- NO_SUCH_ALERT: 'no such alert',
- NO_SUCH_ELEMENT: 'no such element',
- NO_SUCH_FRAME: 'no such frame',
- NO_SUCH_WINDOW: 'no such window',
- SCRIPT_TIMEOUT: 'script timeout',
- SESSION_NOT_CREATED: 'session not created',
- STALE_ELEMENT_REFERENCE: 'stale element reference',
- TIMEOUT: 'timeout',
- UNABLE_TO_SET_COOKIE: 'unable to set cookie',
- UNEXPECTED_ALERT_OPEN: 'unexpected alert open',
- UNKNOWN_COMMAND: 'unknown command',
- UNKNOWN_ERROR: 'unknown error',
- UNKNOWN_METHOD: 'unknown method',
- UNSUPPORTED_OPERATION: 'unsupported operation'
-};
-
-
-/**
- * A map of error codes to state string.
- * @private {!Object.}
- */
-bot.Error.CODE_TO_STATE_ = {};
-goog.scope(function () {
- var map = bot.Error.CODE_TO_STATE_;
- var code = bot.ErrorCode;
- var state = bot.Error.State;
-
- map[code.ELEMENT_NOT_SELECTABLE] = state.ELEMENT_NOT_SELECTABLE;
- map[code.ELEMENT_NOT_VISIBLE] = state.ELEMENT_NOT_VISIBLE;
- map[code.IME_ENGINE_ACTIVATION_FAILED] = state.UNKNOWN_ERROR;
- map[code.IME_NOT_AVAILABLE] = state.UNKNOWN_ERROR;
- map[code.INVALID_COOKIE_DOMAIN] = state.INVALID_COOKIE_DOMAIN;
- map[code.INVALID_ELEMENT_COORDINATES] = state.INVALID_ELEMENT_COORDINATES;
- map[code.INVALID_ELEMENT_STATE] = state.INVALID_ELEMENT_STATE;
- map[code.INVALID_SELECTOR_ERROR] = state.INVALID_SELECTOR;
- map[code.INVALID_XPATH_SELECTOR] = state.INVALID_SELECTOR;
- map[code.INVALID_XPATH_SELECTOR_RETURN_TYPE] = state.INVALID_SELECTOR;
- map[code.JAVASCRIPT_ERROR] = state.JAVASCRIPT_ERROR;
- map[code.METHOD_NOT_ALLOWED] = state.UNSUPPORTED_OPERATION;
- map[code.MOVE_TARGET_OUT_OF_BOUNDS] = state.MOVE_TARGET_OUT_OF_BOUNDS;
- map[code.NO_SUCH_ALERT] = state.NO_SUCH_ALERT;
- map[code.NO_SUCH_ELEMENT] = state.NO_SUCH_ELEMENT;
- map[code.NO_SUCH_FRAME] = state.NO_SUCH_FRAME;
- map[code.NO_SUCH_WINDOW] = state.NO_SUCH_WINDOW;
- map[code.SCRIPT_TIMEOUT] = state.SCRIPT_TIMEOUT;
- map[code.SESSION_NOT_CREATED] = state.SESSION_NOT_CREATED;
- map[code.STALE_ELEMENT_REFERENCE] = state.STALE_ELEMENT_REFERENCE;
- map[code.TIMEOUT] = state.TIMEOUT;
- map[code.UNABLE_TO_SET_COOKIE] = state.UNABLE_TO_SET_COOKIE;
- map[code.UNEXPECTED_ALERT_OPEN] = state.UNEXPECTED_ALERT_OPEN;
- map[code.UNKNOWN_ERROR] = state.UNKNOWN_ERROR;
- map[code.UNSUPPORTED_OPERATION] = state.UNKNOWN_COMMAND;
-}); // goog.scope
-
-
-/**
- * Flag used for duck-typing when this code is embedded in a Firefox extension.
- * This is required since an Error thrown in one component and then reported
- * to another will fail instanceof checks in the second component.
- * @type {boolean}
- */
-bot.Error.prototype.isAutomationError = true;
-
-
-if (goog.DEBUG) {
- /**
- * @override
- * @return {string} The string representation of this error.
- */
- bot.Error.prototype.toString = function () {
- return this.name + ': ' + this.message;
- };
-}
diff --git a/javascript/atoms/error.ts b/javascript/atoms/error.ts
new file mode 100644
index 0000000000000..d75dbb3606e97
--- /dev/null
+++ b/javascript/atoms/error.ts
@@ -0,0 +1,170 @@
+// 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 errors as defined by WebDriver's
+ * wire protocol: https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol
+ */
+
+/**
+ * Error codes from the Selenium WebDriver protocol:
+ * https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#response-status-codes
+ *
+ * Using const enum for better tree-shaking once isolatedModules is disabled.
+ */
+export const enum ErrorCode {
+ SUCCESS = 0,
+
+ NO_SUCH_ELEMENT = 7,
+ NO_SUCH_FRAME = 8,
+ UNKNOWN_COMMAND = 9,
+ UNSUPPORTED_OPERATION = 9,
+ STALE_ELEMENT_REFERENCE = 10,
+ ELEMENT_NOT_VISIBLE = 11,
+ INVALID_ELEMENT_STATE = 12,
+ UNKNOWN_ERROR = 13,
+ ELEMENT_NOT_SELECTABLE = 15,
+ JAVASCRIPT_ERROR = 17,
+ XPATH_LOOKUP_ERROR = 19,
+ TIMEOUT = 21,
+ NO_SUCH_WINDOW = 23,
+ INVALID_COOKIE_DOMAIN = 24,
+ UNABLE_TO_SET_COOKIE = 25,
+ UNEXPECTED_ALERT_OPEN = 26,
+ NO_SUCH_ALERT = 27,
+ SCRIPT_TIMEOUT = 28,
+ INVALID_ELEMENT_COORDINATES = 29,
+ IME_NOT_AVAILABLE = 30,
+ IME_ENGINE_ACTIVATION_FAILED = 31,
+ INVALID_SELECTOR_ERROR = 32,
+ SESSION_NOT_CREATED = 33,
+ MOVE_TARGET_OUT_OF_BOUNDS = 34,
+ SQL_DATABASE_ERROR = 35,
+ INVALID_XPATH_SELECTOR = 51,
+ INVALID_XPATH_SELECTOR_RETURN_TYPE = 52,
+ INVALID_ARGUMENT = 61,
+ METHOD_NOT_ALLOWED = 405,
+}
+
+/**
+ * Status strings enumerated in the W3C WebDriver protocol.
+ * @see https://w3c.github.io/webdriver/webdriver-spec.html#handling-errors
+ *
+ * Using const enum for better tree-shaking once isolatedModules is disabled.
+ */
+export const enum State {
+ ELEMENT_NOT_SELECTABLE = 'element not selectable',
+ ELEMENT_NOT_VISIBLE = 'element not visible',
+ INVALID_ARGUMENT = 'invalid argument',
+ INVALID_COOKIE_DOMAIN = 'invalid cookie domain',
+ INVALID_ELEMENT_COORDINATES = 'invalid element coordinates',
+ INVALID_ELEMENT_STATE = 'invalid element state',
+ INVALID_SELECTOR = 'invalid selector',
+ INVALID_SESSION_ID = 'invalid session id',
+ JAVASCRIPT_ERROR = 'javascript error',
+ MOVE_TARGET_OUT_OF_BOUNDS = 'move target out of bounds',
+ NO_SUCH_ALERT = 'no such alert',
+ NO_SUCH_ELEMENT = 'no such element',
+ NO_SUCH_FRAME = 'no such frame',
+ NO_SUCH_WINDOW = 'no such window',
+ SCRIPT_TIMEOUT = 'script timeout',
+ SESSION_NOT_CREATED = 'session not created',
+ STALE_ELEMENT_REFERENCE = 'stale element reference',
+ TIMEOUT = 'timeout',
+ UNABLE_TO_SET_COOKIE = 'unable to set cookie',
+ UNEXPECTED_ALERT_OPEN = 'unexpected alert open',
+ UNKNOWN_COMMAND = 'unknown command',
+ UNKNOWN_ERROR = 'unknown error',
+ UNKNOWN_METHOD = 'unknown method',
+ UNSUPPORTED_OPERATION = 'unsupported operation',
+}
+
+/**
+ * A map of error codes to state string.
+ */
+const CODE_TO_STATE: Record = {
+ [ErrorCode.ELEMENT_NOT_SELECTABLE]: State.ELEMENT_NOT_SELECTABLE,
+ [ErrorCode.ELEMENT_NOT_VISIBLE]: State.ELEMENT_NOT_VISIBLE,
+ [ErrorCode.IME_ENGINE_ACTIVATION_FAILED]: State.UNKNOWN_ERROR,
+ [ErrorCode.IME_NOT_AVAILABLE]: State.UNKNOWN_ERROR,
+ [ErrorCode.INVALID_COOKIE_DOMAIN]: State.INVALID_COOKIE_DOMAIN,
+ [ErrorCode.INVALID_ELEMENT_COORDINATES]: State.INVALID_ELEMENT_COORDINATES,
+ [ErrorCode.INVALID_ELEMENT_STATE]: State.INVALID_ELEMENT_STATE,
+ [ErrorCode.INVALID_SELECTOR_ERROR]: State.INVALID_SELECTOR,
+ [ErrorCode.INVALID_XPATH_SELECTOR]: State.INVALID_SELECTOR,
+ [ErrorCode.INVALID_XPATH_SELECTOR_RETURN_TYPE]: State.INVALID_SELECTOR,
+ [ErrorCode.JAVASCRIPT_ERROR]: State.JAVASCRIPT_ERROR,
+ [ErrorCode.METHOD_NOT_ALLOWED]: State.UNSUPPORTED_OPERATION,
+ [ErrorCode.MOVE_TARGET_OUT_OF_BOUNDS]: State.MOVE_TARGET_OUT_OF_BOUNDS,
+ [ErrorCode.NO_SUCH_ALERT]: State.NO_SUCH_ALERT,
+ [ErrorCode.NO_SUCH_ELEMENT]: State.NO_SUCH_ELEMENT,
+ [ErrorCode.NO_SUCH_FRAME]: State.NO_SUCH_FRAME,
+ [ErrorCode.NO_SUCH_WINDOW]: State.NO_SUCH_WINDOW,
+ [ErrorCode.SCRIPT_TIMEOUT]: State.SCRIPT_TIMEOUT,
+ [ErrorCode.SESSION_NOT_CREATED]: State.SESSION_NOT_CREATED,
+ [ErrorCode.STALE_ELEMENT_REFERENCE]: State.STALE_ELEMENT_REFERENCE,
+ [ErrorCode.TIMEOUT]: State.TIMEOUT,
+ [ErrorCode.UNABLE_TO_SET_COOKIE]: State.UNABLE_TO_SET_COOKIE,
+ [ErrorCode.UNEXPECTED_ALERT_OPEN]: State.UNEXPECTED_ALERT_OPEN,
+ [ErrorCode.UNKNOWN_ERROR]: State.UNKNOWN_ERROR,
+ [ErrorCode.UNSUPPORTED_OPERATION]: State.UNKNOWN_COMMAND,
+};
+
+/**
+ * Represents an error returned from a WebDriver command request.
+ */
+export class BotError extends Error {
+ /**
+ * This error's status code.
+ */
+ code: ErrorCode;
+
+ /**
+ * The W3C WebDriver state string for this error.
+ */
+ state: State;
+
+ /**
+ * Flag used for duck-typing when this code is embedded in a Firefox extension.
+ * This is required since an Error thrown in one component and then reported
+ * to another will fail instanceof checks in the second component.
+ */
+ isAutomationError: boolean = true;
+
+ constructor(code: ErrorCode, message?: string) {
+ super(message || '');
+
+ this.code = code;
+ this.state = CODE_TO_STATE[code] || State.UNKNOWN_ERROR;
+
+ let name = this.state.replace(/((?:^|\s+)[a-z])/g, (str) => {
+ return str.toUpperCase().replace(/^[\s\xa0]+/g, '');
+ });
+
+ const l = name.length - 'Error'.length;
+ if (l < 0 || name.indexOf('Error', l) !== l) {
+ name += 'Error';
+ }
+
+ this.name = name;
+
+ // Generate a stacktrace for our custom error
+ const template = new Error(this.message);
+ template.name = this.name;
+ this.stack = template.stack || '';
+ }
+}
diff --git a/javascript/atoms/events.js b/javascript/atoms/events.js
deleted file mode 100644
index 69240ff5bc1f8..0000000000000
--- a/javascript/atoms/events.js
+++ /dev/null
@@ -1,787 +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 do with firing and simulating events.
- */
-
-
-goog.provide('bot.events');
-goog.provide('bot.events.EventArgs');
-goog.provide('bot.events.EventType');
-goog.provide('bot.events.KeyboardArgs');
-goog.provide('bot.events.MSGestureArgs');
-goog.provide('bot.events.MSPointerArgs');
-goog.provide('bot.events.MouseArgs');
-goog.provide('bot.events.Touch');
-goog.provide('bot.events.TouchArgs');
-
-goog.require('bot');
-goog.require('bot.Error');
-goog.require('bot.ErrorCode');
-goog.require('bot.userAgent');
-goog.require('goog.array');
-goog.require('goog.dom');
-goog.require('goog.events.BrowserEvent');
-goog.require('goog.style');
-goog.require('goog.userAgent');
-goog.require('goog.userAgent.product');
-goog.require('goog.utils');
-
-
-/**
- * Whether the browser supports the construction of touch events.
- *
- * @const
- * @type {boolean}
- */
-bot.events.SUPPORTS_TOUCH_EVENTS = !(goog.userAgent.IE &&
- !bot.userAgent.isEngineVersion(10));
-
-
-/**
- * Whether the browser supports a native touch api.
- * @private {boolean}
- * @const
- */
-bot.events.BROKEN_TOUCH_API_ = (function () {
- if (goog.userAgent.product.ANDROID) {
- // Native touch api supported starting in version 4.0 (Ice Cream Sandwich).
- return !bot.userAgent.isProductVersion(4);
- }
- return !bot.userAgent.IOS;
-})();
-
-
-/**
- * Whether the browser supports the construction of MSPointer events.
- *
- * @const
- * @type {boolean}
- */
-bot.events.SUPPORTS_MSPOINTER_EVENTS =
- goog.userAgent.IE && bot.getWindow().navigator.msPointerEnabled;
-
-
-/**
- * Arguments to initialize an event.
- *
- * @typedef {bot.events.MouseArgs|bot.events.KeyboardArgs|bot.events.TouchArgs|
- bot.events.MSGestureArgs|bot.events.MSPointerArgs}
- */
-bot.events.EventArgs;
-
-
-/**
- * Arguments to initialize a mouse event.
- *
- * @typedef {{clientX: number,
- * clientY: number,
- * button: number,
- * altKey: boolean,
- * ctrlKey: boolean,
- * shiftKey: boolean,
- * metaKey: boolean,
- * relatedTarget: Element,
- * wheelDelta: number}}
- */
-bot.events.MouseArgs;
-
-
-/**
- * Arguments to initialize a keyboard event.
- *
- * @typedef {{keyCode: number,
- * charCode: number,
- * altKey: boolean,
- * ctrlKey: boolean,
- * shiftKey: boolean,
- * metaKey: boolean,
- * preventDefault: boolean}}
- */
-bot.events.KeyboardArgs;
-
-
-/**
- * Argument to initialize a touch event.
- *
- * @typedef {{touches: !Array.,
- * targetTouches: !Array.,
- * changedTouches: !Array.,
- * altKey: boolean,
- * ctrlKey: boolean,
- * shiftKey: boolean,
- * metaKey: boolean,
- * relatedTarget: Element,
- * scale: number,
- * rotation: number}}
- */
-bot.events.TouchArgs;
-
-
-/**
- * @typedef {{identifier: number,
- * screenX: number,
- * screenY: number,
- * clientX: number,
- * clientY: number,
- * pageX: number,
- * pageY: number}}
- */
-bot.events.Touch;
-
-
-/**
- * Arguments to initialize an MSGesture event.
- *
- * @typedef {{clientX: number,
- * clientY: number,
- * translationX: number,
- * translationY: number,
- * scale: number,
- * expansion: number,
- * rotation: number,
- * velocityX: number,
- * velocityY: number,
- * velocityExpansion: number,
- * velocityAngular: number,
- * relatedTarget: Element}}
- */
-bot.events.MSGestureArgs;
-
-
-/**
- * Arguments to initialize an MSPointer event.
- *
- * @typedef {{clientX: number,
- * clientY: number,
- * button: number,
- * altKey: boolean,
- * ctrlKey: boolean,
- * shiftKey: boolean,
- * metaKey: boolean,
- * relatedTarget: Element,
- * width: number,
- * height: number,
- * pressure: number,
- * rotation: number,
- * pointerId: number,
- * tiltX: number,
- * tiltY: number,
- * pointerType: number,
- * isPrimary: boolean}}
- */
-bot.events.MSPointerArgs;
-
-
-
-/**
- * Factory for event objects of a specific type.
- *
- * @constructor
- * @param {string} type Type of the created events.
- * @param {boolean} bubbles Whether the created events bubble.
- * @param {boolean} cancelable Whether the created events are cancelable.
- * @private
- */
-bot.events.EventFactory_ = function (type, bubbles, cancelable) {
- /** @private {string} */
- this.type_ = type;
-
- /** @private {boolean} */
- this.bubbles_ = bubbles;
-
- /** @private {boolean} */
- this.cancelable_ = cancelable;
-};
-
-
-/**
- * Creates an event.
- *
- * @param {!Element|!Window} target Target element of the event.
- * @param {bot.events.EventArgs=} opt_args Event arguments.
- * @return {!Event} Newly created event.
- */
-bot.events.EventFactory_.prototype.create = function (target, opt_args) {
- var doc = goog.dom.getOwnerDocument(target);
-
- var event = doc.createEvent('HTMLEvents');
- event.initEvent(this.type_, this.bubbles_, this.cancelable_);
-
- return event;
-};
-
-
-/**
- * Overriding toString to return the unique type string improves debugging,
- * and it allows event types to be mapped in JS objects without collisions.
- *
- * @return {string} String representation of the event type.
- * @override
- */
-bot.events.EventFactory_.prototype.toString = function () {
- return this.type_;
-};
-
-
-
-/**
- * Factory for mouse event objects of a specific type.
- *
- * @constructor
- * @param {string} type Type of the created events.
- * @param {boolean} bubbles Whether the created events bubble.
- * @param {boolean} cancelable Whether the created events are cancelable.
- * @extends {bot.events.EventFactory_}
- * @private
- */
-bot.events.MouseEventFactory_ = function (type, bubbles, cancelable) {
- bot.events.EventFactory_.call(this, type, bubbles, cancelable);
-};
-goog.utils.inherits(bot.events.MouseEventFactory_, bot.events.EventFactory_);
-
-
-/**
- * @override
- * @param {!Element|!Window} target Target element of the event.
- * @param {bot.events.EventArgs=} opt_args Event arguments.
- * @return {!Event} Newly created event.
- */
-bot.events.MouseEventFactory_.prototype.create = function (target, opt_args) {
- // Only Gecko supports the mouse pixel scroll event.
- if (!goog.userAgent.GECKO && this == bot.events.EventType.MOUSEPIXELSCROLL) {
- throw new bot.Error(bot.ErrorCode.UNSUPPORTED_OPERATION,
- 'Browser does not support a mouse pixel scroll event.');
- }
-
- var args = /** @type {!bot.events.MouseArgs} */ (opt_args);
- var doc = goog.dom.getOwnerDocument(target);
- var event;
-
- var view = goog.dom.getWindow(doc);
- event = doc.createEvent('MouseEvents');
- var detail = 1;
-
- // All browser but Firefox provide the wheelDelta value in the event.
- // Firefox provides the scroll amount in the detail field, where it has the
- // opposite polarity of the wheelDelta (upward scroll is negative) and is a
- // factor of 40 less than the wheelDelta value.
- // The wheelDelta value is normally some multiple of 40.
- if (this == bot.events.EventType.MOUSEWHEEL) {
- if (!goog.userAgent.GECKO) {
- event.wheelDelta = args.wheelDelta;
- }
- if (goog.userAgent.GECKO) {
- detail = args.wheelDelta / -40;
- }
- }
-
- // Only Gecko supports a mouse pixel scroll event, so we use it as the
- // "standard" and pass it along as is as the "detail" of the event.
- if (goog.userAgent.GECKO && this == bot.events.EventType.MOUSEPIXELSCROLL) {
- detail = args.wheelDelta;
- }
-
- // For screenX and screenY, we set those to clientX and clientY values.
- // While not strictly correct, applications under test depend on
- // accurate relative positioning which is satisfied.
- event.initMouseEvent(this.type_, this.bubbles_, this.cancelable_, view,
- detail, /*screenX*/ args.clientX, /*screenY*/ args.clientY,
- args.clientX, args.clientY, args.ctrlKey, args.altKey,
- args.shiftKey, args.metaKey, args.button, args.relatedTarget);
-
- // Trying to modify the properties throws an error,
- // so we define getters to return the correct values.
- if (goog.userAgent.IE &&
- event.pageX === 0 && event.pageY === 0 && Object.defineProperty) {
- var scrollElem = goog.dom.getDomHelper(target).getDocumentScrollElement();
- var clientElem = goog.style.getClientViewportElement(doc);
- var pageX = args.clientX + scrollElem.scrollLeft - clientElem.clientLeft;
- var pageY = args.clientY + scrollElem.scrollTop - clientElem.clientTop;
-
- Object.defineProperty(event, 'pageX', {
- get: function () {
- return pageX;
- }
- });
- Object.defineProperty(event, 'pageY', {
- get: function () {
- return pageY;
- }
- });
- }
-
- return event;
-};
-
-
-
-/**
- * Factory for keyboard event objects of a specific type.
- *
- * @constructor
- * @param {string} type Type of the created events.
- * @param {boolean} bubbles Whether the created events bubble.
- * @param {boolean} cancelable Whether the created events are cancelable.
- * @extends {bot.events.EventFactory_}
- * @private
- */
-bot.events.KeyboardEventFactory_ = function (type, bubbles, cancelable) {
- bot.events.EventFactory_.call(this, type, bubbles, cancelable);
-};
-goog.utils.inherits(bot.events.KeyboardEventFactory_, bot.events.EventFactory_);
-
-
-/**
- * @override
- * @param {!Element|!Window} target Target element of the event.
- * @param {bot.events.EventArgs=} opt_args Event arguments.
- * @return {!Event} Newly created event.
- */
-bot.events.KeyboardEventFactory_.prototype.create = function (target, opt_args) {
- var args = /** @type {!bot.events.KeyboardArgs} */ (opt_args);
- var doc = goog.dom.getOwnerDocument(target);
- var event;
-
- if (goog.userAgent.GECKO && !bot.userAgent.isEngineVersion(93)) {
- var view = goog.dom.getWindow(doc);
- var keyCode = args.charCode ? 0 : args.keyCode;
- event = doc.createEvent('KeyboardEvent');
- event.initKeyEvent(this.type_, this.bubbles_, this.cancelable_, view,
- args.ctrlKey, args.altKey, args.shiftKey, args.metaKey, keyCode,
- args.charCode);
- // https://bugzilla.mozilla.org/show_bug.cgi?id=501496
- if (this.type_ == bot.events.EventType.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 (goog.userAgent.GECKO) {
- event.keyCode = args.charCode ? 0 : args.keyCode;
- event.charCode = args.charCode;
- } else {
- event.keyCode = args.charCode || args.keyCode;
- if (goog.userAgent.WEBKIT || goog.userAgent.EDGE) {
- event.charCode = (this == bot.events.EventType.KEYPRESS) ?
- event.keyCode : 0;
- }
- }
- }
-
- return event;
-};
-
-
-
-/**
- * Enum representing which mechanism to use for creating touch events.
- * @enum {number}
- * @private
- */
-bot.events.TouchEventStrategy_ = {
- MOUSE_EVENTS: 1,
- INIT_TOUCH_EVENT: 2,
- TOUCH_EVENT_CTOR: 3
-};
-
-
-
-/**
- * Factory for touch event objects of a specific type.
- *
- * @constructor
- * @param {string} type Type of the created events.
- * @param {boolean} bubbles Whether the created events bubble.
- * @param {boolean} cancelable Whether the created events are cancelable.
- * @extends {bot.events.EventFactory_}
- * @private
- */
-bot.events.TouchEventFactory_ = function (type, bubbles, cancelable) {
- bot.events.EventFactory_.call(this, type, bubbles, cancelable);
-};
-goog.utils.inherits(bot.events.TouchEventFactory_, bot.events.EventFactory_);
-
-
-/**
- * @override
- * @param {!Element|!Window} target Target element of the event.
- * @param {bot.events.EventArgs=} opt_args Event arguments.
- * @return {!Event} Newly created event.
- */
-bot.events.TouchEventFactory_.prototype.create = function (target, opt_args) {
- if (!bot.events.SUPPORTS_TOUCH_EVENTS) {
- throw new bot.Error(bot.ErrorCode.UNSUPPORTED_OPERATION,
- 'Browser does not support firing touch events.');
- }
-
- var args = /** @type {!bot.events.TouchArgs} */ (opt_args);
- var doc = goog.dom.getOwnerDocument(target);
- var view = goog.dom.getWindow(doc);
-
- // Creates a TouchList, using native touch Api, for touch events.
- function createNativeTouchList(touchListArgs) {
- var touches = goog.array.map(touchListArgs, function (touchArg) {
- return doc.createTouch(view, target, touchArg.identifier,
- touchArg.pageX, touchArg.pageY, touchArg.screenX, touchArg.screenY);
- });
-
- return doc.createTouchList.apply(doc, touches);
- }
-
- // Creates a TouchList, using simulated touch Api, for touch events.
- function createGenericTouchList(touchListArgs) {
- var touches = goog.array.map(touchListArgs, function (touchArg) {
- // The target field is not part of the W3C spec, but both android and iOS
- // add the target field to each touch.
- return {
- identifier: touchArg.identifier,
- screenX: touchArg.screenX,
- screenY: touchArg.screenY,
- clientX: touchArg.clientX,
- clientY: touchArg.clientY,
- pageX: touchArg.pageX,
- pageY: touchArg.pageY,
- target: target
- };
- });
- touches.item = function (i) {
- return touches[i];
- };
- return touches;
- }
-
- function createTouchEventTouchList(touchListArgs) {
- /** @type {!Array} */
- var touches = goog.array.map(touchListArgs, function (touchArg) {
- return new Touch({
- identifier: touchArg.identifier,
- screenX: touchArg.screenX,
- screenY: touchArg.screenY,
- clientX: touchArg.clientX,
- clientY: touchArg.clientY,
- pageX: touchArg.pageX,
- pageY: touchArg.pageY,
- target: target
- });
- });
- return touches;
- }
-
- function createTouchList(touchStrategy, touches) {
- switch (touchStrategy) {
- case bot.events.TouchEventStrategy_.MOUSE_EVENTS:
- return createGenericTouchList(touches);
- case bot.events.TouchEventStrategy_.INIT_TOUCH_EVENT:
- return createNativeTouchList(touches);
- case bot.events.TouchEventStrategy_.TOUCH_EVENT_CTOR:
- return createTouchEventTouchList(touches);
- }
- return null;
- }
-
- // TODO(juangj): Always use the TouchEvent constructor, if available.
- var strategy;
- if (bot.events.BROKEN_TOUCH_API_) {
- strategy = bot.events.TouchEventStrategy_.MOUSE_EVENTS;
- } else {
- if (TouchEvent.prototype.initTouchEvent) {
- strategy = bot.events.TouchEventStrategy_.INIT_TOUCH_EVENT;
- } else if (TouchEvent && TouchEvent.length > 0) {
- strategy = bot.events.TouchEventStrategy_.TOUCH_EVENT_CTOR;
- } else {
- throw new bot.Error(
- bot.ErrorCode.UNSUPPORTED_OPERATION,
- 'Not able to create touch events in this browser');
- }
- }
-
- // As a performance optimization, reuse the created touchlist when the lists
- // are the same, which is often the case in practice.
- var changedTouches = createTouchList(strategy, args.changedTouches);
- var touches = (args.touches == args.changedTouches) ?
- changedTouches : createTouchList(strategy, args.touches);
- var targetTouches = (args.targetTouches == args.changedTouches) ?
- changedTouches : createTouchList(strategy, args.targetTouches);
-
- var event;
- if (strategy == bot.events.TouchEventStrategy_.MOUSE_EVENTS) {
- event = doc.createEvent('MouseEvents');
- event.initMouseEvent(this.type_, this.bubbles_, this.cancelable_, view,
- /*detail*/ 1, /*screenX*/ 0, /*screenY*/ 0, args.clientX, args.clientY,
- args.ctrlKey, args.altKey, args.shiftKey, args.metaKey, /*button*/ 0,
- args.relatedTarget);
- event.touches = touches;
- event.targetTouches = targetTouches;
- event.changedTouches = changedTouches;
- event.scale = args.scale;
- event.rotation = args.rotation;
- } else if (strategy == bot.events.TouchEventStrategy_.INIT_TOUCH_EVENT) {
- event = doc.createEvent('TouchEvent');
- // Different browsers have different implementations of initTouchEvent.
- if (event.initTouchEvent.length == 0) {
- // Chrome/Android.
- event.initTouchEvent(touches, targetTouches, changedTouches,
- this.type_, view, /*screenX*/ 0, /*screenY*/ 0, args.clientX,
- args.clientY, args.ctrlKey, args.altKey, args.shiftKey, args.metaKey);
- } else {
- // iOS.
- event.initTouchEvent(this.type_, this.bubbles_, this.cancelable_, view,
- /*detail*/ 1, /*screenX*/ 0, /*screenY*/ 0, args.clientX,
- args.clientY, args.ctrlKey, args.altKey, args.shiftKey, args.metaKey,
- touches, targetTouches, changedTouches, args.scale, args.rotation);
- }
- event.relatedTarget = args.relatedTarget;
- } else if (strategy == bot.events.TouchEventStrategy_.TOUCH_EVENT_CTOR) {
- var touchProperties = /** @type {!TouchEventInit} */ ({
- touches: touches,
- targetTouches: targetTouches,
- changedTouches: changedTouches,
- 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 bot.Error(
- bot.ErrorCode.UNSUPPORTED_OPERATION,
- 'Illegal TouchEventStrategy_ value (this is a bug)');
- }
-
- return event;
-};
-
-
-
-/**
- * Factory for MSGesture event objects of a specific type.
- *
- * @constructor
- * @param {string} type Type of the created events.
- * @param {boolean} bubbles Whether the created events bubble.
- * @param {boolean} cancelable Whether the created events are cancelable.
- * @extends {bot.events.EventFactory_}
- * @private
- */
-bot.events.MSGestureEventFactory_ = function (type, bubbles, cancelable) {
- bot.events.EventFactory_.call(this, type, bubbles, cancelable);
-};
-goog.utils.inherits(bot.events.MSGestureEventFactory_, bot.events.EventFactory_);
-
-
-/**
- * @override
- * @param {!Element|!Window} target Target element of the event.
- * @param {bot.events.EventArgs=} opt_args Event arguments.
- * @return {!Event} Newly created event.
- */
-bot.events.MSGestureEventFactory_.prototype.create = function (target,
- opt_args) {
- if (!bot.events.SUPPORTS_MSPOINTER_EVENTS) {
- throw new bot.Error(bot.ErrorCode.UNSUPPORTED_OPERATION,
- 'Browser does not support MSGesture events.');
- }
-
- var args = /** @type {!bot.events.MSGestureArgs} */ (opt_args);
- var doc = goog.dom.getOwnerDocument(target);
- var view = goog.dom.getWindow(doc);
- var event = doc.createEvent('MSGestureEvent');
- var timestamp = (new Date).getTime();
-
- // See http://msdn.microsoft.com/en-us/library/windows/apps/hh441187.aspx
- event.initGestureEvent(this.type_, this.bubbles_, this.cancelable_, view,
- /*detail*/ 1, /*screenX*/ 0, /*screenY*/ 0,
- args.clientX, args.clientY, /*offsetX*/ 0,
- /*offsetY*/ 0, args.translationX, args.translationY,
- args.scale, args.expansion, args.rotation,
- args.velocityX, args.velocityY, args.velocityExpansion,
- args.velocityAngular, timestamp, args.relatedTarget);
- return event;
-};
-
-
-
-/**
- * Factory for MSPointer event objects of a specific type.
- *
- * @constructor
- * @param {string} type Type of the created events.
- * @param {boolean} bubbles Whether the created events bubble.
- * @param {boolean} cancelable Whether the created events are cancelable.
- * @extends {bot.events.EventFactory_}
- * @private
- */
-bot.events.MSPointerEventFactory_ = function (type, bubbles, cancelable) {
- bot.events.EventFactory_.call(this, type, bubbles, cancelable);
-};
-goog.utils.inherits(bot.events.MSPointerEventFactory_, bot.events.EventFactory_);
-
-
-/**
- * @override
- * @param {!Element|!Window} target Target element of the event.
- * @param {bot.events.EventArgs=} opt_args Event arguments.
- * @return {!Event} Newly created event.
- * @suppress {checkTypes} Closure compiler externs don't know about pointer
- * events
- */
-bot.events.MSPointerEventFactory_.prototype.create = function (target,
- opt_args) {
- if (!bot.events.SUPPORTS_MSPOINTER_EVENTS) {
- throw new bot.Error(bot.ErrorCode.UNSUPPORTED_OPERATION,
- 'Browser does not support MSPointer events.');
- }
-
- var args = /** @type {!bot.events.MSPointerArgs} */ (opt_args);
- var doc = goog.dom.getOwnerDocument(target);
- var view = goog.dom.getWindow(doc);
- var event = doc.createEvent('MSPointerEvent');
-
- // See http://msdn.microsoft.com/en-us/library/ie/hh772109(v=vs.85).aspx
- event.initPointerEvent(this.type_, this.bubbles_, this.cancelable_, view,
- /*detail*/ 0, /*screenX*/ 0, /*screenY*/ 0,
- args.clientX, args.clientY, args.ctrlKey, args.altKey,
- args.shiftKey, args.metaKey, args.button,
- args.relatedTarget, /*offsetX*/ 0, /*offsetY*/ 0,
- args.width, args.height, args.pressure, args.rotation,
- args.tiltX, args.tiltY, args.pointerId,
- args.pointerType, /*hwTimeStamp*/ 0, args.isPrimary);
-
- return event;
-};
-
-
-/**
- * The types of events this modules supports firing.
- *
- * 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:
- *
- * they do not refer to "document" which is undefined in the context of a
- * Firefox extension;
- * they use a default NsResolver for browsers that do not provide
- * document.createNSResolver (e.g. Android); and
- * 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, !Array.>}
- * @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.events.EventFactory_>}
- */
-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:
- *
- * in a Firefox extension, tests the engine version through the XUL version
- * comparator service, because no window.navigator object is available
- * 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:
- *
- * in a Firefox extension, tests the product version through the XUL version
- * comparator service, because no window.navigator object is available
- * 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")