@qawolf/web
Advanced tools
Comparing version 0.9.2 to 0.9.3-alpha.0
@@ -1,9 +0,3 @@ | ||
interface AttributeValuePair { | ||
attribute: string; | ||
value: string; | ||
} | ||
export declare const getClickableAncestor: (element: HTMLElement, attribute: string) => HTMLElement; | ||
export declare const getAttributeValue: (element: HTMLElement, attribute: string) => AttributeValuePair | null; | ||
export declare const getClickableAncestor: (element: HTMLElement) => HTMLElement; | ||
export declare const isClickable: (element: HTMLElement, computedStyle: CSSStyleDeclaration) => boolean; | ||
export declare const isVisible: (element: Element, computedStyle?: CSSStyleDeclaration | undefined) => boolean; | ||
export {}; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
const xpath_1 = require("./xpath"); | ||
exports.getClickableAncestor = (element, attribute) => { | ||
exports.getClickableAncestor = (element) => { | ||
let ancestor = element; | ||
console.debug("qawolf: get clickable ancestor for", xpath_1.getXpath(element)); | ||
while (ancestor.parentElement) { | ||
const attributeValue = exports.getAttributeValue(ancestor, attribute); | ||
if (attributeValue) { | ||
console.debug(`qawolf: found clickable ancestor: ${JSON.stringify(attributeValue)}"`, xpath_1.getXpath(ancestor)); | ||
return ancestor; | ||
} | ||
if (["a", "button", "input"].includes(ancestor.tagName.toLowerCase())) { | ||
@@ -18,20 +13,10 @@ console.debug(`qawolf: found clickable ancestor: ${ancestor.tagName}`, xpath_1.getXpath(ancestor)); | ||
if (!exports.isClickable(ancestor.parentElement, window.getComputedStyle(ancestor.parentElement))) { | ||
console.debug("qawolf: no clickable ancestor, use target", xpath_1.getXpath(element)); | ||
return element; | ||
console.debug("qawolf: found clickable ancestor", xpath_1.getXpath(ancestor)); | ||
return ancestor; | ||
} | ||
ancestor = ancestor.parentElement; | ||
} | ||
console.debug("qawolf: found clickable ancestor", xpath_1.getXpath(ancestor)); | ||
return ancestor; | ||
}; | ||
exports.getAttributeValue = (element, attribute) => { | ||
if (!attribute) | ||
return null; | ||
const attributes = attribute.split(",").map(attr => attr.trim()); | ||
for (let attribute of attributes) { | ||
const value = element.getAttribute(attribute); | ||
if (value) | ||
return { attribute, value }; | ||
} | ||
return null; | ||
}; | ||
exports.isClickable = (element, computedStyle) => { | ||
@@ -38,0 +23,0 @@ const clickable = computedStyle.cursor === "pointer"; |
import { ElementEvent } from "@qawolf/types"; | ||
export declare const isKeyEvent: (event: ElementEvent | null) => boolean | null; | ||
export declare const isPasteEvent: (event: ElementEvent | null) => boolean | null; | ||
export declare const isSelectAllEvent: (event: ElementEvent | null) => boolean | null; | ||
export declare const isTypeEvent: (event: ElementEvent | null) => boolean | null; |
@@ -7,3 +7,4 @@ "use strict"; | ||
exports.isPasteEvent = (event) => event && event.isTrusted && event.name === "paste"; | ||
exports.isSelectAllEvent = (event) => event && event.isTrusted && event.name === "selectall"; | ||
exports.isTypeEvent = (event) => exports.isKeyEvent(event) || exports.isPasteEvent(event); | ||
//# sourceMappingURL=event.js.map |
@@ -18,4 +18,6 @@ "use strict"; | ||
} | ||
description = description.replace("'", ""); | ||
description = description.replace(/[^\x20-\x7E]/g, ""); | ||
if (description.length) { | ||
return ` "${description}"`.replace("'", ""); | ||
return ` "${description}"`; | ||
} | ||
@@ -22,0 +24,0 @@ return ""; |
@@ -12,3 +12,3 @@ import * as element from "./element"; | ||
declare const describeDoc: (html: import("@qawolf/types").DocSelector) => string; | ||
declare const isKeyEvent: (event: import("@qawolf/types").ElementEvent | null) => boolean | null, isPasteEvent: (event: import("@qawolf/types").ElementEvent | null) => boolean | null, isTypeEvent: (event: import("@qawolf/types").ElementEvent | null) => boolean | null; | ||
declare const isKeyEvent: (event: import("@qawolf/types").ElementEvent | null) => boolean | null, isPasteEvent: (event: import("@qawolf/types").ElementEvent | null) => boolean | null, isSelectAllEvent: (event: import("@qawolf/types").ElementEvent | null) => boolean | null, isTypeEvent: (event: import("@qawolf/types").ElementEvent | null) => boolean | null; | ||
declare const compareAttributes: (a: any, b: any) => { | ||
@@ -24,4 +24,5 @@ attrs: { | ||
declare const sleep: (milliseconds: number) => Promise<void>, waitFor: <T>(valueFunction: () => T | Promise<T> | null, timeoutMs: number, sleepMs?: number) => Promise<T | null>, waitUntil: (predicate: () => boolean | Promise<boolean>, timeoutMs: number, sleepMs?: number) => Promise<void>; | ||
export { compareAttributes, compareContent, compareDoc, describeDoc, decodeHtml, htmlToDoc, isKeyEvent, isNil, isPasteEvent, isTypeEvent, matchDocSelector, serializeDocSelector, sleep, waitFor, waitUntil }; | ||
export { compareAttributes, compareContent, compareDoc, describeDoc, decodeHtml, htmlToDoc, isKeyEvent, isNil, isPasteEvent, isSelectAllEvent, isTypeEvent, matchDocSelector, serializeDocSelector, sleep, waitFor, waitUntil }; | ||
declare const webExports: { | ||
buildCssSelector: ({ attribute, isClick, target }: import("./buildCssSelector").BuildCssSelectorOptions) => string | undefined; | ||
captureLogs: (logLevel: string, callback: (level: string, message: string) => void) => void; | ||
@@ -32,2 +33,3 @@ element: typeof element; | ||
format: typeof format; | ||
getAttributeValue: (element: HTMLElement, attribute: string) => import("./buildCssSelector").AttributeValuePair | null; | ||
lang: typeof lang; | ||
@@ -34,0 +36,0 @@ Recorder: typeof Recorder; |
@@ -10,2 +10,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
const buildCssSelector_1 = require("./buildCssSelector"); | ||
const captureLogs_1 = require("./captureLogs"); | ||
@@ -25,5 +26,6 @@ const element = __importStar(require("./element")); | ||
exports.describeDoc = describeDoc; | ||
const { isKeyEvent, isPasteEvent, isTypeEvent } = event; | ||
const { isKeyEvent, isPasteEvent, isSelectAllEvent, isTypeEvent } = event; | ||
exports.isKeyEvent = isKeyEvent; | ||
exports.isPasteEvent = isPasteEvent; | ||
exports.isSelectAllEvent = isSelectAllEvent; | ||
exports.isTypeEvent = isTypeEvent; | ||
@@ -46,2 +48,3 @@ const { compareAttributes, compareContent, compareDoc, matchDocSelector } = find; | ||
const webExports = { | ||
buildCssSelector: buildCssSelector_1.buildCssSelector, | ||
captureLogs: captureLogs_1.captureLogs, | ||
@@ -52,2 +55,3 @@ element, | ||
format, | ||
getAttributeValue: buildCssSelector_1.getAttributeValue, | ||
lang, | ||
@@ -54,0 +58,0 @@ Recorder: Recorder_1.Recorder, |
@@ -56,2 +56,75 @@ var qawolf = (function (exports) { | ||
var buildCssSelector = function (_a) { | ||
var attribute = _a.attribute, isClick = _a.isClick, target = _a.target; | ||
// find the closest element to the target with attribute | ||
var elementWithSelector = findAttribute(target, attribute); | ||
if (!elementWithSelector) { | ||
console.debug("qawolf: no css selector built. attribute not found on target or ancestors " + attribute, getXpath(target)); | ||
return undefined; | ||
} | ||
var attributeValue = elementWithSelector.attributeValue; | ||
var attributeSelector = "[" + attributeValue.attribute + "='" + attributeValue.value + "']"; | ||
if (elementWithSelector.element === target) { | ||
console.debug("qawolf: css selector built for target " + attributeSelector, getXpath(target)); | ||
return attributeSelector; | ||
} | ||
var descendantSelector = buildDescendantSelector(target, elementWithSelector.element, isClick); | ||
var targetSelector = "" + attributeSelector + descendantSelector; | ||
console.debug("qawolf: css selector built for ancestor " + targetSelector, getXpath(target)); | ||
return targetSelector; | ||
}; | ||
var buildDescendantSelector = function (descendant, ancestor, isClick) { | ||
var inputElement = descendant; | ||
if (["checkbox", "radio"].includes(inputElement.type) && inputElement.value) { | ||
// Target the value for these input types | ||
return " [value='" + inputElement.value + "']"; | ||
} | ||
if (isClick) { | ||
return findClickableDescendantTag(descendant, ancestor); | ||
} | ||
if (descendant.contentEditable === "true") { | ||
return " [contenteditable='true']"; | ||
} | ||
return " " + descendant.tagName.toLowerCase(); | ||
}; | ||
var findAttribute = function (element, attribute) { | ||
var ancestor = element; | ||
while (ancestor) { | ||
var attributeValue = getAttributeValue(ancestor, attribute); | ||
if (attributeValue) { | ||
return { attributeValue: attributeValue, element: ancestor }; | ||
} | ||
ancestor = ancestor.parentElement; | ||
} | ||
return null; | ||
}; | ||
var findClickableDescendantTag = function (descendant, ancestor) { | ||
/** | ||
* Target common clickable descendant tags. | ||
* Ex. the DatePicker's date button | ||
*/ | ||
var parent = descendant; | ||
// stop when we hit the ancestor | ||
while (parent !== ancestor) { | ||
var tagName = parent.tagName.toLowerCase(); | ||
if (["a", "button", "input"].includes(tagName)) { | ||
return " " + tagName; | ||
} | ||
parent = parent.parentElement; | ||
} | ||
return ""; | ||
}; | ||
var getAttributeValue = function (element, attribute) { | ||
if (!attribute || !element.getAttribute) | ||
return null; | ||
var attributes = attribute.split(",").map(function (attr) { return attr.trim(); }); | ||
for (var _i = 0, attributes_1 = attributes; _i < attributes_1.length; _i++) { | ||
var attribute_1 = attributes_1[_i]; | ||
var value = element.getAttribute(attribute_1); | ||
if (value) | ||
return { attribute: attribute_1, value: value }; | ||
} | ||
return null; | ||
}; | ||
var LOG_LEVELS = ["debug", "error", "info", "log", "warn"]; | ||
@@ -98,7 +171,6 @@ var formatArgument = function (argument) { | ||
var getClickableAncestor = function (element, attribute) { | ||
var getClickableAncestor = function (element) { | ||
/** | ||
* Crawl up until we reach the top "clickable" ancestor. | ||
* If a target is the descendant of a clickable element with the attribute choose it. | ||
* If the target is the descendant of "a"/"button"/"input" choose it. | ||
* If the target is the descendant of "a"/"button"/"input" or a clickable element choose it. | ||
* Otherwise choose the original element as the target. | ||
@@ -109,34 +181,18 @@ */ | ||
while (ancestor.parentElement) { | ||
// choose the data value element as the clickable ancestor | ||
var attributeValue = getAttributeValue(ancestor, attribute); | ||
if (attributeValue) { | ||
console.debug("qawolf: found clickable ancestor: " + JSON.stringify(attributeValue) + "\"", getXpath(ancestor)); | ||
return ancestor; | ||
} | ||
// choose the common clickable element type as the clickable ancestor | ||
if (["a", "button", "input"].includes(ancestor.tagName.toLowerCase())) { | ||
// stop crawling when the ancestor is a good clickable tag | ||
console.debug("qawolf: found clickable ancestor: " + ancestor.tagName, getXpath(ancestor)); | ||
return ancestor; | ||
} | ||
// stop crawling at the first non-clickable element | ||
if (!isClickable(ancestor.parentElement, window.getComputedStyle(ancestor.parentElement))) { | ||
console.debug("qawolf: no clickable ancestor, use target", getXpath(element)); | ||
return element; | ||
// stop crawling at the first non-clickable element | ||
console.debug("qawolf: found clickable ancestor", getXpath(ancestor)); | ||
return ancestor; | ||
} | ||
ancestor = ancestor.parentElement; | ||
} | ||
// stop crawling at the root | ||
console.debug("qawolf: found clickable ancestor", getXpath(ancestor)); | ||
return ancestor; | ||
}; | ||
var getAttributeValue = function (element, attribute) { | ||
if (!attribute) | ||
return null; | ||
var attributes = attribute.split(",").map(function (attr) { return attr.trim(); }); | ||
for (var _i = 0, attributes_1 = attributes; _i < attributes_1.length; _i++) { | ||
var attribute_1 = attributes_1[_i]; | ||
var value = element.getAttribute(attribute_1); | ||
if (value) | ||
return { attribute: attribute_1, value: value }; | ||
} | ||
return null; | ||
}; | ||
var isClickable = function (element, computedStyle) { | ||
@@ -161,3 +217,2 @@ // assume it is clickable if the cursor is a pointer | ||
getClickableAncestor: getClickableAncestor, | ||
getAttributeValue: getAttributeValue, | ||
isClickable: isClickable, | ||
@@ -175,2 +230,5 @@ isVisible: isVisible | ||
}; | ||
var isSelectAllEvent = function (event) { | ||
return event && event.isTrusted && event.name === "selectall"; | ||
}; | ||
var isTypeEvent = function (event) { | ||
@@ -184,2 +242,3 @@ return isKeyEvent(event) || isPasteEvent(event); | ||
isPasteEvent: isPasteEvent, | ||
isSelectAllEvent: isSelectAllEvent, | ||
isTypeEvent: isTypeEvent | ||
@@ -333,2 +392,13 @@ }); | ||
var __assign = function() { | ||
__assign = Object.assign || function __assign(t) { | ||
for (var s, i = 1, n = arguments.length; i < n; i++) { | ||
s = arguments[i]; | ||
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; | ||
} | ||
return t; | ||
}; | ||
return __assign.apply(this, arguments); | ||
}; | ||
function __awaiter(thisArg, _arguments, P, generator) { | ||
@@ -463,5 +533,10 @@ return new (P || (P = Promise))(function (resolve, reject) { | ||
} | ||
// strip single quotes so the description is valid javascript | ||
description = description.replace("'", ""); | ||
// remove invisible characters which look like an empty string but have a length | ||
// https://www.w3resource.com/javascript-exercises/javascript-string-exercise-32.php | ||
// https://stackoverflow.com/a/21797208/230462 | ||
description = description.replace(/[^\x20-\x7E]/g, ""); | ||
if (description.length) { | ||
// strip single quotes so the description is valid javascript | ||
return (" \"" + description + "\"").replace("'", ""); | ||
return " \"" + description + "\""; | ||
} | ||
@@ -981,19 +1056,28 @@ return ""; | ||
}; | ||
Recorder.prototype.recordEvent = function (eventName, handler) { | ||
var _this = this; | ||
this.listen(eventName, function (ev) { | ||
var event = handler(ev); | ||
if (!event) | ||
return; | ||
console.debug("qawolf: Recorder " + _this._id + ": " + eventName + " event", ev, ev.target, "recorded:", event); | ||
_this._sendEvent(event); | ||
}); | ||
Recorder.prototype.sendEvent = function (eventName, event, value) { | ||
var target = event.target; | ||
var elementEvent = { | ||
cssSelector: buildCssSelector({ | ||
attribute: this._attribute, | ||
isClick: eventName === "click" || eventName === "mousedown", | ||
target: target | ||
}), | ||
isTrusted: event.isTrusted, | ||
name: eventName, | ||
page: this._pageIndex, | ||
target: nodeToDocSelector(target), | ||
time: Date.now(), | ||
value: value | ||
}; | ||
console.debug("qawolf: Recorder " + this._id + ": " + eventName + " event", event, event.target, "recorded:", elementEvent); | ||
this._sendEvent(elementEvent); | ||
}; | ||
Recorder.prototype.recordEvents = function () { | ||
var _this = this; | ||
this.recordEvent("click", function (event) { | ||
// getClickableAncestor chooses the ancestor if it has a data-attribute | ||
// which is very likely the target we want to click on. | ||
// If there is not a data-attribute on any of the clickable ancestors | ||
// it will take the top most clickable ancestor. | ||
this.listen("mousedown", function (event) { | ||
// only the main button (not right clicks/etc) | ||
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button | ||
if (event.button !== 0) | ||
return; | ||
// getClickableAncestor chooses the top most clickable ancestor. | ||
// The ancestor is likely a better target than the descendant. | ||
@@ -1003,53 +1087,42 @@ // Ex. when you click on the i (button > i) or rect (a > svg > rect) | ||
// XXX if anyone runs into issues with this behavior we can allow disabling it from a flag. | ||
var target = getClickableAncestor(event.target, _this._attribute); | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "click", | ||
page: _this._pageIndex, | ||
target: nodeToDocSelector(target), | ||
time: Date.now() | ||
}; | ||
var target = getClickableAncestor(event.target); | ||
_this.sendEvent("mousedown", __assign(__assign({}, event), { target: target })); | ||
}); | ||
this.recordEvent("input", function (event) { | ||
var element = event.target; | ||
this.listen("click", function (event) { | ||
if (event.button !== 0) | ||
return; | ||
var target = getClickableAncestor(event.target); | ||
_this.sendEvent("click", __assign(__assign({}, event), { target: target })); | ||
}); | ||
this.listen("input", function (event) { | ||
var target = event.target; | ||
// ignore input events not on selects | ||
if (element.tagName.toLowerCase() !== "select") | ||
// other input events are captured in click and type listeners | ||
if (target.tagName.toLowerCase() !== "select") | ||
return; | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "input", | ||
page: _this._pageIndex, | ||
target: nodeToDocSelector(element), | ||
time: Date.now(), | ||
value: element.value | ||
}; | ||
_this.sendEvent("input", event, target.value); | ||
}); | ||
this.recordEvent("keydown", function (event) { return ({ | ||
isTrusted: event.isTrusted, | ||
name: "keydown", | ||
page: _this._pageIndex, | ||
target: nodeToDocSelector(event.target), | ||
time: Date.now(), | ||
value: event.key | ||
}); }); | ||
this.recordEvent("keyup", function (event) { return ({ | ||
isTrusted: event.isTrusted, | ||
name: "keyup", | ||
page: _this._pageIndex, | ||
target: nodeToDocSelector(event.target), | ||
time: Date.now(), | ||
value: event.key | ||
}); }); | ||
this.recordEvent("paste", function (event) { | ||
this.listen("keydown", function (event) { | ||
_this.sendEvent("keydown", event, event.key); | ||
}); | ||
this.listen("keyup", function (event) { | ||
_this.sendEvent("keyup", event, event.key); | ||
}); | ||
this.listen("paste", function (event) { | ||
if (!event.clipboardData) | ||
return; | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "paste", | ||
page: _this._pageIndex, | ||
target: nodeToDocSelector(event.target), | ||
time: Date.now(), | ||
value: event.clipboardData.getData("text") | ||
}; | ||
var value = event.clipboardData.getData("text"); | ||
_this.sendEvent("paste", event, value); | ||
}); | ||
// XXX select only supports input/textarea | ||
// We can combine selectstart/mouseup to support content editables | ||
this.listen("select", function (event) { | ||
var target = event.target; | ||
if (target.selectionStart !== 0 || | ||
target.selectionEnd !== target.value.length) { | ||
// Only record select all, not other selection events | ||
return; | ||
} | ||
_this.sendEvent("selectall", event); | ||
}); | ||
this.recordScrollEvent(); | ||
@@ -1063,3 +1136,3 @@ }; | ||
// because it fires after the element.scrollLeft & element.scrollTop are updated | ||
this.recordEvent("scroll", function (event) { | ||
this.listen("scroll", function (event) { | ||
if (!lastWheelEvent || event.timeStamp - lastWheelEvent.timeStamp > 100) { | ||
@@ -1073,18 +1146,12 @@ // We record mouse wheel initiated scrolls only | ||
} | ||
var element = event.target; | ||
var target = event.target; | ||
if (event.target === document || event.target === document.body) { | ||
element = (document.scrollingElement || | ||
target = (document.scrollingElement || | ||
document.documentElement); | ||
} | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "scroll", | ||
page: _this._pageIndex, | ||
target: nodeToDocSelector(element), | ||
time: Date.now(), | ||
value: { | ||
x: element.scrollLeft, | ||
y: element.scrollTop | ||
} | ||
var value = { | ||
x: target.scrollLeft, | ||
y: target.scrollTop | ||
}; | ||
_this.sendEvent("scroll", __assign(__assign({}, event), { target: target }), value); | ||
}); | ||
@@ -1183,3 +1250,3 @@ }; | ||
var describeDoc$1 = describeDoc; | ||
var isKeyEvent$1 = isKeyEvent, isPasteEvent$1 = isPasteEvent, isTypeEvent$1 = isTypeEvent; | ||
var isKeyEvent$1 = isKeyEvent, isPasteEvent$1 = isPasteEvent, isSelectAllEvent$1 = isSelectAllEvent, isTypeEvent$1 = isTypeEvent; | ||
var compareAttributes$1 = compareAttributes, compareContent$1 = compareContent, compareDoc$1 = compareDoc, matchDocSelector$1 = matchDocSelector; | ||
@@ -1191,2 +1258,3 @@ var decodeHtml$1 = decodeHtml, isNil$1 = isNil; | ||
var webExports = { | ||
buildCssSelector: buildCssSelector, | ||
captureLogs: captureLogs, | ||
@@ -1197,2 +1265,3 @@ element: element, | ||
format: format, | ||
getAttributeValue: getAttributeValue, | ||
lang: lang, | ||
@@ -1219,2 +1288,3 @@ Recorder: Recorder, | ||
exports.isPasteEvent = isPasteEvent$1; | ||
exports.isSelectAllEvent = isSelectAllEvent$1; | ||
exports.isTypeEvent = isTypeEvent$1; | ||
@@ -1221,0 +1291,0 @@ exports.matchDocSelector = matchDocSelector$1; |
@@ -12,3 +12,3 @@ import * as types from "@qawolf/types"; | ||
private listen; | ||
private recordEvent; | ||
private sendEvent; | ||
private recordEvents; | ||
@@ -15,0 +15,0 @@ private recordScrollEvent; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
const buildCssSelector_1 = require("./buildCssSelector"); | ||
const element_1 = require("./element"); | ||
@@ -26,63 +27,59 @@ const serialize_1 = require("./serialize"); | ||
} | ||
recordEvent(eventName, handler) { | ||
this.listen(eventName, (ev) => { | ||
const event = handler(ev); | ||
if (!event) | ||
return; | ||
console.debug(`qawolf: Recorder ${this._id}: ${eventName} event`, ev, ev.target, "recorded:", event); | ||
this._sendEvent(event); | ||
}); | ||
sendEvent(eventName, event, value) { | ||
const target = event.target; | ||
const elementEvent = { | ||
cssSelector: buildCssSelector_1.buildCssSelector({ | ||
attribute: this._attribute, | ||
isClick: eventName === "click" || eventName === "mousedown", | ||
target | ||
}), | ||
isTrusted: event.isTrusted, | ||
name: eventName, | ||
page: this._pageIndex, | ||
target: serialize_1.nodeToDocSelector(target), | ||
time: Date.now(), | ||
value | ||
}; | ||
console.debug(`qawolf: Recorder ${this._id}: ${eventName} event`, event, event.target, "recorded:", elementEvent); | ||
this._sendEvent(elementEvent); | ||
} | ||
recordEvents() { | ||
this.recordEvent("click", event => { | ||
const target = element_1.getClickableAncestor(event.target, this._attribute); | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "click", | ||
page: this._pageIndex, | ||
target: serialize_1.nodeToDocSelector(target), | ||
time: Date.now() | ||
}; | ||
this.listen("mousedown", event => { | ||
if (event.button !== 0) | ||
return; | ||
const target = element_1.getClickableAncestor(event.target); | ||
this.sendEvent("mousedown", Object.assign(Object.assign({}, event), { target })); | ||
}); | ||
this.recordEvent("input", event => { | ||
const element = event.target; | ||
if (element.tagName.toLowerCase() !== "select") | ||
this.listen("click", event => { | ||
if (event.button !== 0) | ||
return; | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "input", | ||
page: this._pageIndex, | ||
target: serialize_1.nodeToDocSelector(element), | ||
time: Date.now(), | ||
value: element.value | ||
}; | ||
const target = element_1.getClickableAncestor(event.target); | ||
this.sendEvent("click", Object.assign(Object.assign({}, event), { target })); | ||
}); | ||
this.recordEvent("keydown", event => ({ | ||
isTrusted: event.isTrusted, | ||
name: "keydown", | ||
page: this._pageIndex, | ||
target: serialize_1.nodeToDocSelector(event.target), | ||
time: Date.now(), | ||
value: event.key | ||
})); | ||
this.recordEvent("keyup", event => ({ | ||
isTrusted: event.isTrusted, | ||
name: "keyup", | ||
page: this._pageIndex, | ||
target: serialize_1.nodeToDocSelector(event.target), | ||
time: Date.now(), | ||
value: event.key | ||
})); | ||
this.recordEvent("paste", event => { | ||
this.listen("input", event => { | ||
const target = event.target; | ||
if (target.tagName.toLowerCase() !== "select") | ||
return; | ||
this.sendEvent("input", event, target.value); | ||
}); | ||
this.listen("keydown", event => { | ||
this.sendEvent("keydown", event, event.key); | ||
}); | ||
this.listen("keyup", event => { | ||
this.sendEvent("keyup", event, event.key); | ||
}); | ||
this.listen("paste", event => { | ||
if (!event.clipboardData) | ||
return; | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "paste", | ||
page: this._pageIndex, | ||
target: serialize_1.nodeToDocSelector(event.target), | ||
time: Date.now(), | ||
value: event.clipboardData.getData("text") | ||
}; | ||
const value = event.clipboardData.getData("text"); | ||
this.sendEvent("paste", event, value); | ||
}); | ||
this.listen("select", event => { | ||
const target = event.target; | ||
if (target.selectionStart !== 0 || | ||
target.selectionEnd !== target.value.length) { | ||
return; | ||
} | ||
this.sendEvent("selectall", event); | ||
}); | ||
this.recordScrollEvent(); | ||
@@ -93,3 +90,3 @@ } | ||
this.listen("wheel", ev => (lastWheelEvent = ev)); | ||
this.recordEvent("scroll", event => { | ||
this.listen("scroll", event => { | ||
if (!lastWheelEvent || event.timeStamp - lastWheelEvent.timeStamp > 100) { | ||
@@ -99,18 +96,12 @@ console.debug("qawolf: ignore non-wheel scroll event", event); | ||
} | ||
let element = event.target; | ||
let target = event.target; | ||
if (event.target === document || event.target === document.body) { | ||
element = (document.scrollingElement || | ||
target = (document.scrollingElement || | ||
document.documentElement); | ||
} | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "scroll", | ||
page: this._pageIndex, | ||
target: serialize_1.nodeToDocSelector(element), | ||
time: Date.now(), | ||
value: { | ||
x: element.scrollLeft, | ||
y: element.scrollTop | ||
} | ||
const value = { | ||
x: target.scrollLeft, | ||
y: target.scrollTop | ||
}; | ||
this.sendEvent("scroll", Object.assign(Object.assign({}, event), { target }), value); | ||
}); | ||
@@ -117,0 +108,0 @@ } |
{ | ||
"name": "@qawolf/web", | ||
"description": "qawolf web library", | ||
"version": "0.9.2", | ||
"version": "0.9.3-alpha.0", | ||
"license": "BSD-3.0", | ||
@@ -30,3 +30,3 @@ "main": "./lib/index.js", | ||
"devDependencies": { | ||
"@qawolf/types": "0.9.2", | ||
"@qawolf/types": "0.9.3-alpha.0", | ||
"rollup": "^1.23.1", | ||
@@ -37,3 +37,3 @@ "rollup-plugin-commonjs": "^10.1.0", | ||
}, | ||
"gitHead": "c9e483b27df4203243b4604e8268c0885c612e7e" | ||
"gitHead": "4a729506a9cbe8fe126682d77000727472a440ba" | ||
} |
import { getXpath } from "./xpath"; | ||
interface AttributeValuePair { | ||
attribute: string; | ||
value: string; | ||
} | ||
export const getClickableAncestor = ( | ||
element: HTMLElement, | ||
attribute: string | ||
): HTMLElement => { | ||
export const getClickableAncestor = (element: HTMLElement): HTMLElement => { | ||
/** | ||
* Crawl up until we reach the top "clickable" ancestor. | ||
* If a target is the descendant of a clickable element with the attribute choose it. | ||
* If the target is the descendant of "a"/"button"/"input" choose it. | ||
* If the target is the descendant of "a"/"button"/"input" or a clickable element choose it. | ||
* Otherwise choose the original element as the target. | ||
@@ -22,15 +13,4 @@ */ | ||
while (ancestor.parentElement) { | ||
// choose the data value element as the clickable ancestor | ||
const attributeValue = getAttributeValue(ancestor, attribute); | ||
if (attributeValue) { | ||
console.debug( | ||
`qawolf: found clickable ancestor: ${JSON.stringify(attributeValue)}"`, | ||
getXpath(ancestor) | ||
); | ||
return ancestor; | ||
} | ||
// choose the common clickable element type as the clickable ancestor | ||
if (["a", "button", "input"].includes(ancestor.tagName.toLowerCase())) { | ||
// stop crawling when the ancestor is a good clickable tag | ||
console.debug( | ||
@@ -43,3 +23,2 @@ `qawolf: found clickable ancestor: ${ancestor.tagName}`, | ||
// stop crawling at the first non-clickable element | ||
if ( | ||
@@ -51,7 +30,5 @@ !isClickable( | ||
) { | ||
console.debug( | ||
"qawolf: no clickable ancestor, use target", | ||
getXpath(element) | ||
); | ||
return element; | ||
// stop crawling at the first non-clickable element | ||
console.debug("qawolf: found clickable ancestor", getXpath(ancestor)); | ||
return ancestor; | ||
} | ||
@@ -62,20 +39,8 @@ | ||
// stop crawling at the root | ||
console.debug("qawolf: found clickable ancestor", getXpath(ancestor)); | ||
return ancestor; | ||
}; | ||
export const getAttributeValue = ( | ||
element: HTMLElement, | ||
attribute: string | ||
): AttributeValuePair | null => { | ||
if (!attribute) return null; | ||
const attributes = attribute.split(",").map(attr => attr.trim()); | ||
for (let attribute of attributes) { | ||
const value = element.getAttribute(attribute); | ||
if (value) return { attribute, value }; | ||
} | ||
return null; | ||
}; | ||
export const isClickable = ( | ||
@@ -82,0 +47,0 @@ element: HTMLElement, |
@@ -11,3 +11,6 @@ import { ElementEvent } from "@qawolf/types"; | ||
export const isSelectAllEvent = (event: ElementEvent | null) => | ||
event && event.isTrusted && event.name === "selectall"; | ||
export const isTypeEvent = (event: ElementEvent | null) => | ||
isKeyEvent(event) || isPasteEvent(event); |
@@ -10,3 +10,3 @@ import { DocSelector } from "@qawolf/types"; | ||
// ex. "departure date" | ||
let description = | ||
let description: string = | ||
attrs.labels || | ||
@@ -26,5 +26,12 @@ attrs.name || | ||
// strip single quotes so the description is valid javascript | ||
description = description.replace("'", ""); | ||
// remove invisible characters which look like an empty string but have a length | ||
// https://www.w3resource.com/javascript-exercises/javascript-string-exercise-32.php | ||
// https://stackoverflow.com/a/21797208/230462 | ||
description = description.replace(/[^\x20-\x7E]/g, ""); | ||
if (description.length) { | ||
// strip single quotes so the description is valid javascript | ||
return ` "${description}"`.replace("'", ""); | ||
return ` "${description}"`; | ||
} | ||
@@ -31,0 +38,0 @@ |
@@ -0,1 +1,2 @@ | ||
import { buildCssSelector, getAttributeValue } from "./buildCssSelector"; | ||
import { captureLogs } from "./captureLogs"; | ||
@@ -16,3 +17,3 @@ import * as element from "./element"; | ||
const { describeDoc } = format; | ||
const { isKeyEvent, isPasteEvent, isTypeEvent } = event; | ||
const { isKeyEvent, isPasteEvent, isSelectAllEvent, isTypeEvent } = event; | ||
const { | ||
@@ -37,2 +38,3 @@ compareAttributes, | ||
isPasteEvent, | ||
isSelectAllEvent, | ||
isTypeEvent, | ||
@@ -48,2 +50,3 @@ matchDocSelector, | ||
const webExports = { | ||
buildCssSelector, | ||
captureLogs, | ||
@@ -54,2 +57,3 @@ element, | ||
format, | ||
getAttributeValue, | ||
lang, | ||
@@ -56,0 +60,0 @@ Recorder, |
import * as types from "@qawolf/types"; | ||
import { buildCssSelector } from "./buildCssSelector"; | ||
import { getClickableAncestor } from "./element"; | ||
@@ -43,27 +44,40 @@ import { nodeToDocSelector } from "./serialize"; | ||
private recordEvent<K extends keyof DocumentEventMap>( | ||
eventName: K, | ||
handler: (ev: DocumentEventMap[K]) => types.ElementEvent | undefined | ||
private sendEvent<K extends keyof DocumentEventMap>( | ||
eventName: types.ElementEventName, | ||
event: DocumentEventMap[K], | ||
value?: string | types.ScrollValue | null | ||
) { | ||
this.listen(eventName, (ev: DocumentEventMap[K]) => { | ||
const event = handler(ev); | ||
if (!event) return; | ||
const target = event.target as HTMLElement; | ||
console.debug( | ||
`qawolf: Recorder ${this._id}: ${eventName} event`, | ||
ev, | ||
ev.target, | ||
"recorded:", | ||
event | ||
); | ||
this._sendEvent(event); | ||
}); | ||
const elementEvent = { | ||
cssSelector: buildCssSelector({ | ||
attribute: this._attribute, | ||
isClick: eventName === "click" || eventName === "mousedown", | ||
target | ||
}), | ||
isTrusted: event.isTrusted, | ||
name: eventName, | ||
page: this._pageIndex, | ||
target: nodeToDocSelector(target), | ||
time: Date.now(), | ||
value | ||
}; | ||
console.debug( | ||
`qawolf: Recorder ${this._id}: ${eventName} event`, | ||
event, | ||
event.target, | ||
"recorded:", | ||
elementEvent | ||
); | ||
this._sendEvent(elementEvent); | ||
} | ||
private recordEvents() { | ||
this.recordEvent("click", event => { | ||
// getClickableAncestor chooses the ancestor if it has a data-attribute | ||
// which is very likely the target we want to click on. | ||
// If there is not a data-attribute on any of the clickable ancestors | ||
// it will take the top most clickable ancestor. | ||
this.listen("mousedown", event => { | ||
// only the main button (not right clicks/etc) | ||
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button | ||
if (event.button !== 0) return; | ||
// getClickableAncestor chooses the top most clickable ancestor. | ||
// The ancestor is likely a better target than the descendant. | ||
@@ -73,63 +87,53 @@ // Ex. when you click on the i (button > i) or rect (a > svg > rect) | ||
// XXX if anyone runs into issues with this behavior we can allow disabling it from a flag. | ||
const target = getClickableAncestor( | ||
event.target as HTMLElement, | ||
this._attribute | ||
); | ||
const target = getClickableAncestor(event.target as HTMLElement); | ||
this.sendEvent("mousedown", { ...event, target }); | ||
}); | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "click", | ||
page: this._pageIndex, | ||
target: nodeToDocSelector(target), | ||
time: Date.now() | ||
}; | ||
this.listen("click", event => { | ||
if (event.button !== 0) return; | ||
const target = getClickableAncestor(event.target as HTMLElement); | ||
this.sendEvent("click", { ...event, target }); | ||
}); | ||
this.recordEvent("input", event => { | ||
const element = event.target as HTMLInputElement; | ||
this.listen("input", event => { | ||
const target = event.target as HTMLInputElement; | ||
// ignore input events not on selects | ||
if (element.tagName.toLowerCase() !== "select") return; | ||
// other input events are captured in click and type listeners | ||
if (target.tagName.toLowerCase() !== "select") return; | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "input", | ||
page: this._pageIndex, | ||
target: nodeToDocSelector(element), | ||
time: Date.now(), | ||
value: element.value | ||
}; | ||
this.sendEvent("input", event, target.value); | ||
}); | ||
this.recordEvent("keydown", event => ({ | ||
isTrusted: event.isTrusted, | ||
name: "keydown", | ||
page: this._pageIndex, | ||
target: nodeToDocSelector(event.target as HTMLElement), | ||
time: Date.now(), | ||
value: event.key | ||
})); | ||
this.listen("keydown", event => { | ||
this.sendEvent("keydown", event, event.key); | ||
}); | ||
this.recordEvent("keyup", event => ({ | ||
isTrusted: event.isTrusted, | ||
name: "keyup", | ||
page: this._pageIndex, | ||
target: nodeToDocSelector(event.target as HTMLElement), | ||
time: Date.now(), | ||
value: event.key | ||
})); | ||
this.listen("keyup", event => { | ||
this.sendEvent("keyup", event, event.key); | ||
}); | ||
this.recordEvent("paste", event => { | ||
this.listen("paste", event => { | ||
if (!event.clipboardData) return; | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "paste", | ||
page: this._pageIndex, | ||
target: nodeToDocSelector(event.target as HTMLElement), | ||
time: Date.now(), | ||
value: event.clipboardData.getData("text") | ||
}; | ||
const value = event.clipboardData.getData("text"); | ||
this.sendEvent("paste", event, value); | ||
}); | ||
// XXX select only supports input/textarea | ||
// We can combine selectstart/mouseup to support content editables | ||
this.listen("select", event => { | ||
const target = event.target as HTMLInputElement; | ||
if ( | ||
target.selectionStart !== 0 || | ||
target.selectionEnd !== target.value.length | ||
) { | ||
// Only record select all, not other selection events | ||
return; | ||
} | ||
this.sendEvent("selectall", event); | ||
}); | ||
this.recordScrollEvent(); | ||
@@ -144,3 +148,3 @@ } | ||
// because it fires after the element.scrollLeft & element.scrollTop are updated | ||
this.recordEvent("scroll", event => { | ||
this.listen("scroll", event => { | ||
if (!lastWheelEvent || event.timeStamp - lastWheelEvent.timeStamp > 100) { | ||
@@ -155,21 +159,16 @@ // We record mouse wheel initiated scrolls only | ||
let element = event.target as HTMLElement; | ||
let target = event.target as HTMLElement; | ||
if (event.target === document || event.target === document.body) { | ||
element = (document.scrollingElement || | ||
target = (document.scrollingElement || | ||
document.documentElement) as HTMLElement; | ||
} | ||
return { | ||
isTrusted: event.isTrusted, | ||
name: "scroll", | ||
page: this._pageIndex, | ||
target: nodeToDocSelector(element), | ||
time: Date.now(), | ||
value: { | ||
x: element.scrollLeft, | ||
y: element.scrollTop | ||
} | ||
const value = { | ||
x: target.scrollLeft, | ||
y: target.scrollTop | ||
}; | ||
this.sendEvent("scroll", { ...event, target }, value); | ||
}); | ||
} | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
161909
87
3354