diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index 6df50033b..9c7920ad7 100644 --- a/tracker/tracker/package.json +++ b/tracker/tracker/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker", "description": "The OpenReplay tracker main package", - "version": "3.5.4", + "version": "3.5.5", "keywords": [ "logging", "replay" diff --git a/tracker/tracker/src/main/app/context.ts b/tracker/tracker/src/main/app/context.ts index 781f91ea8..fd7ec11dd 100644 --- a/tracker/tracker/src/main/app/context.ts +++ b/tracker/tracker/src/main/app/context.ts @@ -41,13 +41,13 @@ export function isInstance(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] diff --git a/tracker/tracker/src/main/app/index.ts b/tracker/tracker/src/main/app/index.ts index 59129cbec..de9518af6 100644 --- a/tracker/tracker/src/main/app/index.ts +++ b/tracker/tracker/src/main/app/index.ts @@ -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 } diff --git a/tracker/tracker/src/main/modules/input.ts b/tracker/tracker/src/main/modules/input.ts index ad8cda673..0cad3c58b 100644 --- a/tracker/tracker/src/main/modules/input.ts +++ b/tracker/tracker/src/main/modules/input.ts @@ -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): 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): 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); diff --git a/tracker/tracker/src/main/modules/mouse.ts b/tracker/tracker/src/main/modules/mouse.ts index 90cf17908..196a89653 100644 --- a/tracker/tracker/src/main/modules/mouse.ts +++ b/tracker/tracker/src/main/modules/mouse.ts @@ -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( - document.documentElement, + document.documentElement, 'mouseover', (e: MouseEvent): void => { const target = getTarget(e.target); diff --git a/tracker/tracker/src/main/modules/scroll.ts b/tracker/tracker/src/main/modules/scroll.ts index 0f54ba8f9..f9c80e6d9 100644 --- a/tracker/tracker/src/main/modules/scroll.ts +++ b/tracker/tracker/src/main/modules/scroll.ts @@ -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]); } }); diff --git a/tracker/tracker/src/webworker/index.ts b/tracker/tracker/src/webworker/index.ts index cf0d1586a..f598ac0a4 100644 --- a/tracker/tracker/src/webworker/index.ts +++ b/tracker/tracker/src/webworker/index.ts @@ -31,42 +31,18 @@ let sendIntervalID: ReturnType | null = null; const sendQueue: Array = []; 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(); }