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:
ShiKhu 2022-04-10 15:53:24 +02:00
parent c6a5ec4236
commit 89b63cae27
7 changed files with 101 additions and 74 deletions

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
"version": "3.5.4",
"version": "3.5.5",
"keywords": [
"logging",
"replay"

View file

@ -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]

View file

@ -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
}

View file

@ -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);

View file

@ -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);

View file

@ -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]);
}
});

View file

@ -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();
}