diff --git a/javascript/atoms/BUILD.bazel b/javascript/atoms/BUILD.bazel index 6aec84d218eae..33063c61807f3 100644 --- a/javascript/atoms/BUILD.bazel +++ b/javascript/atoms/BUILD.bazel @@ -65,6 +65,55 @@ js_binary( entry_point = "typescript/wrap-as-global.js", ) +js_binary( + name = "wrap_find_elements_as_global", + data = ["typescript/wrap-find-elements-as-global.js"], + entry_point = "typescript/wrap-find-elements-as-global.js", +) + +js_run_binary( + name = "find-elements-typescript-compiled", + srcs = ["typescript/find-elements.ts"], + outs = ["typescript/find-elements.compiled.js"], + args = [ + "--target", + "ES2017", + "--module", + "none", + "--moduleResolution", + "node", + "--removeComments", + "--pretty", + "false", + "--outFile", + "$(rootpath :typescript/find-elements.compiled.js)", + "$(rootpath :typescript/find-elements.ts)", + ], + tool = "@npm_typescript//:tsc", +) + +js_run_binary( + name = "find-elements-typescript-generated", + srcs = [":find-elements-typescript-compiled"], + outs = ["typescript/find-elements.generated.js"], + args = [ + "$(rootpath :find-elements-typescript-compiled)", + "$(rootpath :typescript/find-elements.generated.js)", + ], + tool = ":strip_trailing_semicolon", +) + +js_run_binary( + name = "find-elements-global", + srcs = [":find-elements-typescript-compiled"], + outs = ["typescript/find-elements-global.js"], + args = [ + "$(rootpath :find-elements-typescript-compiled)", + "$(rootpath :typescript/find-elements-global.js)", + ], + tool = ":wrap_find_elements_as_global", +) + js_run_binary( name = "is-displayed-typescript-compiled", srcs = ["typescript/is-displayed.ts"], @@ -117,7 +166,7 @@ filegroup( "**/*.png", "**/*.svg", "**/*.ts", - ]) + [":get-attribute-global"] + [":is-displayed-global"], + ]) + [":get-attribute-global"] + [":is-displayed-global"] + [":find-elements-global"], visibility = [ "//dotnet/test:__subpackages__", "//java/test/org/openqa/selenium/environment:__pkg__", diff --git a/javascript/atoms/fragments/BUILD.bazel b/javascript/atoms/fragments/BUILD.bazel index c1aa281e0e534..c24f0cb5685fe 100644 --- a/javascript/atoms/fragments/BUILD.bazel +++ b/javascript/atoms/fragments/BUILD.bazel @@ -225,6 +225,20 @@ closure_fragment( ], ) +copy_file( + name = "find-elements-typescript", + src = "//javascript/atoms:typescript/find-elements.generated.js", + out = "find-elements-typescript.js", + 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__", + ], +) + closure_fragment( name = "get-text", function = "bot.dom.getVisibleText", diff --git a/javascript/atoms/test/find_elements_typescript_test.html b/javascript/atoms/test/find_elements_typescript_test.html new file mode 100644 index 0000000000000..7964397d6c25c --- /dev/null +++ b/javascript/atoms/test/find_elements_typescript_test.html @@ -0,0 +1,166 @@ + + + + + + find_elements_typescript_test + + + + + + +
+
+
+

First paragraph

+

Second paragraph

+

Third paragraph

+ Click me + Partial match link + + A span +
+
+ + + + diff --git a/javascript/atoms/typescript/find-elements.ts b/javascript/atoms/typescript/find-elements.ts new file mode 100644 index 0000000000000..612f4d85bed8e --- /dev/null +++ b/javascript/atoms/typescript/find-elements.ts @@ -0,0 +1,326 @@ +// 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. + +(function (): (target: Record, root?: Document | Element) => Element[] { + type LocatorTarget = Record + type Root = Document | Element + type Rect = { left: number; top: number; width: number; height: number } + type RelativeFilter = { kind: string; args: unknown[] } + + const INVALID_SELECTOR = 'invalid selector' + const INVALID_ARGUMENT = 'invalid argument' + const NO_SUCH_ELEMENT = 'no such element' + + function botError(code: string, message: string): Error { + const err = new Error(message) + ;(err as unknown as Record)['code'] = code + return err + } + + function cssEscapeId(s: string): string { + return s.replace(/[\s'"\\#.:;,!?+<>=~*^$|%&@`{}\-/\[\]()]/g, (c) => '\\' + c) + } + + function classNameMany(target: string, root: Root): Element[] { + if (!target) { + throw botError(INVALID_SELECTOR, 'No class name specified') + } + target = target.trim() + if (target.indexOf(' ') !== -1) { + throw botError(INVALID_SELECTOR, 'Compound class names not permitted') + } + try { + return Array.from(root.querySelectorAll('.' + target.replace(/\\/g, '\\\\').replace(/\./g, '\\.'))) + } catch (_e) { + throw botError(INVALID_SELECTOR, 'An invalid or illegal class name was specified') + } + } + + function cssMany(target: string, root: Root): Element[] { + try { + return Array.from(root.querySelectorAll(target)) + } catch (_e) { + throw botError(INVALID_SELECTOR, 'An invalid or illegal CSS selector was specified: ' + target) + } + } + + function idMany(target: string, root: Root): Element[] { + if (!target) { + return [] + } + if (!/^\d/.test(target)) { + try { + return Array.from(root.querySelectorAll('#' + cssEscapeId(target))) + } catch (_e) { + return [] + } + } + return Array.from(root.querySelectorAll('*')).filter(el => el.getAttribute('id') === target) + } + + function getLinkText(el: Element): string { + const text = (el as HTMLElement).innerText !== undefined ? (el as HTMLElement).innerText : el.textContent || '' + return text.replace(/^[\s]+|[\s]+$/g, '') + } + + function linkTextMany(target: string, root: Root, partial: boolean): Element[] { + return Array.from(root.querySelectorAll('a')).filter(el => { + const text = getLinkText(el) + return partial ? text.indexOf(target) !== -1 : text === target + }) + } + + function nameMany(target: string, root: Root): Element[] { + return Array.from(root.querySelectorAll('*')).filter(el => el.getAttribute('name') === target) + } + + function tagNameMany(target: string, root: Root): Element[] { + if (target === '') { + throw botError(INVALID_SELECTOR, 'Unable to locate an element with the tagName ""') + } + return Array.from(root.getElementsByTagName(target)) + } + + const DEFAULT_NS_RESOLVER = (function () { + const ns: Record = { svg: 'http://www.w3.org/2000/svg' } + return (prefix: string): string | null => ns[prefix] || null + })() + + const ORDERED_NODE_SNAPSHOT_TYPE = 7 + + function xpathMany(target: string, root: Root): Element[] { + const doc = (root as Document).documentElement ? (root as Document) : (root as Element).ownerDocument! + if (!doc.documentElement) { + return [] + } + try { + const reversedNs: Record = {} + const allNodes = doc.getElementsByTagName('*') + for (let i = 0; i < allNodes.length; i++) { + const n = allNodes[i] + const ns = n.namespaceURI + if (ns && !reversedNs[ns]) { + let prefix = n.lookupPrefix(ns) + if (!prefix) { + const m = ns.match('.*/(\\w+)/?$') + prefix = m ? m[1] : 'xhtml' + } + reversedNs[ns] = prefix! + } + } + const namespaces: Record = {} + for (const key in reversedNs) { + namespaces[reversedNs[key]] = key + } + let resolver: XPathNSResolver | ((prefix: string | null) => string | null) = + (prefix: string | null): string | null => namespaces[prefix || ''] || null + + let result: XPathResult | null = null + try { + result = doc.evaluate(target, root, resolver, ORDERED_NODE_SNAPSHOT_TYPE, null) + } catch (te) { + if ((te as Error).name === 'TypeError') { + const fallback = doc.createNSResolver ? doc.createNSResolver(doc.documentElement!) : DEFAULT_NS_RESOLVER + result = doc.evaluate(target, root, fallback, ORDERED_NODE_SNAPSHOT_TYPE, null) + } else { + throw te + } + } + + if (!result) { + return [] + } + + const results: Element[] = [] + for (let i = 0; i < result.snapshotLength; i++) { + const node = result.snapshotItem(i) + if (!node || node.nodeType !== Node.ELEMENT_NODE) { + throw botError( + INVALID_SELECTOR, + 'The result of the xpath expression "' + target + '" is: ' + node + '. It should be an element.', + ) + } + results.push(node as Element) + } + return results + } catch (ex) { + if ((ex as Error & { name?: string }).name === 'NS_ERROR_ILLEGAL_VALUE') { + return [] + } + if ((ex as Error & { code?: string }).code === INVALID_SELECTOR) { + throw ex + } + throw botError( + INVALID_SELECTOR, + 'Unable to locate an element with the xpath expression ' + target + ' because of the following error:\n' + ex, + ) + } + } + + function getClientRect(element: Element): Rect { + const r = element.getBoundingClientRect() + return { left: r.left, top: r.top, width: r.width, height: r.height } + } + + function resolveAnchor(selector: unknown): Element { + if (selector instanceof Element) { + return selector + } + if (typeof selector === 'function') { + return resolveAnchor((selector as () => unknown)()) + } + if (selector && typeof selector === 'object') { + const found = findElements(selector as LocatorTarget) + if (!found.length) { + throw botError(NO_SUCH_ELEMENT, 'No element has been found by ' + JSON.stringify(selector)) + } + return found[0] + } + throw botError(INVALID_ARGUMENT, 'Selector is of wrong type: ' + JSON.stringify(selector)) + } + + function makeProximityFilter(selector: unknown, test: (anchor: Rect, candidate: Rect) => boolean) { + return (candidate: Element): boolean => test(getClientRect(resolveAnchor(selector)), getClientRect(candidate)) + } + + const RELATIVE_STRATEGIES: Record (el: Element) => boolean> = { + above: (sel) => makeProximityFilter(sel, (a, c) => c.top + c.height <= a.top), + below: (sel) => makeProximityFilter(sel, (a, c) => c.top >= a.top + a.height), + left: (sel) => makeProximityFilter(sel, (a, c) => c.left + c.width <= a.left), + right: (sel) => makeProximityFilter(sel, (a, c) => c.left >= a.left + a.width), + near: (sel, distArg) => { + const distFromSelector = typeof (sel as Record)['distance'] === 'number' + ? ((sel as Record)['distance'] as number) + : 0 + const distance = typeof distArg === 'number' && distArg > 0 ? distArg : distFromSelector || 50 + return (candidate: Element): boolean => { + const anchor = resolveAnchor(sel) + if (anchor === candidate) return false + const a = getClientRect(anchor) + const c = getClientRect(candidate) + const expanded = { left: a.left - distance, top: a.top - distance, width: a.width + distance * 2, height: a.height + distance * 2 } + return ( + c.left < expanded.left + expanded.width && + c.left + c.width > expanded.left && + c.top < expanded.top + expanded.height && + c.top + c.height > expanded.top + ) + } + }, + straightAbove: (sel) => + makeProximityFilter(sel, (a, c) => c.left < a.left + a.width && c.left + c.width > a.left && c.top + c.height <= a.top), + straightBelow: (sel) => + makeProximityFilter(sel, (a, c) => c.left < a.left + a.width && c.left + c.width > a.left && c.top >= a.top + a.height), + straightLeft: (sel) => + makeProximityFilter(sel, (a, c) => c.top < a.top + a.height && c.top + c.height > a.top && c.left + c.width <= a.left), + straightRight: (sel) => + makeProximityFilter(sel, (a, c) => c.top < a.top + a.height && c.top + c.height > a.top && c.left >= a.left + a.width), + } + + function sortByProximity(anchor: Element, elements: Element[]): Element[] { + const ar = getClientRect(anchor) + const acx = ar.left + Math.max(1, ar.width) / 2 + const acy = ar.top + Math.max(1, ar.height) / 2 + return elements.slice().sort((a, b) => { + const ra = getClientRect(a) + const rb = getClientRect(b) + const da = Math.sqrt(Math.pow(acx - (ra.left + Math.max(1, ra.width) / 2), 2) + Math.pow(acy - (ra.top + Math.max(1, ra.height) / 2), 2)) + const db = Math.sqrt(Math.pow(acx - (rb.left + Math.max(1, rb.width) / 2), 2) + Math.pow(acy - (rb.top + Math.max(1, rb.height) / 2), 2)) + return da - db + }) + } + + function relativeMany(target: Record, root: Root): Element[] { + if (!Object.prototype.hasOwnProperty.call(target, 'root') || !Object.prototype.hasOwnProperty.call(target, 'filters')) { + throw botError(INVALID_ARGUMENT, 'Locator not suitable for relative locators: ' + JSON.stringify(target)) + } + const filters = target['filters'] + if (!Array.isArray(filters)) { + throw botError(INVALID_ARGUMENT, 'Targets should be an array: ' + JSON.stringify(target)) + } + + let elements: Element[] + const rootTarget = target['root'] + if (rootTarget instanceof Element) { + elements = [rootTarget] + } else { + elements = findElements(rootTarget as LocatorTarget, root) + } + + if (!elements.length) { + return [] + } + + const matched = elements.filter(el => { + if (!el) return false + return (filters as RelativeFilter[]).every(filter => { + const strategy = RELATIVE_STRATEGIES[filter.kind] + if (!strategy) { + throw botError(INVALID_ARGUMENT, 'Cannot find filter suitable for ' + filter.kind) + } + return strategy(...filter.args)(el) + }) + }) + + const finalFilter = filters[filters.length - 1] as RelativeFilter | undefined + if (!finalFilter || !RELATIVE_STRATEGIES[finalFilter.kind]) { + return matched + } + const lastAnchor = resolveAnchor(finalFilter.args[0]) + return sortByProximity(lastAnchor, matched) + } + + function findElements(target: LocatorTarget, root?: Root): Element[] { + const actualRoot: Root = root || document + const keys = Object.keys(target).filter(k => Object.prototype.hasOwnProperty.call(target, k)) + if (!keys.length) { + throw botError(INVALID_ARGUMENT, 'Unsupported locator strategy: (empty)') + } + const key = keys[0] + const value = target[key] + + switch (key) { + case 'className': + case 'class name': + return classNameMany(value as string, actualRoot) + case 'css': + case 'css selector': + return cssMany(value as string, actualRoot) + case 'id': + return idMany(value as string, actualRoot) + case 'linkText': + case 'link text': + return linkTextMany(value as string, actualRoot, false) + case 'partialLinkText': + case 'partial link text': + return linkTextMany(value as string, actualRoot, true) + case 'name': + return nameMany(value as string, actualRoot) + case 'tagName': + case 'tag name': + return tagNameMany(value as string, actualRoot) + case 'xpath': + return xpathMany(value as string, actualRoot) + case 'relative': + return relativeMany(value as Record, actualRoot) + default: + throw botError(INVALID_ARGUMENT, 'Unsupported locator strategy: ' + key) + } + } + + return findElements +})() diff --git a/javascript/atoms/typescript/wrap-find-elements-as-global.js b/javascript/atoms/typescript/wrap-find-elements-as-global.js new file mode 100644 index 0000000000000..bd6793e42a647 --- /dev/null +++ b/javascript/atoms/typescript/wrap-find-elements-as-global.js @@ -0,0 +1,62 @@ +// 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. + +// Wraps the compiled findElements TypeScript output as a browser global so that +// test HTML pages can load it directly via