feat-fix(tracker):3.5.5
* Fix behaviour when initialised inside iframe * Capture select element changes * Retry /i when response is not 200 (and not 401) * Capture initial element scroll on start * Fallback Label vallue to name, id or class
This commit is contained in:
parent
c6a5ec4236
commit
89b63cae27
7 changed files with 101 additions and 74 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "3.5.4",
|
||||
"version": "3.5.5",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@ export function isInstance<T extends WindowConstructor>(node: Node, constr: Cons
|
|||
// @ts-ignore (for EI, Safary)
|
||||
doc.parentWindow ||
|
||||
doc.defaultView; // TODO: smart global typing for Window object
|
||||
while((context.parent || context.top) && context.parent !== context) {
|
||||
while(context !== window) {
|
||||
// @ts-ignore
|
||||
if (node instanceof context[constr.name]) {
|
||||
return true
|
||||
}
|
||||
// @ts-ignore
|
||||
context = context.parent || context.top
|
||||
context = context.parent || window
|
||||
}
|
||||
// @ts-ignore
|
||||
return node instanceof context[constr.name]
|
||||
|
|
|
|||
|
|
@ -234,10 +234,11 @@ export default class App {
|
|||
);
|
||||
}
|
||||
|
||||
// TODO: full correct semantic
|
||||
checkRequiredVersion(version: string): boolean {
|
||||
const reqVer = version.split('.')
|
||||
const ver = this.version.split('.')
|
||||
for (let i = 0; i < ver.length; i++) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (Number(ver[i]) < Number(reqVer[i]) || isNaN(Number(ver[i])) || isNaN(Number(reqVer[i]))) {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { normSpaces, IN_BROWSER, getLabelAttribute, hasOpenreplayAttribute } from "../utils.js";
|
||||
import {
|
||||
normSpaces,
|
||||
IN_BROWSER,
|
||||
getLabelAttribute,
|
||||
hasOpenreplayAttribute,
|
||||
} from "../utils.js";
|
||||
import App from "../app/index.js";
|
||||
import { SetInputTarget, SetInputValue, SetInputChecked } from "../../messages/index.js";
|
||||
|
||||
|
|
@ -31,14 +36,14 @@ function isCheckable(node: any): node is HTMLInputElement {
|
|||
}
|
||||
|
||||
const labelElementFor: (
|
||||
node: TextEditableElement,
|
||||
element: TextEditableElement,
|
||||
) => HTMLLabelElement | undefined =
|
||||
IN_BROWSER && 'labels' in HTMLInputElement.prototype
|
||||
? (node): HTMLLabelElement | undefined => {
|
||||
? (node) => {
|
||||
let p: Node | null = node;
|
||||
while ((p = p.parentNode) !== null) {
|
||||
if (p.nodeName === 'LABEL') {
|
||||
return p as HTMLLabelElement;
|
||||
if (p instanceof HTMLLabelElement) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
const labels = node.labels;
|
||||
|
|
@ -46,10 +51,10 @@ const labelElementFor: (
|
|||
return labels[0];
|
||||
}
|
||||
}
|
||||
: (node): HTMLLabelElement | undefined => {
|
||||
: (node) => {
|
||||
let p: Node | null = node;
|
||||
while ((p = p.parentNode) !== null) {
|
||||
if (p.nodeName === 'LABEL') {
|
||||
if (p instanceof HTMLLabelElement) {
|
||||
return p as HTMLLabelElement;
|
||||
}
|
||||
}
|
||||
|
|
@ -66,10 +71,12 @@ export function getInputLabel(node: TextEditableElement): string {
|
|||
let label = getLabelAttribute(node);
|
||||
if (label === null) {
|
||||
const labelElement = labelElementFor(node);
|
||||
label =
|
||||
labelElement === undefined
|
||||
? node.placeholder || node.name
|
||||
: labelElement.innerText;
|
||||
label = (labelElement && labelElement.innerText)
|
||||
|| node.placeholder
|
||||
|| node.name
|
||||
|| node.id
|
||||
|| node.className
|
||||
|| node.type
|
||||
}
|
||||
return normSpaces(label).slice(0, 100);
|
||||
}
|
||||
|
|
@ -101,7 +108,7 @@ export default function (app: App, opts: Partial<Options>): void {
|
|||
app.send(new SetInputTarget(id, label));
|
||||
}
|
||||
}
|
||||
function sendInputValue(id: number, node: TextEditableElement): void {
|
||||
function sendInputValue(id: number, node: TextEditableElement | HTMLSelectElement): void {
|
||||
let value = node.value;
|
||||
let inputMode: InputMode = options.defaultInputMode;
|
||||
if (node.type === 'password' || hasOpenreplayAttribute(node, 'hidden')) {
|
||||
|
|
@ -175,6 +182,13 @@ export default function (app: App, opts: Partial<Options>): void {
|
|||
if (id === undefined) {
|
||||
return;
|
||||
}
|
||||
// TODO: support multiple select (?): use selectedOptions; Need send target?
|
||||
if (node instanceof HTMLSelectElement) {
|
||||
sendInputValue(id, node)
|
||||
app.attachEventListener(node, "change", () => {
|
||||
sendInputValue(id, node)
|
||||
})
|
||||
}
|
||||
if (isTextEditable(node)) {
|
||||
inputValues.set(id, node.value);
|
||||
sendInputValue(id, node);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { normSpaces, hasOpenreplayAttribute, getLabelAttribute } from "../utils.js";
|
||||
import {
|
||||
normSpaces,
|
||||
hasOpenreplayAttribute,
|
||||
getLabelAttribute,
|
||||
} from "../utils.js";
|
||||
import App from "../app/index.js";
|
||||
import { MouseMove, MouseClick } from "../../messages/index.js";
|
||||
import { getInputLabel } from "./input.js";
|
||||
|
|
@ -24,6 +28,18 @@ function _getSelector(target: Element): string {
|
|||
return selector
|
||||
}
|
||||
|
||||
function isClickable(element: Element): boolean {
|
||||
const tag = element.tagName.toUpperCase()
|
||||
return tag === 'BUTTON' ||
|
||||
tag === 'A' ||
|
||||
tag === 'LI' ||
|
||||
tag === 'SELECT' ||
|
||||
(element as HTMLElement).onclick != null ||
|
||||
element.getAttribute('role') === 'button'
|
||||
//|| element.className.includes("btn")
|
||||
// MBTODO: intersect addEventListener
|
||||
}
|
||||
|
||||
//TODO: fix (typescript doesn't allow work when the guard is inside the function)
|
||||
function getTarget(target: EventTarget | null): Element | null {
|
||||
if (target instanceof Element) {
|
||||
|
|
@ -56,13 +72,7 @@ function _getTarget(target: Element): Element | null {
|
|||
if (tag === 'INPUT') {
|
||||
return element;
|
||||
}
|
||||
if (
|
||||
tag === 'BUTTON' ||
|
||||
tag === 'A' ||
|
||||
tag === 'LI' ||
|
||||
tag === 'SELECT' ||
|
||||
(element as HTMLElement).onclick != null ||
|
||||
element.getAttribute('role') === 'button' ||
|
||||
if (isClickable(element) ||
|
||||
getLabelAttribute(element) !== null
|
||||
) {
|
||||
return element;
|
||||
|
|
@ -83,19 +93,16 @@ export default function (app: App): void {
|
|||
if (dl !== null) {
|
||||
return dl;
|
||||
}
|
||||
const tag = target.tagName.toUpperCase();
|
||||
if (tag === 'INPUT') {
|
||||
return getInputLabel(target as HTMLInputElement)
|
||||
if (target instanceof HTMLInputElement) {
|
||||
return getInputLabel(target)
|
||||
}
|
||||
if (tag === 'BUTTON' ||
|
||||
tag === 'A' ||
|
||||
tag === 'LI' ||
|
||||
tag === 'SELECT' ||
|
||||
(target as HTMLElement).onclick != null ||
|
||||
target.getAttribute('role') === 'button'
|
||||
) {
|
||||
const label: string = app.sanitizer.getInnerTextSecure(target as HTMLElement);
|
||||
return normSpaces(label).slice(0, 100);
|
||||
if (isClickable(target)) {
|
||||
let label = ''
|
||||
if (target instanceof HTMLElement) {
|
||||
label = app.sanitizer.getInnerTextSecure(target)
|
||||
}
|
||||
label = label || target.id || target.className
|
||||
return normSpaces(label).slice(0, 100)
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
|
@ -126,7 +133,7 @@ export default function (app: App): void {
|
|||
}
|
||||
|
||||
app.attachEventListener(
|
||||
<HTMLElement>document.documentElement,
|
||||
document.documentElement,
|
||||
'mouseover',
|
||||
(e: MouseEvent): void => {
|
||||
const target = getTarget(e.target);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default function (app: App): void {
|
|||
),
|
||||
);
|
||||
|
||||
const sendSetNodeScroll = app.safe((s, node): void => {
|
||||
const sendSetNodeScroll = app.safe((s: [number, number], node: Node): void => {
|
||||
const id = app.nodes.getID(node);
|
||||
if (id !== undefined) {
|
||||
app.send(new SetNodeScroll(id, s[0], s[1]));
|
||||
|
|
@ -34,6 +34,12 @@ export default function (app: App): void {
|
|||
nodeScroll.clear();
|
||||
});
|
||||
|
||||
app.nodes.attachNodeCallback(node => {
|
||||
if (node instanceof Element && node.scrollLeft + node.scrollTop > 0) {
|
||||
nodeScroll.set(node, [node.scrollLeft, node.scrollTop]);
|
||||
}
|
||||
})
|
||||
|
||||
app.attachEventListener(window, 'scroll', (e: Event): void => {
|
||||
const target = e.target;
|
||||
if (target === document) {
|
||||
|
|
@ -41,9 +47,7 @@ export default function (app: App): void {
|
|||
return;
|
||||
}
|
||||
if (target instanceof Element) {
|
||||
{
|
||||
nodeScroll.set(target, [target.scrollLeft, target.scrollTop]);
|
||||
}
|
||||
nodeScroll.set(target, [target.scrollLeft, target.scrollTop]);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -31,42 +31,18 @@ let sendIntervalID: ReturnType<typeof setInterval> | null = null;
|
|||
const sendQueue: Array<Uint8Array> = [];
|
||||
let busy = false;
|
||||
let attemptsCount = 0;
|
||||
let ATTEMPT_TIMEOUT = 8000;
|
||||
let ATTEMPT_TIMEOUT = 3000;
|
||||
let MAX_ATTEMPTS_COUNT = 10;
|
||||
|
||||
// TODO?: exploit https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
|
||||
function sendBatch(batch: Uint8Array):void {
|
||||
const req = new XMLHttpRequest();
|
||||
const xhr = new XMLHttpRequest();
|
||||
// TODO: async=false (3d param) instead of sendQueue array ?
|
||||
req.open("POST", ingestPoint + "/v1/web/i", false); // TODO opaque request?
|
||||
req.setRequestHeader("Authorization", "Bearer " + token);
|
||||
// req.setRequestHeader("Content-Type", "");
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState === 4) {
|
||||
if (this.status == 0) {
|
||||
return; // happens simultaneously with onerror TODO: clear codeflow
|
||||
}
|
||||
if (this.status >= 400) { // TODO: test workflow. After 400+ it calls /start for some reason
|
||||
busy = false;
|
||||
reset();
|
||||
sendQueue.length = 0;
|
||||
if (this.status === 401) { // Unauthorised (Token expired)
|
||||
self.postMessage("restart")
|
||||
return
|
||||
}
|
||||
self.postMessage(null);
|
||||
return
|
||||
}
|
||||
//if (this.response == null)
|
||||
const nextBatch = sendQueue.shift();
|
||||
if (nextBatch) {
|
||||
sendBatch(nextBatch);
|
||||
} else {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
req.onerror = function(e) {
|
||||
xhr.open("POST", ingestPoint + "/v1/web/i", false);
|
||||
xhr.setRequestHeader("Authorization", "Bearer " + token);
|
||||
// xhr.setRequestHeader("Content-Type", "");
|
||||
|
||||
function retry() {
|
||||
if (attemptsCount >= MAX_ATTEMPTS_COUNT) {
|
||||
reset();
|
||||
self.postMessage(null);
|
||||
|
|
@ -75,8 +51,32 @@ function sendBatch(batch: Uint8Array):void {
|
|||
attemptsCount++;
|
||||
setTimeout(() => sendBatch(batch), ATTEMPT_TIMEOUT);
|
||||
}
|
||||
// TODO: handle offline exception
|
||||
req.send(batch.buffer);
|
||||
xhr.onreadystatechange = function() {
|
||||
if (this.readyState === 4) {
|
||||
if (this.status == 0) {
|
||||
return; // happens simultaneously with onerror TODO: clear codeflow
|
||||
}
|
||||
if (this.status === 401) { // Unauthorised (Token expired)
|
||||
busy = false
|
||||
self.postMessage("restart")
|
||||
return
|
||||
} else if (this.status >= 400) { // TODO: test workflow. After 400+ it calls /start for some reason
|
||||
retry()
|
||||
return
|
||||
}
|
||||
// Success
|
||||
attemptsCount = 0
|
||||
const nextBatch = sendQueue.shift();
|
||||
if (nextBatch) {
|
||||
sendBatch(nextBatch);
|
||||
} else {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.onerror = retry // TODO: when in Offline mode it doesn't handle the error
|
||||
// TODO: handle offline exception (?)
|
||||
xhr.send(batch.buffer);
|
||||
}
|
||||
|
||||
function send(): void {
|
||||
|
|
@ -101,6 +101,7 @@ function reset() {
|
|||
clearInterval(sendIntervalID);
|
||||
sendIntervalID = null;
|
||||
}
|
||||
sendQueue.length = 0;
|
||||
writer.reset();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue