diff --git a/.gitignore b/.gitignore index 9e8bee9d..e7c06c85 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ # Build results build/ +clarity.js +decode.js + +# npm packages +.rpt2_cache # npm packages node_modules diff --git a/decode/clarity.ts b/decode/clarity.ts new file mode 100644 index 00000000..29b1adcc --- /dev/null +++ b/decode/clarity.ts @@ -0,0 +1,225 @@ +import version from "../src/core/version"; +import { Event, Payload, Token } from "../types/data"; +import { MetricEvent, PageEvent, PingEvent, SummaryEvent, TagEvent, UploadEvent } from "../types/decode/data"; +import { DecodedEvent, DecodedPayload } from "../types/decode/decode"; +import { ImageErrorEvent, ScriptErrorEvent } from "../types/decode/diagnostic"; +import { InputChangeEvent, PointerEvent, ResizeEvent, ScrollEvent } from "../types/decode/interaction"; +import { SelectionEvent, UnloadEvent, VisibilityEvent } from "../types/decode/interaction"; +import { BoxModelEvent, DocumentEvent, DomEvent, HashEvent, ResourceEvent, TargetEvent } from "../types/decode/layout"; + +import * as data from "./data"; +import * as diagnostic from "./diagnostic"; +import * as interaction from "./interaction"; +import * as layout from "./layout"; +import * as r from "./render"; + +let pageId: string = null; + +export function decode(input: string): DecodedPayload { + let json: Payload = typeof input === "string" ? JSON.parse(input) : input; + let envelope = data.envelope(json.e); + let timestamp = Date.now(); + let payload: DecodedPayload = { timestamp, envelope }; + let encoded: Token[][] = json.d; + + if (payload.envelope.version !== version) { + throw new Error(`Invalid Clarity Version. Actual: ${payload.envelope.version} | Expected: ${version} | ${input.substr(0, 250)}`); + } + + /* Reset components before decoding to keep them stateless */ + data.reset(); + layout.reset(); + + for (let entry of encoded) { + data.summarize(entry); + switch (entry[1]) { + case Event.Page: + if (payload.page === undefined) { payload.page = []; } + payload.page.push(data.decode(entry) as PageEvent); + break; + case Event.Ping: + if (payload.ping === undefined) { payload.ping = []; } + payload.ping.push(data.decode(entry) as PingEvent); + break; + case Event.Tag: + if (payload.tag === undefined) { payload.tag = []; } + payload.tag.push(data.decode(entry) as TagEvent); + break; + case Event.Metric: + if (payload.metric === undefined) { payload.metric = []; } + payload.metric.push(data.decode(entry) as MetricEvent); + break; + case Event.Upload: + if (payload.upload === undefined) { payload.upload = []; } + payload.upload.push(data.decode(entry) as UploadEvent); + break; + case Event.MouseDown: + case Event.MouseUp: + case Event.MouseMove: + case Event.MouseWheel: + case Event.Click: + case Event.DoubleClick: + case Event.RightClick: + case Event.TouchStart: + case Event.TouchCancel: + case Event.TouchEnd: + case Event.TouchMove: + if (payload.pointer === undefined) { payload.pointer = []; } + payload.pointer.push(interaction.decode(entry) as PointerEvent); + break; + case Event.Scroll: + if (payload.scroll === undefined) { payload.scroll = []; } + payload.scroll.push(interaction.decode(entry) as ScrollEvent); + break; + case Event.Resize: + if (payload.resize === undefined) { payload.resize = []; } + payload.resize.push(interaction.decode(entry) as ResizeEvent); + break; + case Event.Selection: + if (payload.selection === undefined) { payload.selection = []; } + payload.selection.push(interaction.decode(entry) as SelectionEvent); + break; + case Event.InputChange: + if (payload.input === undefined) { payload.input = []; } + payload.input.push(interaction.decode(entry) as InputChangeEvent); + break; + case Event.Unload: + if (payload.unload === undefined) { payload.unload = []; } + payload.unload.push(interaction.decode(entry) as UnloadEvent); + break; + case Event.Visibility: + if (payload.visibility === undefined) { payload.visibility = []; } + payload.visibility.push(interaction.decode(entry) as VisibilityEvent); + break; + case Event.Target: + if (payload.target === undefined) { payload.target = []; } + payload.target.push(layout.decode(entry) as TargetEvent); + break; + case Event.BoxModel: + if (payload.boxmodel === undefined) { payload.boxmodel = []; } + payload.boxmodel.push(layout.decode(entry) as BoxModelEvent); + break; + case Event.Discover: + case Event.Mutation: + if (payload.dom === undefined) { payload.dom = []; } + payload.dom.push(layout.decode(entry) as DomEvent); + break; + case Event.Hash: + if (payload.hash === undefined) { payload.hash = []; } + payload.hash.push(layout.decode(entry) as HashEvent); + break; + case Event.Document: + if (payload.doc === undefined) { payload.doc = []; } + payload.doc.push(layout.decode(entry) as DocumentEvent); + break; + case Event.ScriptError: + if (payload.script === undefined) { payload.script = []; } + payload.script.push(diagnostic.decode(entry) as ScriptErrorEvent); + break; + case Event.ImageError: + if (payload.image === undefined) { payload.image = []; } + payload.image.push(diagnostic.decode(entry) as ImageErrorEvent); + break; + default: + console.error(`No handler for Event: ${JSON.stringify(entry)}`); + break; + } + } + + /* Enrich decoded payload with derived events */ + payload.summary = data.summary() as SummaryEvent[]; + if (payload.dom && payload.dom.length > 0) { payload.hash = layout.hash() as HashEvent[]; } + if (layout.resources.length > 0) { payload.resource = layout.resource() as ResourceEvent[]; } + + return payload; +} + +export function html(decoded: DecodedPayload): string { + let iframe = document.createElement("iframe"); + render(decoded, iframe); + return iframe.contentDocument.documentElement.outerHTML; +} + +export function render(decoded: DecodedPayload, iframe: HTMLIFrameElement, header?: HTMLElement): void { + // Reset rendering if we receive a new pageId + if (pageId !== decoded.envelope.pageId) { + pageId = decoded.envelope.pageId; + r.reset(); + } + + // Replay events + let events: DecodedEvent[] = []; + for (let key in decoded) { + if (Array.isArray(decoded[key])) { + events = events.concat(decoded[key]); + } + } + replay(events.sort(sort), iframe, header); +} + +export async function replay(events: DecodedEvent[], iframe: HTMLIFrameElement, header?: HTMLElement): Promise { + let start = events[0].time; + for (let entry of events) { + if (entry.time - start > 16) { start = await wait(entry.time); } + + switch (entry.event) { + case Event.Page: + let pageEvent = entry as PageEvent; + r.page(pageEvent.data, iframe); + break; + case Event.Metric: + let metricEvent = entry as MetricEvent; + if (header) { r.metric(metricEvent.data, header); } + break; + case Event.Discover: + case Event.Mutation: + let domEvent = entry as DomEvent; + r.markup(domEvent.data, iframe); + break; + case Event.BoxModel: + let boxModelEvent = entry as BoxModelEvent; + r.boxmodel(boxModelEvent.data, iframe); + break; + case Event.MouseDown: + case Event.MouseUp: + case Event.MouseMove: + case Event.MouseWheel: + case Event.Click: + case Event.DoubleClick: + case Event.RightClick: + case Event.TouchStart: + case Event.TouchCancel: + case Event.TouchEnd: + case Event.TouchMove: + let pointerEvent = entry as PointerEvent; + r.pointer(pointerEvent.event, pointerEvent.data, iframe); + break; + case Event.InputChange: + let changeEvent = entry as InputChangeEvent; + r.change(changeEvent.data, iframe); + break; + case Event.Selection: + let selectionEvent = entry as SelectionEvent; + r.selection(selectionEvent.data, iframe); + break; + case Event.Resize: + let resizeEvent = entry as ResizeEvent; + r.resize(resizeEvent.data, iframe); + break; + case Event.Scroll: + let scrollEvent = entry as ScrollEvent; + r.scroll(scrollEvent.data, iframe); + break; + } + } +} + +async function wait(timestamp: number): Promise { + return new Promise((resolve: FrameRequestCallback): void => { + setTimeout(resolve, 10, timestamp); + }); +} + +function sort(a: DecodedEvent, b: DecodedEvent): number { + return a.time - b.time; +} diff --git a/decode/data.ts b/decode/data.ts new file mode 100644 index 00000000..f4320a5d --- /dev/null +++ b/decode/data.ts @@ -0,0 +1,79 @@ +import { BooleanFlag, Envelope, Event, MetricData, PageData, PingData } from "../types/data"; +import { SummaryData, TagData, Token, Upload, UploadData } from "../types/data"; +import { DataEvent } from "../types/decode/data"; + +let summaries: { [key: number]: SummaryData[] } = null; +const SUMMARY_THRESHOLD = 30; + +export function reset(): void { + summaries = {}; +} + +export function decode(tokens: Token[]): DataEvent { + let time = tokens[0] as number; + let event = tokens[1] as Event; + switch (event) { + case Event.Page: + let page: PageData = { + timestamp: tokens[2] as number, + ua: tokens[3] as string, + url: tokens[4] as string, + referrer: tokens[5] as string, + lean: tokens[6] as BooleanFlag, + }; + return { time, event, data: page }; + case Event.Ping: + let ping: PingData = { gap: tokens[2] as number }; + return { time, event, data: ping }; + case Event.Tag: + let tag: TagData = { key: tokens[2] as string, value: tokens[3] as string }; + return { time, event, data: tag }; + case Event.Upload: + let upload: UploadData = { sequence: tokens[2] as number, attempts: tokens[3] as number, status: tokens[4] as number}; + return { time, event, data: upload }; + case Event.Metric: + let i = 2; // Start from 3rd index since first two are used for time & event + let metrics: MetricData = {}; + while (i < tokens.length) { + metrics[tokens[i++] as number] = tokens[i++] as number; + } + return { time, event, data: metrics }; + } +} + +export function envelope(tokens: Token[]): Envelope { + return { + sequence: tokens[0] as number, + version: tokens[1] as string, + projectId: tokens[2] as string, + userId: tokens[3] as string, + sessionId: tokens[4] as string, + pageId: tokens[5] as string, + upload: tokens[6] as Upload, + end: tokens[7] as BooleanFlag + }; +} + +export function summarize(entry: Token[]): void { + let time = entry[0] as number; + let type = entry[1] as Event; + let data: SummaryData = { event: type, start: time, end: time }; + if (!(type in summaries)) { summaries[type] = [data]; } + + let s = summaries[type][summaries[type].length - 1]; + if (time - s.end < SUMMARY_THRESHOLD) { s.end = time; } else { summaries[type].push(data); } +} + +export function summary(): DataEvent[] { + let data: SummaryData[] = []; + let time = null; + for (let type in summaries) { + if (summaries[type]) { + for (let d of summaries[type]) { + time = time ? Math.min(time, d.start) : d.start; + data.push(d); + } + } + } + return data.length > 0 ? [{ time, event: Event.Summary, data }] : null; +} diff --git a/decode/diagnostic.ts b/decode/diagnostic.ts new file mode 100644 index 00000000..c05343f9 --- /dev/null +++ b/decode/diagnostic.ts @@ -0,0 +1,25 @@ +import { Event, Token } from "../types/data"; +import { DiagnosticEvent } from "../types/decode/diagnostic"; +import { ImageErrorData, ScriptErrorData } from "../types/diagnostic"; + +export function decode(tokens: Token[]): DiagnosticEvent { + let time = tokens[0] as number; + let event = tokens[1] as Event; + switch (event) { + case Event.ImageError: + let imageError: ImageErrorData = { + source: tokens[2] as string, + target: tokens[3] as number + }; + return { time, event, data: imageError }; + case Event.ScriptError: + let scriptError: ScriptErrorData = { + message: tokens[3] as string, + line: tokens[4] as number, + column: tokens[5] as number, + stack: tokens[6] as string, + source: tokens[2] as string + }; + return { time, event, data: scriptError }; + } +} diff --git a/decode/index.ts b/decode/index.ts new file mode 100644 index 00000000..7e423511 --- /dev/null +++ b/decode/index.ts @@ -0,0 +1 @@ +export * from "./clarity"; diff --git a/decode/interaction.ts b/decode/interaction.ts new file mode 100644 index 00000000..83db4678 --- /dev/null +++ b/decode/interaction.ts @@ -0,0 +1,49 @@ +import { Event, Token } from "../types/data"; +import { InteractionEvent } from "../types/decode/interaction"; +import { InputChangeData, PointerData, ResizeData, ScrollData, SelectionData, UnloadData, VisibilityData } from "../types/interaction"; + +export function decode(tokens: Token[]): InteractionEvent { + let time = tokens[0] as number; + let event = tokens[1] as Event; + switch (event) { + case Event.MouseDown: + case Event.MouseUp: + case Event.MouseMove: + case Event.MouseWheel: + case Event.Click: + case Event.DoubleClick: + case Event.RightClick: + case Event.TouchStart: + case Event.TouchCancel: + case Event.TouchEnd: + case Event.TouchMove: + let pointerData: PointerData = { target: tokens[2] as number, x: tokens[3] as number, y: tokens[4] as number }; + return { time, event, data: pointerData }; + case Event.Resize: + let resizeData: ResizeData = { width: tokens[2] as number, height: tokens[3] as number }; + return { time, event, data: resizeData }; + case Event.InputChange: + let changeData: InputChangeData = { + target: tokens[2] as number, + value: tokens[3] as string + }; + return { time, event, data: changeData }; + case Event.Selection: + let selectionData: SelectionData = { + start: tokens[2] as number, + startOffset: tokens[3] as number, + end: tokens[4] as number, + endOffset: tokens[5] as number + }; + return { time, event, data: selectionData }; + case Event.Scroll: + let scrollData: ScrollData = { target: tokens[2] as number, x: tokens[3] as number, y: tokens[4] as number }; + return { time, event, data: scrollData }; + case Event.Visibility: + let visibleData: VisibilityData = { visible: tokens[2] as string }; + return { time, event, data: visibleData }; + case Event.Unload: + let unloadData: UnloadData = { name: tokens[2] as string }; + return { time, event, data: unloadData }; + } +} diff --git a/decode/layout.ts b/decode/layout.ts new file mode 100644 index 00000000..909d282b --- /dev/null +++ b/decode/layout.ts @@ -0,0 +1,166 @@ +import generateHash from "../src/data/hash"; +import { resolve } from "../src/data/token"; +import selector from "../src/layout/selector"; +import { Event, Token } from "../types/data"; +import { DomData, LayoutEvent } from "../types/decode/layout"; +import { Attributes, BoxModelData, DocumentData, HashData, ResourceData, TargetData } from "../types/layout"; + +let placeholderImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNiOAMAANUAz5n+TlUAAAAASUVORK5CYII="; +export let hashes: { [key: number]: HashData } = {}; +export let resources: ResourceData[]; +let lastTime: number; + +export function reset(): void { + hashes = {}; + resources = []; + lastTime = null; +} + +export function decode(tokens: Token[]): LayoutEvent { + let time = lastTime = tokens[0] as number; + let event = tokens[1] as Event; + + switch (event) { + case Event.Document: + let documentData: DocumentData = { width: tokens[2] as number, height: tokens[3] as number }; + return { time, event, data: documentData }; + case Event.BoxModel: + let boxmodelData: BoxModelData[] = []; + for (let i = 2; i < tokens.length; i += 2) { + let boxmodel: BoxModelData = { id: tokens[i] as number, box: tokens[i + 1] as number[] }; + boxmodelData.push(boxmodel); + } + return { time, event, data: boxmodelData }; + case Event.Target: + let targetData: TargetData[] = []; + for (let i = 2; i < tokens.length; i += 3) { + let target: TargetData = { id: tokens[i] as number, hash: tokens[i + 1] as string, box: tokens[i + 2] as number[] }; + targetData.push(target); + } + return { time, event, data: targetData }; + case Event.Hash: + let reference = 0; + let hashData: HashData[] = []; + for (let i = 2; i < tokens.length; i += 2) { + let id = (tokens[i] as number) + reference; + let token = tokens[i + 1]; + let cs: HashData = { id, hash: typeof(token) === "object" ? tokens[token[0]] : token }; + hashData.push(cs); + reference = id; + } + return { time, event, data: hashData }; + case Event.Discover: + case Event.Mutation: + let lastType = null; + let node = []; + let tagIndex = 0; + let domData: DomData[] = []; + for (let i = 2; i < tokens.length; i++) { + let token = tokens[i]; + let type = typeof(token); + switch (type) { + case "number": + if (type !== lastType && lastType !== null) { + domData.push(process(node, tagIndex)); + node = []; + tagIndex = 0; + } + node.push(token); + tagIndex++; + break; + case "string": + node.push(token); + break; + case "object": + let subtoken = token[0]; + let subtype = typeof(subtoken); + switch (subtype) { + case "string": + let keys = resolve(token as string); + for (let key of keys) { + node.push(key); + } + break; + case "number": + token = tokens.length > subtoken ? tokens[subtoken] : null; + node.push(token); + break; + } + } + lastType = type; + } + // Process last node + domData.push(process(node, tagIndex)); + + return { time, event, data: domData }; + } +} + +export function hash(): LayoutEvent[] { + let data = []; + for (let id in hashes) { if (hashes[id]) { data.push(hashes[id]); } } + return data.length > 0 ? [{ time: lastTime, event: Event.Hash, data }] : null; +} + +export function resource(): LayoutEvent[] { + return resources.length > 0 ? [{ time: lastTime, event: Event.Resource, data: resources }] : null; +} + +function process(node: any[] | number[], tagIndex: number): DomData { + let [tag, position]: string[] = node[tagIndex] ? node[tagIndex].split("~") : [node[tagIndex]]; + let output: DomData = { + id: node[0], + parent: tagIndex > 1 ? node[1] : null, + next: tagIndex > 2 ? node[2] : null, + tag, + position: position ? parseInt(position, 10) : null + }; + let hasAttribute = false; + let attributes: Attributes = {}; + let value = null; + let prefix = output.parent in hashes ? `${hashes[output.parent].selector}>` : (output.parent ? "" : null); + + for (let i = tagIndex + 1; i < node.length; i++) { + let token = node[i] as string; + let keyIndex = token.indexOf("="); + let lastChar = token[token.length - 1]; + if (i === (node.length - 1) && output.tag === "STYLE") { + value = token; + } else if (lastChar === ">" && keyIndex === -1) { + prefix = token; + } else if (output.tag !== "*T" && keyIndex > 0) { + hasAttribute = true; + let k = token.substr(0, keyIndex); + let v = token.substr(keyIndex + 1); + switch (k) { + case "src": + v = v.length === 0 ? placeholderImage : v; + break; + default: + break; + } + attributes[k] = v; + } else if (output.tag === "*T") { + value = token; + } + } + + let s = selector(output.tag, prefix, attributes, output.position); + if (s.length > 0) { hashes[output.id] = { id: output.id, hash: generateHash(s), selector: s }; } + + getResource(output.tag, attributes); + if (hasAttribute) { output.attributes = attributes; } + if (value) { output.value = value; } + + return output; +} + +function getResource(tag: string, attributes: Attributes): void { + switch (tag) { + case "LINK": + if ("href" in attributes && "rel" in attributes && attributes["rel"] === "stylesheet") { + resources.push({ tag, url: attributes["href"]}); + } + break; + } +} diff --git a/decode/render.ts b/decode/render.ts new file mode 100644 index 00000000..f8a77a50 --- /dev/null +++ b/decode/render.ts @@ -0,0 +1,338 @@ +import { Event, Metric, MetricData, PageData } from "../types/data"; +import { DomData } from "../types/decode/layout"; +import { InputChangeData, PointerData, ResizeData, ScrollData, SelectionData } from "../types/interaction"; +import { BoxModelData, Constant } from "../types/layout"; + +let nodes = {}; +let boxmodels = {}; +let metrics: MetricData = null; +let lean = false; +const METRIC_MAP = {}; +METRIC_MAP[Metric.Nodes] = { name: "Node Count", unit: ""}; +METRIC_MAP[Metric.LayoutBytes] = { name: "Layout Bytes", unit: "KB"}; +METRIC_MAP[Metric.InteractionBytes] = { name: "Interaction Bytes", unit: "KB"}; +METRIC_MAP[Metric.NetworkBytes] = { name: "Network Bytes", unit: "KB"}; +METRIC_MAP[Metric.DiagnosticBytes] = { name: "Diagnostic Bytes", unit: "KB"}; +METRIC_MAP[Metric.Mutations] = { name: "Mutation Count", unit: ""}; +METRIC_MAP[Metric.Interactions] = { name: "Interaction Count", unit: ""}; +METRIC_MAP[Metric.Clicks] = { name: "Click Count", unit: ""}; +METRIC_MAP[Metric.Selections] = { name: "Selection Count", unit: ""}; +METRIC_MAP[Metric.ScriptErrors] = { name: "Script Errors", unit: ""}; +METRIC_MAP[Metric.ImageErrors] = { name: "Image Errors", unit: ""}; +METRIC_MAP[Metric.DiscoverTime] = { name: "Discover Time", unit: "ms"}; +METRIC_MAP[Metric.MutationTime] = { name: "Mutation Time", unit: "ms"}; +METRIC_MAP[Metric.BoxModelTime] = { name: "Box Model Time", unit: "ms"}; +METRIC_MAP[Metric.StartTime] = { name: "Start Time", unit: "s"}; +METRIC_MAP[Metric.ActiveTime] = { name: "Active Time", unit: "ms"}; +METRIC_MAP[Metric.EndTime] = { name: "End Time", unit: "s"}; +METRIC_MAP[Metric.ViewportWidth] = { name: "Viewport Width", unit: "px"}; +METRIC_MAP[Metric.ViewportHeight] = { name: "Viewport Height", unit: "px"}; +METRIC_MAP[Metric.DocumentWidth] = { name: "Document Width", unit: "px"}; +METRIC_MAP[Metric.DocumentHeight] = { name: "Document Height", unit: "px"}; + +// tslint:disable-next-line: max-line-length +let pointerIcon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAYCAYAAAD6S912AAAABGdBTUEAALGOfPtRkwAAACBjSFJNAAB6JQAAgIMAAPn/AACA6AAAdTAAAOpgAAA6lwAAF2+XqZnUAAACaUlEQVR4nGL8f58BHYgAsT8Q2wGxBBAzQcX/AfFrID4CxOuA+BWKLoX/YAoggBjRDHQD4ngglgRiPgyrIOAzEL8E4lVQg1EMBAggFiSFYUAcA8RSOAyCAV4oTgViTiBeiiwJEEAw71gRaRgyEAXiKCB2RBYECCCQgcIMEG+SYhgMiANxEhDzwwQAAghkoAMQK5NhGAwoALE1jAMQQCADQU7mpMBAZqijwAAggEAGqgAxOwUGskHNAAOAAAIZyEtIh4INg3bfHHD6xAUEYAyAAAIZ+IuQgU9fMLCXdzDIzV3JIIhDyQ8YAyCAQAaCUv8/fAZysDP8+/OXgTG7jkFhwRoMQ0F6n8M4AAEEMvAKsg34wM9fDEwgQ1dtRSQTIPgNxFdhHIAAAhm4AYg/EmMgCHz7zsCUVMaguHob3FCQYzbD5AECCGTgJSDeCbWJKPD1GwNzSjmD4tZ9DFxgvQr/b8PkAAIIlvVWA/FuUgz99IWBOTyXQcE+nOEOsjhAACGXNnJAHAnE9kAshqyIV5vB4Ms3cALGBkAlj9////9PgTgAAcSEJPEIiDuBeBYQP2CAhOt3BsLJCpSfNzAyMpqDOAABhF4ewh3FAMmf2kAsyqnBUPDjJ8HcdBvoSjWAAGIEEgTUMTAAbf/AwICSVGCgD4hPgJQA8WegWdsBAogFiyJC4C0QgxI3KLj4gIasRpYECCAGkAsJYSAAuRDEAKUEQwZIzgDxvwCxCrJagAAi1kAQAYpFESh/BlQMhJuR1QIEELEGlgOxHBLflAGSh0Gc60DMBpMDCCCiDMRhyXoGSJUaDgpPmDhAgAEAN5Ugk0bMYNIAAAAASUVORK5CYII="; +// tslint:disable-next-line: max-line-length +let clickIcon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAYCAYAAAD6S912AAAABGdBTUEAALGOfPtRkwAAACBjSFJNAAB6JQAAgIMAAPn/AACA6AAAdTAAAOpgAAA6lwAAF2+XqZnUAAADvklEQVR4nGL8//8/AzJgZGQQAVL+QGwHxBJAzASV+gfEr4H4CBCvA2p7xYAFAAQQI7KBQMPcgFQ8EEsCMR82DUDwGYhfAvEqoNZ16JIAAQQ3EGhYGJCKAWIpHAahA5BrlwC1L0UWBAggJqhhViQaBgKiQBwF1OuILAgQQExAAWEGiDeRDJsEFOM1AFplzMCgrcnAcIoTh6HiQJwENIMfJgAQQCAXOgCxMkLNaqBkvgIDg8Z3BoaC5wwMz9kYGKIVGRg+MjFgB0C1DNYwDkAAgRSBnIzkgnUCDAyswIBdf4+Bof8ZA0PHYwaGO0D5/Tw4DGSGOgoMAAIIZKAKELMj5D8AFXD+BUbyXwhf9Scw5QAt+MmIw0A2qBlgABBAIAN5cSgkBQjAGAABxALEv1BdiA4YobgFmDZPcjMwZLyB+JILmNAl/0AV/YCpBgggkIGg9MTNgMgRWAAL0MuvWRkYJgNzznRgzP4AqmUHGpj/AhgFnxgYlD4yMKiBVQIEEMiQK8g2YAJQ2JUAY/vFZQaGmfeBvgO6qhUYUU5AQ7qASc1Tg4FB35+RkRGUXRkAAgjkwg0MDMedGBh2AW3m+QtxCQuSgZxAl9h+gbAtvzEwJAN9VAXMxzc+Qlwa+JSBoRQUZL1AvBEggECB4wdMJqsYGH6zQ8IKlBWlgOF6/SowpoGGvQKaDgoqKSDxCWjAA2Cs6gF99AXIfgLEGsuA+kAJuwqYjRkBAgjk5S5gQQL00vHJDAwnLgFz1G9gPCElEbE/EMNAAGSBHjR4eIBiGtuBjKVQV4ABQACBDASG5t/dDAwWPQwMZkDbJlyApMG/UEN38UBc9hvIPwCMPFDy/AmM6UnXgN6eDywcniKHOEAAgQw8BsRBQGezASU7gfm9jYGh8hxQIzD2GIDZ7yYwjXwHuuYPMIHfApr2HxghbxcBY/giA4PmE6TUAgYAAQRilALxASCeBYwpb2A4bGBkTNnLAMmf2sDYBWbN73cZGDiAZeBxYBlp1w00CBg5DBlIXpUH4mcgBkAAMUDLw2So5CQQHxkDQRwQCzFAEn8oAyRVg/J+IVQM5ChgvmfYDFIPEEDIGudCDQ1HM9CFAZI9gckJXC2AggmUfwOh8mpQfZUgPkAAIWsEaToOxF+B2ATdpbgwEKRCDbQB8QECCF1SB4hBkXEeiPmJNBCUbEDZlwfEBwgg5CwBUnAFGDHpQCYw+TBsAbI3MUBqO2xFFyj9CELDdRlQLzg3AQQQLlujoAH9H6oRG/4HlT8ExCowvQABBgBOKHD8+UgEvgAAAABJRU5ErkJggg=="; +// tslint:disable-next-line: max-line-length +let touchIcon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAYCAYAAAD6S912AAAACXBIWXMAAAsTAAALEwEAmpwYAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOS0wOS0xMVQwNjo1MzozMC0wNzowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTktMDktMTFUMDY6NTQ6NDAtMDc6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTktMDktMTFUMDY6NTQ6NDAtMDc6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODZmNTE5ZGQtN2E3MS1hOTQyLTgwNTktMjc3OTJjNTM1YTNlIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjg2ZjUxOWRkLTdhNzEtYTk0Mi04MDU5LTI3NzkyYzUzNWEzZSIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjg2ZjUxOWRkLTdhNzEtYTk0Mi04MDU5LTI3NzkyYzUzNWEzZSI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ODZmNTE5ZGQtN2E3MS1hOTQyLTgwNTktMjc3OTJjNTM1YTNlIiBzdEV2dDp3aGVuPSIyMDE5LTA5LTExVDA2OjUzOjMwLTA3OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIi8+IDwvcmRmOlNlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PgqF3McAAAOgSURBVDiNlZVdbBRVFMd/d7o7penS7c6ghT60JRLCRx9s0JAIUvajJtCkVYw1IVaKUr/wpQk+qO8YRV8aEw3Q1EjpAwkNlkZNDMEYxUDoh9gSH7CNrXQptbtsHeluu7vHh5kuQ90l8k9ucuece373zJl77igRwa1YXK0BmoFdwFpAc1xZYBb4EegzAnKbPFJuYCyungEOAOuAsnwBwN/ADHDGCEhfQWAsrlqAl4DKAqCVmgV6jICcdhs1B/bUQ8IAHgH2x+IqeB8wFlcm9mvmYOFwq2kavsdNg22msWVzf39VSQFoBfBKLK787gx3A48tG44cCfpHhk/VwKYFn+9QFKL6wTZ9/Z07mvYfnK0aYIcbGARyGXzR/Wg5eOXq4Oj4H5Mnp3fVN0zBjZJjHz3tKwAscpLKATcAxcsGkUQRlGQMYzED0Ng4mgJNLEtXBYC6w8gBVxdY+DAqdwMXH7RSKQBFT090XXVVe+XQULl+7VqZPjWle1zLkssTD/Z5KuVeR+SRR2DWa1ndaxsingpIalCc9ftfu/XW4aH5UOhGIhK+l+Goe4eVsixdmeaB6Fxs5tdIw7MTUJ6urj40BaFEInG88oOjVzc1RJLNSqnmZeC5s2fXZ2u3tlfs3NG2Bma9duK2AoGF7OG3r1gAHR0X75pm0+zQ8MnbX39z5SYEE3V1rddBpoFP7OJAE3jPwFKx/ShA5eL4xK0xvz+bjUa9nlSqiJqaZDqR0LTBwQo9FIomLUtpY2OGtn37XK9p4AfeExGlgN+gqPjEiarzHm+m/mDbwmYQxidio35/NvuA75UFLgCfmgZvAu+LiNKAjZD5bt/zEx83NU32trRsG4GyTDqtKYDOzjpfIqFpS0uorq4tpZkMpFL8FQ63Xr982ew2AnLTvYsGXAL2mQa6EZAPP/v826P1u58cSqfVPLDQ/1VtkWV50um0Sg0M1GZEmIpGV305Mnzql7175v50nZbc5B3ge+C4UqpRRM719akL2P25dXJyVXB+Xv+9tDQ9c+knvWxw0Dy2d8/cXeANp+AA1cA0AM59+Krj7BQR3AN4GTCw2/MF7N4tATocmwcYB86LCO7ALgf64gpgBLs9vdi/BQ27f59z/BuduHdXAlcDPwP/AE+szLTQANod4M77gI6zFpgHhgH//wSexm5fn4jgbnBEZFQp9TrQCwwopfqd85bv6soAAaeuvSJi5T5Knl33O4UWJzDfyDr+H4ANy7H/AvM1z7/FQv/oAAAAAElFTkSuQmCC"; + +export function reset(): void { + nodes = {}; + boxmodels = {}; + metrics = {}; +} + +export function metric(data: MetricData, header: HTMLElement): void { + let html = []; + + // Copy over metrics for future reference + for (let m in data) { + if (data[m]) { metrics[m] = data[m]; } + } + for (let entry in metrics) { + if (metrics[entry]) { + let m = metrics[entry]; + let map = METRIC_MAP[entry]; + html.push(`
  • ${value(m, map.unit)}${map.unit}

    ${map.name}
  • `); + } + } + + header.innerHTML = `
      ${html.join("")}
    `; +} + +function value(input: number, unit: string): number { + switch (unit) { + case "KB": return Math.round(input / 1024); + case "s": return Math.round(input / 1000); + default: return input; + } +} + +export function page(data: PageData, iframe: HTMLIFrameElement): void { + lean = !!data.lean; +} + +export function boxmodel(data: BoxModelData[], iframe: HTMLIFrameElement): void { + let doc = iframe.contentDocument; + for (let bm of data) { + let el = element(bm.id) as HTMLElement; + if (lean) { + let box = el ? el : doc.createElement("DIV"); + box.style.left = bm.box[0] + "px"; + box.style.top = bm.box[1] + "px"; + box.style.width = bm.box[2] + "px"; + box.style.height = bm.box[3] + "px"; + box.style.position = "absolute"; + box.style.border = "1px solid red"; + doc.body.appendChild(box); + nodes[bm.id] = box; + } else if (el && el.tagName === "IFRAME") { + let s = getComputedStyle(el, null); + let width = bm.box[2]; + let height = bm.box[3]; + if (s["boxSizing"] !== "border-box") { + width -= (css(s, "paddingLeft") + css(s, "paddingRight") + css(s, "borderLeftWidth") + css(s, "borderRightWidth")); + height -= (css(s, "paddingTop") + css(s, "paddingBottom") + css(s, "borderTopWidth") + css(s, "borderBottomWidth")); + } + el.style.width = width + "px"; + el.style.height = height + "px"; + if (el.tagName === "IFRAME") { el.style.backgroundColor = "maroon"; } + boxmodels[bm.id] = bm; + } + } +} + +function css(style: CSSStyleDeclaration, field: string): number { + return parseInt(style[field], 10); +} + +export function markup(data: DomData[], iframe: HTMLIFrameElement): void { + let doc = iframe.contentDocument; + for (let node of data) { + let parent = element(node.parent); + let next = element(node.next); + switch (node.tag) { + case "*D": + if (typeof XMLSerializer !== "undefined") { + doc.open(); + doc.write(new XMLSerializer().serializeToString( + doc.implementation.createDocumentType( + node.attributes["name"], + node.attributes["publicId"], + node.attributes["systemId"] + ) + )); + doc.close(); + } + break; + case "*T": + let textElement = element(node.id); + textElement = textElement ? textElement : doc.createTextNode(null); + textElement.nodeValue = unmask(node.value); + insert(node, parent, textElement, next); + break; + case "HTML": + let docElement = element(node.id); + if (docElement === null) { + let newDoc = doc.implementation.createHTMLDocument(""); + docElement = newDoc.documentElement; + let p = doc.importNode(docElement, true); + doc.replaceChild(p, doc.documentElement); + if (doc.head) { doc.head.parentNode.removeChild(doc.head); } + if (doc.body) { doc.body.parentNode.removeChild(doc.body); } + } + setAttributes(doc.documentElement as HTMLElement, node.attributes); + nodes[node.id] = doc.documentElement; + break; + case "HEAD": + let headElement = element(node.id); + if (headElement === null) { + headElement = doc.createElement(node.tag); + let base = doc.createElement("base"); + base.href = node.attributes["*B"]; + headElement.appendChild(base); + delete node.attributes["*B"]; + } + setAttributes(headElement as HTMLElement, node.attributes); + insert(node, parent, headElement, next); + break; + case "STYLE": + let styleElement = element(node.id); + styleElement = styleElement ? styleElement : doc.createElement(node.tag); + setAttributes(styleElement as HTMLElement, node.attributes); + styleElement.textContent = node.value; + insert(node, parent, styleElement, next); + break; + default: + let domElement = element(node.id); + domElement = domElement ? domElement : createElement(doc, node.tag, parent as HTMLElement); + if (!node.attributes) { node.attributes = {}; } + node.attributes["data-id"] = `${node.id}`; + setAttributes(domElement as HTMLElement, node.attributes); + if (node.id in boxmodels) { boxmodel([boxmodels[node.id]], iframe); } + insert(node, parent, domElement, next); + break; + } + } +} + +function createElement(doc: Document, tag: string, parent: HTMLElement): HTMLElement { + if (tag && tag.indexOf(Constant.SVG_PREFIX) === 0) { + return doc.createElementNS(Constant.SVG_NAMESPACE as string, tag.substr(Constant.SVG_PREFIX.length)) as HTMLElement; + } + return doc.createElement(tag); +} + +function element(nodeId: number): Node { + return nodeId !== null && nodeId > 0 && nodeId in nodes ? nodes[nodeId] : null; +} + +function insert(data: DomData, parent: Node, node: Node, next: Node): void { + if (parent !== null) { + next = next && next.parentElement !== parent ? null : next; + try { + parent.insertBefore(node, next); + } catch (ex) { + console.warn("Node: " + node + " | Parent: " + parent + " | Data: " + JSON.stringify(data)); + console.warn("Exception encountered while inserting node: " + ex); + } + } else if (parent === null && node.parentElement !== null) { + node.parentElement.removeChild(node); + node = null; + } + nodes[data.id] = node; +} + +function setAttributes(node: HTMLElement, attributes: object): void { + // First remove all its existing attributes + if (node.attributes) { + let length = node.attributes.length; + while (node.attributes && length > 0) { + node.removeAttribute(node.attributes[0].name); + length--; + } + } + + // Add new attributes + for (let attribute in attributes) { + if (attributes[attribute] !== undefined) { + try { + let v = unmask(attributes[attribute]); + if (attribute.indexOf("xlink:") === 0) { + node.setAttributeNS("http://www.w3.org/1999/xlink", attribute, v); + } else { + node.setAttribute(attribute, v); + } + } catch (ex) { + console.warn("Node: " + node + " | " + JSON.stringify(attributes)); + console.warn("Exception encountered while adding attributes: " + ex); + } + } + } +} + +export function scroll(data: ScrollData, iframe: HTMLIFrameElement): void { + let target = getNode(data.target); + if (target) { target.scrollTo(data.x, data.y); } +} + +export function resize(data: ResizeData, iframe: HTMLIFrameElement): void { + iframe.removeAttribute("style"); + let margin = 10; + let px = "px"; + let width = data.width; + let height = data.height; + let availableWidth = (iframe.contentWindow.innerWidth - (2 * margin)); + let scaleWidth = Math.min(availableWidth / width, 1); + let scaleHeight = Math.min((iframe.contentWindow.innerHeight - (16 * margin)) / height, 1); + let scale = Math.min(scaleWidth, scaleHeight); + iframe.style.position = "relative"; + iframe.style.width = width + px; + iframe.style.height = height + px; + iframe.style.left = ((availableWidth - (width * scale)) / 2) + px; + iframe.style.transformOrigin = "0 0 0"; + iframe.style.transform = "scale(" + scale + ")"; + iframe.style.border = "1px solid #cccccc"; + iframe.style.margin = margin + px; + iframe.style.overflow = "hidden"; +} + +export function change(data: InputChangeData, iframe: HTMLIFrameElement): void { + let el = element(data.target) as HTMLInputElement; + if (el) { el.value = data.value; } +} + +export function selection(data: SelectionData, iframe: HTMLIFrameElement): void { + let doc = iframe.contentDocument; + let s = doc.getSelection(); + s.setBaseAndExtent(element(data.start), data.startOffset, element(data.end), data.endOffset); +} + +export function pointer(event: Event, data: PointerData, iframe: HTMLIFrameElement): void { + let doc = iframe.contentDocument; + let p = doc.getElementById("clarity-pointer"); + let pointerWidth = 20; + let pointerHeight = 24; + + if (p === null) { + p = doc.createElement("DIV"); + p.id = "clarity-pointer"; + p.style.position = "absolute"; + p.style.zIndex = "1000"; + p.style.width = pointerWidth + "px"; + p.style.height = pointerHeight + "px"; + doc.body.appendChild(p); + } + + p.style.left = (data.x - 8) + "px"; + p.style.top = (data.y - 8) + "px"; + switch (event) { + case Event.Click: + case Event.RightClick: + case Event.DoubleClick: + p.style.background = `url(${clickIcon}) no-repeat left center`; + break; + case Event.TouchMove: + case Event.TouchStart: + case Event.TouchEnd: + case Event.TouchCancel: + p.style.background = `url(${touchIcon}) no-repeat left center`; + break; + default: + p.style.background = `url(${pointerIcon}) no-repeat left center`; + break; + } +} + +function getNode(id: number): HTMLElement { + return id in nodes ? nodes[id] : null; +} + +function unmask(v: string): string { + if (v && v.length > 0) { + let parts = v.split("*"); + let placeholder = "x"; + if (parts.length === 3 && parts[0] === "") { + let textCount = parseInt(parts[1], 36); + let wordCount = parseInt(parts[2], 36); + if (isFinite(textCount) && isFinite(wordCount)) { + if (wordCount > 0 && textCount === 0) { + v = " "; + } else if (wordCount === 0 && textCount > 0) { + v = Array(textCount + 1).join(placeholder); + } else if (wordCount > 0 && textCount > 0) { + v = ""; + let avg = Math.floor(textCount / wordCount); + while (v.length < textCount + wordCount) { + let gap = Math.min(avg, textCount + wordCount - v.length); + v += Array(gap + 1).join(placeholder) + " "; + } + } else { + v = Array(textCount + wordCount + 1).join(placeholder); + } + } + } + } + return v; +} diff --git a/karma/configs/base.js b/karma/configs/base.js deleted file mode 100644 index ca47f2c1..00000000 --- a/karma/configs/base.js +++ /dev/null @@ -1,83 +0,0 @@ -"use strict"; -module.exports = function (config) { - config.set({ - - // Base path that will be used to resolve all patterns (eg. files, exclude). - basePath: "../..", - - // Frameworks to use. - // Available frameworks: https://npmjs.org/browse/keyword/karma-adapter - frameworks: ["karma-typescript", "fixture", "jasmine"], - - // List of files to load in the browser. - files: [ - "karma/fixtures/**/*.html", - "package.json", - "build/clarity.min.js", - "src/converters/**/*.ts", - "karma/setup/**/*.ts", - "karma/tests/**/*.ts", - "src/**/*.ts" - ], - - // Preprocess matching files before serving them to the browser. - // Available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor - preprocessors: { - "**/*.ts": ["karma-typescript"], - "**/*.html": ["html2js"], - "**/*.json": ["json_fixtures"], - }, - - karmaTypescriptConfig: { - tsconfig: "tsconfig.json", - - compilerOptions: { - "sourceMap": true - }, - - bundlerOptions: { - // There is a module resolution bug for typings files with tsconfig paths - // A workaround is to add each such file to bundlerOptions.exclude - // https://github.com/monounity/karma-typescript/issues/315#issuecomment-461746455 - exclude: [ - "@clarity-types/compressionworker", - "@clarity-types/config", - "@clarity-types/core", - "@clarity-types/index", - "@clarity-types/instrumentation", - "@clarity-types/layout", - "@clarity-types/performance", - "@clarity-types/pointer", - "@clarity-types/viewport" - ] - }, - - // Exclude all files from coverage - // NOTE: When we want to run coverage, we need a way to exclude BLOB files created by compression workers - // Otherwise Karma can't map those blobs to an actual file and it causes an error '__cov_..... is undefined' - coverageOptions: { - exclude: [/^.*$/] - } - }, - - jsonFixturesPreprocessor: { - // Strip full file system part from the file path / fixture name - stripPrefix: ".+/", - // Change the global fixtures variable name - variableName: "__test_jsons" - }, - - // Start these browsers. - // Available browser launchers: https://npmjs.org/browse/keyword/karma-launcher - browsers: [], - - // Test results reporter to use. - // Possible values: 'dots', 'progress'. - // Available reporters: https://npmjs.org/browse/keyword/karma-reporter - reporters: ["progress"], - - // Disallow Karma launching multiple browsers at the same time - concurrency: 1 - - }); -}; diff --git a/karma/configs/chrome.js b/karma/configs/chrome.js deleted file mode 100644 index c2c96732..00000000 --- a/karma/configs/chrome.js +++ /dev/null @@ -1,22 +0,0 @@ -"use strict"; - -let setCommonOptions = require("./base"); - -module.exports = function (config) { - - setCommonOptions(config); - - config.set({ - - browsers: ["ChromeNoSandbox"], - - // Chrome crashes the page when opened in a sandbox - customLaunchers: { - ChromeNoSandbox: { - base: "Chrome", - flags: ["--no-sandbox"] - } - }, - - }); -}; diff --git a/karma/configs/firefox.js b/karma/configs/firefox.js deleted file mode 100644 index a4c84327..00000000 --- a/karma/configs/firefox.js +++ /dev/null @@ -1,12 +0,0 @@ -"use strict"; - -let setCommonOptions = require("./base"); - -module.exports = function (config) { - - setCommonOptions(config); - - config.set({ - browsers: ["Firefox"] - }); -}; diff --git a/karma/configs/headless.js b/karma/configs/headless.js deleted file mode 100644 index c2591e3c..00000000 --- a/karma/configs/headless.js +++ /dev/null @@ -1,29 +0,0 @@ -"use strict"; - -let setCommonOptions = require("./base"); - -module.exports = function (config) { - - setCommonOptions(config); - - config.set({ - - browsers: ["ChromeHeadless"], - - customLaunchers: { - ChromeHeadless: { - base: "Chrome", - flags: [ - "--headless", - "--disable-gpu", - "--no-sandbox", - // Without a remote debugging port, Google Chrome exits immediately. - "--remote-debugging-port=9222" - ] - } - }, - - singleRun: true - - }); -}; diff --git a/karma/configs/ie.js b/karma/configs/ie.js deleted file mode 100644 index 53a1e5c5..00000000 --- a/karma/configs/ie.js +++ /dev/null @@ -1,12 +0,0 @@ -"use strict"; - -let setCommonOptions = require("./base"); - -module.exports = function (config) { - - setCommonOptions(config); - - config.set({ - browsers: ["IE"], - }); -}; diff --git a/karma/fixtures/clarity.fixture.html b/karma/fixtures/clarity.fixture.html deleted file mode 100644 index 099997ed..00000000 --- a/karma/fixtures/clarity.fixture.html +++ /dev/null @@ -1,17 +0,0 @@ - -
    -

    Clarity

    -
    - -

    - The Clarity project contains code for analyzing users' interactions with webpages. -

    -

    - For more information, check out: clarity project. -

    -
    -
    diff --git a/karma/setup/clarity.ts b/karma/setup/clarity.ts deleted file mode 100644 index 65d1f37d..00000000 --- a/karma/setup/clarity.ts +++ /dev/null @@ -1,148 +0,0 @@ -// @ts-ignore: 'merge' implicityly has 'any' type. There are no typings for 'merge' -import * as merge from "merge"; - -import { UploadCallback } from "@clarity-types/core"; -import { ClarityJs, IConfig } from "@clarity-types/index"; -import { setRealTimeout } from "./proxyapis/jasmineclock"; -import { publishAsync, publishSync, PubSubEvents, waitFor } from "./pubsub"; -import { getFullImpressionWatchResult, resetWatcher } from "./watch"; - -export interface IStartClarityOptions { - flushInitialActivity: boolean; -} - -declare var clarity: typeof ClarityJs; - -const uploadDelay: number = 500; -const defaultStartOptions: IStartClarityOptions = { - flushInitialActivity: true -}; - -let activeConfig: Partial = null; - -export async function startClarity(_config?: Partial, _startOptions?: IStartClarityOptions): Promise { - const testConfig: IConfig = merge(true, _config); - testConfig.delay = uploadDelay; - - const _uploadHandler = testConfig.uploadHandler; - testConfig.uploadHandler = (payload: string, onSuccess?: UploadCallback, onFailure?: UploadCallback): void => { - testUploadHandler(payload, onSuccess, onFailure); - if (_uploadHandler) { - _uploadHandler(payload, onSuccess, onFailure); - } - }; - - resetWatcher(); - activeConfig = testConfig; - clarity.start(testConfig); - - const startOptions = merge(true, defaultStartOptions, _startOptions); - if (startOptions.flushInitialActivity) { - await flushInitialActivity(); - } -} - -export function stopClarity(): void { - clarity.stop(); -} - -export async function restartClarity( - _config?: Partial, - _startOptions?: IStartClarityOptions, - beforeStart?: () => void -): Promise { - stopClarity(); - if (beforeStart) { - beforeStart(); - } - await startClarity(_config, _startOptions); -} - -export function triggerClarity(key?: string): void { - clarity.trigger(key || "testtrigger"); -} - -export async function triggerClarityAndWaitForUpload(): Promise { - return new Promise((resolve: any, reject: any): void => { - waitFor(PubSubEvents.UPLOAD).then(() => resolve()); - triggerClarity(); - }); -} - -export function triggerMutationEvent(attrs?: { [key: string]: string }): HTMLElement { - const triggerElement = document.createElement("eventtrigger"); - if (attrs) { - const attrNames = Object.keys(attrs); - for (const name of attrNames) { - const val = attrs[name]; - triggerElement.setAttribute(name, val); - } - } - document.body.appendChild(triggerElement); - return triggerElement; -} - -export async function triggerMutationEventAndWaitForUpload( - attrs?: { [key: string]: string } -): Promise { - return new Promise((resolve: any, reject: any): void => { - const triggerElem = triggerMutationEvent(attrs); - waitFor(PubSubEvents.MUTATION).then(() => { - waitFor(PubSubEvents.UPLOAD).then(() => resolve(triggerElem)); - jasmine.clock().tick(activeConfig.delay); - }); - }); -} - -export function getActiveConfig(): Partial { - return activeConfig; -} - -export function getVersion(): string { - return clarity.version; -} - -function testUploadHandler(payload: string, onSuccess?: UploadCallback, onFailure?: UploadCallback): void { - publishSync(PubSubEvents.SYNC_AFTER_UPLOAD, payload); - publishAsync(PubSubEvents.UPLOAD, payload); -} - -async function flushInitialActivity(): Promise { - return new Promise((resolve: any, reject: any): void => { - function tryResolve(): void { - hasActivity() - .then((active: boolean) => { - if (active) { - jasmine.clock().tick(100000); - const nextActivityIndicator = ( - activeConfig.backgroundMode - ? PubSubEvents.WORKER_COMPRESSED_BATCH_MESSAGE - : PubSubEvents.UPLOAD - ); - waitFor(nextActivityIndicator).then(tryResolve); - } else { - resolve(); - } - }); - } - tryResolve(); - }); -} - -async function hasActivity(): Promise { - return new Promise((resolve: any, reject: any): void => { - const allWatch = getFullImpressionWatchResult(); - const processedEvents = activeConfig.backgroundMode ? allWatch.compressedEvents : allWatch.sentEvents; - if (allWatch.coreEvents.length === processedEvents.length) { - // Trigger any timeout'ed activity in Clarity and then set a real timeout to give our - // async PubSub notifications a chance to report any activity if it happened - jasmine.clock().tick(100000); - setRealTimeout(() => { - const refreshAllWatch = getFullImpressionWatchResult(); - resolve(!(refreshAllWatch.coreEvents.length === allWatch.coreEvents.length)); - }, 0); - } else { - resolve(true); - } - }); -} diff --git a/karma/setup/mocks/error.ts b/karma/setup/mocks/error.ts deleted file mode 100644 index 07c63920..00000000 --- a/karma/setup/mocks/error.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const MockErrorMessage: string = "MockErrorMessage"; - -export const MockErrorInit: ErrorEventInit = { - message: "MockErrorEventMessage", - filename: "mockfile.ts", - lineno: 1111111, - colno: 2222222 -}; diff --git a/karma/setup/mocks/event.ts b/karma/setup/mocks/event.ts deleted file mode 100644 index f6c98dc3..00000000 --- a/karma/setup/mocks/event.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { IEnvelope, IEvent } from "@clarity-types/core"; - -export const MockEventName = "ClarityTestMockEvent"; - -export function createMockEvent(eventName?: string): IEvent { - let mockEvent: IEvent = { - id: -1, - state: {}, - time: -1, - type: eventName || MockEventName - }; - return mockEvent; -} - -export function createMockEnvelope(sequenceNumber?: number): IEnvelope { - return { - clarityId: "MockClarityId", - impressionId: "MockImpressionId", - projectId: "MockProjectId", - sequenceNumber: sequenceNumber >= 0 ? sequenceNumber : -1, - time: -1, - url: window.location.toString(), - version: "0.0.0" - }; -} diff --git a/karma/setup/mocks/performance.ts b/karma/setup/mocks/performance.ts deleted file mode 100644 index 77d5f5db..00000000 --- a/karma/setup/mocks/performance.ts +++ /dev/null @@ -1,130 +0,0 @@ -type Mutable = { - -readonly[P in keyof T]: T[P] -}; - -type IMockPerformanceTiming = Partial>; -type IMockPerformanceEntry = Partial>; -type IMockPerformanceResourceTiming = Partial>; - -export interface IMockPerformance { - timing: IMockPerformanceTiming; - addEntry(entry: IMockPerformanceEntry): void; - getEntriesByType(type: string): IMockPerformanceEntry[]; - clearResourceTimings(): void; -} - -export function createMockPerformanceObject(): IMockPerformance { - let entries: IMockPerformanceEntry[] = []; - return { - timing: createMockPerformanceTiming(), - addEntry(entry: IMockPerformanceEntry): void { - entries.push(entry); - }, - getEntriesByType(type: string): IMockPerformanceEntry[] { - return entries.filter((entry: IMockPerformanceEntry): boolean => entry.entryType === type); - }, - clearResourceTimings(): void { - entries = entries.filter((entry: IMockPerformanceEntry): boolean => entry.entryType !== "resource"); - } - }; -} - -export function createMockPerformanceTiming(): IMockPerformanceTiming { - return { - connectEnd: 1548794791246, - connectStart: 1548794791216, - domComplete: 1548794793320, - domContentLoadedEventEnd: 1548794792866, - domContentLoadedEventStart: 1548794792596, - domInteractive: 1548794792596, - domLoading: 1548794791381, - domainLookupEnd: 1548794791216, - domainLookupStart: 1548794791207, - fetchStart: 1548794791203, - loadEventEnd: 1548794793323, - loadEventStart: 1548794793320, - navigationStart: 1548794791202, - redirectEnd: 0, - redirectStart: 0, - requestStart: 1548794791246, - responseEnd: 1548794792046, - responseStart: 1548794791354, - secureConnectionStart: 1548794791225, - unloadEventEnd: 1548794791364, - unloadEventStart: 1548794791363 - }; -} - -export function createMockPerformanceResourceTimings(): IMockPerformanceResourceTiming[] { - return [ - { - connectEnd: 276.8000001087785, - connectStart: 276.8000001087785, - decodedBodySize: 52853, - domainLookupEnd: 276.8000001087785, - domainLookupStart: 276.8000001087785, - duration: 335.1000000257045, - encodedBodySize: 12851, - entryType: "resource", - fetchStart: 276.8000001087785, - initiatorType: "script", - name: "https://mockperformance.test/resource/1", - nextHopProtocol: "h2", - redirectEnd: 0, - redirectStart: 0, - requestStart: 506.2000001780689, - responseEnd: 611.900000134483, - responseStart: 510.5000000912696, - secureConnectionStart: 0, - startTime: 276.8000001087785, - transferSize: 12948, - workerStart: 0 - }, - { - connectEnd: 287.40000003017485, - connectStart: 287.40000003017485, - decodedBodySize: 701397, - domainLookupEnd: 287.40000003017485, - domainLookupStart: 287.40000003017485, - duration: 553.7000000476837, - encodedBodySize: 158455, - entryType: "resource", - fetchStart: 287.40000003017485, - initiatorType: "script", - name: "https://mockperformance.test/resource/2", - nextHopProtocol: "h2", - redirectEnd: 0, - redirectStart: 0, - requestStart: 609.6000000834465, - responseEnd: 841.1000000778586, - responseStart: 647.1000001765788, - secureConnectionStart: 0, - startTime: 287.40000003017485, - transferSize: 158658, - workerStart: 0 - }, - { - connectEnd: 2888.3000002242625, - connectStart: 2888.3000002242625, - decodedBodySize: 499, - domainLookupEnd: 2888.3000002242625, - domainLookupStart: 2888.3000002242625, - duration: 395.5999999307096, - encodedBodySize: 499, - entryType: "resource", - fetchStart: 2888.3000002242625, - initiatorType: "img", - name: "https://mockperformance.test/resource/3", - nextHopProtocol: "h2", - redirectEnd: 0, - redirectStart: 0, - requestStart: 2898.100000107661, - responseEnd: 3283.900000154972, - responseStart: 3282.0000001229346, - secureConnectionStart: 0, - startTime: 2888.3000002242625, - transferSize: 812, - workerStart: 0 - } - ]; -} diff --git a/karma/setup/page.ts b/karma/setup/page.ts deleted file mode 100644 index 94f49cc9..00000000 --- a/karma/setup/page.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { IConfig } from "@clarity-types/config"; -import { IStartClarityOptions, startClarity, stopClarity } from "./clarity"; -import { installEventListenerProxies, uninstallEventListenerProxies } from "./proxyapis/eventlistener"; -import { installJasmineClock, uninstallJasmineClock } from "./proxyapis/jasmineclock"; -import { installMutationObserverProxy, uninstallMutationObserverProxy } from "./proxyapis/mutationobserver"; -import { installPerformanceProxy, uninstallPerformanceProxy } from "./proxyapis/performance"; -import { installWorkerProxy, uninstallWorkerProxy } from "./proxyapis/worker"; -import { revokeAllMessages, unsubscribeAll } from "./pubsub"; -import { stopWatching } from "./watch"; - -export async function setupPage(done: DoneFn, _config?: IConfig, _startOptions?: IStartClarityOptions): Promise { - // Relative to karma config location - fixture.setBase("karma/fixtures"); - fixture.load("clarity.fixture.html"); - - // Install API proxies - installJasmineClock(); - installMutationObserverProxy(); - installEventListenerProxies(); - installWorkerProxy(); - installPerformanceProxy(); - - await startClarity(_config, _startOptions); - done(); -} - -export function cleanupPage(): void { - stopWatching(); - stopClarity(); - - // Uninstall API proxies - uninstallJasmineClock(); - uninstallMutationObserverProxy(); - uninstallEventListenerProxies(); - uninstallWorkerProxy(); - uninstallPerformanceProxy(); - - // Reset PubSub - unsubscribeAll(); - revokeAllMessages(); - - fixture.cleanup(); -} diff --git a/karma/setup/proxyapis/eventlistener.ts b/karma/setup/proxyapis/eventlistener.ts deleted file mode 100644 index 5237e1e5..00000000 --- a/karma/setup/proxyapis/eventlistener.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { publishAsync, PubSubEvents } from "@karma/setup/pubsub"; - -let _addEventListener: typeof Node.prototype.addEventListener = null; -let _removeEventListener: typeof Node.prototype.removeEventListener = null; - -const CLARITY_TEST_PROXY_LISTENER_ATTRIBUTE = "CLARITY_TEST_PROXY_LISTENER"; - -export function installEventListenerProxies(): void { - installAddEventListenerProxy(); - installRemoveEventListenerProxy(); -} - -export function uninstallEventListenerProxies(): void { - uninstallAddEventListenerProxy(); - uninstallRemoveEventListenerProxy(); -} - -function installAddEventListenerProxy(): void { - if (_addEventListener === null) { - _addEventListener = Node.prototype.addEventListener; - } - function proxyAddEventListener( - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: boolean | AddEventListenerOptions - ): void { - let proxyListener = (evt: Event): void => { - if (listener) { - (listener as EventListenerObject).handleEvent - ? (listener as EventListenerObject).handleEvent(evt) - : (listener as EventListener)(evt); - } - publishAsync(getTopicFromEventType(evt.type), null); - }; - proxyListener = listener[CLARITY_TEST_PROXY_LISTENER_ATTRIBUTE] || proxyListener; - listener[CLARITY_TEST_PROXY_LISTENER_ATTRIBUTE] = proxyListener; - - _addEventListener.call(this, type, proxyListener, options); - } - Node.prototype.addEventListener = proxyAddEventListener; -} - -function installRemoveEventListenerProxy(): void { - if (_removeEventListener === null) { - _removeEventListener = Node.prototype.removeEventListener; - } - function proxyRemoveEventListener( - type: string, - listener: EventListenerOrEventListenerObject | null, - options?: boolean | AddEventListenerOptions - ): void { - const proxyListener = listener[CLARITY_TEST_PROXY_LISTENER_ATTRIBUTE] || listener; - _removeEventListener.call(this, type, proxyListener, options); - } - Node.prototype.removeEventListener = proxyRemoveEventListener; -} - -function uninstallAddEventListenerProxy(): void { - if (_addEventListener !== null) { - Node.prototype.addEventListener = _addEventListener; - _addEventListener = null; - } -} - -function uninstallRemoveEventListenerProxy(): void { - if (_removeEventListener !== null) { - Node.prototype.removeEventListener = _removeEventListener; - _removeEventListener = null; - } -} - -function getTopicFromEventType(type: string): PubSubEvents { - switch (type) { - case "click": - return PubSubEvents.CLICK; - case "scroll": - return PubSubEvents.SCROLL; - case "input": - return PubSubEvents.INPUT; - case "change": - return PubSubEvents.CHANGE; - default: - return PubSubEvents.UNKNOWN; - } -} diff --git a/karma/setup/proxyapis/jasmineclock.ts b/karma/setup/proxyapis/jasmineclock.ts deleted file mode 100644 index 6eb484bd..00000000 --- a/karma/setup/proxyapis/jasmineclock.ts +++ /dev/null @@ -1,24 +0,0 @@ -const w: any = window; -const _setTimeout = window.setTimeout; -const _clearTimeout = window.clearTimeout; - -export function installJasmineClock(): void { - jasmine.clock().install(); -} - -export function uninstallJasmineClock(): void { - jasmine.clock().uninstall(); -} - -export const setRealTimeout: typeof window.setTimeout = (...args: any[]): number => { - w._setTimeout = _setTimeout; - const retval = w._setTimeout(...args); - delete w._setTimeout; - return retval; -}; - -export const clearRealTimeout: typeof window.clearTimeout = (...args: any[]): void => { - w._clearTimeout = _clearTimeout; - w._clearTimeout(...args); - delete w._clearTimeout; -}; diff --git a/karma/setup/proxyapis/mutationobserver.ts b/karma/setup/proxyapis/mutationobserver.ts deleted file mode 100644 index b8afd15c..00000000 --- a/karma/setup/proxyapis/mutationobserver.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { publishAsync, PubSubEvents } from "@karma/setup/pubsub"; - -let _MutationObserver: typeof MutationObserver = null; - -export const installMutationObserverProxy = (): void => { - if (_MutationObserver === null) { - _MutationObserver = MutationObserver; - } - MutationObserver = ((callback: MutationCallback): typeof MutationObserver => { - return new _MutationObserver( - (mutations: MutationRecord[], observer: MutationObserver): void => { - callback(mutations, observer); - publishAsync(PubSubEvents.MUTATION, null); - } - ) as any as typeof MutationObserver; - }) as any as typeof MutationObserver; -}; - -export const uninstallMutationObserverProxy = (): void => { - if (_MutationObserver !== null) { - MutationObserver = _MutationObserver; - _MutationObserver = null; - } -}; diff --git a/karma/setup/proxyapis/performance.ts b/karma/setup/proxyapis/performance.ts deleted file mode 100644 index a4cb2787..00000000 --- a/karma/setup/proxyapis/performance.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createMockPerformanceObject } from "@karma/setup/mocks/performance"; - -const w: any = window; -let _performance: Performance = null; - -export function installPerformanceProxy(): void { - if (_performance === null) { - _performance = performance; - w.performance = createMockPerformanceObject(); - } -} - -export function uninstallPerformanceProxy(): void { - if (_performance !== null) { - w.performance = _performance; - _performance = null; - } -} diff --git a/karma/setup/proxyapis/worker.ts b/karma/setup/proxyapis/worker.ts deleted file mode 100644 index 3b06cab7..00000000 --- a/karma/setup/proxyapis/worker.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { IAddEventMessage, ICompressedBatchMessage, IWorkerMessage, WorkerMessageType } from "@clarity-types/compressionworker"; -import { publishAsync, publishSync, PubSubEvents } from "@karma/setup/pubsub"; -import { setRealTimeout } from "./jasmineclock"; - -interface IWorkerProxy extends Worker { - _postMessage: typeof Worker.prototype.postMessage; - _onmessage: typeof Worker.prototype.onmessage; -} - -let _Worker: typeof Worker = null; - -export const installWorkerProxy = (): void => { - if (_Worker === null) { - _Worker = Worker; - } - Worker = (function(aURL: string, options: WorkerOptions, ig: string): Worker { - const instance: IWorkerProxy = new _Worker(aURL, options) as IWorkerProxy; - instance._postMessage = instance.postMessage; - instance.postMessage = (msg: IWorkerMessage): void => { - instance._postMessage(msg); - processWorkerMessage(msg); - }; - - // In Clarity code Worker's 'ongmessage' handler is assigned synchronously immediately after its instantiation - // This means that at this time we don't have access to it and we need to come back and proxy it once its set. - // NOTE: This won't handle more complex scenarios where 'onmessage' might be set or changed asynchronously at - // some point in the future, however it's not something that's relevant to our use case right now. When/if such - // complexity comes along, we can use object mutation detection libraries to wrap updated handlers as well. - setRealTimeout(() => { - instance._onmessage = instance.onmessage; - instance.onmessage = (msgEvt: MessageEvent): void => { - instance._onmessage(msgEvt); - processWorkerMessage(msgEvt.data); - }; - }, 0); - - return instance; - }) as any as typeof Worker; -}; - -export const uninstallWorkerProxy = (): void => { - if (_Worker !== null) { - Worker = _Worker; - _Worker = null; - } -}; - -function processWorkerMessage(message: IWorkerMessage): void { - switch (message.type) { - - // Page ===> Worker - case WorkerMessageType.AddEvent: - let addEventMsg = message as IAddEventMessage; - publishSync(PubSubEvents.SYNC_AFTER_WORKER_ADD_EVENT_MESSAGE, addEventMsg); - publishAsync(PubSubEvents.WORKER_ADD_EVENT_MESSAGE, addEventMsg); - break; - case WorkerMessageType.ForceCompression: - publishAsync(PubSubEvents.WORKER_FORCE_COMPRESSION_MESSAGE, message); - break; - - // Page <=== Worker - case WorkerMessageType.CompressedBatch: - let uploadMsg = message as ICompressedBatchMessage; - publishSync(PubSubEvents.SYNC_AFTER_WORKER_COMPRESSED_BATCH_MESSAGE, message); - publishAsync(PubSubEvents.WORKER_COMPRESSED_BATCH_MESSAGE, uploadMsg); - break; - default: - break; - } -} diff --git a/karma/setup/pubsub.ts b/karma/setup/pubsub.ts deleted file mode 100644 index 8e9a7c14..00000000 --- a/karma/setup/pubsub.ts +++ /dev/null @@ -1,103 +0,0 @@ -import * as PubSub from "pubsub-js"; - -import { clearRealTimeout, setRealTimeout } from "./proxyapis/jasmineclock"; - -const _publishSync = PubSub.publishSync; - -// Development option per package documentation: -// https://www.npmjs.com/package/pubsub-js -(PubSub as any).immediateExceptions = true; - -// Our tests are instrumented with Jasmine clock, which means that async publish wouldn't trigger until we manually -// move the clock forward. However, jasmine clock tick is synchronous, so then publish would work as publishSync. -// One should use an explicit publishAsync utility function provided below to achieve true async behavior. -(PubSub as any).publish = (): boolean => { - throw new Error("Direct use of PubSub 'publish' is disallowed."); -}; - -// Synchronous publishing can get out of control very easily and result in some complicated bugs - avoid using it -// If it is absolutely required, use an explicity publishSync utility function provided below. -(PubSub as any).publishSync = (): boolean => { - throw new Error("Direct use of PubSub 'publishSync' is disallowed."); -}; - -export enum PubSubEvents { - - // Page ====> Worker messages - WORKER_ADD_EVENT_MESSAGE, - WORKER_FORCE_COMPRESSION_MESSAGE, - SYNC_AFTER_WORKER_ADD_EVENT_MESSAGE, - - // Page <==== Worker messages - WORKER_COMPRESSED_BATCH_MESSAGE, - SYNC_AFTER_WORKER_COMPRESSED_BATCH_MESSAGE, - - // End output monitoring - UPLOAD, - SYNC_AFTER_UPLOAD, - - // Browser callbacks - MUTATION, - CLICK, - SCROLL, - INPUT, - CHANGE, - UNKNOWN -} - -const timeouts: { [key: string]: number } = {}; - -// WARNING: Be careful with this and use only when absolutely required! -// PubSub docs say "USE WITH CAUTION, HERE BE DRAGONS!!!". I saw them - they are marvelous, but terrifying creatures. -// If you still end up using it, try to keep your sync subscribers as dumb as possible to perform some -// simple task and return immediately without calling other functions (ideally). -export function publishSync(message: any, data: any): void { - _publishSync.apply(PubSub, [message, data]); -} - -export function publishAsync(message: any, data: any): void { - const timeoutId = setRealTimeout(() => { - delete timeouts[timeoutId]; - _publishSync.apply(PubSub, [message, data]); - }, 0); - timeouts[timeoutId] = timeoutId; -} - -export function unsubscribeAll(): void { - PubSub.clearAllSubscriptions(); -} - -export function revokeAllMessages(): void { - const timeoutIds = Object.keys(timeouts); - for (const timeoutIdStr of timeoutIds) { - const timeoutId = timeouts[timeoutIdStr]; - clearRealTimeout(timeoutId); - delete timeouts[timeoutIdStr]; - } -} - -export async function waitFor(message: any, abandonAfterMs?: number): Promise { - return new Promise((resolve: any, reject: any): void => { - function onMessage(): void { - PubSub.unsubscribe(subToken); - clearRealTimeout(abandonTimeout); - resolve(true); - } - function onTimeout(): void { - PubSub.unsubscribe(subToken); - resolve(false); - } - let abandonTimeout: number = null; - const subToken = PubSub.subscribe(message, onMessage); - if (abandonAfterMs !== undefined) { - abandonTimeout = setRealTimeout(onTimeout, abandonAfterMs); - } - }); -} - -// Yielding thread allows subscribers receive and process async messages -export async function yieldThread(): Promise { - return new Promise((resolve: any, reject: any): void => { - setRealTimeout(resolve, 0); - }); -} diff --git a/karma/setup/testasync.ts b/karma/setup/testasync.ts deleted file mode 100644 index cfe10e10..00000000 --- a/karma/setup/testasync.ts +++ /dev/null @@ -1,9 +0,0 @@ -// There is a known issue where an (assertion) error within an async test doesn't cause the test to fail -// Instead, it causes a test timeout. Wrapping async test function with the code below lets us fail test -// immediately on (assertion) error, although stack trace and error message are altered slightly -// https://github.com/mochajs/mocha/issues/1128 -export function testAsync(testFn: (done: DoneFn) => Promise): (done: DoneFn) => void { - return (done: DoneFn): void => { - testFn(done).catch(done); - }; -} diff --git a/karma/setup/uncompress.ts b/karma/setup/uncompress.ts deleted file mode 100644 index 953e16bb..00000000 --- a/karma/setup/uncompress.ts +++ /dev/null @@ -1,190 +0,0 @@ -/* Credit: http://pieroxy.net/blog/pages/lz-string/index.html */ - -// Excluding 3rd party code from tslint -/* tslint:disable */ -export default function(compressed: string) { - // use decompressFromBase64 to parallel the compress function - var keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; - if (compressed == null) return ""; - if (compressed == "") return null; - var f = String.fromCharCode; - var length = compressed.length; - var resetValue = 32; - // @ts-ignore: Implicit 'any' type - var getNextValue = function(index) { return getBaseValue(keyStrBase64, compressed.charAt(index)); } - var baseReverseDic = {}; - - // @ts-ignore: Implicit 'any' type - function getBaseValue(alphabet, character) { - if (!baseReverseDic[alphabet]) { - baseReverseDic[alphabet] = {}; - for (var i = 0; i < alphabet.length; i++) { - baseReverseDic[alphabet][alphabet.charAt(i)] = i; - } - } - return baseReverseDic[alphabet][character]; - } - - var dictionary = [], - // Not modifying 3rd party code - suppressing ts error instead. - // @ts-ignore: Unused local variable - next, - enlargeIn = 4, - dictSize = 4, - numBits = 3, - entry = "", - result = [], - i, - w, - bits, resb, maxpower, power, - c, - data = { val: getNextValue(0), position: resetValue, index: 1 }; - - for (i = 0; i < 3; i += 1) { - dictionary[i] = i; - } - - bits = 0; - maxpower = Math.pow(2, 2); - power = 1; - while (power != maxpower) { - resb = data.val & data.position; - data.position >>= 1; - if (data.position == 0) { - data.position = resetValue; - data.val = getNextValue(data.index++); - } - bits |= (resb > 0 ? 1 : 0) * power; - power <<= 1; - } - - switch (next = bits) { - case 0: - bits = 0; - maxpower = Math.pow(2, 8); - power = 1; - while (power != maxpower) { - resb = data.val & data.position; - data.position >>= 1; - if (data.position == 0) { - data.position = resetValue; - data.val = getNextValue(data.index++); - } - bits |= (resb > 0 ? 1 : 0) * power; - power <<= 1; - } - c = f(bits); - break; - case 1: - bits = 0; - maxpower = Math.pow(2, 16); - power = 1; - while (power != maxpower) { - resb = data.val & data.position; - data.position >>= 1; - if (data.position == 0) { - data.position = resetValue; - data.val = getNextValue(data.index++); - } - bits |= (resb > 0 ? 1 : 0) * power; - power <<= 1; - } - c = f(bits); - break; - case 2: - return ""; - } - dictionary[3] = c; - w = c; - result.push(c); - while (true) { - if (data.index > length) { - return ""; - } - - bits = 0; - maxpower = Math.pow(2, numBits); - power = 1; - while (power != maxpower) { - resb = data.val & data.position; - data.position >>= 1; - if (data.position == 0) { - data.position = resetValue; - data.val = getNextValue(data.index++); - } - bits |= (resb > 0 ? 1 : 0) * power; - power <<= 1; - } - - switch (c = bits) { - case 0: - bits = 0; - maxpower = Math.pow(2, 8); - power = 1; - while (power != maxpower) { - resb = data.val & data.position; - data.position >>= 1; - if (data.position == 0) { - data.position = resetValue; - data.val = getNextValue(data.index++); - } - bits |= (resb > 0 ? 1 : 0) * power; - power <<= 1; - } - - dictionary[dictSize++] = f(bits); - c = dictSize - 1; - enlargeIn--; - break; - case 1: - bits = 0; - maxpower = Math.pow(2, 16); - power = 1; - while (power != maxpower) { - resb = data.val & data.position; - data.position >>= 1; - if (data.position == 0) { - data.position = resetValue; - data.val = getNextValue(data.index++); - } - bits |= (resb > 0 ? 1 : 0) * power; - power <<= 1; - } - dictionary[dictSize++] = f(bits); - c = dictSize - 1; - enlargeIn--; - break; - case 2: - return result.join(''); - } - - if (enlargeIn == 0) { - enlargeIn = Math.pow(2, numBits); - numBits++; - } - - if (dictionary[c]) { - // @ts-ignore: Type 'string | number' is not assignable to type 'string' - entry = dictionary[c]; - } else { - if (c === dictSize) { - entry = w + w.charAt(0); - } else { - return null; - } - } - result.push(entry); - - // Add w+entry[0] to the dictionary. - dictionary[dictSize++] = w + entry.charAt(0); - enlargeIn--; - - w = entry; - - if (enlargeIn == 0) { - enlargeIn = Math.pow(2, numBits); - numBits++; - } - - } -} diff --git a/karma/setup/watch.ts b/karma/setup/watch.ts deleted file mode 100644 index e69eda22..00000000 --- a/karma/setup/watch.ts +++ /dev/null @@ -1,91 +0,0 @@ -import uncompress from "./uncompress"; - -import { IAddEventMessage, ICompressedBatchMessage } from "@clarity-types/compressionworker"; -import { IEvent, IEventArray, IPayload } from "@clarity-types/core"; -import { decode } from "@src/converters/convert"; -import { SchemaManager } from "@src/converters/schema"; -import { PubSubEvents } from "./pubsub"; - -interface IWatchResult { - coreEvents: IEvent[]; - compressedEvents: IEvent[]; - sentEvents: IEvent[]; -} - -let schemas: SchemaManager = new SchemaManager(); -let watchResult: IWatchResult = null; -let fullImpression: IWatchResult = null; -let addEventMessageSub: any = null; -let compressedBatchSub: any = null; -let uploadSub: any = null; -let watching: boolean = false; - -export function watch(): any { - if (watching) { - throw new Error("Already watching"); - } else { - watchResult = { - coreEvents: [], - compressedEvents: [], - sentEvents: [] - }; - watching = true; - } -} - -export function stopWatching(): IWatchResult { - watching = false; - return watchResult; -} - -export function getFullImpressionWatchResult(): IWatchResult { - return JSON.parse(JSON.stringify(fullImpression)); -} - -export function resetWatcher(): void { - fullImpression = { - coreEvents: [], - compressedEvents: [], - sentEvents: [] - }; - schemas.reset(); - - // Resubscribe to all relevant topics - PubSub.unsubscribe(addEventMessageSub); - PubSub.unsubscribe(compressedBatchSub); - PubSub.unsubscribe(uploadSub); - - addEventMessageSub = PubSub.subscribe(PubSubEvents.SYNC_AFTER_WORKER_ADD_EVENT_MESSAGE, onAddEventMessage); - compressedBatchSub = PubSub.subscribe(PubSubEvents.SYNC_AFTER_WORKER_COMPRESSED_BATCH_MESSAGE, onCompressedBatch); - uploadSub = PubSub.subscribe(PubSubEvents.SYNC_AFTER_UPLOAD, onUpload); -} - -export function onAddEventMessage(pubSubMessage: any, workerMessage: IAddEventMessage): void { - const decodedEvent: IEvent = decode(workerMessage.event, schemas); - fullImpression.coreEvents.push(decodedEvent); - if (watching) { - watchResult.coreEvents.push(decodedEvent); - } -} - -export function onCompressedBatch(pubSubMessage: any, workerMessage: ICompressedBatchMessage): void { - const compressedEvents = workerMessage.rawData.events.map((encodedEvent: IEventArray): IEvent => decode(encodedEvent, schemas)); - fullImpression.compressedEvents.push(...compressedEvents); - if (watching) { - watchResult.compressedEvents.push(...compressedEvents); - } -} - -export function onUpload(message: any, data: any): void { - const compressedPayload = data; - const payload: IPayload = JSON.parse(uncompress(compressedPayload)); - const decodedEvents = payload.events.map((encodedEvent: IEventArray): IEvent => decode(encodedEvent, schemas)); - fullImpression.sentEvents.push(...decodedEvents); - if (watching) { - watchResult.sentEvents.push(...decodedEvents); - } -} - -export function filterEventsByType(events: IEvent[], type: string): IEvent[] { - return events.filter((event: IEvent): boolean => event.type === type); -} diff --git a/karma/tests/_setup/asyncstart.ts b/karma/tests/_setup/asyncstart.ts deleted file mode 100644 index 0c52c8a8..00000000 --- a/karma/tests/_setup/asyncstart.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { restartClarity } from "@karma/setup/clarity"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { setRealTimeout } from "@karma/setup/proxyapis/jasmineclock"; -import { testAsync } from "@karma/setup/testasync"; -import { getFullImpressionWatchResult, stopWatching, watch } from "@karma/setup/watch"; -import { assert } from "chai"; - -describe("Setup: Asynchronous clarity start tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("checks that intial activity events have been generated and sent on async start", (done: DoneFn) => { - const fullImpressionWatch = getFullImpressionWatchResult(); - assert.isAbove(fullImpressionWatch.coreEvents.length, 0); - assert.isAbove(fullImpressionWatch.sentEvents.length, 0); - assert.equal(fullImpressionWatch.coreEvents.length, fullImpressionWatch.sentEvents.length); - done(); - }); - - it("checks that 'setupPage' flushes initial Clarity synchronous activity", (done: DoneFn) => { - watch(); - jasmine.clock().tick(10000000); - const events = stopWatching().coreEvents; - assert.equal(events.length, 0); - done(); - }); - - it("checks that 'setupPage' flushes initial Clarity asynchronous activity", (done: DoneFn) => { - watch(); - setRealTimeout(() => { - const events = stopWatching().coreEvents; - assert.equal(events.length, 0); - done(); - }, 1); - }); - - it("checks that 'setupPage' flushes all initial Clarity activity but nothing's sent in background mode", - testAsync(async (done: DoneFn) => { - await restartClarity({ backgroundMode: true }); - setRealTimeout(() => { - const allEvents = getFullImpressionWatchResult(); - assert.equal(allEvents.coreEvents.length, allEvents.compressedEvents.length); - assert.equal(allEvents.sentEvents.length, 0); - done(); - }, 1); - }) - ); - -}); diff --git a/karma/tests/_setup/pubsub.ts b/karma/tests/_setup/pubsub.ts deleted file mode 100644 index a82d4d17..00000000 --- a/karma/tests/_setup/pubsub.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { PubSubEvents, waitFor } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { assert } from "chai"; - -describe("Setup: PubSub Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("checks that 'waitFor' async function works as intended", testAsync(async (done: DoneFn) => { - watch(); - document.body.appendChild(document.createElement("div")); - await waitFor(PubSubEvents.MUTATION); - const events = stopWatching().coreEvents; - assert.isAbove(events.length, 0); - done(); - })); - -}); diff --git a/karma/tests/_setup/syncstart.ts b/karma/tests/_setup/syncstart.ts deleted file mode 100644 index 548e734d..00000000 --- a/karma/tests/_setup/syncstart.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { getFullImpressionWatchResult } from "@karma/setup/watch"; -import { assert } from "chai"; - -describe("Setup: Synchronous clarity start tests", () => { - - beforeEach((done: DoneFn) => setupPage(done, {}, { flushInitialActivity: false })); - afterEach(cleanupPage); - - it("checks that events have been generated but not sent yet on synchonous start", (done: DoneFn) => { - const fullImpressionWatch = getFullImpressionWatchResult(); - assert.isAbove(fullImpressionWatch.coreEvents.length, 0); - assert.equal(fullImpressionWatch.sentEvents.length, 0); - done(); - }); - -}); diff --git a/karma/tests/compressionworker.ts b/karma/tests/compressionworker.ts deleted file mode 100644 index be71a900..00000000 --- a/karma/tests/compressionworker.ts +++ /dev/null @@ -1,158 +0,0 @@ -import schemas from "@src/converters/schema"; - -import { IAddEventMessage, ITimestampedWorkerMessage, WorkerMessageType } from "@clarity-types/compressionworker"; -import { IEvent } from "@clarity-types/core"; -import { Instrumentation, IXhrErrorEventState } from "@clarity-types/instrumentation"; -import { createMockEnvelope, createMockEvent, MockEventName } from "@karma/setup/mocks/event"; -import { installWorkerProxy, uninstallWorkerProxy } from "@karma/setup/proxyapis/worker"; -import { PubSubEvents, unsubscribeAll, waitFor } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { resetWatcher, stopWatching, watch } from "@karma/setup/watch"; -import { createCompressionWorker } from "@src/compressionworker"; -import { encode } from "@src/converters/convert"; -import { assert } from "chai"; - -// TODO: Remove src imports and modify compression worker to accept batchLimit as an argument -import { config } from "@src/config"; - -const InstrumentationEventName = "Instrumentation"; -const WorkerMessageWaitTime = 1000; - -describe("Compression Worker Tests", () => { - let worker: Worker = null; - - beforeEach(() => { - resetWatcher(); - installWorkerProxy(); - schemas.reset(); - worker = createTestWorker(); - }); - afterEach(() => { - worker.terminate(); - unsubscribeAll(); - uninstallWorkerProxy(); - }); - - it("validates that events are batched for upload correctly when total length is below the limit", - testAsync(async (done: DoneFn) => { - watch(); - let firstMockEventName = "FirstMockEvent"; - let firstMockEvent = createMockEvent(firstMockEventName); - let addEventMessage = createAddEventMessage(firstMockEvent); - worker.postMessage(addEventMessage); - - let secondMockEventName = "SecondMockEvent"; - let secondMockEvent = createMockEvent(secondMockEventName); - addEventMessage.event = encode(secondMockEvent); - worker.postMessage(addEventMessage); - - let forceCompressionMsg = createForceCompressionMessage(); - worker.postMessage(forceCompressionMsg); - await waitFor(PubSubEvents.WORKER_COMPRESSED_BATCH_MESSAGE); - - const compressedEvents = stopWatching().compressedEvents; - assert.equal(compressedEvents.length, 2); - assert.equal(compressedEvents[0].type, firstMockEventName); - assert.equal(compressedEvents[1].type, secondMockEventName); - done(); - - }) - ); - - it("validates that events are batched for upload correctly when total length is above the limit", - testAsync(async (done: DoneFn) => { - watch(); - let firstMockEventName = "FirstMockEvent"; - let firstMockEvent = createMockEvent(firstMockEventName); - let firstMockData = Array(Math.round(config.batchLimit * (2 / 3))).join("1"); - firstMockEvent.state = { data: firstMockData }; - let addEventMessage = createAddEventMessage(firstMockEvent); - worker.postMessage(addEventMessage); - - let secondMockEventName = "SecondMockEvent"; - let secondMockEvent = createMockEvent(secondMockEventName); - let secondMockData = Array(Math.round(config.batchLimit / 2)).join("2"); - secondMockEvent.state = { data: secondMockData }; - addEventMessage.event = encode(secondMockEvent); - worker.postMessage(addEventMessage); - - let forceCompressionMsg = createForceCompressionMessage(); - worker.postMessage(forceCompressionMsg); - await waitFor(PubSubEvents.WORKER_COMPRESSED_BATCH_MESSAGE); - - const firstBatchEvents = stopWatching().compressedEvents; - watch(); - await waitFor(PubSubEvents.WORKER_COMPRESSED_BATCH_MESSAGE); - - const secondBatchEvents = stopWatching().compressedEvents; - assert.equal(firstBatchEvents.length, 1); - assert.equal(firstBatchEvents[0].type, firstMockEventName); - assert.equal(secondBatchEvents.length, 1); - assert.equal(secondBatchEvents[0].type, secondMockEventName); - done(); - }) - ); - - it("validates that single event is sent when its own length is above the limit", testAsync(async (done: DoneFn) => { - watch(); - let mockEvent = createMockEvent(); - let mockEventData = Array(Math.round(config.batchLimit + 1)).join("1"); - mockEvent.state = { data: mockEventData }; - let addEventMessage = createAddEventMessage(mockEvent); - worker.postMessage(addEventMessage); - await waitFor(PubSubEvents.WORKER_COMPRESSED_BATCH_MESSAGE); - const compressedEvents = stopWatching().compressedEvents; - assert.equal(compressedEvents.length, 1); - assert.equal(compressedEvents[0].type, MockEventName); - done(); - })); - - it("validates that payloads consisting of a single XHR error event are not uploaded", - testAsync(async (done: DoneFn) => { - let mockXhrErrorEvent = createMockEvent(InstrumentationEventName); - let mockXhrErrorEventState = { - type: Instrumentation.XhrError - } as IXhrErrorEventState; - mockXhrErrorEvent.state = mockXhrErrorEventState; - const addEventMessage = createAddEventMessage(mockXhrErrorEvent); - // let addEventMessage: IAddEventMessage = { - // type: WorkerMessageType.AddEvent, - // time: -1, - // event: MockEventToArray(mockXhrErrorEvent), - // isXhrErrorEvent: true - // }; - worker.postMessage(addEventMessage); - - let forceCompressionMsg = createForceCompressionMessage(); - worker.postMessage(forceCompressionMsg); - const receivedCompressedBatch = await waitFor(PubSubEvents.UPLOAD, WorkerMessageWaitTime); - - receivedCompressedBatch - ? done.fail("Received batch consisting of a single XHR event") - : done(); - }) - ); - - function createTestWorker(): Worker { - return createCompressionWorker(createMockEnvelope(), () => { - // ignore - }); - } - - function createAddEventMessage(event: IEvent): IAddEventMessage { - let addEventMessage: IAddEventMessage = { - type: WorkerMessageType.AddEvent, - time: -1, - event: encode(event) - }; - return addEventMessage; - } - - function createForceCompressionMessage(): ITimestampedWorkerMessage { - let forceCompressionMessage: ITimestampedWorkerMessage = { - type: WorkerMessageType.ForceCompression, - time: -1 - }; - return forceCompressionMessage; - } -}); diff --git a/karma/tests/convert.ts b/karma/tests/convert.ts deleted file mode 100644 index 06e33263..00000000 --- a/karma/tests/convert.ts +++ /dev/null @@ -1,28 +0,0 @@ -import FromArray from "@src/converters/fromarray"; -import ToArray from "@src/converters/toarray"; - -import { IEvent } from "@clarity-types/core"; -import { createMockEvent } from "@karma/setup/mocks/event"; -import { assert } from "chai"; - -describe("Data Conversion Tests", () => { - - it("validates that conversion works", (done: DoneFn) => { - let mapObj = { - country: "USA", - cities: ["Seattle", "Boston"], - properties: { - zoom: 1.5 - } - }; - - let evt: IEvent = createMockEvent(); - evt.state = mapObj; - - let array = ToArray(evt); - let original = FromArray(array); - assert.equal(JSON.stringify(evt).length, JSON.stringify(original).length); - done(); - }); - -}); diff --git a/karma/tests/core/compression.ts b/karma/tests/core/compression.ts deleted file mode 100644 index f34ea66f..00000000 --- a/karma/tests/core/compression.ts +++ /dev/null @@ -1,18 +0,0 @@ -import uncompress from "@karma/setup/uncompress"; -import compress from "@src/compress"; - -import { assert } from "chai"; - -describe("Data Compression Tests", () => { - - it("validates that compression works", () => { - let str = "This is a string with some repetitions in the string."; - str += str + str; - let compressed = compress(str); - let uncompressed = uncompress(compressed); - assert.equal(compressed.length > 0 && compressed.length < str.length, true); - assert.equal(compressed === str, false); - assert.equal(uncompressed === str, true); - }); - -}); diff --git a/karma/tests/core/core.ts b/karma/tests/core/core.ts deleted file mode 100644 index 4a00f6e6..00000000 --- a/karma/tests/core/core.ts +++ /dev/null @@ -1,58 +0,0 @@ -import * as PubSub from "pubsub-js"; - -import { getActiveConfig, getVersion, triggerMutationEvent } from "@karma/setup/clarity"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { PubSubEvents, waitFor, yieldThread } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { assert } from "chai"; - -describe("Core Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("validates that core.ts version matches package.json", (done: DoneFn) => { - let testJsons = window["__test_jsons"]; - let packageJson = testJsons && testJsons["package"]; - assert.equal(packageJson.version, getVersion()); - done(); - }); - - it("validates that force compression message is sent after config.delay milliseconds without new events", - testAsync(async (done: DoneFn) => { - triggerMutationEvent(); - await waitFor(PubSubEvents.MUTATION); - PubSub.subscribe(PubSubEvents.WORKER_FORCE_COMPRESSION_MESSAGE, done); - jasmine.clock().tick(getActiveConfig().delay); - }) - ); - - it("validates that force compression message is NOT sent after less than config.delay milliseconds without new events", - testAsync(async (done: DoneFn) => { - triggerMutationEvent(); - await waitFor(PubSubEvents.MUTATION); - jasmine.clock().tick(getActiveConfig().delay - 1); - PubSub.subscribe(PubSubEvents.WORKER_FORCE_COMPRESSION_MESSAGE, () => { done.fail("Received force compression message"); }); - await yieldThread(); - done(); - }) - ); - - it("validates that force compression timeout is reset with each new event", testAsync(async (done: DoneFn) => { - PubSub.subscribe(PubSubEvents.WORKER_FORCE_COMPRESSION_MESSAGE, () => { done.fail("Received force compression message"); }); - - triggerMutationEvent(); - await waitFor(PubSubEvents.MUTATION); - jasmine.clock().tick(getActiveConfig().delay - 1); - - // If force compression timeout wasn't reset between two events, second config.delay - 1 tick - // would trigger force compression and fail the test - triggerMutationEvent(); - await waitFor(PubSubEvents.MUTATION); - jasmine.clock().tick(getActiveConfig().delay - 1); - - await yieldThread(); - done(); - })); - -}); diff --git a/karma/tests/core/teardown.ts b/karma/tests/core/teardown.ts deleted file mode 100644 index 10eb9d63..00000000 --- a/karma/tests/core/teardown.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { restartClarity, stopClarity, triggerMutationEventAndWaitForUpload } from "@karma/setup/clarity"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { testAsync } from "@karma/setup/testasync"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { assert } from "chai"; - -describe("Core: Teardown Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("validates that pending events are sent on teardown", (done: DoneFn) => { - // Start clarity synchronously, so that initial events are "stuck" in compression workers when we call stopClarity() - restartClarity({}, { flushInitialActivity: false }, watch); - stopClarity(); - const sentEvents = stopWatching().sentEvents; - assert.isAbove(sentEvents.length, 0); - done(); - }); - - it("validates that config is reset to default on teardown", testAsync(async (done: DoneFn) => { - const mockSensitiveAttr: string = "mock-sensitive-attribute"; - - watch(); - const triggerAttributes = {}; - triggerAttributes[mockSensitiveAttr] = "1"; - await triggerMutationEventAndWaitForUpload(triggerAttributes); - - let events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.attributes[mockSensitiveAttr], "1"); - - // Sensitive attributes are masked - await restartClarity({ sensitiveAttributes: [mockSensitiveAttr] }); - await restartClarity(); - - watch(); - await triggerMutationEventAndWaitForUpload(triggerAttributes); - - events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.attributes[mockSensitiveAttr], "1"); - done(); - })); - -}); diff --git a/karma/tests/core/trigger.ts b/karma/tests/core/trigger.ts deleted file mode 100644 index 0f883642..00000000 --- a/karma/tests/core/trigger.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { stopClarity, triggerClarityAndWaitForUpload } from "@karma/setup/clarity"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { testAsync } from "@karma/setup/testasync"; -import { getFullImpressionWatchResult } from "@karma/setup/watch"; -import { assert } from "chai"; - -describe("Core: Trigger Tests", () => { - - beforeEach((done: DoneFn) => setupPage(done, { backgroundMode: true })); - afterEach(cleanupPage); - - it("validates that nothing is sent on teardown in background mode without trigger", (done: DoneFn) => { - stopClarity(); - const allSentEvents = getFullImpressionWatchResult().sentEvents; - assert.equal(allSentEvents.length, 0); - done(); - }); - - it("validates that upload queue is flushed when Clarity trigger is fired", testAsync(async (done: DoneFn) => { - let allSentEvents = getFullImpressionWatchResult().sentEvents; - assert.equal(allSentEvents.length, 0); - await triggerClarityAndWaitForUpload(); - allSentEvents = getFullImpressionWatchResult().sentEvents; - assert.isAbove(allSentEvents.length, 0); - done(); - })); - -}); diff --git a/karma/tests/core/upload.ts b/karma/tests/core/upload.ts deleted file mode 100644 index 1848fb4a..00000000 --- a/karma/tests/core/upload.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { UploadCallback } from "@clarity-types/core"; -import { Instrumentation } from "@clarity-types/instrumentation"; -import { Action } from "@clarity-types/layout"; -import { getActiveConfig, restartClarity, triggerMutationEvent, triggerMutationEventAndWaitForUpload } from "@karma/setup/clarity"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { PubSubEvents, waitFor } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { assert } from "chai"; - -const InstrumentationEventName = "Instrumentation"; -const LayoutEventName = "Layout"; - -describe("Data Upload Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("validates that custom upload handler is invoked when passed through config", testAsync(async (done: DoneFn) => { - let customerUploadHandlerInvoked: boolean = false; - function customUploadHandler(payload: string, onSuccess: UploadCallback, onFailure?: UploadCallback): void { - customerUploadHandlerInvoked = true; - } - await restartClarity({ uploadHandler: customUploadHandler }); - - assert.equal(customerUploadHandlerInvoked, true); - done(); - })); - - it("validates that XhrError is logged for failed requests through the 'onFailure' upload callback", - testAsync(async (done: DoneFn) => { - let shouldMockFailure: boolean = false; - function customUploadHandler(payload: string, onSuccess: UploadCallback, onFailure?: UploadCallback): void { - if (shouldMockFailure) { - // Suppose XHR was opened and returned a 400 code - onFailure(400); - } - } - await restartClarity({ - instrument: true, - uploadHandler: customUploadHandler - }); - - shouldMockFailure = true; - - // Trigger event and wait for it to be processed by core, so that it doesn't pollute our watch - triggerMutationEvent(); - await waitFor(PubSubEvents.MUTATION); - - watch(); - jasmine.clock().tick(getActiveConfig().delay); - await waitFor(PubSubEvents.UPLOAD); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].type, InstrumentationEventName); - assert.equal(events[0].state.type, Instrumentation.XhrError); - assert.equal(events[0].state.requestStatus, 400); - done(); - }) - ); - - it("validates that Clarity tears down and logs instrumentation when total byte limit is exceeded", - testAsync(async (done: DoneFn) => { - let shouldMockSuccess: boolean = false; - function customUploadHandler(payload: string, onSuccess: UploadCallback, onFailure?: UploadCallback): void { - if (shouldMockSuccess) { - // Internal onSuccess callback is where clarity increments uploaded bytes and matches it against the configured limit - onSuccess(200); - } - } - await restartClarity({ - instrument: true, - totalLimit: 0, - uploadHandler: customUploadHandler - }); - - shouldMockSuccess = true; - - // Trigger event and wait for it to be processed by core, so that it doesn't pollute our watch - triggerMutationEvent(); - await waitFor(PubSubEvents.MUTATION); - - watch(); - jasmine.clock().tick(getActiveConfig().delay); - await waitFor(PubSubEvents.UPLOAD); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 2); - assert.equal(events[0].state.type, Instrumentation.TotalByteLimitExceeded); - assert.equal(events[1].state.type, Instrumentation.Teardown); - done(); - }) - ); - - it("validates that dropped payloads are re-sent through the next request's 'onSuccess' callback", - testAsync(async (done: DoneFn) => { - const triggerKeyAttrName: string = "data-trigger-key"; - let shouldMockSuccess: boolean = false; - let shouldMockFailure: boolean = false; - function customUploadHandler(payload: string, onSuccess: UploadCallback, onFailure?: UploadCallback): void { - if (shouldMockSuccess) { - // Internal onSuccess callback is where clarity increments uploaded bytes and matches it against the configured limit - onSuccess(200); - } - if (shouldMockFailure) { - // Suppose XHR was opened and returned a 400 code - onFailure(400); - } - } - await restartClarity({ - instrument: true, - uploadHandler: customUploadHandler - }); - - // Part 1: Mock an XHR failure, so that trigger event 1 is stored for re-delivery and XHR failure instrumentation is logged - shouldMockFailure = true; - const firstTriggerAttrs = {}; - firstTriggerAttrs[triggerKeyAttrName] = "1"; - const firstTrigger = await triggerMutationEventAndWaitForUpload(firstTriggerAttrs); - - // Part 2: Successfully send second payload, which should trigger re-send of the first payload - shouldMockFailure = false; - shouldMockSuccess = true; - watch(); - const secondTriggerAttrs = {}; - secondTriggerAttrs[triggerKeyAttrName] = "2"; - const secondTrigger = await triggerMutationEventAndWaitForUpload(secondTriggerAttrs); - - // We expect to have 5 events: - // 1. XHR error: this event got generated immediately after trigger event 1 upload failed - // 2. Trigger event 2: Our manually-crafted second trigger, after which we waited for upload (#1 + #2 event batch) - // 3. Mutation perf - // 4. Trigger event 1: Upload from the line above, synchronously triggers a re-upload of event trigger 1 - // 5. Mutation perf - const sentEvents = stopWatching().sentEvents; - assert.equal(sentEvents.length, 5); - assert.equal(sentEvents[0].type, InstrumentationEventName); - assert.equal(sentEvents[0].state.type, Instrumentation.XhrError); - assert.equal(sentEvents[0].state.requestStatus, 400); - assert.equal(sentEvents[1].type, LayoutEventName); - assert.equal(sentEvents[1].state.action, Action.Insert); - assert.equal(sentEvents[1].state.attributes[triggerKeyAttrName], secondTrigger.attributes[triggerKeyAttrName].value); - assert.equal(sentEvents[2].type, InstrumentationEventName); - assert.equal(sentEvents[2].state.procedure, "Mutation"); - assert.equal(sentEvents[3].type, LayoutEventName); - assert.equal(sentEvents[3].state.action, Action.Insert); - assert.equal(sentEvents[3].state.attributes[triggerKeyAttrName], firstTrigger.attributes[triggerKeyAttrName].value); - assert.equal(sentEvents[4].type, InstrumentationEventName); - assert.equal(sentEvents[4].state.procedure, "Mutation"); - done(); - }) - ); - -}); diff --git a/karma/tests/instrumentation/activate.ts b/karma/tests/instrumentation/activate.ts deleted file mode 100644 index 189e99a2..00000000 --- a/karma/tests/instrumentation/activate.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Instrumentation } from "@clarity-types/instrumentation"; -import { restartClarity } from "@karma/setup/clarity"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { testAsync } from "@karma/setup/testasync"; -import { getFullImpressionWatchResult } from "@karma/setup/watch"; -import { ClarityAttribute } from "@src/core"; -import { assert } from "chai"; - -const instrumentationEventName = "Instrumentation"; - -describe("Instrumentation: Activate Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("validates that missing feature event is sent when required feature is missing", testAsync(async (done: DoneFn) => { - - // Function.prototype.bind is a required API for Clarity to work - // Mocking a browser that doesn't support it by temporarily deleting it - const _bind = Function.prototype.bind; - Function.prototype.bind = undefined; - await restartClarity({ instrument: true }); - Function.prototype.bind = _bind; - - const events = getFullImpressionWatchResult().sentEvents; - assert.equal(events.length, 2); - assert.equal(events[0].type, instrumentationEventName); - assert.equal(events[0].state.type, Instrumentation.MissingFeature); - assert.equal(events[1].type, instrumentationEventName); - assert.equal(events[1].state.type, Instrumentation.Teardown); - done(); - })); - - it("validates that error during clarity activate is caught and logged correctly", testAsync(async (done: DoneFn) => { - const _worker = Worker; - const mockErrorText = "Mock error!"; - Worker = ((): void => { throw new Error(mockErrorText); }) as any; - await restartClarity({ instrument: true }); - Worker = _worker; - - const events = getFullImpressionWatchResult().sentEvents; - assert.equal(events.length, 2); - assert.equal(events[0].type, instrumentationEventName); - assert.equal(events[0].state.type, Instrumentation.ClarityActivateError); - assert.equal(events[0].state.error, mockErrorText); - assert.equal(events[1].type, instrumentationEventName); - assert.equal(events[1].state.type, Instrumentation.Teardown); - done(); - })); - - it("validates that Clarity logs instrumentation and tears down, when another instance of Clarity is running", - testAsync(async (done: DoneFn) => { - const mockExistingImpressionId = "MockExistingImpressionId"; - await restartClarity({ instrument: true }, null, () => { - document[ClarityAttribute] = mockExistingImpressionId; - }); - - const events = getFullImpressionWatchResult().sentEvents; - assert.equal(events.length, 2); - assert.equal(events[0].type, instrumentationEventName); - assert.equal(events[0].state.type, Instrumentation.ClarityDuplicated); - assert.equal(events[0].state.currentImpressionId, mockExistingImpressionId); - assert.equal(events[1].type, instrumentationEventName); - assert.equal(events[1].state.type, Instrumentation.Teardown); - done(); - }) - ); - -}); diff --git a/karma/tests/instrumentation/error.ts b/karma/tests/instrumentation/error.ts deleted file mode 100644 index 71bd0de8..00000000 --- a/karma/tests/instrumentation/error.ts +++ /dev/null @@ -1,101 +0,0 @@ -// @ts-ignore: 'merge' implicityly has 'any' type. There are no typings for 'merge' -import * as merge from "merge"; - -import { Instrumentation } from "@clarity-types/instrumentation"; -import { MockErrorInit, MockErrorMessage } from "@karma/setup/mocks/error"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { assert } from "chai"; - -describe("Error Tests", () => { - - let jasmineErrorHandler: ErrorEventHandler = null; - - function preventDefault(e: Event): void { - e.preventDefault(); - } - - beforeEach((done: DoneFn) => { - setupPage(done, ({ instrument: true })); - jasmineErrorHandler = window.onerror; - window.onerror = null; - window.addEventListener("error", preventDefault); - }); - afterEach(() => { - window.removeEventListener("error", preventDefault); - window.onerror = jasmineErrorHandler; - cleanupPage(); - }); - - it("checks that a single error event is logged", (done: DoneFn) => { - watch(); - throwError(MockErrorMessage); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.type, Instrumentation.JsError); - assert.equal(events[0].state.message, MockErrorMessage); - assert.equal(events[0].state.source, MockErrorInit.filename); - assert.equal(events[0].state.lineno, MockErrorInit.lineno); - assert.equal(events[0].state.colno, MockErrorInit.colno); - done(); - }); - - it("checks empty message when not passed", (done: DoneFn) => { - watch(); - throwError(); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.type, Instrumentation.JsError); - assert.equal(events[0].state.message, "undefined"); - assert.equal(events[0].state.source, MockErrorInit.filename); - assert.equal(events[0].state.lineno, MockErrorInit.lineno); - assert.equal(events[0].state.colno, MockErrorInit.colno); - done(); - }); - - it("checks that multiple error events are logged", (done: DoneFn) => { - const secondMockErrorMessage = "SecondMockErrorMessage"; - watch(); - throwError(MockErrorMessage); - throwError(secondMockErrorMessage); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 2); - assert.equal(events[0].state.type, Instrumentation.JsError); - assert.equal(events[0].state.message, MockErrorMessage); - assert.equal(events[1].state.type, Instrumentation.JsError); - assert.equal(events[1].state.message, secondMockErrorMessage); - done(); - }); - - it("checks that error event's message is logged, when error object is not provided", (done: DoneFn) => { - watch(); - throwError(null, { error: null }); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.type, Instrumentation.JsError); - assert.equal(events[0].state.message, MockErrorInit.message); - done(); - }); - - function throwError(errMessage?: string, errEventInit?: ErrorEventInit): void { - const errInit = merge(true, MockErrorInit, errEventInit); - const script = document.createElement("script"); - script.type = "text/javascript"; - script.innerText = ` - (function() { - var errInit = ${JSON.stringify(errInit)}; - if (!("error" in errInit)) { - errInit.error = new Error("${errMessage}"); - } - var errEvt = new ErrorEvent("error", errInit); - window.dispatchEvent(errEvt); - }()); - `; - document.body.appendChild(script); - } - -}); diff --git a/karma/tests/instrumentation/perf.ts b/karma/tests/instrumentation/perf.ts deleted file mode 100644 index 039898c2..00000000 --- a/karma/tests/instrumentation/perf.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { IEvent } from "@clarity-types/core"; -import { Instrumentation } from "@clarity-types/instrumentation"; -import { restartClarity } from "@karma/setup/clarity"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { PubSubEvents, waitFor } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { getFullImpressionWatchResult, stopWatching, watch } from "@karma/setup/watch"; -import { assert } from "chai"; - -const instrumentationEventName = "Instrumentation"; - -describe("Instrumentation: Performance Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("validates that discover performance instrumentation is logged", testAsync(async (done: DoneFn) => { - await restartClarity({ instrument: true }); - - const sentEvents = getFullImpressionWatchResult().sentEvents; - let discoverPerfEvent: IEvent = null; - for (let i = 0; i < sentEvents.length; i++) { - const event: IEvent = sentEvents[i]; - if ( - event.type === instrumentationEventName - && event.state.type === Instrumentation.Performance - && event.state.procedure === "Discover" - ) { - discoverPerfEvent = event; - } - } - - assert.notEqual(discoverPerfEvent, null); - assert.isNumber(discoverPerfEvent.state.duration); - assert.isAbove(discoverPerfEvent.state.nodeCount, 0); - done(); - })); - - it("validates that mutation performance instrumentation is logged", testAsync(async (done: DoneFn) => { - await restartClarity({ instrument: true }); - - watch(); - let div = document.createElement("div"); - document.body.appendChild(div); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 2); - assert.equal(events[0].type, "Layout"); - assert.equal(events[1].type, instrumentationEventName); - assert.equal(events[1].state.type, Instrumentation.Performance); - assert.equal(events[1].state.procedure, "Mutation"); - assert.equal(events[1].state.mutationCount, 1); - assert.equal(events[1].state.mutationSequence, 0); - assert.isNumber(events[1].state.stateGenDuration); - assert.equal(events[1].state.summaryCounts.inserts, 1); - assert.equal(events[1].state.summaryCounts.moves, 0); - assert.equal(events[1].state.summaryCounts.updates, 0); - assert.equal(events[1].state.summaryCounts.removes, 0); - - done(); - })); -}); diff --git a/karma/tests/instrumentation/trigger.ts b/karma/tests/instrumentation/trigger.ts deleted file mode 100644 index 2fb27140..00000000 --- a/karma/tests/instrumentation/trigger.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Instrumentation } from "@clarity-types/instrumentation"; -import { restartClarity, triggerClarity } from "@karma/setup/clarity"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { testAsync } from "@karma/setup/testasync"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { assert } from "chai"; - -const instrumentationEventName = "Instrumentation"; - -describe("Instrumentation: Trigger Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("validates that trigger instrumentation is logged when Clarity trigger is fired", testAsync(async (done: DoneFn) => { - const triggerKey = "Test Trigger"; - await restartClarity({ instrument: true, backgroundMode: true }); - - watch(); - triggerClarity(triggerKey); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].type, instrumentationEventName); - assert.equal(events[0].state.type, Instrumentation.Trigger); - assert.equal(events[0].state.key, triggerKey); - done(); - })); -}); diff --git a/karma/tests/layout/cssrules.ts b/karma/tests/layout/cssrules.ts deleted file mode 100644 index 1de18554..00000000 --- a/karma/tests/layout/cssrules.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Action } from "@clarity-types/layout"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { PubSubEvents, waitFor } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { assert } from "chai"; - -describe("Layout: CSS Rules Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("checks that we capture cssRule modifications via javascript when no innerText found", testAsync(async (done: DoneFn) => { - watch(); - - // Add a style tag and later modify styles using javascript - let dom = document.getElementById("clarity"); - let style = document.createElement("style"); - dom.appendChild(style); - let stylesheet = style.sheet as CSSStyleSheet; - stylesheet.insertRule("body { color: red; }"); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - - // Assert that style state has css rules and that style's child text node is ignored - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(!!events[0].state.cssRules, true); - assert.equal(events[0].state.cssRules.length, 1); - assert.equal(events[0].state.cssRules[0].indexOf("red") > 0, true); - done(); - })); - -}); diff --git a/karma/tests/layout/input.ts b/karma/tests/layout/input.ts deleted file mode 100644 index 1d8def36..00000000 --- a/karma/tests/layout/input.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Action, Source } from "@clarity-types/layout"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { PubSubEvents, waitFor } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { mask } from "@src/utils"; -import { assert } from "chai"; - -describe("Layout: Input Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("checks that input change capturing works on inserted element", testAsync(async (done: DoneFn) => { - let newValueString = "new value"; - let input = document.createElement("input"); - document.body.appendChild(input); - await waitFor(PubSubEvents.MUTATION); - - watch(); - input.value = newValueString; - - // Programmatic value change doesn't trigger "onchange" event, so we need to trigger it manually - let onChangeEvent = document.createEvent("HTMLEvents"); - onChangeEvent.initEvent("change", false, true); - input.dispatchEvent(onChangeEvent); - await waitFor(PubSubEvents.CHANGE); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Update); - assert.equal(events[0].state.source, Source.Input); - assert.equal(events[0].state.value, mask(newValueString)); - done(); - })); - - it("checks that input change capturing works on inserted textarea element", testAsync(async (done: DoneFn) => { - let newValueString = "new value"; - let textarea = document.createElement("textarea"); - document.body.appendChild(textarea); - await waitFor(PubSubEvents.MUTATION); - - watch(); - textarea.value = newValueString; - - // Programmatic value change doesn't trigger "onchange" event, so we need to trigger it manually - let onInputEvent = document.createEvent("HTMLEvents"); - onInputEvent.initEvent("input", false, true); - textarea.dispatchEvent(onInputEvent); - await waitFor(PubSubEvents.INPUT); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Update); - assert.equal(events[0].state.source, Source.Input); - assert.equal(events[0].state.value, mask(newValueString)); - done(); - })); - -}); diff --git a/karma/tests/layout/mutation.ts b/karma/tests/layout/mutation.ts deleted file mode 100644 index 1a780e24..00000000 --- a/karma/tests/layout/mutation.ts +++ /dev/null @@ -1,547 +0,0 @@ -import { IEvent } from "@clarity-types/core"; -import { Action } from "@clarity-types/layout"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { PubSubEvents, waitFor } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { NodeIndex, Tags } from "@src/plugins/layout/states/generic"; -import { assert } from "chai"; - -describe("Layout: Mutation Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("checks that dom additions are captured by clarity", testAsync(async (done: DoneFn) => { - watch(); - let div = document.createElement("div"); - let span = document.createElement("span"); - span.innerHTML = "Clarity"; - div.appendChild(span); - document.body.insertBefore(div, document.body.firstChild); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 3); - assert.equal(events[0].state.tag, "DIV"); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[1].state.tag, "SPAN"); - assert.equal(events[2].state.tag, "*TXT*"); - done(); - })); - - it("checks that dom removals are captured by clarity", testAsync(async (done: DoneFn) => { - watch(); - let dom = document.getElementById("clarity"); - dom.parentNode.removeChild(dom); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.tag, "DIV"); - assert.equal(events[0].state.action, Action.Remove); - done(); - })); - - it("checks that dom moves are captured correctly by clarity", testAsync(async (done: DoneFn) => { - watch(); - let dom = document.getElementById("clarity"); - let backup = document.getElementById("backup"); - backup.appendChild(dom); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Move); - done(); - })); - - it("checks that insertBefore works correctly", testAsync(async (done: DoneFn) => { - watch(); - - // Insert a node before an existing node - let dom = document.getElementById("clarity"); - let backup = document.getElementById("backup"); - let domIndex = dom[NodeIndex]; - let firstChildIndex = dom.firstChild[NodeIndex]; - dom.insertBefore(backup, dom.firstChild); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Move); - assert.equal(events[0].state.parent, domIndex); - assert.equal(events[0].state.next, firstChildIndex); - done(); - })); - - it("checks that moving two known nodes to a new location such that they are siblings works correctly", - testAsync(async (done: DoneFn) => { - watch(); - - // Move multiple nodes from one parent to another - let dom = document.getElementById("clarity"); - let backup = document.getElementById("backup"); - let span = document.createElement("span"); - dom.parentElement.appendChild(span); - span.appendChild(dom); - span.appendChild(backup); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - - assert.equal(events.length, 3); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, "SPAN"); - - assert.equal(events[1].state.action, Action.Move); - assert.equal(events[1].state.index, dom[NodeIndex]); - assert.equal(events[1].state.next, backup[NodeIndex]); - - assert.equal(events[2].state.action, Action.Move); - assert.equal(events[2].state.index, backup[NodeIndex]); - assert.equal(events[2].state.next, null); - done(); - }) - ); - - it("checks dom changes are captured accurately when multiple siblings are moved to another parent", testAsync(async (done: DoneFn) => { - watch(); - - // Move multiple nodes from one parent to another - let dom = document.getElementById("clarity"); - let backup = document.getElementById("backup"); - let backupIndex = backup[NodeIndex]; - let childrenCount = dom.childNodes.length; - while (dom.childNodes.length > 0) { - backup.appendChild(dom.childNodes[0]); - } - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, childrenCount); - assert.equal(events[0].state.action, Action.Move); - assert.equal(events[0].state.parent, backupIndex); - assert.equal(events[4].state.parent, backupIndex); - assert.equal(events[4].state.next, events[5].state.index); - done(); - })); - - it("checks that insertion of multiple nodes in the same mutation record is handled correctly", testAsync(async (done: DoneFn) => { - watch(); - let df = document.createDocumentFragment(); - let n1 = document.createElement("div"); - let n2 = document.createElement("span"); - let n3 = document.createElement("a"); - let backup = document.getElementById("backup"); - let backupPrevious = backup.previousSibling; - df.appendChild(n1); - df.appendChild(n2); - df.appendChild(n3); - backup.parentElement.insertBefore(df, backup); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - - assert.equal(events.length, 3); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, "DIV"); - assert.equal(events[0].state.previous, backupPrevious[NodeIndex]); - assert.equal(events[0].state.next, n2[NodeIndex]); - - // Check SPAN insert event - assert.equal(events[1].state.action, Action.Insert); - assert.equal(events[1].state.tag, "SPAN"); - assert.equal(events[1].state.next, n3[NodeIndex]); - - // Check A insert event - assert.equal(events[2].state.action, Action.Insert); - assert.equal(events[2].state.tag, "A"); - assert.equal(events[2].state.next, backup[NodeIndex]); - done(); - })); - - it("checks that removal of multiple nodes in the same mutation record is handled correctly", testAsync(async (done: DoneFn) => { - watch(); - let dom = document.getElementById("clarity"); - let children = []; - let childIndices = []; - for (let i = 0; i < dom.childNodes.length; i++) { - let child = dom.childNodes[i]; - let index = child[NodeIndex]; - children.push(child); - childIndices[index] = true; - } - - // Remove all children - dom.innerHTML = ""; - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - - // Make sure that there is a remove event for every child - for (let i = 0; i < events.length; i++) { - let index = events[i].state.index; - assert.equal(events[i].state.action, Action.Remove); - assert.equal(childIndices[index], true); - delete childIndices[index]; - } - - // Make sure that clarity index is cleared from all removed nodes - for (let i = 0; i < children.length; i++) { - assert.equal(NodeIndex in children[i], false); - } - done(); - })); - - // Nodes that are inserted and then removed in the same mutation don't produce any events and their mutations are ignored - // However, it's possible that some other observed node can be appended to the ignored node and then get removed from the - // DOM as a part of the ignored node's subtree. This test makes sure that removing observed node this way is captured correctly. - it("checks that removal of a known node through a subtree of its ignored parent is handled correctly", - testAsync(async (done: DoneFn) => { - watch(); - let clarityNode = document.getElementById("clarity"); - let backupNode = document.getElementById("backup"); - let backupNodeIndex = backupNode[NodeIndex]; - let tempNode = document.createElement("div"); - clarityNode.appendChild(tempNode); - tempNode.appendChild(backupNode); - clarityNode.removeChild(tempNode); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Remove); - assert.equal(events[0].state.index, backupNodeIndex); - - // Make sure that clarity index is cleared from all removed nodes - assert.equal(NodeIndex in tempNode, false); - assert.equal(NodeIndex in backupNode, false); - done(); - }) - ); - - it("checks that dom addition with immediate move ignores the 'Move' action", testAsync(async (done: DoneFn) => { - watch(); - - // Add a node to the document - let clarity = document.getElementById("clarity"); - let div = document.createElement("div"); - document.body.appendChild(div); - clarity.appendChild(div); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, "DIV"); - assert.equal(events[0].state.parent, clarity[NodeIndex]); - done(); - })); - - it("checks that dom addition with the follow-up attribute change captures the 'Update' action", testAsync(async (done: DoneFn) => { - watch(); - let div = document.createElement("div"); - document.body.appendChild(div); - await waitFor(PubSubEvents.MUTATION); - - div.setAttribute("data-clarity", "test"); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 2); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, "DIV"); - assert.equal(events[1].state.action, Action.Update); - done(); - })); - - it("checks that dom addition with immediate attribute change ignores the 'Update' action", testAsync(async (done: DoneFn) => { - watch(); - let div = document.createElement("div"); - document.body.appendChild(div); - div.setAttribute("data-clarity", "test"); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, "DIV"); - done(); - })); - - it("checks that nodes that are added and removed in the same mutation don't create index gaps in event logs ", - testAsync(async (done: DoneFn) => { - watch(); - let div1 = document.createElement("div"); - let div2 = document.createElement("div"); - let div3 = document.createElement("div"); - document.body.appendChild(div1); - document.body.appendChild(div3); - document.body.appendChild(div2); - document.body.removeChild(div3); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 2); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.index, div1[NodeIndex]); - assert.equal(events[1].state.action, Action.Insert); - assert.equal(events[1].state.index, div2[NodeIndex]); - assert.equal(div1[NodeIndex], div2[NodeIndex] - 1); - done(); - }) - ); - - it("checks that we do not instrument disconnected dom tree", testAsync(async (done: DoneFn) => { - watch(); - let div = document.createElement("div"); - document.body.appendChild(div); - document.body.removeChild(div); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - - // Prove that we didn't send any extra instrumentation back for no-op mutation - assert.equal(events.length, 0); - - // Make sure that clarity index is cleared from all removed nodes - assert.equal(NodeIndex in div, false); - done(); - })); - - it("checks that we do not instrument child nodes within disconnected dom tree", testAsync(async (done: DoneFn) => { - watch(); - let div = document.createElement("div"); - let span = document.createElement("span"); - document.body.appendChild(div); - div.appendChild(span); - document.body.removeChild(div); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - - // Prove that we didn't send any extra instrumentation back for no-op mutation - assert.equal(events.length, 0); - - // Make sure that clarity index is cleared from all removed nodes - assert.equal(NodeIndex in div, false); - assert.equal(NodeIndex in span, false); - done(); - })); - - it("checks that we do not instrument child nodes within a previously observed disconnected dom tree", - testAsync(async (done: DoneFn) => { - watch(); - let clarityDiv = document.getElementById("clarity"); - let span = document.createElement("span"); - let clarityDivIndex = clarityDiv[NodeIndex]; - clarityDiv.appendChild(span); - clarityDiv.parentElement.removeChild(clarityDiv); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - - // Prove that we didn't send any extra instrumentation back for no-op mutation - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Remove); - assert.equal(events[0].state.index, clarityDivIndex); - - // Make sure that clarity index is cleared from all removed nodes - assert.equal(NodeIndex in clarityDiv, false); - assert.equal(NodeIndex in span, false); - done(); - }) - ); - - it("checks that we do not instrument inserted nodes twice", testAsync(async (done: DoneFn) => { - watch(); - - // Edge case scenario for the test: - // 1. Node n1 is added to the page - // 2. Immediately node n2 is appended to n1 - // They both show up as insertions in the same batch of mutations - // When we serialize n1, we will discover its children and serialize n2 as well - // Make sure we don't serialize n2 again when we move on to n2 insertion mutation record - let n1 = document.createElement("div"); - let n2 = document.createElement("span"); - let bodyIndex = document.body[NodeIndex]; - document.body.appendChild(n1); - n1.appendChild(n2); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 2); - - // Check DIV insert event - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.parent, bodyIndex); - assert.equal(events[0].state.tag, "DIV"); - - // Check SPAN insert event - assert.equal(events[1].state.action, Action.Insert); - assert.equal(events[1].state.parent, n1[NodeIndex]); - assert.equal(events[1].state.tag, "SPAN"); - done(); - })); - - it("checks that all kinds of mutations within the same batch have the same mutation sequence", testAsync(async (done: DoneFn) => { - watch(); - let divOne = document.createElement("div"); - let clarityDiv = document.getElementById("clarity"); - let backup = document.getElementById("backup"); - document.body.appendChild(divOne); // Insert - clarityDiv.firstElementChild.id = "updatedId"; // Update - divOne.appendChild(clarityDiv); // Move - backup.parentElement.removeChild(backup); // Remove - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 4); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[1].state.action, Action.Move); - assert.equal(events[2].state.action, Action.Update); - assert.equal(events[3].state.action, Action.Remove); - - // Make sure all events have the same mutation sequence - let mutationSequence = events[0].state.mutationSequence; - assert.isTrue(mutationSequence >= 0); - assert.equal(events[0].state.mutationSequence, mutationSequence); - assert.equal(events[1].state.mutationSequence, mutationSequence); - assert.equal(events[2].state.mutationSequence, mutationSequence); - assert.equal(events[3].state.mutationSequence, mutationSequence); - done(); - })); - - it("checks that mutation sequence number is incremented between mutation callbacks", testAsync(async (done: DoneFn) => { - watch(); - let divOne = document.createElement("div"); - let divTwo = document.createElement("div"); - document.body.appendChild(divOne); - await waitFor(PubSubEvents.MUTATION); - - document.body.appendChild(divTwo); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 2); - assert.equal(events[0].state.mutationSequence, 0); - assert.equal(events[1].state.mutationSequence, 1); - done(); - })); - - it("checks that script element and its text are ignored", testAsync(async (done: DoneFn) => { - watch(); - let script = document.createElement("script"); - script.innerText = "/*some javascriptcode*/"; - document.body.appendChild(script); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 2); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, Tags.Ignore); - assert.equal(events[0].state.nodeType, Node.ELEMENT_NODE); - - assert.equal(events[1].state.action, Action.Insert); - assert.equal(events[1].state.tag, Tags.Ignore); - assert.equal(events[1].state.nodeType, Node.TEXT_NODE); - done(); - })); - - it("checks that comment node is ignored", testAsync(async (done: DoneFn) => { - watch(); - let comment = document.createComment("some explanation"); - document.body.appendChild(comment); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, Tags.Ignore); - assert.equal(events[0].state.nodeType, Node.COMMENT_NODE); - done(); - })); - - it("checks that meta element is ignored", testAsync(async (done: DoneFn) => { - watch(); - let meta = document.createElement("meta"); - document.body.appendChild(meta); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, Tags.Ignore); - assert.equal(events[0].state.nodeType, Node.ELEMENT_NODE); - done(); - })); - - // This test is related to a specific MutationObserver behavior in Internet Explorer for this scenario: - // 1. Append script 'myscript' to the page - // 2. 'myscript' executes and appends 'mydiv' div to the page - // 3. Inspect MutationObserver callback - // In Chrome: - // MutationObserver will invoke a callback and show 2 mutation records: - // (1) script added - // (2)
    added - // In IE: - // (1) MutationObserver will invoke first callback with 1 mutation record:
    added - // (2) MutationObserver will invoke second callback with 1 mutation record: script added - // The problem with this behavior in IE is that during the first MutationObserver's callback, script element - // can already be observed in the DOM, even though its mutation is not reported in the callback. - // As a result, after processing (1), DOM and ShadowDOM states are not consistent until (2) is processed. - // This breaks functionality, because after (1) we determine that ShadowDOM arrived to the inconsistent - // state and stop processing mutations. - // Solution: - // Wait for 2 consequtive mutations that bring ShadowDOM to the inconsistent state before disabling mutation processing. - it("checks that inserting script, which inserts an element, works correctly", testAsync(async (done: DoneFn) => { - watch(); - let events: IEvent[] = []; - let script = document.createElement("script"); - script.type = "text/javascript"; - script.innerHTML = `var div=document.createElement("div");div.id="newdiv";document.body.appendChild(div);`; - document.body.appendChild(script); - await waitFor(PubSubEvents.MUTATION); - - let newEvents = stopWatching().coreEvents; - events = events.concat(newEvents); - - if (events.length === 1) { - watch(); - await waitFor(PubSubEvents.MUTATION); - - newEvents = stopWatching().coreEvents; - events = events.concat(newEvents); - watch(); - - // Add another mutation to ensure that we continue processing mutations - document.body.appendChild(document.createElement("span")); - await waitFor(PubSubEvents.MUTATION); - - newEvents = stopWatching().coreEvents; - assert.equal(events.length, 4); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, "DIV"); - assert.equal(events[1].state.action, Action.Insert); - assert.equal(events[1].state.tag, Tags.Ignore); - assert.equal(events[2].state.action, Action.Insert); - assert.equal(events[2].state.tag, Tags.Ignore); - assert.equal(events[3].state.action, Action.Insert); - assert.equal(events[3].state.tag, "SPAN"); - done(); - } else { - // Non-IE path: Both div and script are reported in the first callback - assert.equal(events.length, 3); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, Tags.Ignore); - assert.equal(events[1].state.action, Action.Insert); - assert.equal(events[1].state.tag, Tags.Ignore); - assert.equal(events[2].state.action, Action.Insert); - assert.equal(events[2].state.tag, "DIV"); - done(); - } - })); -}); diff --git a/karma/tests/layout/privacy.ts b/karma/tests/layout/privacy.ts deleted file mode 100644 index 5406ea34..00000000 --- a/karma/tests/layout/privacy.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Action, Source } from "@clarity-types/layout"; -import { restartClarity } from "@karma/setup/clarity"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { PubSubEvents, waitFor } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { mask } from "@src/utils"; -import { assert } from "chai"; - -describe("Layout: Privacy Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("checks that default sensitive attributes are masked", testAsync(async (done: DoneFn) => { - const sensitiveAttributeName = "placeholder"; - watch(); - let value = "value"; - let maskedValue = mask(value); - let div = document.createElement("div"); - div.setAttribute(sensitiveAttributeName, value); - document.body.appendChild(div); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, div.tagName); - assert.equal(events[0].state.attributes[sensitiveAttributeName], maskedValue); - done(); - })); - - it("checks that configurable sensitive attributes are masked", testAsync(async (done: DoneFn) => { - const sensitiveAttributeName = "data-sensitive-attribute"; - await restartClarity({ sensitiveAttributes: [sensitiveAttributeName] }); - - watch(); - let value = "value"; - let maskedValue = mask(value); - let div = document.createElement("div"); - div.setAttribute(sensitiveAttributeName, value); - document.body.appendChild(div); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, div.tagName); - assert.equal(events[0].state.attributes[sensitiveAttributeName], maskedValue); - done(); - })); - - it("checks that input value is masked on insert", testAsync(async (done: DoneFn) => { - watch(); - let input = document.createElement("input"); - let valueString = "value"; - let maskedValueString = mask(valueString); - input.setAttribute("value", valueString); - document.body.appendChild(input); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.tag, "INPUT"); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.value, maskedValueString); - done(); - })); - - it("checks that input value is masked on input update", testAsync(async (done: DoneFn) => { - let newValueString = "new value"; - let maskedValueString = mask(newValueString); - let input = document.createElement("input"); - document.body.appendChild(input); - await waitFor(PubSubEvents.MUTATION); - - watch(); - input.value = newValueString; - - // Programmatic value change doesn't trigger "onchange" event, so we need to trigger it manually - let onChangeEvent = document.createEvent("HTMLEvents"); - onChangeEvent.initEvent("change", false, true); - input.dispatchEvent(onChangeEvent); - await waitFor(PubSubEvents.CHANGE); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Update); - assert.equal(events[0].state.source, Source.Input); - assert.equal(events[0].state.value, maskedValueString); - done(); - })); - -}); diff --git a/karma/tests/layout/scroll.ts b/karma/tests/layout/scroll.ts deleted file mode 100644 index 5ff4369e..00000000 --- a/karma/tests/layout/scroll.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Action, Source } from "@clarity-types/layout"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { PubSubEvents, waitFor } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { assert } from "chai"; - -describe("Layout: Scroll Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("checks that scroll capturing works on inserted element", testAsync(async (done: DoneFn) => { - - // Add a scrollable DIV - let outerDiv = document.createElement("div"); - let innerDiv = document.createElement("div"); - outerDiv.style.overflowY = "auto"; - outerDiv.style.width = "200px"; - outerDiv.style.maxHeight = "100px"; - innerDiv.style.height = "300px"; - outerDiv.appendChild(innerDiv); - document.body.appendChild(outerDiv); - await waitFor(PubSubEvents.MUTATION); - - watch(); - - // Trigger scroll - outerDiv.scrollTop = 100; - await waitFor(PubSubEvents.SCROLL); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Update); - assert.equal(events[0].state.source, Source.Scroll); - done(); - })); - - it("checks that scroll capturing works on overflow hidden element after a mutation update", testAsync(async (done: DoneFn) => { - - // Add a scrollable DIV - let outerDiv = document.createElement("div"); - let innerDiv = document.createElement("div"); - outerDiv.style.overflowY = "hidden"; - outerDiv.style.width = "200px"; - outerDiv.style.maxHeight = "100px"; - innerDiv.style.height = "300px"; - outerDiv.appendChild(innerDiv); - document.body.appendChild(outerDiv); - await waitFor(PubSubEvents.MUTATION); - - // Force a mutation to ensure that layout updates also capture scroll position - outerDiv.setAttribute("data-attribute", "1"); - await waitFor(PubSubEvents.MUTATION); - - watch(); - - // Trigger scroll after a mutation update - outerDiv.scrollTop = 100; - await waitFor(PubSubEvents.SCROLL); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Update); - assert.equal(events[0].state.source, Source.Scroll); - done(); - })); - - it("checks that scroll capturing works on element that enables scrolling after a mutation update", testAsync(async (done: DoneFn) => { - - // Add a scrollable DIV - let outerDiv = document.createElement("div"); - let innerDiv = document.createElement("div"); - outerDiv.style.overflowY = "visible"; - outerDiv.style.width = "200px"; - innerDiv.style.height = "300px"; - outerDiv.appendChild(innerDiv); - document.body.appendChild(outerDiv); - await waitFor(PubSubEvents.MUTATION); - - // Make the element scrollable - outerDiv.style.maxHeight = "100px"; - outerDiv.style.overflowY = "hidden"; - - // Force a mutation to ensure that layout updates also capture scroll position - outerDiv.setAttribute("data-attribute", "1"); - await waitFor(PubSubEvents.MUTATION); - - watch(); - - // Trigger scroll after a mutation update - outerDiv.scrollTop = 100; - await waitFor(PubSubEvents.SCROLL); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.action, Action.Update); - assert.equal(events[0].state.source, Source.Scroll); - done(); - })); - -}); diff --git a/karma/tests/layout/unmask.ts b/karma/tests/layout/unmask.ts deleted file mode 100644 index 75658426..00000000 --- a/karma/tests/layout/unmask.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Action } from "@clarity-types/layout"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { PubSubEvents, waitFor } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { UnmaskAttribute } from "@src/plugins/layout/nodeinfo"; -import { assert } from "chai"; - -describe("Layout: Unmasking Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("checks that child value is unmasked when parent unmask attribute is applied", - testAsync(async (done: DoneFn) => { - watch(); - let valueString = "value"; - let input = document.createElement("input"); - let child = document.createTextNode(valueString); - input.setAttribute(UnmaskAttribute, "true"); - input.appendChild(child); - document.body.appendChild(input); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 2); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, input.tagName); - assert.equal(events[1].state.action, Action.Insert); - assert.equal(events[1].state.tag, "*TXT*"); - assert.equal(events[1].state.content, valueString); - done(); - }) - ); - - it(`checks that node value is unmasked when skip-parent unmask attribute is applied`, - testAsync(async (done: DoneFn) => { - watch(); - let valueString = "value"; - let skipParent = document.createElement("div"); - let parent = document.createElement("div"); - let child = document.createTextNode(valueString); - parent.appendChild(child); - skipParent.appendChild(parent); - skipParent.setAttribute(UnmaskAttribute, "true"); - document.body.appendChild(skipParent); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 3); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.tag, skipParent.tagName); - assert.equal(events[1].state.action, Action.Insert); - assert.equal(events[1].state.tag, parent.tagName); - assert.equal(events[2].state.action, Action.Insert); - assert.equal(events[2].state.tag, "*TXT*"); - assert.equal(events[2].state.content, valueString); - done(); - }) - ); - - it("checks that input value is unmasked on insert if unmask attribute is applied", - testAsync(async (done: DoneFn) => { - watch(); - let input = document.createElement("input"); - let valueString = "value"; - input.setAttribute("value", valueString); - input.setAttribute(UnmaskAttribute, "true"); - document.body.appendChild(input); - await waitFor(PubSubEvents.MUTATION); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 1); - assert.equal(events[0].state.tag, "INPUT"); - assert.equal(events[0].state.action, Action.Insert); - assert.equal(events[0].state.value, valueString); - done(); - }) - ); - -}); diff --git a/karma/tests/performance.ts b/karma/tests/performance.ts deleted file mode 100644 index 4905785a..00000000 --- a/karma/tests/performance.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { IPerformanceResourceTimingState, IPerformanceTiming } from "@clarity-types/performance"; -import { restartClarity } from "@karma/setup/clarity"; -import { createMockPerformanceResourceTimings, IMockPerformance } from "@karma/setup/mocks/performance"; -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { testAsync } from "@karma/setup/testasync"; -import { filterEventsByType, getFullImpressionWatchResult, stopWatching, watch } from "@karma/setup/watch"; -import { ResourceTimingEventType } from "@src/plugins/performance"; -import { assert } from "chai"; - -let resourceTimingEventName = "ResourceTiming"; -let stateErrorEventName = "PerformanceStateError"; -let navigationTimingEventName = "NavigationTiming"; - -declare const performance: IMockPerformance; - -describe("Performance Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("checks that w3c performance timing is logged by clarity", (done: DoneFn) => { - const sentEvents = getFullImpressionWatchResult().sentEvents; - const navTimingEvents = filterEventsByType(sentEvents, navigationTimingEventName); - assert.equal(navTimingEvents.length, 1); - - const eventTiming: IPerformanceTiming = navTimingEvents[0].state.timing; - assert.equal(eventTiming.connectStart, formatTiming(performance.timing.connectStart)); - assert.equal(eventTiming.connectEnd, formatTiming(performance.timing.connectEnd)); - done(); - }); - - it("checks that network resource timings are logged by clarity", (done: DoneFn) => { - watch(); - const resourceTiming = createMockPerformanceResourceTimings()[0]; - performance.addEntry(resourceTiming); - triggerNextPerformancePoll(); - - const events = filterEventsByType(stopWatching().coreEvents, resourceTimingEventName); - assert.equal(events.length, 1); - assert.equal(events[0].type, ResourceTimingEventType); - - // Check sample string value and sample numeric value - const eventState: IPerformanceResourceTimingState = events[0].state; - assert.equal(eventState.name, resourceTiming.name); - assert.equal(eventState.connectStart, Math.round(resourceTiming.connectStart)); - done(); - }); - - it("checks that error is logged when entries are cleared", (done: DoneFn) => { - watch(); - const resourceTiming = createMockPerformanceResourceTimings()[0]; - performance.addEntry(resourceTiming); - triggerNextPerformancePoll(); - - let events = filterEventsByType(stopWatching().coreEvents, resourceTimingEventName); - assert.equal(events.length, 1); - - watch(); - performance.clearResourceTimings(); - triggerNextPerformancePoll(); - - events = filterEventsByType(stopWatching().coreEvents, stateErrorEventName); - assert.equal(events.length, 1); - done(); - }); - - // "Incomplete" entries are entries for resources that are not finished loading yet - it("checks that incomplete entries are not logged initially, but then revisited", (done: DoneFn) => { - let resourceTimings = createMockPerformanceResourceTimings(); - const completeEntry = resourceTimings[0]; - const incompleteEntry = resourceTimings[1]; - const _responseEnd = incompleteEntry.responseEnd; - incompleteEntry.responseEnd = 0; - - watch(); - performance.addEntry(completeEntry); - performance.addEntry(incompleteEntry); - triggerNextPerformancePoll(); - - let events = filterEventsByType(stopWatching().coreEvents, resourceTimingEventName); - assert.equal(events.length, 1); - assert.equal(events[0].type, ResourceTimingEventType); - assert.equal(events[0].state.name, completeEntry.name); - - // Adjust the entry to have a valid response end time and wait for snapshot to propagate - watch(); - incompleteEntry.responseEnd = _responseEnd; - triggerNextPerformancePoll(); - - events = filterEventsByType(stopWatching().coreEvents, resourceTimingEventName); - assert.equal(events.length, 1); - assert.equal(events[0].type, ResourceTimingEventType); - assert.equal(events[0].state.name, incompleteEntry.name); - assert.equal(events[0].state.responseEnd, Math.round(_responseEnd)); - done(); - }); - - it("checks that network resource timings blacklisted in config are ignored", testAsync(async (done: DoneFn) => { - const blacklistedUrl = "https://www.blacklisted.url"; - const resourceTiming = createMockPerformanceResourceTimings()[0]; - await restartClarity({ urlBlacklist: [blacklistedUrl] }); - - watch(); - resourceTiming.name = getFullUrl(blacklistedUrl); - performance.addEntry(resourceTiming); - triggerNextPerformancePoll(); - - const events = filterEventsByType(stopWatching().coreEvents, resourceTimingEventName); - assert.equal(events.length, 0); - done(); - })); - - function formatTiming(value: number): number { - return value === 0 ? 0 : Math.round(value - performance.timing.navigationStart); - } - - function getFullUrl(partialUrl: string): string { - const a = document.createElement("a"); - a.href = partialUrl; - return a.href; - } - - function triggerNextPerformancePoll(): void { - jasmine.clock().tick(100000); - } -}); diff --git a/karma/tests/pointer.ts b/karma/tests/pointer.ts deleted file mode 100644 index 203e8cc2..00000000 --- a/karma/tests/pointer.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { cleanupPage, setupPage } from "@karma/setup/page"; -import { PubSubEvents, waitFor } from "@karma/setup/pubsub"; -import { testAsync } from "@karma/setup/testasync"; -import { stopWatching, watch } from "@karma/setup/watch"; -import { getTimestamp } from "@src/core"; -import { assert } from "chai"; - -let distanceThreshold = 20; -let timeThreshold = 500; - -describe("Pointer Tests", () => { - - beforeEach(setupPage); - afterEach(cleanupPage); - - it("validates that mouse events are processed by clarity", testAsync(async (done: DoneFn) => { - watch(); - - // Trigger mousemove events followed by a click event - let dom = document.getElementById("clarity"); - let x = 250; - let xDelta = (distanceThreshold + 1); - triggerMouseEvent(dom, "mousemove", x, 100); - triggerMouseEvent(dom, "mousemove", x + xDelta, 100); - triggerMouseEvent(dom, "mousemove", x + (xDelta * 2), 100); - triggerMouseEvent(dom, "click", 260, 100); - await waitFor(PubSubEvents.CLICK); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 4); - assert.equal(events[0].state.event, "mousemove"); - assert.equal(events[0].state.x, x); - assert.equal(events[1].state.event, "mousemove"); - assert.equal(events[1].state.x, x + xDelta); - assert.equal(events[2].state.event, "mousemove"); - assert.equal(events[2].state.x, x + (xDelta * 2)); - assert.equal(events[3].state.event, "click"); - done(); - })); - - // Make sure that we don't record mouse events that are too close to each other - it("validates that mouse events are throttled by distance", testAsync(async (done: DoneFn) => { - watch(); - - // Trigger mousemove events followed by a click event - let dom = document.getElementById("clarity"); - let x = 250; - let xDelta = Math.ceil(distanceThreshold / 2) + 1; - triggerMouseEvent(dom, "mousemove", x, 100); - triggerMouseEvent(dom, "mousemove", x + xDelta, 100); - triggerMouseEvent(dom, "mousemove", x + (xDelta * 2), 100); - triggerMouseEvent(dom, "click", 260, 100); - await waitFor(PubSubEvents.CLICK); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 3); - assert.equal(events[0].state.event, "mousemove"); - assert.equal(events[0].state.x, x); - assert.equal(events[1].state.event, "mousemove"); - assert.equal(events[1].state.x, x + (xDelta * 2)); - assert.equal(events[2].state.event, "click"); - done(); - })); - - // Make sure that we don't record mouse events that are too close to each other - it("validates that mouse events are throttled by time", testAsync(async (done: DoneFn) => { - watch(); - - // Trigger mousemove events followed by a click event - let dom = document.getElementById("clarity"); - let x = 250; - triggerMouseEvent(dom, "mousemove", x, 100); - triggerMouseEvent(dom, "mousemove", x, 100); - - let thresholdTs = getTimestamp(true) + (timeThreshold * 2); - while (getTimestamp(true) < thresholdTs) { - // Wait for time threadhold to expire - } - triggerMouseEvent(dom, "mousemove", x, 100); - triggerMouseEvent(dom, "click", 260, 100); - await waitFor(PubSubEvents.CLICK); - - const events = stopWatching().coreEvents; - assert.equal(events.length, 3); - assert.equal(events[0].state.event, "mousemove"); - assert.equal(events[0].state.x, x); - assert.equal(events[1].state.event, "mousemove"); - assert.equal(events[1].state.x, x); - assert.equal(events[2].state.event, "click"); - done(); - })); - - function triggerMouseEvent(target: EventTarget, type: string, x: number, y: number): void { - let mouseEvent; - if (typeof MouseEvent !== "function") { - mouseEvent = document.createEvent("MouseEvents"); - mouseEvent.initMouseEvent( - type, - true, // bubbles = true - false, // cancellable = false - window, - 0, - x, // screenX - y, // screenY - x, // clientX - y, // clientY - false, // ctrlKey - false, // altKey - false, // shftKey - false, // metaKey - 0, // button - null // relatedTarget - ); - } else { - mouseEvent = new MouseEvent(type, { - clientX: x, - clientY: y, - view: window, - bubbles: true, - cancelable: true - }); - } - target.dispatchEvent(mouseEvent); - } -}); diff --git a/package.json b/package.json index d1bfadab..92c8c73b 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "clarity-js", - "version": "0.3.0", + "version": "1.0.0-beta.10", "description": "An analytics library that uses web page interactions to generate aggregated insights", "author": "Microsoft Corp.", "license": "MIT", - "main": "build/index.js", + "main": "clarity.js", "types": "types/index.d.ts", "keywords": [ "clarity", @@ -63,14 +63,11 @@ }, "scripts": { "build": "yarn build:clean && yarn build:main && yarn build:cjs", + "build:dev": "yarn build:clean && yarn build:main && yarn build:cjs:dev", "build:main": "webpack --config webpack/configs/index.ts", "build:cjs": "webpack --config webpack/configs/prod.ts", "build:cjs:dev": "webpack --config webpack/configs/dev.ts", - "build:clean": "del-cli build/*", - "test": "karma start karma/configs/headless.js", - "test:chrome": "karma start karma/configs/chrome.js", - "test:firefox": "karma start karma/configs/firefox.js", - "test:ie": "karma start karma/configs/ie.js", + "build:clean": "del-cli build/* && del-cli clarity.js && del-cli decode.js", "tslint": "tslint --project ./", "tslint:fix": "tslint --fix --project ./ --force" }, diff --git a/src/clarity.ts b/src/clarity.ts index fd06cd24..6403826b 100644 --- a/src/clarity.ts +++ b/src/clarity.ts @@ -1,21 +1,68 @@ -import { IConfig } from "@clarity-types/config"; -import { State } from "@clarity-types/core"; -import { config } from "./config"; -import { activate, onTrigger, state, teardown } from "./core"; -import { mapProperties } from "./utils"; -export { version } from "./core"; - -export function start(customConfig?: IConfig): void { - if (state !== State.Activated) { - mapProperties(customConfig, null, true, config); - activate(); +import { Config } from "@clarity-types/core"; +import * as core from "@src/core"; +import configuration from "@src/core/config"; +import { bind } from "@src/core/event"; +import * as data from "@src/data"; +import * as diagnostic from "@src/diagnostic"; +import * as interaction from "@src/interaction"; +import * as layout from "@src/layout"; + +let status = false; + +export function config(override: Config): boolean { + // Process custom configuration overrides, if available + if (status) { return false; } + for (let key in override) { + if (key in configuration) { configuration[key] = override[key]; } + } + return true; +} + +export function start(override: Config = {}): void { + if (core.check()) { + config(override); + status = true; + + core.start(); + data.start(); + diagnostic.start(); + layout.start(); + interaction.start(); } } -export function stop(): void { - teardown(); +export function pause(): void { + end(); + bind(document, "mousemove", resume); + bind(document, "touchstart", resume); + bind(window, "resize", resume); + bind(window, "scroll", resume); + bind(window, "pageshow", resume); +} + +export function resume(): void { + start(); +} + +export function end(): void { + if (status) { + interaction.end(); + layout.end(); + diagnostic.end(); + data.end(); + core.end(); + + status = false; + } +} + +export function tag(key: string, value: string): void { + // Do not process tags if Clarity is not already activated + if (status) { + data.tag(key, value); + } } -export function trigger(key: string): void { - onTrigger(key); +export function active(): boolean { + return status; } diff --git a/src/compress.ts b/src/compress.ts deleted file mode 100644 index dbc3a562..00000000 --- a/src/compress.ts +++ /dev/null @@ -1,238 +0,0 @@ -/* Credit: http://pieroxy.net/blog/pages/lz-string/index.html */ - -// Excluding 3rd party code from tslint -/* tslint:disable */ - -// This library supports various encoding options (UTF16, standard UTF16, Base64, UINT8) -// And, different encoding choices impact the final compress bytes count -// By default, UTF16 which takes 16 bytes per character only work in WebKit browsers -// There's also an option for standard UTF16, which uses 15 bytes per character and -// that's efficient however payload encoded on iPhone can't be decoded using node.js -// For this reason, we are using Base64 at the moment. -export default function(uncompressed: string) { - var bitsPerChar = 6; - var keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; - // @ts-ignore: Implicity 'any' type - var getCharFromInt = function(a) { return keyStrBase64.charAt(a); } - if (uncompressed == null) return ""; - var i, value, - context_dictionary = {}, - context_dictionaryToCreate = {}, - context_c = "", - context_wc = "", - context_w = "", - context_enlargeIn = 2, // Compensate for the first entry which should not count - context_dictSize = 3, - context_numBits = 2, - context_data = [], - context_data_val = 0, - context_data_position = 0, - ii; - - for (ii = 0; ii < uncompressed.length; ii += 1) { - context_c = uncompressed.charAt(ii); - if (!Object.prototype.hasOwnProperty.call(context_dictionary, context_c)) { - context_dictionary[context_c] = context_dictSize++; - context_dictionaryToCreate[context_c] = true; - } - - context_wc = context_w + context_c; - if (Object.prototype.hasOwnProperty.call(context_dictionary, context_wc)) { - context_w = context_wc; - } else { - if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate, context_w)) { - if (context_w.charCodeAt(0) < 256) { - for (i = 0; i < context_numBits; i++) { - context_data_val = (context_data_val << 1); - if (context_data_position == bitsPerChar - 1) { - context_data_position = 0; - context_data.push(getCharFromInt(context_data_val)); - context_data_val = 0; - } else { - context_data_position++; - } - } - value = context_w.charCodeAt(0); - for (i = 0; i < 8; i++) { - context_data_val = (context_data_val << 1) | (value & 1); - if (context_data_position == bitsPerChar - 1) { - context_data_position = 0; - context_data.push(getCharFromInt(context_data_val)); - context_data_val = 0; - } else { - context_data_position++; - } - value = value >> 1; - } - } else { - value = 1; - for (i = 0; i < context_numBits; i++) { - context_data_val = (context_data_val << 1) | value; - if (context_data_position == bitsPerChar - 1) { - context_data_position = 0; - context_data.push(getCharFromInt(context_data_val)); - context_data_val = 0; - } else { - context_data_position++; - } - value = 0; - } - value = context_w.charCodeAt(0); - for (i = 0; i < 16; i++) { - context_data_val = (context_data_val << 1) | (value & 1); - if (context_data_position == bitsPerChar - 1) { - context_data_position = 0; - context_data.push(getCharFromInt(context_data_val)); - context_data_val = 0; - } else { - context_data_position++; - } - value = value >> 1; - } - } - context_enlargeIn--; - if (context_enlargeIn == 0) { - context_enlargeIn = Math.pow(2, context_numBits); - context_numBits++; - } - delete context_dictionaryToCreate[context_w]; - } else { - value = context_dictionary[context_w]; - for (i = 0; i < context_numBits; i++) { - context_data_val = (context_data_val << 1) | (value & 1); - if (context_data_position == bitsPerChar - 1) { - context_data_position = 0; - context_data.push(getCharFromInt(context_data_val)); - context_data_val = 0; - } else { - context_data_position++; - } - value = value >> 1; - } - - - } - context_enlargeIn--; - if (context_enlargeIn == 0) { - context_enlargeIn = Math.pow(2, context_numBits); - context_numBits++; - } - // Add wc to the dictionary. - context_dictionary[context_wc] = context_dictSize++; - context_w = String(context_c); - } - } - - // Output the code for w. - if (context_w !== "") { - if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate, context_w)) { - if (context_w.charCodeAt(0) < 256) { - for (i = 0; i < context_numBits; i++) { - context_data_val = (context_data_val << 1); - if (context_data_position == bitsPerChar - 1) { - context_data_position = 0; - context_data.push(getCharFromInt(context_data_val)); - context_data_val = 0; - } else { - context_data_position++; - } - } - value = context_w.charCodeAt(0); - for (i = 0; i < 8; i++) { - context_data_val = (context_data_val << 1) | (value & 1); - if (context_data_position == bitsPerChar - 1) { - context_data_position = 0; - context_data.push(getCharFromInt(context_data_val)); - context_data_val = 0; - } else { - context_data_position++; - } - value = value >> 1; - } - } else { - value = 1; - for (i = 0; i < context_numBits; i++) { - context_data_val = (context_data_val << 1) | value; - if (context_data_position == bitsPerChar - 1) { - context_data_position = 0; - context_data.push(getCharFromInt(context_data_val)); - context_data_val = 0; - } else { - context_data_position++; - } - value = 0; - } - value = context_w.charCodeAt(0); - for (i = 0; i < 16; i++) { - context_data_val = (context_data_val << 1) | (value & 1); - if (context_data_position == bitsPerChar - 1) { - context_data_position = 0; - context_data.push(getCharFromInt(context_data_val)); - context_data_val = 0; - } else { - context_data_position++; - } - value = value >> 1; - } - } - context_enlargeIn--; - if (context_enlargeIn == 0) { - context_enlargeIn = Math.pow(2, context_numBits); - context_numBits++; - } - delete context_dictionaryToCreate[context_w]; - } else { - value = context_dictionary[context_w]; - for (i = 0; i < context_numBits; i++) { - context_data_val = (context_data_val << 1) | (value & 1); - if (context_data_position == bitsPerChar - 1) { - context_data_position = 0; - context_data.push(getCharFromInt(context_data_val)); - context_data_val = 0; - } else { - context_data_position++; - } - value = value >> 1; - } - - - } - context_enlargeIn--; - if (context_enlargeIn == 0) { - context_enlargeIn = Math.pow(2, context_numBits); - context_numBits++; - } - } - - // Mark the end of the stream - value = 2; - for (i = 0; i < context_numBits; i++) { - context_data_val = (context_data_val << 1) | (value & 1); - if (context_data_position == bitsPerChar - 1) { - context_data_position = 0; - context_data.push(getCharFromInt(context_data_val)); - context_data_val = 0; - } else { - context_data_position++; - } - value = value >> 1; - } - - // Flush the last char - while (true) { - context_data_val = (context_data_val << 1); - if (context_data_position == bitsPerChar - 1) { - context_data.push(getCharFromInt(context_data_val)); - break; - } - else context_data_position++; - } - var res = context_data.join(''); - switch (res.length % 4) { // To produce valid Base64 - default: // When could this happen ? - case 0: return res; - case 1: return res + "==="; - case 2: return res + "=="; - case 3: return res + "="; - } -} diff --git a/src/compressionworker.ts b/src/compressionworker.ts deleted file mode 100644 index cbba5602..00000000 --- a/src/compressionworker.ts +++ /dev/null @@ -1,110 +0,0 @@ -import Compress from "./compress"; - -import { IAddEventMessage, ICompressedBatchMessage, ITimestampedWorkerMessage, WorkerMessageType } from "@clarity-types/compressionworker"; -import { IEnvelope, IEventArray, IPayload } from "@clarity-types/core"; -import { config as Config } from "./config"; - -export function createCompressionWorker( - envelope: IEnvelope, - onMessage?: (e: MessageEvent) => void, - onError?: (e: ErrorEvent) => void -): Worker { - let worker = null; - if (Worker) { - let workerUrl = createWorkerUrl(envelope); - worker = new Worker(workerUrl); - worker.onmessage = onMessage || null; - worker.onerror = onError || null; - } - return worker; -} - -function workerContext(): void { - let workerGlobalScope = self as any; - let compress = workerGlobalScope.compress; - let config = workerGlobalScope.config; - let envelope: IEnvelope = workerGlobalScope.envelope; - let nextBatchEvents: IEventArray[] = []; - let nextBatchBytes = 0; - let sequence = 0; - - // Edge case: Flag to skip uploading batches consisting of a single XhrError instrumentation event - // This helps us avoid the infinite loop in the case when all requests fail (e.g. dropped internet connection) - // Infinite loop comes from sending instrumentation about failing to deliver previous delivery failure instrumentation. - let nextBatchIsSingleXhrErrorEvent: boolean = false; - - self.onmessage = (evt: MessageEvent): void => { - let message = evt.data; - switch (message.type) { - case WorkerMessageType.AddEvent: - let addEventMsg = message as IAddEventMessage; - addEvent(addEventMsg.event, addEventMsg.time, addEventMsg.isXhrErrorEvent); - break; - case WorkerMessageType.ForceCompression: - let forceCompressionMsg = message as ITimestampedWorkerMessage; - postNextBatchToCore(forceCompressionMsg.time); - break; - default: - break; - } - }; - - function addEvent(event: IEventArray, time: number, isXhrErrorEvent?: boolean): void { - let eventStr = JSON.stringify(event); - - // If appending new event to next batch would exceed batch limit, then post next batch first - if (nextBatchBytes > 0 && nextBatchBytes + eventStr.length > config.batchLimit) { - postNextBatchToCore(time); - } - - // // Append new event to the next batch - nextBatchIsSingleXhrErrorEvent = (nextBatchEvents.length === 0 && isXhrErrorEvent); - nextBatchEvents.push(event); - nextBatchBytes += eventStr.length; - - // Even if we just posted next batch, it is possible that a single new event exceeds batch limit by itself, so we need to check again - if (nextBatchBytes >= config.batchLimit) { - postNextBatchToCore(time); - } - } - - function postNextBatchToCore(time: number): void { - if (nextBatchBytes > 0 && !nextBatchIsSingleXhrErrorEvent) { - envelope.sequenceNumber = sequence++; - envelope.time = time; - let raw: IPayload = { envelope, events: nextBatchEvents }; - let rawStr = JSON.stringify(raw); - let compressed = compress(rawStr); - nextBatchEvents = []; - nextBatchBytes = 0; - postToCore(compressed, raw); - } - } - - function postToCore(compressed: string, raw: IPayload): void { - let message: ICompressedBatchMessage = { - type: WorkerMessageType.CompressedBatch, - compressedData: compressed, - rawData: raw - }; - - // Post message to the main thread - workerGlobalScope.postMessage(message); - } -} - -// Workers are initialized with a URL, pointing to the code which is going to be executed within worker's scope. -// URL can point to file, however we don't want to load a file with worker's code separately, so we create a Blob -// with a string containing worker's code. To build such string, we stitch together string representations of -// all functions and objects that are going to be required within the worker's scope. -// Once Blob is created, we create a URL pointing to it, which can be passed to worker's constructor. -function createWorkerUrl(envelope: IEnvelope): string { - let workerContextStr = workerContext.toString(); - let workerStr = workerContextStr.substring(workerContextStr.indexOf("{") + 1, workerContextStr.lastIndexOf("}")); - let code = `self.compress=${Compress.toString()};` - + `self.config=${JSON.stringify(Config)};` - + `self.envelope=${JSON.stringify(envelope)};` - + workerStr; - let blob = new Blob([code], {type: "application/javascript"}); - return URL.createObjectURL(blob); -} diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 7cd38f65..00000000 --- a/src/config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { IConfig } from "@clarity-types/config"; -import { mapProperties } from "./utils"; - -// Default configuration -export let config: IConfig = { - plugins: ["viewport", "layout", "pointer", "performance", "errors"], - uploadUrl: "", - urlBlacklist: [], - delay: 500, - batchLimit: 100 * 1024, // 100 kilobytes - totalLimit: 20 * 1024 * 1024, // 20 megabytes - reUploadLimit: 1, - disableCookie: false, - sensitiveAttributes: ["value"], - instrument: false, - cssRules: false, - uploadHandler: null, - uploadHeaders: { - "Content-Type": "application/json" - }, - customInstrumentation: null, - debug: false, - validateConsistency: false, - backgroundMode: false -}; - -export function resetConfig(): void { - mapProperties(defaultConfig, null, false, config); -} - -let defaultConfig: IConfig = {}; -mapProperties(config, null, false, defaultConfig); diff --git a/src/converters/convert.ts b/src/converters/convert.ts deleted file mode 100644 index 9a311429..00000000 --- a/src/converters/convert.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as encode } from "./toarray"; -export { default as decode } from "./fromarray"; -export { SchemaManager } from "./schema"; diff --git a/src/converters/fromarray.ts b/src/converters/fromarray.ts deleted file mode 100644 index 56c00044..00000000 --- a/src/converters/fromarray.ts +++ /dev/null @@ -1,67 +0,0 @@ -import defaultSchemas from "./schema"; - -import { IEvent, IEventArray, ObjectType } from "@clarity-types/core"; -import { SchemaManager } from "./schema"; - -export default function(eventArray: IEventArray, schemas?: SchemaManager): IEvent { - let id, type, time, stateArray, schema; - [id, type, time, stateArray, schema] = eventArray; - - if (!schemas) { - schemas = defaultSchemas; - } - - if (typeof schema === "number") { - schema = schemas.getSchema(schema); - } else { - schemas.addSchema(schema); - } - - let state = dataFromArray(stateArray, schema as any[]); - let event: IEvent = { id, type, time, state }; - return event; -} - -// This function reconstructs the original object from array of data and a schema that describes it. Check schema.md for details: -// https://github.com/Microsoft/clarity-js/blob/master/converters/schema.md -function dataFromArray(dataArray: any[], schema: any[]): any { - // Schema is of type "string" or null when data that matches it is not an Array or an Object, - // so no additional reconstruction on the data is required. - if (typeof schema === "string" || schema === null) { - return dataArray; - } - - let data = null; - let dataType = null; - let subschemas = null; - if (schema.length === 2) { - // Schema is [ObjectType, [Array values' or Object properties' schemas (based on the object type) ]] - dataType = schema[0]; - subschemas = schema[1]; - } else if (schema.length === 3) { - // Schema is [Property name in parent object, ObjectType, [Array values' or Object properties' schemas (based on the object type) ]] - dataType = schema[1]; - subschemas = schema[2]; - } - - if (dataType === ObjectType.Object) { - data = {}; - for (let i = 0; i < subschemas.length; i++) { - let currentSubschema = subschemas[i]; - let currentProperty = null; - if (typeof currentSubschema === "string") { - currentProperty = currentSubschema; - } else { - currentProperty = currentSubschema[0]; - } - data[currentProperty] = dataFromArray(dataArray[i], currentSubschema); - } - } else if (dataType === ObjectType.Array) { - data = []; - for (let i = 0; i < subschemas.length; i++) { - let nextSubschema = subschemas[i]; - data.push(dataFromArray(dataArray[i], nextSubschema)); - } - } - return data; -} diff --git a/src/converters/schema.md b/src/converters/schema.md deleted file mode 100644 index 47800755..00000000 --- a/src/converters/schema.md +++ /dev/null @@ -1,66 +0,0 @@ -Schema represents the structure of an arbitrary JavaScript variable. For Objects, the goal is to list its property names without property values, as an array. - -The gist of the idea can be illustrated with a simple example: - -```javascript -let car = { - make: "Tesla", - year: 2018 -}; -schema(car) = ["make", "year"] -``` - -However, schemas needs to be able to handle more complex objects, which can have properties of arbitrary types, which might require their own schemas. When we think about these slightly harder cases, there are two issues that come up that need to be handled as special cases. - -### Handling nested Objects - -```javascript -let car = { - make: "Tesla", - year: 2018, - features: { - color: "red", - } -} -``` - -Now, to construct a schema for 'car', it's not enough to just say ["make, "year", "features"], because features has a schema of its own, which needs to be respected as well. To take that into account, we need to add an extra dimension to the features representation, which would allow us to pass two pieces of inofrmation for the features object - property name, under which it's stored in the car object and features's own schema. It can look something like this: - -featuresSchema = ["features", ["color"]] -schema(car) = [ "make", "year", featuresSchema ] - -### Differentiating Object's schema arrays from actual arrays of data - -Last problem that we need to handle is disambiguation between the arrays describing Objects schemas and arrays describing Array schemas. We can do it the same way we handle nested objects - by adding an extra dimension indicating what is the object type that is being described by this schema - Object or an Array. - -Here is a more complicated example containing three schema types (primitive, array, object) nested within another object. - -```javascript -let car = { - make: "Tesla", - year: 2018, - features: { - color: "red", - }, - passenges: [ - "John", - "Lena" - ] -} -``` - -featuresSchema = ["features", ObjectType.Object, ["color"]] -passengersSchema = ["passengers", ObjectType.Array, [null, null]]; -schema(car) = [ ObjectType.Object, [ "make", "year", featuresSchema, passengerSchema ] ] - -More generally speaking, schema is determined by (1) the type of variable for which schema is created, and (2) whether this variable is contained within some other object as a value for a property with a name. Table below contains all permutations of those two factors: - -[comment]: # (Markdown for table below is auto-generated and renders correctly) -[comment]: # (Generator URL: https://www.tablesgenerator.com/markdown_tables) - -| Is a named property value | Primitive | Array | Object | -|---------------------------|---------------|--------------------------------------------------------------------------------|--------------------------------------------------------------------------------| -| FALSE | null | [ObjectType.Array, [Array of array values' recursive Schemas]] | [ObjectType.Object, [Array of array values' recursive Schemas]] | -| TRUE | Property name | [Property name, ObjectType.Array, [Array of object values' recursive Schemas]] | [Property name, ObjectType.Array, [Array of object values' recursive Schemas]] | - -[comment]: # (End of table) \ No newline at end of file diff --git a/src/converters/schema.ts b/src/converters/schema.ts deleted file mode 100644 index 64ef5f9b..00000000 --- a/src/converters/schema.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ClarityDataSchema, ObjectType } from "@clarity-types/core"; - -// Details about schema generation are in schema.md: -// https://github.com/Microsoft/clarity-js/blob/master/converters/schema.md - -export class SchemaManager { - - private schemas: ClarityDataSchema[]; - private schemaToIdMap: { [key: string]: number }; - - constructor() { - this.reset(); - } - - public reset(): void { - this.schemas = []; - this.schemaToIdMap = {}; - } - - public addSchema(schema: ClarityDataSchema): boolean { - let schemaStr = JSON.stringify(schema); - let isNew = !(schemaStr in this.schemaToIdMap); - if (isNew) { - this.schemaToIdMap[schemaStr] = this.schemas.length; - this.schemas.push(schema); - } - return isNew; - } - - public createSchema(data: any, name?: string): ClarityDataSchema { - let schema: ClarityDataSchema = name || null; - if (data !== null) { - if (typeof data === "object" && (data.constructor === Object || data.constructor === Array)) { - // Objects with properties and Arrays require nested schema generation - schema = this.createNestedSchema(data, name); - } else { - // Primitives and simple objects can return schema immediately - schema = typeof name === "string" ? name : null; - } - } - return schema; - } - - public getSchema(schemaId: number): ClarityDataSchema { - return this.schemas[schemaId]; - } - - public getSchemaId(schema: ClarityDataSchema): number | undefined { - return this.schemaToIdMap[JSON.stringify(schema)]; - } - - private createNestedSchema(data: object | any[], name?: string): ClarityDataSchema { - let nestedObjectType = null; - let dataSchema = null; - if (data.constructor === Object) { - nestedObjectType = ObjectType.Object; - dataSchema = this.createObjectSchema(data); - } else if (data.constructor === Array) { - nestedObjectType = ObjectType.Array; - dataSchema = this.createArraySchema(data as any[]); - } - let schema = [nestedObjectType, dataSchema]; - if (typeof name === "string") { - schema.unshift(name); - } - return schema; - } - - private createObjectSchema(data: object): ClarityDataSchema { - let schema = []; - let keys = Object.keys(data); - for (let i = 0; i < keys.length; i++) { - let key = keys[i]; - let val = data[key]; - let subschema = this.createSchema(val, key); - schema.push(subschema); - } - return schema; - } - - private createArraySchema(data: any[]): ClarityDataSchema { - let schema = []; - for (let i = 0; i < data.length; i++) { - let subschema = this.createSchema(data[i]); - schema.push(subschema); - } - return schema; - } -} - -export function resetSchemas(): void { - schemaManager.reset(); -} - -let schemaManager: SchemaManager = new SchemaManager(); -export default schemaManager; diff --git a/src/converters/toarray.ts b/src/converters/toarray.ts deleted file mode 100644 index f694516b..00000000 --- a/src/converters/toarray.ts +++ /dev/null @@ -1,46 +0,0 @@ -import schemas from "./schema"; - -import { IEvent, IEventArray } from "@clarity-types/core"; - -// We serialize send a lot of JSONs with identical structures and putting all property names on the -// wire every time can be very redundant. We can save bytes by splitting JSON into a separate nested array of -// its property values and a separate nested array of its property names (schema). This way, each unique schema -// has to only be sent for the first event of that type and the following events can then re-use this schema -// on the server for reconstructing the full JSON. -export default function(event: IEvent): IEventArray { - let schema = schemas.createSchema(event.state); - let newSchema = schemas.addSchema(schema); - let stateArray = dataToArray(event.state); - let schemaPayload = newSchema ? schema : schemas.getSchemaId(schema); - let array = [event.id, event.type, event.time, stateArray, schemaPayload] as IEventArray; - return array; -} - -// Arbitrary JavaScript object can be represented as a value or an array of values without any property names -// Crunching objects to arrays is done the following way: -// 1. Array: Keep object an array, but recursively crunch its contents -// 2. Object with properties: Create an array of recursively crunched property values in the order returned by Object.keys -// Example: let car = { make: "tesla", model: "3"}. Crunched array would be ["tesla", "3"] -// 3. Other: Just send the value as is -function dataToArray(data: any): any[] { - if (data === null || typeof data !== "object") { - return data; - } - - let dataArray = []; - if (data.constructor === Object) { - let keys = Object.keys(data); - for (let i = 0; i < keys.length; i++) { - let key = keys[i]; - let val = data[key]; - dataArray.push(dataToArray(val)); - } - } else if (data.constructor === Array) { - for (let i = 0; i < data.length; i++) { - let arrayValue = dataToArray(data[i]); - dataArray.push(arrayValue); - } - } - - return dataArray; -} diff --git a/src/core.ts b/src/core.ts deleted file mode 100644 index 7526b202..00000000 --- a/src/core.ts +++ /dev/null @@ -1,385 +0,0 @@ -import compress from "./compress"; -import EventToArray from "./converters/toarray"; -import getPlugin from "./plugins"; - -import { IAddEventMessage, ICompressedBatchMessage, ITimestampedWorkerMessage, WorkerMessageType } from "@clarity-types/compressionworker"; -import { - IBindingContainer, IClarityFields, IEnvelope, IEvent, IEventArray, IEventBindingPair, IEventData, IPayload, IPlugin, State -} from "@clarity-types/core"; -import { - IClarityActivateErrorState, IClarityDuplicatedEventState, IInstrumentationEventState, - IMissingFeatureEventState, Instrumentation, ITriggerState -} from "@clarity-types/instrumentation"; -import { createCompressionWorker } from "./compressionworker"; -import { config, resetConfig } from "./config"; -import { resetSchemas } from "./converters/schema"; -import { enqueuePayload, flushPayloadQueue, resetUploads, upload } from "./upload"; -import { getCookie, getEventId, guid, isNumber, setCookie } from "./utils"; - -export const version = "0.3.0"; -export const ClarityAttribute = "clarity-iid"; -export const InstrumentationEventName = "Instrumentation"; -const Cookie = "ClarityID"; - -let startTime: number; -let cid: string; -let impressionId: string; -let sequence: number; -let envelope: IEnvelope; -let activePlugins: IPlugin[]; -let bindings: IBindingContainer; - -// Counters -let eventCount: number; - -// Storage for events that were posted to compression worker, but have not returned to core as compressed batches yet. -// When page is unloaded, keeping such event copies in core allows us to terminate compression worker safely and then -// compress and upload remaining events synchronously from the main thread. -let pendingEvents: { [key: number]: IEventArray }; - -let backgroundMode: boolean; -let compressionWorker: Worker; -let timeout: number; - -export let state: State = State.Loaded; - -export function activate(): void { - state = State.Activating; - - // First, try to initalize core variables to allow Clarity perform minimal logging and safe teardowns. - // If this step fails, attempt a potentially unsafe logging and teardown. - try { - init(); - } catch (e) { - onActivateErrorUnsafe(e); - return; - } - - // Next, prepare for activation and activate available plugins. - // If anything goes wrong at this stage, we should be able to perform a safe teardown. - try { - let readyToActivatePlugins = prepare(); - if (readyToActivatePlugins) { - activatePlugins(); - } else { - teardown(); - return; - } - } catch (e) { - onActivateError(e); - return; - } - - state = State.Activated; -} - -export function teardown(): void { - if (state === State.Activating || state === State.Activated) { - state = State.Unloading; - for (let plugin of activePlugins) { - plugin.teardown(); - } - - // Walk through existing list of bindings and remove them all - for (let evt in bindings) { - if (bindings.hasOwnProperty(evt)) { - let eventBindings = bindings[evt] as IEventBindingPair[]; - for (let i = 0; i < eventBindings.length; i++) { - (eventBindings[i].target).removeEventListener(evt, eventBindings[i].listener); - } - } - } - - if (compressionWorker) { - // Immediately terminate the worker and kill its thread. - // Any possible pending incoming messages from the worker will be ignored in the 'Unloaded' state. - // Copies of all the events that were sent to the worker, but have not been returned as a compressed batch yet, - // are stored in the 'pendingEvents' object, so we will compress and upload them synchronously in this thread. - compressionWorker.terminate(); - } - state = State.Unloaded; - - // Instrument teardown and upload residual events - instrument({ type: Instrumentation.Teardown }); - uploadPendingEvents(); - resetConfig(); - - delete document[ClarityAttribute]; - } -} - -export function bind(target: EventTarget, event: string, listener: EventListener): void { - let eventBindings = bindings[event] || []; - target.addEventListener(event, listener, false); - eventBindings.push({ - target, - listener - }); - bindings[event] = eventBindings; -} - -export function addEvent(event: IEventData, scheduleUpload: boolean = true): void { - let evtJson: IEvent = { - id: eventCount++, - time: isNumber(event.time) ? event.time : getTimestamp(), - type: event.type, - state: event.state - }; - let evt = EventToArray(evtJson); - let addEventMessage: IAddEventMessage = { - type: WorkerMessageType.AddEvent, - event: evt, - time: getTimestamp(), - isXhrErrorEvent: event.type === InstrumentationEventName && event.state.type === Instrumentation.XhrError - }; - if (compressionWorker) { - compressionWorker.postMessage(addEventMessage); - } - pendingEvents[evtJson.id] = evt; - if (scheduleUpload) { - clearTimeout(timeout); - timeout = window.setTimeout(forceCompression, config.delay); - } -} - -export function addMultipleEvents(events: IEventData[]): void { - if (events.length > 0) { - // Don't schedule upload until we add the last event - for (let i = 0; i < events.length - 1; i++) { - addEvent(events[i], false); - } - let lastEvent = events[events.length - 1]; - addEvent(lastEvent, true); - } -} - -export function onTrigger(key: string): void { - if (state === State.Activated) { - let triggerState: ITriggerState = { - type: Instrumentation.Trigger, - key - }; - instrument(triggerState); - backgroundMode = false; - flushPayloadQueue(); - } -} - -export function forceCompression(): void { - if (compressionWorker) { - let forceCompressionMessage: ITimestampedWorkerMessage = { - type: WorkerMessageType.ForceCompression, - time: getTimestamp() - }; - compressionWorker.postMessage(forceCompressionMessage); - } -} - -export function getTimestamp(unix?: boolean, raw?: boolean): number { - let time = unix ? getUnixTimestamp() : getPageContextBasedTimestamp(); - return (raw ? time : Math.round(time)); -} - -export function instrument(eventState: IInstrumentationEventState): void { - if (config.instrument) { - addEvent({type: InstrumentationEventName, state: eventState}); - } -} - -export function onWorkerMessage(evt: MessageEvent): void { - if (state !== State.Unloaded) { - let message = evt.data; - switch (message.type) { - case WorkerMessageType.CompressedBatch: - let uploadMsg = message as ICompressedBatchMessage; - if (backgroundMode) { - enqueuePayload(uploadMsg.compressedData, uploadMsg.rawData); - } else { - upload(uploadMsg.compressedData, uploadMsg.rawData); - } - sequence = uploadMsg.rawData.envelope.sequenceNumber + 1; - - // Clear records for the compressed events returned by the worker - let events = uploadMsg.rawData.events; - for (let i = 0; i < events.length; i++) { - let evtId = getEventId(events[i]); - delete pendingEvents[evtId]; - } - break; - default: - break; - } - } -} - -function getUnixTimestamp(): number { - return (window.performance && performance.now && performance.timing) - ? performance.now() + performance.timing.navigationStart - : new Date().getTime(); -} - -// If performance.now function is not available, we do our best to approximate the time since page start -// by using the timestamp from when Clarity script got invoked as a starting point. -// In such case this number may not reflect the 'time since page start' accurately, -// especially if Clarity script is post-loaded or injected after page load. -function getPageContextBasedTimestamp(): number { - return (window.performance && performance.now) - ? performance.now() - : new Date().getTime() - startTime; -} - -function uploadPendingEvents(): void { - // We don't want to upload any data if Clarity is in background mode - if (backgroundMode) { - return; - } - let events: IEventArray[] = []; - let keys = Object.keys(pendingEvents); - for (let i = 0; i < keys.length; i++) { - let key = keys[i]; - let val = pendingEvents[key]; - events.push(val); - } - if (events.length > 0) { - envelope.sequenceNumber = sequence++; - envelope.time = getTimestamp(); - let raw: IPayload = { envelope, events }; - let compressed = compress(JSON.stringify(raw)); - upload(compressed, raw); - } -} - -function init(): void { - // Set ClarityID cookie, if it's not set already and is allowed by config - if (!config.disableCookie && !getCookie(Cookie)) { - // setting our ClarityId cookie for 2 years - setCookie(Cookie, guid(), 7 * 52 * 2); - } - - cid = config.disableCookie ? guid() : getCookie(Cookie); - impressionId = guid(); - - startTime = getUnixTimestamp(); - sequence = 0; - - envelope = { - clarityId: cid, - impressionId, - projectId: config.projectId || null, - url: window.location.href, - version - }; - - resetSchemas(); - resetUploads(); - - if (config.customInstrumentation) { - let fields: IClarityFields = { - impressionId, - clientId: cid, - projectId: config.projectId - }; - let customInst = config.customInstrumentation(fields); - envelope.extraInfo = {}; - for (let key in customInst) { - if (customInst.hasOwnProperty(key) && customInst[key]) { - envelope.extraInfo[key] = customInst[key]; - } - } - } - - activePlugins = []; - bindings = {}; - pendingEvents = []; - backgroundMode = config.backgroundMode; - - eventCount = 0; - - compressionWorker = createCompressionWorker(envelope, onWorkerMessage); -} - -function prepare(): boolean { - // If critical API is missing, don't activate Clarity - if (!checkFeatures()) { - return false; - } - - // Check that no other instance of Clarity is already running on the page - if (document[ClarityAttribute]) { - let eventState: IClarityDuplicatedEventState = { - type: Instrumentation.ClarityDuplicated, - currentImpressionId: document[ClarityAttribute] - }; - instrument(eventState); - return false; - } - - document[ClarityAttribute] = impressionId; - bind(window, "beforeunload", teardown); - bind(window, "unload", teardown); - return true; -} - -function activatePlugins(): void { - for (let plugin of config.plugins) { - let pluginClass = getPlugin(plugin); - if (pluginClass) { - let instance = new (pluginClass)(); - instance.reset(); - instance.activate(); - activePlugins.push(instance); - } - } -} - -function onActivateErrorUnsafe(e: Error): void { - try { - onActivateError(e); - } catch (e) { - // If there is an error at this stage, there is not much we can do any more, so just ignore and exit. - } -} - -function onActivateError(e: Error): void { - let clarityActivateError: IClarityActivateErrorState = { - type: Instrumentation.ClarityActivateError, - error: e.message - }; - instrument(clarityActivateError); - teardown(); -} - -function checkFeatures(): boolean { - let missingFeatures = []; - let expectedFeatures = [ - "document.implementation.createHTMLDocument", - "document.documentElement.classList", - "Function.prototype.bind", - "window.Worker" - ]; - - for (let feature of expectedFeatures) { - let parts = feature.split("."); - let api = window; - for (let part of parts) { - if (typeof api[part] === "undefined") { - missingFeatures.push(feature); - break; - } - api = api[part]; - } - } - - if (missingFeatures.length > 0) { - instrument({ - type: Instrumentation.MissingFeature, - missingFeatures - } as IMissingFeatureEventState); - return false; - } - - return true; -} - -// Initialize bindings early, so that registering and wiring up can be done properly -bindings = {}; diff --git a/src/core/config.ts b/src/core/config.ts new file mode 100644 index 00000000..6e974c87 --- /dev/null +++ b/src/core/config.ts @@ -0,0 +1,22 @@ +import { Config } from "@clarity-types/core"; + +let config: Config = { + projectId: null, + longtask: 30, // 30 milliseconds + lookahead: 500, // 500 milliseconds + distance: 20, // 20 pixels + interval: 25, // 25 milliseconds + delay: 1000, // 1 second + expire: 7, // 7 days + ping: 60 * 1000, // 1 minute + timeout: 10 * 60 * 1000, // 10 minutes + shutdown: 2 * 60 * 60 * 1000, // 2 hours + cssRules: false, + lean: false, + tokens: [], + url: "", + onstart: null, + upload: null +}; + +export default config; diff --git a/src/core/copy.ts b/src/core/copy.ts new file mode 100644 index 00000000..188c338c --- /dev/null +++ b/src/core/copy.ts @@ -0,0 +1,3 @@ +export default function(input: T): T { + return JSON.parse(JSON.stringify(input)); +} diff --git a/src/core/event.ts b/src/core/event.ts new file mode 100644 index 00000000..9d012e13 --- /dev/null +++ b/src/core/event.ts @@ -0,0 +1,16 @@ +import { BrowserEvent } from "@clarity-types/core"; + +let bindings: BrowserEvent[] = []; + +export function bind(target: EventTarget, event: string, listener: EventListener, capture: boolean = false): void { + target.addEventListener(event, listener, capture); + bindings.push({ event, target, listener, capture }); +} + +export function reset(): void { + // Walk through existing list of bindings and remove them all + for (let binding of bindings) { + (binding.target).removeEventListener(binding.event, binding.listener, binding.capture); + } + bindings = []; +} diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 00000000..efe3d2b8 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,26 @@ +import * as event from "@src/core/event"; + +export let startTime = 0; + +export function start(): void { + startTime = performance.now(); + event.reset(); +} + +export function end(): void { + event.reset(); + startTime = 0; +} + +export function check(): boolean { + try { + return typeof Promise !== "undefined" && + window["MutationObserver"] && + document["createTreeWalker"] && + "now" in Date && + "now" in performance && + typeof WeakMap !== "undefined"; + } catch (ex) { + return false; + } +} diff --git a/src/core/mask.ts b/src/core/mask.ts new file mode 100644 index 00000000..1934a9e0 --- /dev/null +++ b/src/core/mask.ts @@ -0,0 +1,14 @@ +export default function(value: string): string { + let wasWhiteSpace = false; + let textCount = 0; + let wordCount = 0; + for (let i = 0; i < value.length; i++) { + let code = value.charCodeAt(i); + let isWhiteSpace = (code === 32 || code === 10 || code === 9 || code === 13); + let isNotCharacter = ((code >= 33 && code <= 47) || (code >= 91 && code <= 96) || (code >= 123 && code <= 126)); + textCount += isWhiteSpace || isNotCharacter ? 0 : 1; + wordCount += isWhiteSpace && !wasWhiteSpace ? 1 : 0; + wasWhiteSpace = isWhiteSpace; + } + return `*${textCount.toString(36)}*${wordCount.toString(36)}`; +} diff --git a/src/core/task.ts b/src/core/task.ts new file mode 100644 index 00000000..053dad1e --- /dev/null +++ b/src/core/task.ts @@ -0,0 +1,68 @@ +import { AsyncTask, TaskFunction, TaskResolve, TaskTiming } from "@clarity-types/core"; +import { Metric } from "@clarity-types/data"; +import config from "@src/core/config"; +import * as metrics from "@src/data/metric"; + +let tracker: TaskTiming = {}; +let threshold = config.longtask; +let queue: AsyncTask[] = []; +let active: AsyncTask = null; + +export async function schedule(task: TaskFunction): Promise { + // If this task is already scheduled, skip it + for (let q of queue) { + if (q.task === task) { + return; + } + } + + let promise = new Promise((resolve: TaskResolve): void => { + queue.push({task, resolve}); + }); + + if (active === null) { run(); } + + return promise; +} + +function run(): void { + let entry = queue.shift(); + if (entry) { + active = entry; + entry.task().then(() => { + entry.resolve(); + active = null; + run(); + }); + } +} + +export function longtask(method: Metric): boolean { + let elapsed = Date.now() - tracker[method]; + return (elapsed > threshold); +} + +export function start(method: Metric): void { + if (!(method in tracker)) { + tracker[method] = 0; + } + tracker[method] = Date.now(); +} + +export function stop(method: Metric): void { + let end = Date.now(); + let duration = end - tracker[method]; + metrics.counter(method, duration); +} + +export async function idle(method: Metric): Promise { + stop(method); + await wait(); + start(method); +} + +async function wait(): Promise { + return new Promise((resolve: FrameRequestCallback): void => { + requestAnimationFrame(resolve); + }); +} diff --git a/src/core/time.ts b/src/core/time.ts new file mode 100644 index 00000000..55d352d9 --- /dev/null +++ b/src/core/time.ts @@ -0,0 +1,5 @@ +import { startTime } from "@src/core"; + +export default function(): number { + return Math.round(performance.now() - startTime); +} diff --git a/src/core/version.ts b/src/core/version.ts new file mode 100644 index 00000000..864cf815 --- /dev/null +++ b/src/core/version.ts @@ -0,0 +1,2 @@ +let version = "1.0.0-b10"; +export default version; diff --git a/src/data/encode.ts b/src/data/encode.ts new file mode 100644 index 00000000..9d7476f6 --- /dev/null +++ b/src/data/encode.ts @@ -0,0 +1,53 @@ +import {Event, Metric, Token } from "@clarity-types/data"; +import time from "@src/core/time"; +import { metadata } from "@src/data/metadata"; +import * as metric from "@src/data/metric"; +import * as ping from "@src/data/ping"; +import * as tag from "@src/data/tag"; +import { queue, track } from "./upload"; + +export default function(event: Event): void { + let t = time(); + let tokens: Token[] = [t, event]; + switch (event) { + case Event.Ping: + tokens.push(ping.data.gap); + queue(tokens); + break; + case Event.Page: + metric.counter(Metric.StartTime, Math.round(performance.now())); + tokens.push(metadata.page.timestamp); + tokens.push(metadata.page.ua); + tokens.push(metadata.page.url); + tokens.push(metadata.page.referrer); + tokens.push(metadata.page.lean); + queue(tokens); + break; + case Event.Tag: + tokens.push(tag.data.key); + tokens.push(tag.data.value); + queue(tokens); + break; + case Event.Upload: + tokens.push(track.sequence); + tokens.push(track.attempts); + tokens.push(track.status); + queue(tokens); + break; + case Event.Metric: + if (metric.updates.length > 0) { + for (let d in metric.data) { + if (metric.data[d]) { + let m = parseInt(d, 10); + if (metric.updates.indexOf(m) >= 0) { + tokens.push(m); + tokens.push(metric.data[d]); + } + } + } + metric.reset(); + queue(tokens); + } + break; + } +} diff --git a/src/data/hash.ts b/src/data/hash.ts new file mode 100644 index 00000000..11724708 --- /dev/null +++ b/src/data/hash.ts @@ -0,0 +1,19 @@ +// tslint:disable: no-bitwise +export default function(input: string): string { + // Code inspired from C# GetHashCode: https://github.com/Microsoft/referencesource/blob/master/mscorlib/system/string.cs + let hash = 0; + let hashOne = 5381; + let hashTwo = hashOne; + for (let i = 0; i < input.length; i += 2) { + let charOne = input.charCodeAt(i); + hashOne = ((hashOne << 5) + hashOne) ^ charOne; + if (i + 1 < input.length) { + let charTwo = input.charCodeAt(i + 1); + hashTwo = ((hashTwo << 5) + hashTwo) ^ charTwo; + } + } + // Replace the magic number from C# implementation (1566083941) with a smaller prime number (11579) + // This ensures we don't hit integer overflow and prevent collisions + hash = Math.abs(hashOne + (hashTwo * 11579)); + return hash.toString(36).slice(-6); // Limit hashes to 6 characters +} diff --git a/src/data/index.ts b/src/data/index.ts new file mode 100644 index 00000000..54c331ce --- /dev/null +++ b/src/data/index.ts @@ -0,0 +1,22 @@ +import * as metadata from "@src/data/metadata"; +import * as metric from "@src/data/metric"; +import * as ping from "@src/data/ping"; +import * as tag from "@src/data/tag"; +import * as upload from "@src/data/upload"; +export { tag } from "@src/data/tag"; + +export function start(): void { + upload.start(); + metric.start(); + metadata.start(); + ping.start(); + tag.reset(); +} + +export function end(): void { + tag.reset(); + ping.end(); + upload.end(); + metadata.end(); + metric.end(); +} diff --git a/src/data/metadata.ts b/src/data/metadata.ts new file mode 100644 index 00000000..3127f164 --- /dev/null +++ b/src/data/metadata.ts @@ -0,0 +1,85 @@ +import { BooleanFlag, CookieInfo, Envelope, Event, Metadata, PageData, Token, Upload } from "@clarity-types/data"; +import config from "@src/core/config"; +import version from "@src/core/version"; +import encode from "@src/data/encode"; +import hash from "@src/data/hash"; + +const CLARITY_COOKIE_NAME: string = "_clarity"; +const CLARITY_COOKIE_SEPARATOR: string = "|"; +const CLARITY_SESSION_LENGTH = 30 * 60 * 1000; +export let metadata: Metadata = null; + +export function start(): void { + let cookie: CookieInfo = read(); + let ts = Date.now(); + let projectId = config.projectId || hash(location.host); + let userId = cookie && cookie.userId ? cookie.userId : guid(); + let sessionId = cookie && cookie.sessionId && ts - cookie.timestamp < CLARITY_SESSION_LENGTH ? cookie.sessionId : ts.toString(36); + let pageId = guid(); + let ua = navigator && "userAgent" in navigator ? navigator.userAgent : ""; + let upload = Upload.Async; + let lean = config.lean ? BooleanFlag.True : BooleanFlag.False; + let e: Envelope = { sequence: 0, version, pageId, userId, sessionId, projectId, upload, end: BooleanFlag.False }; + let p: PageData = { timestamp: ts, ua, url: location.href, referrer: document.referrer, lean }; + + metadata = { page: p, envelope: e }; + + track({ userId, sessionId, timestamp: ts }); + encode(Event.Page); + if (config.onstart) { config.onstart({ userId, sessionId, pageId}); } +} + +export function end(): void { + metadata = null; +} + +export function envelope(last: boolean): Token[] { + let e = metadata.envelope; + e.upload = last && "sendBeacon" in navigator ? Upload.Beacon : Upload.Async; + e.end = last ? BooleanFlag.True : BooleanFlag.False; + e.sequence++; + + return [e.sequence, e.version, e.projectId, e.userId, e.sessionId, e.pageId, e.upload, e.end]; +} + +// Credit: http://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript +// Excluding 3rd party code from tslint +// tslint:disable +function guid() { + let d = new Date().getTime(); + if (window.performance && performance.now) { + // Use high-precision timer if available + d += performance.now(); + } + let uuid = "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, function(c) { + let r = (d + Math.random() * 16) % 16 | 0; + d = Math.floor(d / 16); + return (c == "x" ? r : (r & 0x3 | 0x8)).toString(16); + }); + return uuid; +} +// tslint:enable + +function track(data: CookieInfo): void { + let expiry = new Date(); + expiry.setDate(expiry.getDate() + config.expire); + let expires = expiry ? "expires=" + expiry.toUTCString() : ""; + let value = `${data.userId}|${data.sessionId}|${data.timestamp}` + ";" + expires + ";path=/"; + document.cookie = CLARITY_COOKIE_NAME + "=" + value; +} + +function read(): CookieInfo { + let cookies: string[] = document.cookie.split(";"); + if (cookies) { + for (let i = 0; i < cookies.length; i++) { + let pair: string[] = cookies[i].split("="); + if (pair.length > 1 && pair[0].indexOf(CLARITY_COOKIE_NAME) >= 0 && pair[1].indexOf(CLARITY_COOKIE_SEPARATOR) > 0) { + let parts = pair[1].split(CLARITY_COOKIE_SEPARATOR); + if (parts.length === 3) { + return { userId: parts[0], sessionId: parts[1], timestamp: parseInt(parts[2], 10) }; + } + } + } + } + return null; +} diff --git a/src/data/metric.ts b/src/data/metric.ts new file mode 100644 index 00000000..6e044eb1 --- /dev/null +++ b/src/data/metric.ts @@ -0,0 +1,39 @@ +import { Event, Metric, MetricData } from "@clarity-types/data"; +import encode from "./encode"; + +export let data: MetricData = null; +export let updates: Metric[] = []; + +export function start(): void { + data = {}; +} + +export function end(): void { + data = {}; +} + +export function counter(metric: Metric, increment: number = 1): void { + if (!(metric in data)) { data[metric] = 0; } + data[metric] += increment; + track(metric); +} + +export function measure(metric: Metric, value: number): void { + if (!(metric in data)) { data[metric] = 0; } + data[metric] = Math.max(value, data[metric]); + track(metric); +} + +export function compute(): void { + encode(Event.Metric); +} + +function track(metric: Metric): void { + if (updates.indexOf(metric) === -1) { + updates.push(metric); + } +} + +export function reset(): void { + updates = []; +} diff --git a/src/data/ping.ts b/src/data/ping.ts new file mode 100644 index 00000000..3611cef3 --- /dev/null +++ b/src/data/ping.ts @@ -0,0 +1,37 @@ +import { Event, PingData } from "@clarity-types/data"; +import { pause } from "@src/clarity"; +import config from "@src/core/config"; +import time from "@src/core/time"; +import encode from "./encode"; + +export let data: PingData; +let last = 0; +let interval = 0; +let timeout: number = null; + +export function start(): void { + interval = config.ping; +} + +export function reset(): void { + if (timeout) { clearTimeout(timeout); } + timeout = window.setTimeout(ping, interval); +} + +function ping(): void { + let now = time(); + data = { gap: now - last }; + encode(Event.Ping); + if (data.gap < config.timeout) { + interval = Math.min(interval * 2, config.timeout); + timeout = window.setTimeout(ping, interval); + } else { pause(); } + + last = now; +} + +export function end(): void { + clearTimeout(timeout); + last = 0; + interval = 0; +} diff --git a/src/data/tag.ts b/src/data/tag.ts new file mode 100644 index 00000000..9b974715 --- /dev/null +++ b/src/data/tag.ts @@ -0,0 +1,13 @@ +import { Event, TagData } from "@clarity-types/data"; +import encode from "@src/data/encode"; + +export let data: TagData = null; + +export function reset(): void { + data = null; +} + +export function tag(key: string, value: string): void { + data = { key, value }; + encode(Event.Tag); +} diff --git a/src/data/token.ts b/src/data/token.ts new file mode 100644 index 00000000..7042b687 --- /dev/null +++ b/src/data/token.ts @@ -0,0 +1,10 @@ +let tokens: string[] = []; + +export function check(hash: string): boolean { + let output = tokens.indexOf(hash) >= 0; + return output; +} + +export function resolve(hash: string): string[] { + return check(hash) ? tokens[hash] : []; +} diff --git a/src/data/upload.ts b/src/data/upload.ts new file mode 100644 index 00000000..e9346ff9 --- /dev/null +++ b/src/data/upload.ts @@ -0,0 +1,119 @@ +import { EncodedPayload, Event, Metric, Token, Transit, UploadData } from "@clarity-types/data"; +import config from "@src/core/config"; +import time from "@src/core/time"; +import encode from "@src/data/encode"; +import { envelope, metadata } from "@src/data/metadata"; +import * as metric from "@src/data/metric"; +import * as ping from "@src/data/ping"; + +const MAX_RETRIES = 2; +let events: string[]; +let timeout: number = null; +let transit: Transit; +let active: boolean; +export let track: UploadData; + +export function start(): void { + active = true; + events = []; + transit = {}; + track = null; +} + +export function queue(data: Token[]): void { + if (active) { + let type = data.length > 1 ? data[1] : null; + let event = JSON.stringify(data); + events.push(event); + + switch (type) { + case Event.Metric: + case Event.Upload: + return; // do not schedule upload callback + case Event.Discover: + case Event.Mutation: + case Event.BoxModel: + case Event.Hash: + case Event.Document: + case Event.Target: + metric.counter(Metric.LayoutBytes, event.length); + break; + case Event.Network: + case Event.Performance: + metric.counter(Metric.NetworkBytes, event.length); + break; + case Event.ScriptError: + case Event.ImageError: + metric.counter(Metric.DiagnosticBytes, event.length); + break; + default: + metric.counter(Metric.InteractionBytes, event.length); + break; + } + + // This is a precautionary check acting as a fail safe mechanism to get out of + // unexpected situations. Ideally, expectation is that pause / resume will work as designed. + // However, in some cases involving script errors, we may fail to pause Clarity instrumentation. + // In those edge cases, we will cut the cord after a configurable shutdown value. + // The only exception is the very last payload, for which we will attempt one final delivery to the server. + if (time() < config.shutdown) { + clearTimeout(timeout); + timeout = window.setTimeout(upload, config.delay); + } + } +} + +export function end(): void { + clearTimeout(timeout); + upload(true); + events = []; + transit = {}; + track = null; + active = false; +} + +function upload(last: boolean = false): void { + metric.compute(); + let payload: EncodedPayload = {e: JSON.stringify(envelope(last)), d: `[${events.join()}]`}; + let data = stringify(payload); + let sequence = metadata.envelope.sequence; + send(data, sequence, last); + if (!last) { ping.reset(); } + + // Send data to upload hook, if defined in the config + if (config.upload) { config.upload(data, sequence, last); } + + // Clear out events now that payload has been dispatched + events = []; +} + +function stringify(payload: EncodedPayload): string { + return `{"e":${payload.e},"d":${payload.d}}`; +} + +function send(data: string, sequence: number = null, last: boolean = false): void { + // Upload data if a valid URL is defined in the config + if (config.url.length > 0) { + if (last && "sendBeacon" in navigator) { + navigator.sendBeacon(config.url, data); + } else { + if (sequence in transit) { transit[sequence].attempts++; } else { transit[sequence] = { data, attempts: 1 }; } + let xhr = new XMLHttpRequest(); + xhr.open("POST", config.url); + if (sequence !== null) { xhr.onreadystatechange = (): void => { check(xhr, sequence); }; } + xhr.send(data); + } + } +} + +function check(xhr: XMLHttpRequest, sequence: number): void { + if (xhr && xhr.readyState === XMLHttpRequest.DONE && sequence in transit) { + if ((xhr.status < 200 || xhr.status > 208) && transit[sequence].attempts <= MAX_RETRIES) { + send(transit[sequence].data, sequence); + } else { + track = { sequence, attempts: transit[sequence].attempts, status: xhr.status }; + encode(Event.Upload); + delete transit[sequence]; + } + } +} diff --git a/src/diagnostic/encode.ts b/src/diagnostic/encode.ts new file mode 100644 index 00000000..2f614d95 --- /dev/null +++ b/src/diagnostic/encode.ts @@ -0,0 +1,29 @@ +import {Event, Metric, Token} from "@clarity-types/data"; +import time from "@src/core/time"; +import * as metric from "@src/data/metric"; +import { queue } from "@src/data/upload"; +import * as image from "@src/diagnostic/image"; +import * as script from "@src/diagnostic/script"; + +export default function(type: Event): Token[] { + let tokens: Token[] = [time(), type]; + + switch (type) { + case Event.ScriptError: + tokens.push(script.data.message); + tokens.push(script.data.line); + tokens.push(script.data.column); + tokens.push(script.data.stack); + tokens.push(script.data.source); + queue(tokens); + metric.counter(Metric.ScriptErrors); + break; + case Event.ImageError: + tokens.push(image.data.source); + tokens.push(image.data.target); + queue(tokens); + break; + } + + return tokens; +} diff --git a/src/diagnostic/image.ts b/src/diagnostic/image.ts new file mode 100644 index 00000000..726874ad --- /dev/null +++ b/src/diagnostic/image.ts @@ -0,0 +1,22 @@ +import { Event } from "@clarity-types/data"; +import { ImageErrorData } from "@clarity-types/diagnostic"; +import { bind } from "@src/core/event"; +import { getId } from "@src/layout/dom"; +import encode from "./encode"; + +export let data: ImageErrorData; + +export function start(): void { + bind(document, "error", handler, true); +} + +function handler(error: ErrorEvent): void { + let target = error.target as HTMLElement; + if (target && target.tagName === "IMG") { + data = { + source: (target as HTMLImageElement).src, + target: getId(target) + }; + encode(Event.ImageError); + } +} diff --git a/src/diagnostic/index.ts b/src/diagnostic/index.ts new file mode 100644 index 00000000..87aa5f67 --- /dev/null +++ b/src/diagnostic/index.ts @@ -0,0 +1,11 @@ +import * as image from "./image"; +import * as script from "./script"; + +export function start(): void { + script.start(); + image.start(); +} + +export function end(): void { + /* cleanup operation */ +} diff --git a/src/diagnostic/script.ts b/src/diagnostic/script.ts new file mode 100644 index 00000000..03aef14a --- /dev/null +++ b/src/diagnostic/script.ts @@ -0,0 +1,24 @@ +import { Event } from "@clarity-types/data"; +import { ScriptErrorData } from "@clarity-types/diagnostic"; +import { bind } from "@src/core/event"; +import encode from "./encode"; + +export let data: ScriptErrorData; + +export function start(): void { + bind(window, "error", handler); +} + +function handler(error: ErrorEvent): void { + let e = error["error"] || error; + + data = { + message: e.message, + line: error["lineno"], + column: error["colno"], + stack: e.stack, + source: error["filename"] + }; + + encode(Event.ScriptError); +} diff --git a/src/index.ts b/src/index.ts index 4e4d6b87..e81ae3fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import * as ClarityJs from "./clarity"; -import * as PayloadEncoder from "./converters/convert"; +import * as clarity from "./clarity"; -export { PayloadEncoder, ClarityJs }; +export { clarity }; diff --git a/src/interaction/change.ts b/src/interaction/change.ts new file mode 100644 index 00000000..b623acaa --- /dev/null +++ b/src/interaction/change.ts @@ -0,0 +1,27 @@ +import { Event } from "@clarity-types/data"; +import { InputChangeData } from "@clarity-types/interaction"; +import { bind } from "@src/core/event"; +import mask from "@src/core/mask"; +import { get } from "@src/layout/dom"; +import * as target from "@src/layout/target"; +import encode from "./encode"; + +export let data: InputChangeData; + +export function start(): void { + bind(document, "change", recompute, true); +} + +function recompute(evt: UIEvent): void { + let input = evt.target as HTMLInputElement; + let value = get(input); + if (input && value) { + target.observe(value.id); + data = { target: value.id, value: value.metadata.masked ? mask(input.value) : input.value }; + encode(Event.InputChange); + } +} + +export function reset(): void { + data = null; +} diff --git a/src/interaction/encode.ts b/src/interaction/encode.ts new file mode 100644 index 00000000..6d0f5c65 --- /dev/null +++ b/src/interaction/encode.ts @@ -0,0 +1,90 @@ +import {Event, Metric, Token} from "@clarity-types/data"; +import time from "@src/core/time"; +import * as metric from "@src/data/metric"; +import { queue } from "@src/data/upload"; +import * as change from "./change"; +import * as pointer from "./pointer"; +import * as resize from "./resize"; +import * as scroll from "./scroll"; +import * as selection from "./selection"; +import * as unload from "./unload"; +import * as visibility from "./visibility"; + +export default function(type: Event): void { + let t = time(); + let tokens: Token[] = [t, type]; + switch (type) { + case Event.MouseDown: + case Event.MouseUp: + case Event.MouseMove: + case Event.MouseWheel: + case Event.Click: + case Event.DoubleClick: + case Event.RightClick: + case Event.TouchStart: + case Event.TouchEnd: + case Event.TouchMove: + case Event.TouchCancel: + for (let i = 0; i < pointer.data[type].length; i++) { + let entry = pointer.data[type][i]; + tokens = [entry.time, type]; + tokens.push(entry.target); + tokens.push(entry.x); + tokens.push(entry.y); + queue(tokens); + } + pointer.reset(); + break; + case Event.Resize: + let r = resize.data; + tokens.push(r.width); + tokens.push(r.height); + queue(tokens); + metric.measure(Metric.ViewportWidth, r.width); + metric.measure(Metric.ViewportHeight, r.height); + resize.reset(); + break; + case Event.Unload: + let u = unload.data; + tokens.push(u.name); + queue(tokens); + metric.counter(Metric.EndTime, t); + unload.reset(); + break; + case Event.InputChange: + let ch = change.data; + tokens.push(ch.target); + tokens.push(ch.value); + queue(tokens); + metric.counter(Metric.Changes); + change.reset(); + break; + case Event.Selection: + let s = selection.data; + tokens.push(s.start); + tokens.push(s.startOffset); + tokens.push(s.end); + tokens.push(s.endOffset); + queue(tokens); + metric.counter(Metric.Selections); + selection.reset(); + break; + case Event.Scroll: + for (let i = 0; i < scroll.data.length; i++) { + let entry = scroll.data[i]; + tokens = [entry.time, type]; + tokens.push(entry.target); + tokens.push(entry.x); + tokens.push(entry.y); + queue(tokens); + } + scroll.reset(); + break; + case Event.Visibility: + let v = visibility.data; + tokens.push(v.visible); + queue(tokens); + visibility.reset(); + break; + } +} diff --git a/src/interaction/index.ts b/src/interaction/index.ts new file mode 100644 index 00000000..e515c6cd --- /dev/null +++ b/src/interaction/index.ts @@ -0,0 +1,21 @@ +import * as pointer from "@src/interaction/pointer"; +import * as resize from "@src/interaction/resize"; +import * as scroll from "@src/interaction/scroll"; +import * as selection from "@src/interaction/selection"; +import * as unload from "@src/interaction/unload"; +import * as visibility from "@src/interaction/visibility"; + +export function start(): void { + pointer.start(); + resize.start(); + visibility.start(); + scroll.start(); + selection.start(); + unload.start(); +} + +export function end(): void { + pointer.end(); + scroll.end(); + selection.end(); +} diff --git a/src/interaction/pointer.ts b/src/interaction/pointer.ts new file mode 100644 index 00000000..331cef54 --- /dev/null +++ b/src/interaction/pointer.ts @@ -0,0 +1,93 @@ +import { Event } from "@clarity-types/data"; +import { PointerData } from "@clarity-types/interaction"; +import config from "@src/core/config"; +import { bind } from "@src/core/event"; +import time from "@src/core/time"; +import { getId } from "@src/layout/dom"; +import * as target from "@src/layout/target"; +import encode from "./encode"; + +export let data: { [key: number]: PointerData[] } = {}; +let timeout: number = null; + +export function start(): void { + reset(); + bind(document, "mousedown", mouse.bind(this, Event.MouseDown), true); + bind(document, "mouseup", mouse.bind(this, Event.MouseUp), true); + bind(document, "mousemove", mouse.bind(this, Event.MouseMove), true); + bind(document, "mousewheel", mouse.bind(this, Event.MouseWheel), true); + bind(document, "dblclick", mouse.bind(this, Event.DoubleClick), true); + bind(document, "click", mouse.bind(this, Event.Click), true); + bind(document, "touchstart", touch.bind(this, Event.TouchStart), true); + bind(document, "touchend", touch.bind(this, Event.TouchEnd), true); + bind(document, "touchmove", touch.bind(this, Event.TouchMove), true); + bind(document, "touchcancel", touch.bind(this, Event.TouchCancel), true); +} + +function mouse(event: Event, evt: MouseEvent): void { + let de = document.documentElement; + let x = "pageX" in evt ? Math.round(evt.pageX) : ("clientX" in evt ? Math.round(evt["clientX"] + de.scrollLeft) : null); + let y = "pageY" in evt ? Math.round(evt.pageY) : ("clientY" in evt ? Math.round(evt["clientY"] + de.scrollTop) : null); + let id = evt.target ? getId(evt.target as Node) : null; + target.observe(id); + event = event === Event.Click && (evt.buttons === 2 || evt.button === 2) ? Event.RightClick : event; + handler(event, {target: id, x, y, time: time()}); +} + +function touch(event: Event, evt: TouchEvent): void { + let de = document.documentElement; + let touches = evt.changedTouches; + let id = evt.target ? getId(evt.target as Node) : null; + let t = time(); + target.observe(id); + if (touches) { + for (let i = 0; i < touches.length; i++) { + let entry = touches[i]; + let x = "clientX" in entry ? Math.round(entry["clientX"] + de.scrollLeft) : null; + let y = "clientY" in entry ? Math.round(entry["clientY"] + de.scrollTop) : null; + handler(event, {target: id, x, y, time: t}); + } + } +} + +function handler(event: Event, current: PointerData): void { + switch (event) { + case Event.MouseMove: + case Event.MouseWheel: + case Event.TouchMove: + let length = data[event].length; + let last = length > 1 ? data[event][length - 2] : null; + if (last && similar(last, current)) { data[event].pop(); } + data[event].push(current); + + clearTimeout(timeout); + timeout = window.setTimeout(encode, config.lookahead, event); + break; + default: + data[event].push(current); + encode(event); + break; + } +} + +export function reset(): void { + data = {}; + let mouseEvents = [Event.MouseDown, Event.MouseUp, Event.MouseWheel, Event.MouseMove, Event.DoubleClick, Event.Click, Event.RightClick]; + let touchEvents = [Event.TouchStart, Event.TouchMove, Event.TouchEnd, Event.TouchCancel]; + let events = mouseEvents.concat(touchEvents); + for (let event of events) { + data[event] = []; + } +} + +function similar(last: PointerData, current: PointerData): boolean { + let dx = last.x - current.x; + let dy = last.y - current.y; + let distance = Math.sqrt(dx * dx + dy * dy); + return (distance < config.distance) && (current.time - last.time < config.interval) && current.target === last.target; +} + +export function end(): void { + clearTimeout(timeout); + data = {}; +} diff --git a/src/interaction/resize.ts b/src/interaction/resize.ts new file mode 100644 index 00000000..f1a3cc82 --- /dev/null +++ b/src/interaction/resize.ts @@ -0,0 +1,23 @@ +import { Event } from "@clarity-types/data"; +import { ResizeData } from "@clarity-types/interaction"; +import { bind } from "@src/core/event"; +import encode from "./encode"; + +export let data: ResizeData; + +export function start(): void { + bind(window, "resize", recompute); + recompute(); +} + +function recompute(): void { + data = { + width: "innerWidth" in window ? window.innerWidth : document.documentElement.clientWidth, + height: "innerHeight" in window ? window.innerHeight : document.documentElement.clientHeight + }; + encode(Event.Resize); +} + +export function reset(): void { + data = null; +} diff --git a/src/interaction/scroll.ts b/src/interaction/scroll.ts new file mode 100644 index 00000000..c9a388a3 --- /dev/null +++ b/src/interaction/scroll.ts @@ -0,0 +1,48 @@ +import { Event } from "@clarity-types/data"; +import { ScrollData } from "@clarity-types/interaction"; +import config from "@src/core/config"; +import { bind } from "@src/core/event"; +import time from "@src/core/time"; +import { getId } from "@src/layout/dom"; +import * as target from "@src/layout/target"; +import encode from "./encode"; + +export let data: ScrollData[] = []; +let timeout: number = null; + +export function start(): void { + bind(window, "scroll", recompute, true); + recompute(); +} + +function recompute(event: UIEvent = null): void { + let eventTarget = event ? (event.target === document ? document.documentElement : event.target) : document.documentElement; + let x = Math.round((eventTarget as HTMLElement).scrollLeft); + let y = Math.round((eventTarget as HTMLElement).scrollTop); + let id = getId(eventTarget as Node); + target.observe(id); + let current: ScrollData = {target: id, x, y, time: time()}; + + let length = data.length; + let last = length > 1 ? data[length - 2] : null; + if (last && similar(last, current)) { data.pop(); } + data.push(current); + + clearTimeout(timeout); + timeout = window.setTimeout(encode, config.lookahead, Event.Scroll); +} + +export function reset(): void { + data = []; +} + +function similar(last: ScrollData, current: ScrollData): boolean { + let dx = last.x - current.x; + let dy = last.y - current.y; + return (dx * dx + dy * dy < config.distance * config.distance) && (current.time - last.time < config.interval); +} + +export function end(): void { + clearTimeout(timeout); + data = []; +} diff --git a/src/interaction/selection.ts b/src/interaction/selection.ts new file mode 100644 index 00000000..986f2fe7 --- /dev/null +++ b/src/interaction/selection.ts @@ -0,0 +1,60 @@ +import { Event } from "@clarity-types/data"; +import { SelectionData } from "@clarity-types/interaction"; +import config from "@src/core/config"; +import { bind } from "@src/core/event"; +import { getId } from "@src/layout/dom"; +import * as target from "@src/layout/target"; +import encode from "./encode"; + +export let data: SelectionData = null; +let previous: Selection = null; +let timeout: number = null; + +export function start(): void { + reset(); + bind(document, "selectstart", recompute, true); + bind(document, "selectionchange", recompute, true); +} + +function recompute(): void { + let current = document.getSelection(); + + // Bail out if we don't have a valid selection + if (current === null) { return; } + + let anchorNode = getId(current.anchorNode); + let focusNode = getId(current.focusNode); + + // Bail out if we got valid selection but not valid nodes + // In Edge, selectionchange gets fired even on interactions like right clicks and + // can result in null anchorNode and focusNode if there was no previous selection on page + if (anchorNode === null && focusNode === null) { return; } + + if (previous !== null && data.start !== null && data.start !== anchorNode) { + clearTimeout(timeout); + target.observe(data.start); + target.observe(data.end); + encode(Event.Selection); + } + + data = { + start: anchorNode, + startOffset: current.anchorOffset, + end: focusNode, + endOffset: current.focusOffset + }; + previous = current; + + clearTimeout(timeout); + timeout = window.setTimeout(encode, config.lookahead, Event.Selection); +} + +export function reset(): void { + previous = null; + data = { start: 0, startOffset: 0, end: 0, endOffset: 0 }; +} + +export function end(): void { + reset(); + clearTimeout(timeout); +} diff --git a/src/interaction/unload.ts b/src/interaction/unload.ts new file mode 100644 index 00000000..c632f490 --- /dev/null +++ b/src/interaction/unload.ts @@ -0,0 +1,23 @@ +import { Event } from "@clarity-types/data"; +import { UnloadData } from "@clarity-types/interaction"; +import { end } from "@src/clarity"; +import { bind } from "@src/core/event"; +import encode from "./encode"; + +export let data: UnloadData; + +export function start(): void { + bind(window, "beforeunload", recompute); + bind(window, "unload", recompute); + bind(window, "pagehide", recompute); +} + +function recompute(evt: UIEvent): void { + data = { name: evt.type }; + encode(Event.Unload); + end(); +} + +export function reset(): void { + data = null; +} diff --git a/src/interaction/visibility.ts b/src/interaction/visibility.ts new file mode 100644 index 00000000..94ea0091 --- /dev/null +++ b/src/interaction/visibility.ts @@ -0,0 +1,20 @@ +import { Event } from "@clarity-types/data"; +import { VisibilityData } from "@clarity-types/interaction"; +import { bind } from "@src/core/event"; +import encode from "./encode"; + +export let data: VisibilityData; + +export function start(): void { + bind(document, "visibilitychange", recompute); + recompute(); +} + +function recompute(): void { + data = { visible: "visibilityState" in document ? document.visibilityState : "default" }; + encode(Event.Visibility); +} + +export function reset(): void { + data = null; +} diff --git a/src/layout/boxmodel.ts b/src/layout/boxmodel.ts new file mode 100644 index 00000000..05a74330 --- /dev/null +++ b/src/layout/boxmodel.ts @@ -0,0 +1,95 @@ +import { Event, Metric } from "@clarity-types/data"; +import { BoxModelData } from "@clarity-types/layout"; +import config from "@src/core/config"; +import * as task from "@src/core/task"; +import encode from "@src/layout/encode"; +import * as dom from "./dom"; + +let bm: {[key: number]: BoxModelData} = {}; +let updateMap: number[] = []; +let timeout: number = null; + +export function compute(): void { + clearTimeout(timeout); + timeout = window.setTimeout(schedule, config.lookahead); +} + +function schedule(): void { + task.schedule(boxmodel); +} + +async function boxmodel(): Promise { + let timer = Metric.BoxModelTime; + task.start(timer); + let values = dom.boxmodel(); + let doc = document.documentElement; + let x = "pageXOffset" in window ? window.pageXOffset : doc.scrollLeft; + let y = "pageYOffset" in window ? window.pageYOffset : doc.scrollTop; + + for (let value of values) { + if (task.longtask(timer)) { + await task.idle(timer); + x = "pageXOffset" in window ? window.pageXOffset : doc.scrollLeft; + y = "pageYOffset" in window ? window.pageYOffset : doc.scrollTop; + } + update(value.id, layout(dom.getNode(value.id) as Element, x, y)); + } + + if (updateMap.length > 0) { await encode(Event.BoxModel); } + task.stop(timer); +} + +export function updates(): BoxModelData[] { + let summary = []; + for (let id of updateMap) { + summary.push(bm[id]); + } + updateMap = []; + return summary; +} + +function update(id: number, box: number[]): void { + let changed = box !== null; + if (id in bm && box !== null && bm[id].box !== null) { + changed = box.length === bm[id].box.length ? false : true; + if (changed === false) { + for (let i = 0; i < box.length; i++) { + if (box[i] !== bm[id].box[i]) { + changed = true; + break; + } + } + } + } + + if (changed) { + if (updateMap.indexOf(id) === -1) { updateMap.push(id); } + bm[id] = {id, box}; + } +} + +export function layout(element: Element, x: number = 0, y: number = 0): number[] { + let box: number[] = null; + let rect = element.getBoundingClientRect(); + + if (rect && rect.width > 0 && rect.height > 0) { + // getBoundingClientRect returns relative positioning to viewport and therefore needs + // addition of window scroll position to get position relative to document + // Also: using Math.floor() instead of Math.round() below because in Edge, + // getBoundingClientRect returns partial pixel values (e.g. 162.5px) and Chrome already + // floors the value (e.g. 162px). Keeping behavior consistent across + box = [ + Math.floor(rect.left + x), + Math.floor(rect.top + y), + Math.floor(rect.width), + Math.floor(rect.height) + ]; + } + return box; +} + +export function reset(): void { + clearTimeout(timeout); + updateMap = []; + bm = {}; +} diff --git a/src/layout/discover.ts b/src/layout/discover.ts new file mode 100644 index 00000000..f42e5d52 --- /dev/null +++ b/src/layout/discover.ts @@ -0,0 +1,30 @@ +import { Event, Metric } from "@clarity-types/data"; +import { Source } from "@clarity-types/layout"; +import config from "@src/core/config"; +import * as task from "@src/core/task"; +import * as boxmodel from "@src/layout/boxmodel"; +import * as doc from "@src/layout/document"; +import encode from "@src/layout/encode"; + +import processNode from "./node"; + +export function start(): void { + task.schedule(discover).then(() => { + doc.compute(); + boxmodel.compute(); + }); +} + +async function discover(): Promise { + let timer = Metric.DiscoverTime; + task.start(timer); + let walker = document.createTreeWalker(document, NodeFilter.SHOW_ALL, null, false); + let node = walker.nextNode(); + while (node) { + if (task.longtask(timer)) { await task.idle(timer); } + processNode(node, Source.Discover); + node = walker.nextNode(); + } + await encode(config.lean ? Event.Hash : Event.Discover); + task.stop(timer); +} diff --git a/src/layout/document.ts b/src/layout/document.ts new file mode 100644 index 00000000..cca59cec --- /dev/null +++ b/src/layout/document.ts @@ -0,0 +1,28 @@ +import { Event } from "@clarity-types/data"; +import { DocumentData } from "@clarity-types/layout"; +import encode from "./encode"; + +export let data: DocumentData; + +export function reset(): void { + data = null; +} + +export function compute(): void { + let body = document.body; + let d = document.documentElement; + let width = body ? body.clientWidth : null; + let bodyClientHeight = body ? body.clientHeight : null; + let bodyScrollHeight = body ? body.scrollHeight : null; + let bodyOffsetHeight = body ? body.offsetHeight : null; + let documentClientHeight = d ? d.clientHeight : null; + let documentScrollHeight = d ? d.scrollHeight : null; + let documentOffsetHeight = d ? d.offsetHeight : null; + let height = Math.max(bodyClientHeight, bodyScrollHeight, bodyOffsetHeight, + documentClientHeight, documentScrollHeight, documentOffsetHeight); + + if (data === null || width !== data.width || height !== data.height) { + data = { width, height }; + encode(Event.Document); + } +} diff --git a/src/layout/dom.ts b/src/layout/dom.ts new file mode 100644 index 00000000..96720dcb --- /dev/null +++ b/src/layout/dom.ts @@ -0,0 +1,280 @@ +import { Constant, NodeChange, NodeInfo, NodeValue, Source } from "@clarity-types/layout"; +import time from "@src/core/time"; +import selector from "@src/layout/selector"; + +let index: number = 1; + +let nodes: Node[] = []; +let values: NodeValue[] = []; +let changes: NodeChange[][] = []; +let updateMap: number[] = []; +let selectorMap: number[] = []; +let idMap: WeakMap = null; + +export function reset(): void { + index = 1; + nodes = []; + values = []; + updateMap = []; + changes = []; + selectorMap = []; + idMap = new WeakMap(); + if (Constant.DEVTOOLS_HOOK in window) { window[Constant.DEVTOOLS_HOOK] = { get, getNode, history }; } +} + +export function getId(node: Node, autogen: boolean = false): number { + if (node === null) { return null; } + let id = idMap.get(node); + if (!id && autogen) { + id = index++; + idMap.set(node, id); + } + + return id ? id : null; +} + +export function add(node: Node, data: NodeInfo, source: Source): void { + let id = getId(node, true); + let parentId = node.parentElement ? getId(node.parentElement) : null; + let nextId = getNextId(node); + let masked = true; + let parent = null; + + if (parentId >= 0 && values[parentId]) { + parent = values[parentId]; + parent.children.push(id); + masked = parent.metadata.masked; + } + + if (data.attributes && Constant.MASK_ATTRIBUTE in data.attributes) { masked = true; } + if (data.attributes && Constant.UNMASK_ATTRIBUTE in data.attributes) { masked = false; } + + nodes[id] = node; + values[id] = { + id, + parent: parentId, + next: nextId, + children: [], + position: null, + data, + selector: "", + metadata: { active: true, boxmodel: false, masked } + }; + updateSelector(values[id]); + layout(data.tag, id, parentId); + track(id, source); +} + +export function update(node: Node, data: NodeInfo, source: Source): void { + let id = getId(node); + let parentId = node.parentElement ? getId(node.parentElement) : null; + let nextId = getNextId(node); + + if (id in values) { + let value = values[id]; + value.metadata.active = true; + + // Handle case where internal ordering may have changed + if (value["next"] !== nextId) { + value["next"] = nextId; + } + + // Handle case where parent might have been updated + if (value["parent"] !== parentId) { + let oldParentId = value["parent"]; + value["parent"] = parentId; + // Move this node to the right location under new parent + if (parentId !== null && parentId >= 0) { + if (nextId !== null && nextId >= 0) { + values[parentId].children.splice(nextId + 1, 0 , id); + } else { + values[parentId].children.push(id); + } + } else { + // Mark this element as deleted if the parent has been updated to null + remove(id, source); + } + + // Remove reference to this node from the old parent + if (oldParentId !== null && oldParentId >= 0) { + let nodeIndex = values[oldParentId].children.indexOf(id); + if (nodeIndex >= 0) { + values[oldParentId].children.splice(nodeIndex, 1); + } + } + } + + // Update data + for (let key in data) { + if (key in value["data"]) { + value["data"][key] = data[key]; + } + } + + // Update selector + updateSelector(value); + + layout(data.tag, id, parentId); + track(id, source); + } +} + +function position(parent: NodeValue, child: NodeValue): number { + let tag = child.data.tag; + // Find relative position of the element to generate :nth-of-type selector + // We restrict relative positioning to handful of tags for now. + if (parent && (tag === "DIV" || tag === "TR" || tag === "P" || tag === "LI")) { + child.position = 1; + let idx = parent ? parent.children.indexOf(child.id) : -1; + while (idx-- > 0) { + let sibling = values[parent.children[idx]]; + if (child.data.tag === sibling.data.tag) { child.position = sibling.position + 1; } + break; + } + } + return child.position; +} + +function updateSelector(value: NodeValue): void { + let parent = value.parent && value.parent in values ? values[value.parent] : null; + let prefix = parent ? `${parent.selector}>` : null; + let ex = value.selector; + let current = selector(value.data.tag, prefix, value.data.attributes, position(parent, value)); + if (current !== ex && selectorMap.indexOf(value.id) === -1) { selectorMap.push(value.id); } + value.selector = current; +} + +export function getNode(id: number): Node { + if (id in nodes) { + return nodes[id]; + } + return null; +} + +export function getValue(id: number): NodeValue { + if (id in values) { + return values[id]; + } + return null; +} + +export function get(node: Node): NodeValue { + let id = getId(node); + return values[id]; +} + +export function has(node: Node): boolean { + return getId(node) in nodes; +} + +export function boxmodel(): NodeValue[] { + let v = []; + for (let id in values) { + if (values[id].metadata.active && values[id].metadata.boxmodel) { + v.push(values[id]); + } + } + return v; +} + +export function updates(): NodeValue[] { + let output = []; + for (let id of updateMap) { + if (id in values) { + let v = values[id]; + let p = v.parent; + let hasId = "attributes" in v.data && "id" in v.data.attributes; + v.data.path = p === null || p in updateMap || hasId || v.selector.length === 0 ? null : values[p].selector; + output.push(values[id]); + } + } + updateMap = []; + return output; +} + +export function selectors(): NodeValue[] { + let v = []; + for (let id of selectorMap) { + if (id in values) { + v.push(values[id]); + } + } + selectorMap = []; + return v; +} + +function remove(id: number, source: Source): void { + let value = values[id]; + value.metadata.active = false; + value.parent = null; + track(id, source); + for (let child of value.children) { remove(child, source); } + value.children = []; +} + +function layout(tag: string, id: number, parentId: number): void { + if (id !== null && parentId !== null) { + switch (tag) { + case "*T": + // Mark parent as a leaf node only if the text node has valid text and parent is masked. + // For nodes with whitespaces and not real text, skip them. + if (values[parentId].metadata.masked) { + let value = values[id].data.value; + for (let i = 0; i < value.length; i++) { + let code = value.charCodeAt(i); + if (!(code === 32 || code === 10 || code === 9 || code === 13)) { + values[parentId].metadata.boxmodel = true; + break; + } + } + } + break; + case "IMG": + case "IFRAME": + values[id].metadata.boxmodel = true; + break; + default: + // Capture layout for any element with a user defined selector + values[id].metadata.boxmodel = values[id].selector.indexOf("*") === 0; + break; + } + } +} + +function getNextId(node: Node): number { + let id = null; + while (id === null && node.nextSibling) { + id = getId(node.nextSibling); + node = node.nextSibling; + } + return id; +} + +function copy(input: NodeValue[]): NodeValue[] { + return JSON.parse(JSON.stringify(input)); +} + +function track(id: number, source: Source): void { + // Keep track of the order in which mutations happened, they may not be sequential + // Edge case: If an element is added later on, and pre-discovered element is moved as a child. + // In that case, we need to reorder the prediscovered element in the update list to keep visualization consistent. + let uIndex = updateMap.indexOf(id); + if (uIndex >= 0 && source === Source.ChildListAdd) { + updateMap.splice(uIndex, 1); + updateMap.push(id); + } else if (uIndex === -1) { updateMap.push(id); } + + if (Constant.DEVTOOLS_HOOK in window) { + let value = copy([values[id]])[0]; + let change = { time: time(), source, value }; + if (!(id in changes)) { changes[id] = []; } + changes[id].push(change); + } +} + +function history(id: number): NodeChange[] { + if (id in changes) { + return changes[id]; + } + return []; +} diff --git a/src/layout/encode.ts b/src/layout/encode.ts new file mode 100644 index 00000000..2537716d --- /dev/null +++ b/src/layout/encode.ts @@ -0,0 +1,147 @@ +import {Event, Metric, Token} from "@clarity-types/data"; +import {NodeInfo} from "@clarity-types/layout"; +import mask from "@src/core/mask"; +import * as task from "@src/core/task"; +import time from "@src/core/time"; +import hash from "@src/data/hash"; +import * as metric from "@src/data/metric"; +import {check} from "@src/data/token"; +import { queue } from "@src/data/upload"; +import * as boxmodel from "./boxmodel"; +import * as doc from "./document"; +import * as dom from "./dom"; +import * as target from "./target"; + +export default async function(type: Event): Promise { + let tokens: Token[] = [time(), type]; + let timer = type === Event.Discover ? Metric.DiscoverTime : Metric.MutationTime; + let addEventToQueue = true; + switch (type) { + case Event.Document: + let d = doc.data; + tokens.push(d.width); + tokens.push(d.height); + metric.measure(Metric.DocumentWidth, d.width); + metric.measure(Metric.DocumentHeight, d.height); + queue(tokens); + break; + case Event.BoxModel: + let bm = boxmodel.updates(); + for (let value of bm) { + tokens.push(value.id); + tokens.push(value.box); + } + queue(tokens); + break; + case Event.Target: + let targets = target.updates(); + for (let value of targets) { + tokens.push(value.id); + tokens.push(value.hash); + tokens.push(value.box); + } + queue(tokens); + break; + case Event.Hash: + let selectors = dom.selectors(); + let reference = 0; + for (let value of selectors) { + if (task.longtask(timer)) { await task.idle(timer); } + let h = hash(value.selector); + let pointer = tokens.indexOf(h); + tokens.push(value.id - reference); + tokens.push(pointer >= 0 ? [pointer] : h); + reference = value.id; + } + queue(tokens); + break; + case Event.Discover: + case Event.Mutation: + let values = dom.updates(); + for (let value of values) { + if (task.longtask(timer)) { await task.idle(timer); } + let metadata = []; + let data: NodeInfo = value.data; + let active = value.metadata.active; + let keys = active ? ["tag", "path", "attributes", "value"] : ["tag"]; + for (let key of keys) { + if (data[key]) { + switch (key) { + case "tag": + metric.counter(Metric.Nodes); + tokens.push(value.id); + if (value.parent && active) { tokens.push(value.parent); } + if (value.next && active) { tokens.push(value.next); } + metadata.push(value.position ? `${data[key]}~${value.position}` : data[key]); + break; + case "path": + metadata.push(`${value.data.path}>`); + break; + case "attributes": + for (let attr in data[key]) { + if (data[key][attr] !== undefined) { + metadata.push(attribute(value.metadata.masked, attr, data[key][attr])); + if (attr === "clarity") { + switch (data[key][attr]) { + case "no-track": + addEventToQueue = false; + break; + } + } + } + } + break; + case "value": + let parent = dom.getNode(value.parent); + let parentTag = dom.get(parent) ? dom.get(parent).data.tag : null; + let tag = value.data.tag === "STYLE" ? value.data.tag : parentTag; + metadata.push(text(value.metadata.masked, tag, data[key])); + break; + } + } + } + + // Add metadata + metadata = meta(metadata); + for (let token of metadata) { + let index: number = typeof token === "string" ? tokens.indexOf(token) : -1; + tokens.push(index >= 0 && token.length > index.toString().length ? [index] : token); + } + } + if (addEventToQueue) { + queue(tokens); + } + break; + } +} + +function meta(metadata: string[]): string[] | string[][] { + let value = JSON.stringify(metadata); + let hashed = hash(value); + return check(hashed) && hashed.length < value.length ? [[hashed]] : metadata; +} + +function attribute(masked: boolean, key: string, value: string): string { + switch (key) { + case "src": + case "srcset": + case "title": + case "alt": + return `${key}=${masked ? "" : value}`; + case "value": + case "placeholder": + return `${key}=${masked ? mask(value) : value}`; + default: + return `${key}=${value}`; + } +} + +function text(masked: boolean, tag: string, value: string): string { + switch (tag) { + case "STYLE": + case "TITLE": + return value; + default: + return masked ? mask(value) : value; + } +} diff --git a/src/layout/index.ts b/src/layout/index.ts new file mode 100644 index 00000000..bc170081 --- /dev/null +++ b/src/layout/index.ts @@ -0,0 +1,23 @@ +import * as boxmodel from "@src/layout/boxmodel"; +import * as discover from "@src/layout/discover"; +import * as doc from "@src/layout/document"; +import * as dom from "@src/layout/dom"; +import * as mutation from "@src/layout/mutation"; +import * as target from "@src/layout/target"; + +export function start(): void { + doc.reset(); + dom.reset(); + mutation.start(); + discover.start(); + boxmodel.reset(); + target.reset(); +} + +export function end(): void { + dom.reset(); + mutation.end(); + boxmodel.reset(); + target.reset(); + doc.reset(); +} diff --git a/src/layout/mutation.ts b/src/layout/mutation.ts new file mode 100644 index 00000000..0ec31e7e --- /dev/null +++ b/src/layout/mutation.ts @@ -0,0 +1,121 @@ +import { Event, Metric } from "@clarity-types/data"; +import { Source } from "@clarity-types/layout"; +import config from "@src/core/config"; +import * as task from "@src/core/task"; +import * as boxmodel from "@src/layout/boxmodel"; +import * as doc from "@src/layout/document"; +import encode from "@src/layout/encode"; +import processNode from "./node"; + +let observer: MutationObserver; +let mutations: MutationRecord[] = []; +let insertRule: (rule: string, index?: number) => number = null; +let deleteRule: (index?: number) => void = null; + +export function start(): void { + if (observer) { observer.disconnect(); } + observer = window["MutationObserver"] ? new MutationObserver(handle) : null; + observer.observe(document, { attributes: true, childList: true, characterData: true, subtree: true }); + if (insertRule === null) { insertRule = CSSStyleSheet.prototype.insertRule; } + if (deleteRule === null) { deleteRule = CSSStyleSheet.prototype.deleteRule; } + + // Some popular open source libraries, like styled-components, optimize performance + // by injecting CSS using insertRule API vs. appending text node. A side effect of + // using javascript API is that it doesn't trigger DOM mutation and therefore we + // need to override the insertRule API and listen for changes manually. + CSSStyleSheet.prototype.insertRule = function(rule: string, index?: number): number { + let value = insertRule.call(this, rule, index); + generate(this.ownerNode, "characterData"); + return value; + }; + + CSSStyleSheet.prototype.deleteRule = function(index?: number): void { + deleteRule.call(this, index); + generate(this.ownerNode, "characterData"); + }; +} + +export function end(): void { + if (observer) { observer.disconnect(); } + observer = null; + + // Restoring original insertRule + if (insertRule !== null) { + CSSStyleSheet.prototype.insertRule = insertRule; + insertRule = null; + } + + // Restoring original deleteRule + if (deleteRule !== null) { + CSSStyleSheet.prototype.deleteRule = deleteRule; + deleteRule = null; + } + + mutations = []; +} + +function handle(m: MutationRecord[]): void { + // Queue up mutation records for asynchronous processing + for (let i = 0; i < m.length; i++) { mutations.push(m[i]); } + task.schedule(process).then(() => { + doc.compute(); + boxmodel.compute(); + }); +} + +async function process(): Promise { + let timer = Metric.MutationTime; + task.start(timer); + while (mutations.length > 0) { + let mutation = mutations.shift(); + let target = mutation.target; + + switch (mutation.type) { + case "attributes": + if (task.longtask(timer)) { await task.idle(timer); } + processNode(target, Source.Attributes); + break; + case "characterData": + if (task.longtask(timer)) { await task.idle(timer); } + processNode(target, Source.CharacterData); + break; + case "childList": + // Process additions + let addedLength = mutation.addedNodes.length; + for (let j = 0; j < addedLength; j++) { + let walker = document.createTreeWalker(mutation.addedNodes[j], NodeFilter.SHOW_ALL, null, false); + let node = walker.currentNode; + while (node) { + if (task.longtask(timer)) { await task.idle(timer); } + processNode(node, Source.ChildListAdd); + node = walker.nextNode(); + } + } + // Process removes + let removedLength = mutation.removedNodes.length; + for (let j = 0; j < removedLength; j++) { + if (task.longtask(timer)) { await task.idle(timer); } + processNode(mutation.removedNodes[j], Source.ChildListRemove); + } + break; + default: + break; + } + } + await encode(config.lean ? Event.Hash : Event.Mutation); + task.stop(timer); +} + +function generate(target: Node, type: MutationRecordType): void { + handle([{ + addedNodes: null, + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target, + type + }]); +} diff --git a/src/layout/node.ts b/src/layout/node.ts new file mode 100644 index 00000000..97952a44 --- /dev/null +++ b/src/layout/node.ts @@ -0,0 +1,105 @@ +import { Constant, Source } from "@clarity-types/layout"; +import config from "@src/core/config"; +import * as dom from "./dom"; + +const IGNORE_ATTRIBUTES = ["title", "alt", "onload", "onfocus"]; + +export default function(node: Node, source: Source): void { + // Do not track this change if we are attempting to remove a node before discovering it + if (source === Source.ChildListRemove && dom.has(node) === false) { return; } + + // Special handling for text nodes that belong to style nodes + if (source !== Source.Discover && + node.nodeType === Node.TEXT_NODE && + node.parentElement && + node.parentElement.tagName === "STYLE") { + node = node.parentNode; + } + + let call = dom.has(node) ? "update" : "add"; + switch (node.nodeType) { + case Node.DOCUMENT_TYPE_NODE: + let doctype = node as DocumentType; + let docAttributes = { name: doctype.name, publicId: doctype.publicId, systemId: doctype.systemId }; + let docData = { tag: "*D", attributes: docAttributes }; + dom[call](node, docData, source); + break; + case Node.TEXT_NODE: + // Account for this text node only if we are tracking the parent node + // We do not wish to track text nodes for ignored parent nodes, like script tags + // Also, we do not track text nodes for STYLE tags + // The only exception is when we receive a mutation to remove the text node, in that case + // parent will be null, but we can still process the node by checking it's an update call. + let parent = node.parentElement; + if (call === "update" || (parent && dom.has(parent) && parent.tagName !== "STYLE")) { + let textData = { tag: "*T", value: node.nodeValue }; + dom[call](node, textData, source); + } + break; + case Node.ELEMENT_NODE: + let element = (node as HTMLElement); + let tag = element.tagName; + tag = (element.namespaceURI === Constant.SVG_NAMESPACE) ? Constant.SVG_PREFIX + tag : tag; + switch (tag) { + case "SCRIPT": + case "NOSCRIPT": + case "META": + break; + case "HEAD": + let head = { tag, attributes: getAttributes(element.attributes) }; + // Capture base href as part of discovering DOM + if (call === "add") { head.attributes["*B"] = location.protocol + "//" + location.hostname; } + dom[call](node, head, source); + break; + case "STYLE": + let attributes = getAttributes(element.attributes); + let styleData = { tag, attributes, value: getStyleValue(element as HTMLStyleElement) }; + dom[call](node, styleData, source); + break; + default: + let data = { tag, attributes: getAttributes(element.attributes) }; + dom[call](node, data, source); + break; + } + break; + default: + break; + } +} + +function getStyleValue(style: HTMLStyleElement): string { + let value = style.textContent; + if (value.length === 0 || config.cssRules) { + let cssRules = null; + + // Firefox throws a SecurityError when trying to access cssRules of a stylesheet from a different domain + try { + let sheet = style.sheet as CSSStyleSheet; + cssRules = sheet ? sheet.cssRules : []; + } catch (e) { + if (e.name !== "SecurityError") { + throw e; + } + } + + if (cssRules !== null) { + for (let i = 0; i < cssRules.length; i++) { + value += cssRules[i].cssText; + } + } + } + return value; +} + +function getAttributes(attributes: NamedNodeMap): {[key: string]: string} { + let output = {}; + if (attributes && attributes.length > 0) { + for (let i = 0; i < attributes.length; i++) { + let name = attributes[i].name; + if (IGNORE_ATTRIBUTES.indexOf(name) < 0) { + output[name] = attributes[i].value; + } + } + } + return output; +} diff --git a/src/layout/selector.ts b/src/layout/selector.ts new file mode 100644 index 00000000..f9da4519 --- /dev/null +++ b/src/layout/selector.ts @@ -0,0 +1,29 @@ +import { Attributes, Constant } from "../../types/layout"; + +export default function(tag: string, prefix: string, attributes: Attributes, position: number): string { + let empty = ""; + let suffix = position ? `:nth-of-type(${position})` : empty; + switch (tag) { + case "STYLE": + case "TITLE": + case "LINK": + case "META": + case "*T": + case "*D": + return empty; + case "HTML": + return "HTML"; + default: + if (prefix === null) { return empty; } + tag = tag.indexOf(Constant.SVG_PREFIX) === 0 ? tag.substr(Constant.SVG_PREFIX.length) : tag; + let selector = `${prefix}${tag}${suffix}`; + if (Constant.ID_ATTRIBUTE in attributes) { + selector = `*${attributes[Constant.ID_ATTRIBUTE]}`; + } else if ("id" in attributes && attributes["id"].length > 0) { + selector = `${tag}#${attributes.id}`; + } else if ("class" in attributes && attributes["class"].length > 0) { + selector = `${prefix}${tag}.${attributes.class.trim().split(/\s+/).join(".")}${suffix}`; + } + return selector; + } +} diff --git a/src/layout/target.ts b/src/layout/target.ts new file mode 100644 index 00000000..be04b593 --- /dev/null +++ b/src/layout/target.ts @@ -0,0 +1,44 @@ +import { Event } from "@clarity-types/data"; +import { TargetData } from "@clarity-types/layout"; +import config from "@src/core/config"; +import hash from "@src/data/hash"; +import { layout } from "@src/layout/boxmodel"; +import encode from "@src/layout/encode"; +import * as dom from "./dom"; + +let queue: number[] = []; +let timeout: number = null; + +export function reset(): void { + queue = []; + clearTimeout(timeout); + timeout = null; +} + +export function observe(id: number): void { + if (queue.indexOf(id) === -1) { queue.push(id); } + clearTimeout(timeout); + timeout = window.setTimeout(encode, config.lookahead, Event.Target); +} + +export function updates(): TargetData[] { + let data: TargetData[] = []; + if (queue.length > 0) { + let doc = document.documentElement; + let x = "pageXOffset" in window ? window.pageXOffset : doc.scrollLeft; + let y = "pageYOffset" in window ? window.pageYOffset : doc.scrollTop; + + // Process all layout computations in single batch to avoid reflows + for (let id of queue) { + let value = dom.getValue(id); + let node = dom.getNode(id) as Element; + data.push({ + id, + hash: value ? hash(value.selector) : "", + box: node && node.nodeType !== Node.TEXT_NODE ? layout(node, x, y) : [] + }); + } + reset(); + } + return data; +} diff --git a/src/performance/navigation.ts b/src/performance/navigation.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/performance/resource.ts b/src/performance/resource.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/plugins.ts b/src/plugins.ts deleted file mode 100644 index d8e6ee85..00000000 --- a/src/plugins.ts +++ /dev/null @@ -1,19 +0,0 @@ -import errors from "./plugins/errors"; -import layout from "./plugins/layout/layout"; -import performance from "./plugins/performance"; -import pointer from "./plugins/pointer/pointer"; -import viewport from "./plugins/viewport"; - -type ClarityPlugin = typeof errors | typeof layout | typeof performance | typeof pointer | typeof viewport; - -const classes: { [key: string]: ClarityPlugin } = { - layout, - viewport, - pointer, - performance, - errors -}; - -export default function getPlugin(name: string): ClarityPlugin { - return classes[name]; -} diff --git a/src/plugins/errors.ts b/src/plugins/errors.ts deleted file mode 100644 index 98546f55..00000000 --- a/src/plugins/errors.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { IPlugin } from "@clarity-types/core"; -import { IJsErrorEventState, Instrumentation } from "@clarity-types/instrumentation"; -import { bind, instrument } from "@src/core"; - -export default class ErrorMonitor implements IPlugin { - - public activate(): void { - bind(window, "error", logError); - } - - public reset(): void { - return; - } - - public teardown(): void { - return; - } -} - -export function logError(errorToLog: Event): void { - // if errorToLog["error"] doesn't exist, occasionally we can get information directly from errorToLog - let error = errorToLog["error"] || errorToLog; - let source = errorToLog["filename"]; - let lineno = errorToLog["lineno"]; - let colno = errorToLog["colno"]; - let message = error.message; - let stack = error.stack; - - let jsErrorEventState: IJsErrorEventState = { - type: Instrumentation.JsError, - message, - stack, - lineno, - colno, - source - }; - instrument(jsErrorEventState); -} diff --git a/src/plugins/layout/layout.ts b/src/plugins/layout/layout.ts deleted file mode 100644 index 893ae101..00000000 --- a/src/plugins/layout/layout.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { IEventData, IPlugin } from "@clarity-types/core"; -import { - IMutationPerformanceState, INodeStateGenPerformanceState, Instrumentation, IShadowDomInconsistentEventState -} from "@clarity-types/instrumentation"; -import { - Action, IElementLayoutState, ILayoutRoutineInfo, ILayoutState, IMutationRoutineInfo, InsertRuleHandler, - IShadowDomMutationSummary, IShadowDomNode, IStyleLayoutState, LayoutRoutine, NumberJson, Source -} from "@clarity-types/layout"; -import { config } from "@src/config"; -import { addEvent, addMultipleEvents, bind, getTimestamp, instrument } from "@src/core"; -import { debug, traverseNodeTree } from "@src/utils"; -import { ShadowDom } from "./shadowdom"; -import { getElementValue } from "./states/element"; -import { getNodeIndex, NodeIndex } from "./states/generic"; -import { resetStateProvider } from "./states/stateprovider"; -import { getCssRules } from "./states/style"; - -export default class Layout implements IPlugin { - private readonly eventName: string = "Layout"; - private readonly discoverPerfInstName: string = "Discover"; - private readonly mutationPerfInstName: string = "Mutation"; - private readonly cssTimeoutLength: number = 50; - private distanceThreshold: number = 5; - private shadowDom: ShadowDom; - private inconsistentShadowDomCount: number; - private observer: MutationObserver; - private insertRule: InsertRuleHandler; - private cssTimeout: number; - private cssElementQueue: Element[]; - private watchList: boolean[]; - private mutationSequence: number; - private lastConsistentDomJson: NumberJson; - private firstShadowDomInconsistentEvent: IShadowDomInconsistentEventState; - - public reset(): void { - this.shadowDom = new ShadowDom(); - this.inconsistentShadowDomCount = 0; - this.watchList = []; - this.cssElementQueue = []; - this.observer = window["MutationObserver"] ? new MutationObserver(this.mutation.bind(this)) : null; - this.insertRule = CSSStyleSheet.prototype.insertRule; - this.mutationSequence = 0; - this.lastConsistentDomJson = null; - this.firstShadowDomInconsistentEvent = null; - resetStateProvider(); - } - - public activate(): void { - this.discoverDom(); - if (this.observer) { - this.observer.observe(document, { - attributes: true, - childList: true, - characterData: true, - subtree: true - }); - } - if (this.insertRule) { - let that = this; - - // Some popular open source libraries, like styled-components, optimize performance - // by injecting CSS using insertRule API vs. appending text node. A side effect of - // using javascript API is that it doesn't trigger DOM mutation and therefore we - // need to override the insertRule API and listen for changes manually. - CSSStyleSheet.prototype.insertRule = function(rule: string, index?: number): number { - let value = that.insertRule.call(this, rule, index); - that.queueCss(this.ownerNode); - return value; - }; - } - } - - public teardown(): void { - if (this.observer) { - this.observer.disconnect(); - } - - if (this.cssTimeout) { - clearTimeout(this.cssTimeout); - } - - // Restore original insertRule definition - if (this.insertRule) { - CSSStyleSheet.prototype.insertRule = this.insertRule; - } - this.insertRule = null; - - // Clean up node indices on observed nodes - // If Clarity is re-activated within the same page later, - // old, uncleared indices would cause it to work incorrectly - let documentShadowNode = this.shadowDom.shadowDocument; - if (documentShadowNode.node) { - delete documentShadowNode.node[NodeIndex]; - } - let otherNodes = this.shadowDom.shadowDocument.querySelectorAll("*"); - for (let i = 0; i < otherNodes.length; i++) { - let node = (otherNodes[i] as IShadowDomNode).node; - if (node) { - delete node[NodeIndex]; - } - } - } - - private discoverDom(): void { - // All 'Discover' events together should be treated as an atomic 'Discover' operation and should have the same timestamp - const discoverStartTime = getTimestamp(); - let nodeCount = 0; - traverseNodeTree(document, (node: Node) => { - let shadowNode = this.discoverNode(node); - shadowNode.state.action = Action.Insert; - shadowNode.state.source = Source.Discover; - addEvent({ - type: this.eventName, - state: shadowNode.state, - time: discoverStartTime, - }); - nodeCount++; - }); - const discoverPerfState: INodeStateGenPerformanceState = { - type: Instrumentation.Performance, - procedure: this.discoverPerfInstName, - duration: getTimestamp() - discoverStartTime, - nodeCount - }; - instrument(discoverPerfState); - this.checkConsistency({ - action: LayoutRoutine.DiscoverDom - }); - } - - // Add node to the ShadowDom to store initial adjacent node info in a layout and obtain an index - private discoverNode(node: Node): IShadowDomNode { - let shadowNode = this.shadowDom.insertShadowNode(node, getNodeIndex(node.parentNode), getNodeIndex(node.nextSibling)); - shadowNode.computeInfo(); - shadowNode.computeState(); - this.watch(node, shadowNode.state); - return shadowNode; - } - - private watch(node: Node, nodeLayoutState: ILayoutState): void { - - // We only wish to watch elements once and then wait on the events to push changes - if (node.nodeType !== Node.ELEMENT_NODE || this.watchList[nodeLayoutState.index]) { - return; - } - - let element = node as Element; - let layoutState = nodeLayoutState as IElementLayoutState; - let scrollPossible = (layoutState.layout - && ("scrollX" in layoutState.layout - || "scrollY" in layoutState.layout)); - - if (scrollPossible) { - bind(element, "scroll", this.layoutHandler.bind(this, element, Source.Scroll)); - this.watchList[layoutState.index] = true; - } - - // Check if we need to monitor changes on input fields - if (element.tagName === "INPUT" || element.tagName === "SELECT") { - bind(element, "change", this.layoutHandler.bind(this, element, Source.Input)); - this.watchList[layoutState.index] = true; - } else if (element.tagName === "TEXTAREA") { - bind(element, "input", this.layoutHandler.bind(this, element, Source.Input)); - this.watchList[layoutState.index] = true; - } - } - - private queueCss(element: Element): void { - // Clear the timeout if it already exists - if (this.cssTimeout) { - clearTimeout(this.cssTimeout); - } - - // Queue element to be processed after the timeout triggers - if (this.cssElementQueue.indexOf(element) === -1) { - this.cssElementQueue.push(element); - } - - this.cssTimeout = window.setTimeout(this.cssDequeue.bind(this), this.cssTimeoutLength); - } - - private cssDequeue(): void { - for (let element of this.cssElementQueue) { - this.layoutHandler(element, Source.Css); - } - } - - private layoutHandler(element: Element, source: Source): void { - let index = getNodeIndex(element); - let shadowNode = this.shadowDom.getShadowNode(index); - if (shadowNode) { - let layoutState = shadowNode.state as IElementLayoutState; - switch (source) { - case Source.Scroll: - let scrollX = Math.round(element.scrollLeft); - let scrollY = Math.round(element.scrollTop); - let dx = layoutState.layout.scrollX - scrollX; - let dy = layoutState.layout.scrollY - scrollY; - if (dx * dx + dy * dy > this.distanceThreshold * this.distanceThreshold) { - layoutState.source = source; - layoutState.action = Action.Update; - layoutState.layout.scrollX = scrollX; - layoutState.layout.scrollY = scrollY; - addEvent({type: this.eventName, state: layoutState}); - } - break; - case Source.Input: - let input = element as HTMLInputElement; - layoutState.value = getElementValue(input, shadowNode.info); - layoutState.source = source; - layoutState.action = Action.Update; - addEvent({type: this.eventName, state: layoutState}); - break; - case Source.Css: - let styleState = shadowNode.state as IStyleLayoutState; - styleState.cssRules = getCssRules(element as HTMLStyleElement); - styleState.source = source; - styleState.action = Action.Update; - addEvent({type: this.eventName, state: styleState}); - break; - default: - break; - } - } - } - - private mutation(mutations: MutationRecord[]): void { - - const mutationStartTime = getTimestamp(); - let stateGenDuration = 0; - let summaryCounts: IMutationPerformanceState["summaryCounts"] = null; - - // Don't process mutations on top of the inconsistent state. - // ShadowDom mutation processing logic requires consistent state as a prerequisite. - // If we end up in the inconsistent state, that means that something went wrong already, - // so we can give up on the following mutations and should investigate the cause of the error. - // Continuing to process mutations can result in javascript errors and lead to even more inconsistencies. - if (this.allowMutation()) { - - // Perform mutations on the shadow DOM and make sure ShadowDom arrived to the consistent state - let summary = this.shadowDom.applyMutationBatch(mutations); - let actionInfo: IMutationRoutineInfo = { - action: LayoutRoutine.Mutation, - mutationSequence: this.mutationSequence, - batchSize: mutations.length - }; - summaryCounts = { - inserts: summary.newNodes.length, - moves: summary.movedNodes.length, - updates: summary.updatedNodes.length, - removes: summary.removedNodes.length - }; - this.checkConsistency(actionInfo); - if (this.allowMutation()) { - const stateGenStartTime = getTimestamp(); - this.processMutations(summary, mutationStartTime); - stateGenDuration = getTimestamp() - stateGenStartTime; - } - } - - const mutationPerfState: IMutationPerformanceState = { - type: Instrumentation.Performance, - procedure: this.mutationPerfInstName, - duration: getTimestamp() - mutationStartTime, - mutationCount: mutations.length, - mutationSequence: this.mutationSequence, - stateGenDuration, - summaryCounts - }; - instrument(mutationPerfState); - this.mutationSequence++; - } - - private allowMutation(): boolean { - return this.inconsistentShadowDomCount < 2 || !config.validateConsistency; - } - - private processMutations(summary: IShadowDomMutationSummary, time: number): void { - let inserts = summary.newNodes.map(this.processMutation.bind(this, Action.Insert)); - let moves = summary.movedNodes.map(this.processMutation.bind(this, Action.Move)); - let updates = summary.updatedNodes.map(this.processMutation.bind(this, Action.Update)); - let removes = summary.removedNodes.map(this.processMutation.bind(this, Action.Remove)); - let all: IEventData[] = [].concat(inserts, moves, updates, removes); - - // Since we receive batched mutations and reprocess them after, all mutation events from the same batch - // should be treated as a single atomic operation, so all of those events should have the same timestamp - for (let i = 0; i < all.length; i++) { - all[i].time = time; - } - addMultipleEvents(all); - } - - private processMutation(action: Action, shadowNode: IShadowDomNode): IEventData { - shadowNode.computeInfo(); - let state = shadowNode.computeState(); - state.action = action; - state.source = Source.Mutation; - state.mutationSequence = this.mutationSequence; - - // Watch new or updated nodes - if (action === Action.Insert || action === Action.Update) { - this.watch(shadowNode.node, state); - } else if (action === Action.Remove) { - // Removed nodes don't have an index any more, so computed state index will be null, - // however its original index can still be obtained from its matching shadow node id - state.index = parseInt(shadowNode.id, 10); - } - - return { type: this.eventName, state }; - } - - private checkConsistency(lastActionInfo: ILayoutRoutineInfo): void { - if (config.validateConsistency) { - let domJson = this.shadowDom.createIndexJson(document, (node: Node) => { - return getNodeIndex(node); - }); - let shadowDomConsistent = this.shadowDom.isConsistent(); - if (!shadowDomConsistent) { - this.inconsistentShadowDomCount++; - let shadowDomJson = this.shadowDom.createIndexJson(this.shadowDom.shadowDocument, (node: Node) => { - return parseInt((node as IShadowDomNode).id, 10); - }); - let evt: IShadowDomInconsistentEventState = { - type: Instrumentation.ShadowDomInconsistent, - dom: domJson, - shadowDom: shadowDomJson, - lastConsistentShadowDom: this.lastConsistentDomJson, - lastAction: lastActionInfo - }; - if (this.inconsistentShadowDomCount < 2) { - this.firstShadowDomInconsistentEvent = evt; - } else { - evt.firstEvent = this.firstShadowDomInconsistentEvent; - instrument(evt); - } - debug(`>>> ShadowDom doesn't match PageDOM after mutation batch #${this.mutationSequence}!`); - } else { - this.inconsistentShadowDomCount = 0; - this.firstShadowDomInconsistentEvent = null; - this.lastConsistentDomJson = domJson; - } - } - } -} diff --git a/src/plugins/layout/nodeinfo.ts b/src/plugins/layout/nodeinfo.ts deleted file mode 100644 index 41f79dde..00000000 --- a/src/plugins/layout/nodeinfo.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { INodeInfo } from "@clarity-types/layout"; -import { shouldIgnoreNode } from "./states/ignore"; -import { shouldCaptureCssRules } from "./states/style"; -import { isCssText } from "./states/text"; - -export const UnmaskAttribute = "data-clarity-unmask"; - -export function createNodeInfo(node: Node, parentInfo: INodeInfo): INodeInfo { - let ignore = shouldIgnoreNode(node, parentInfo); - let unmask = shouldUnmaskNode(node, parentInfo); - let isCss = isCssText(node); - let captureCssRules = shouldCaptureCssRules(node); - return { ignore, unmask, isCss, captureCssRules }; -} - -function shouldUnmaskNode(node: Node, parentInfo: INodeInfo): boolean { - const parentUnmasked = parentInfo ? parentInfo.unmask : false; - const hasUnmaskAttribute = ( - node && node.nodeType === Node.ELEMENT_NODE - ? (node as Element).getAttribute(UnmaskAttribute) === "true" - : false - ); - return hasUnmaskAttribute || parentUnmasked; -} diff --git a/src/plugins/layout/shadowdom.ts b/src/plugins/layout/shadowdom.ts deleted file mode 100644 index 2f5cedee..00000000 --- a/src/plugins/layout/shadowdom.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { ILayoutState, INodeInfo, IShadowDomMutationSummary, IShadowDomNode, NumberJson } from "@clarity-types/layout"; -import { assert, isNumber, traverseNodeTree } from "@src/utils"; -import { createNodeInfo } from "./nodeinfo"; -import { getNodeIndex, NodeIndex } from "./states/generic"; -import { createLayoutState } from "./states/stateprovider"; - -// Class names to tag actions that happen to nodes in a single mutation batch -const FinalClassName = "cl-final"; -const NewNodeClassName = "cl-new"; -const MovedNodeClassName = "cl-moved"; -const UpdatedNodeClassName = "cl-updated"; - -export class ShadowDom { - public shadowDocument: IShadowDomNode; - - private doc: Document = document.implementation.createHTMLDocument("ShadowDom"); - private nextIndex: number = 0; - private removedNodes: HTMLDivElement = this.doc.createElement("div"); - private shadowDomRoot: HTMLDivElement = this.doc.createElement("div"); - private classifyNodes: boolean = false; - private nodeMap: { [key: number]: IShadowDomNode } = {}; - - constructor() { - this.doc.documentElement.appendChild(this.shadowDomRoot); - this.shadowDocument = document.createElement("div") as IShadowDomNode; - } - - public getShadowNode(index: number): IShadowDomNode { - let node = isNumber(index) ? this.nodeMap[index] : null; - return node; - } - - public insertShadowNode(node: Node, parentIndex: number, nextSiblingIndex: number): IShadowDomNode { - let isDocument = (node === document); - let index = this.assignNodeIndex(node); - let parent = (isDocument ? this.shadowDomRoot : this.getShadowNode(parentIndex)) as IShadowDomNode; - let nextSibling = this.getShadowNode(nextSiblingIndex); - let shadowNode = this.doc.createElement("div") as IShadowDomNode; - shadowNode.id = "" + index; - shadowNode.node = node; - - shadowNode.computeInfo = (): INodeInfo => { - const parentShadowNode = shadowNode.parentNode as IShadowDomNode; - shadowNode.info = createNodeInfo(node, parentShadowNode && parentShadowNode.info); - return shadowNode.info; - }; - shadowNode.computeState = (): ILayoutState => { - shadowNode.state = createLayoutState(node, shadowNode.info); - return shadowNode.state; - }; - this.nodeMap[index] = shadowNode; - - if (isDocument) { - this.shadowDocument = shadowNode; - } - - if (this.classifyNodes) { - this.setClass(shadowNode, NewNodeClassName); - } - - assert(!!parent, "insertShadowNode", "parent is missing"); - if (parent) { - if (nextSibling) { - parent.insertBefore(shadowNode, nextSibling); - } else { - parent.appendChild(shadowNode); - } - } - - return shadowNode; - } - - public moveShadowNode(index: number, newParentIndex: number, newNextSiblingIndex: number): IShadowDomNode { - let shadowNode = this.getShadowNode(index); - let parent = this.getShadowNode(newParentIndex); - let nextSibling = this.getShadowNode(newNextSiblingIndex); - - assert(!!parent, "moveShadowNode", "parent is missing"); - assert(!!shadowNode, "moveShadowNode", "shadowNode is missing"); - if (parent && shadowNode) { - if (this.classifyNodes) { - if (!this.hasClass(shadowNode, NewNodeClassName)) { - this.setClass(shadowNode, MovedNodeClassName); - } - } - - if (nextSibling) { - parent.insertBefore(shadowNode, nextSibling); - } else { - parent.appendChild(shadowNode); - } - } - return shadowNode; - } - - public updateShadowNode(index: number): void { - let shadowNode = this.getShadowNode(index); - if (shadowNode && this.classifyNodes) { - this.setClass(shadowNode, UpdatedNodeClassName); - } - } - - public removeShadowNode(index: number): void { - let shadowNode = this.getShadowNode(index); - if (shadowNode) { - this.setClass(shadowNode, MovedNodeClassName); - this.removedNodes.appendChild(shadowNode); - } - } - - // As we process a batch of mutations, various things can be happening to a single node - // In the end, however, for each affected node we will have one of the following outcomes: - // 1. A new node was added - // 2. Existing node was moved - // 3. Existing node was removed - // 4. Existing node was updated - // 5. Existing node was moved and updated - // Various actions on a node will lead to different finals states. Also a final state of a parent node - // can affect and/or override the state of its children (e.g. if node A was moved to node B, but B was removed, - // that means that A was removed as well). One of the simpler solutions to determining the final states of all - // affected nodes and its children is 'tagging' shadow nodes with class names based on the mutations as they happen, - // and then determining the final states of all affected nodes based on the combination of their classes (getting mutation summary). - // Note: When we process 'remove' action on a node, instead of marking it as removed and permanently removing it from ShadowDOM - // and erasing its index, we actually moved it to a separate 'removedNodes' container, which is not part of the representation - // of the real DOM, but is still a part of the document. This way, if this node is re-inserted to the page, we can just move - // add it back to ShadowDOM and just record the 'Move' event, letting it maintain its index. - public applyMutationBatch(mutations: MutationRecord[]): IShadowDomMutationSummary { - let nextIndexBeforeProcessing = this.nextIndex; - this.doc.documentElement.appendChild(this.removedNodes); - this.classifyNodes = true; - - let length = mutations.length; - for (let i = 0; i < length; i++) { - let mutation = mutations[i]; - let target = mutation.target; - switch (mutation.type) { - case "attributes": - case "characterData": - this.applyUpdate(target, mutation.attributeName, mutation.oldValue); - break; - case "childList": - - // Process inserts - // We use insertBefore to insert nodes into the shadowDom, so the right sibling needs to be inserted - // before the left sibling. For that reason we process elements from last to first (right to left) - let addedLength = mutation.addedNodes.length; - for (let j = addedLength - 1; j >= 0; j--) { - let previous = mutation.previousSibling; - let next = mutation.nextSibling; - if (j > 0) { - previous = mutation.addedNodes[j - 1]; - } - if (j < addedLength - 1) { - next = mutation.addedNodes[j + 1]; - } - this.applyInsert(mutation.addedNodes[j], target, previous, next, false); - } - - // Process removes - let removedLength = mutation.removedNodes.length; - for (let j = 0; j < removedLength; j++) { - this.applyRemove(mutation.removedNodes[j], target); - } - break; - default: - break; - } - } - - // Detach removed nodes - this.removedNodes.parentElement.removeChild(this.removedNodes); - - // Process the new state of the ShadowDom and extract the summary - let summary = this.getMutationSummary(); - - // Clear references to removed nodes and reset shadow dom state to get it ready for the next mutation batch - this.cleanUp(); - - // Re-assign indices for all new nodes that remained attached to DOM such that there are no gaps between them - this.reIndexNewNodes(summary.newNodes, nextIndexBeforeProcessing); - - return summary; - } - - public hasClass(shadowNode: IShadowDomNode, className: string): boolean { - return shadowNode ? shadowNode.classList.contains(className) : false; - } - - public setClass(shadowNode: IShadowDomNode, className: string): void { - if (shadowNode) { - shadowNode.classList.add(className); - } - } - - public removeClass(shadowNode: IShadowDomNode, className: string): void { - if (shadowNode) { - shadowNode.classList.remove(className); - } - } - - public removeAllClasses(shadowNode: IShadowDomNode): void { - if (shadowNode) { - shadowNode.removeAttribute("class"); - } - } - - // As a result of processing mutation batch, some shadow nodes that were affected by mutations have indicative class names. - // Steps to creating a mutation summary from these nodes are the following: - // 1. Record 'Insert' events on nodes with NewNodeClassName. We can ignore and remove other classes on such nodes, because it - // doesn't matter if this node was also moved around or updated - it's new, so we record its state from scratch anyways. - // 2. Record 'Move' events on remaining nodes with MovedNodeClassName class. - // 3. Record 'Update' events on remaining nodes with UpdatedNodeClassName class (there can be nodes that were moved AND updated). - // 4. Inspect nodes inside the 'removedNodes' container: - // - Ignore nodes that have NewNodeClassName class. They are new and were not in the ShadowDom to begin with - // - Record a 'Remove' event for nodes that have MovedNodeClassName. They were explicitly moved and ended up in the 'removed' container - // - Ignore remaining nodes. Since they weren't explicitly moved, they will be auto-removed through the subtree of their removed parent - public getMutationSummary(): IShadowDomMutationSummary { - let summary: IShadowDomMutationSummary = { - newNodes: [], - movedNodes: [], - updatedNodes: [], - removedNodes: [] - }; - - // Collect all new nodes in the top-down order - let newNodes = Array.prototype.slice.call(this.doc.getElementsByClassName(NewNodeClassName)); - for (let i = 0; i < newNodes.length; i++) { - let newNode = newNodes[i] as IShadowDomNode; - summary.newNodes.push(newNode); - this.removeAllClasses(newNode); - } - - let moved = Array.prototype.slice.call(this.doc.getElementsByClassName(MovedNodeClassName)); - for (let i = 0; i < moved.length; i++) { - let next = moved[i] as IShadowDomNode; - summary.movedNodes.push(next); - this.removeClass(next, MovedNodeClassName); - } - - let updated = Array.prototype.slice.call(this.doc.getElementsByClassName(UpdatedNodeClassName)); - for (let i = 0; i < updated.length; i++) { - let next = updated[i] as IShadowDomNode; - summary.updatedNodes.push(next); - this.removeAllClasses(next); - } - - traverseNodeTree(this.removedNodes, (removedNode: IShadowDomNode) => { - if (this.hasClass(removedNode, MovedNodeClassName) && !this.hasClass(removedNode, NewNodeClassName)) { - summary.removedNodes.push(removedNode); - } - }, false); - - return summary; - } - - public createIndexJson(rootNode: Node, getIndexFromNode: (node: Node) => number): NumberJson { - let indexJson: NumberJson = []; - this.writeIndexToJson(rootNode, indexJson, getIndexFromNode); - return indexJson; - } - - public isConsistent(): boolean { - return this.isConstentSubtree(document, this.shadowDocument); - } - - private cleanUp(): void { - - // For each removed dom node, remove its index and its shadow dom reference - traverseNodeTree(this.removedNodes, (removedNode: IShadowDomNode) => { - let index = getNodeIndex(removedNode.node); - delete removedNode.node[NodeIndex]; - delete this.nodeMap[index]; - }, false); - - // Reset the state of the shadow dom to be ready for next mutation batch processing - let finalNodes = Array.prototype.slice.call(this.doc.getElementsByClassName(FinalClassName)); - for (let i = 0; i < finalNodes.length; i++) { - this.removeAllClasses(finalNodes[i]); - } - this.removedNodes.innerHTML = ""; - this.classifyNodes = false; - } - - private writeIndexToJson(node: Node, json: NumberJson, getIndexFromNode: (node: Node) => number): void { - let index = getIndexFromNode(node); - let childJson: number[] = []; - let nextChild = node.firstChild; - json.push(index); - if (nextChild) { - json.push(childJson); - } - while (nextChild) { - this.writeIndexToJson(nextChild, childJson, getIndexFromNode); - nextChild = nextChild.nextSibling as IShadowDomNode; - } - } - - private isConsistentNode(node: Node, shadowNode: IShadowDomNode): boolean { - let index = getNodeIndex(node); - return (isNumber(index) && shadowNode.id === (index).toString() && shadowNode.node === node); - } - - private isConstentSubtree(node: Node, shadowNode: IShadowDomNode): boolean { - let isConsistent = this.isConsistentNode(node, shadowNode); - let nextChild: Node = node.firstChild; - let nextShadowChild: Node = shadowNode.firstChild; - while (isConsistent) { - if (nextChild && nextShadowChild) { - isConsistent = this.isConstentSubtree(nextChild, nextShadowChild as IShadowDomNode); - nextChild = nextChild.nextSibling; - nextShadowChild = nextShadowChild.nextSibling; - } else if (nextChild || nextShadowChild) { - isConsistent = false; - } else { - break; - } - } - return isConsistent; - } - - private applyInsert(addedNode: Node, parent: Node, previousSibling: Node, nextSibling: Node, force: boolean): void { - let addedNodeIndex = getNodeIndex(addedNode); - let parentIndex = getNodeIndex(parent); - let nextSiblingIndex = getNodeIndex(nextSibling); - let validMutation = this.shouldProcessChildListMutation(addedNode, parent) || force; - if (validMutation) { - // If inserted node has no index, then it's a new node and we should process insert - if (addedNodeIndex === null) { - let shadowNode = this.insertShadowNode(addedNode, parentIndex, nextSiblingIndex); - this.setClass(shadowNode, FinalClassName); - - // Process children - // We use insertBefore to insert nodes into the shadowDom, so the right sibling needs to be inserted - // before the left sibling. For that reason we process children from last to first (right to left) - let nextChild: Node = addedNode.lastChild; - while (nextChild) { - this.applyInsert(nextChild, addedNode, nextChild.previousSibling, nextChild.nextSibling, true); - nextChild = nextChild.previousSibling; - } - } else { - this.moveShadowNode(addedNodeIndex, parentIndex, getNodeIndex(nextSibling)); - } - } - } - - private applyRemove(removedNode: Node, parent: Node): void { - let removedNodeIndex = getNodeIndex(removedNode); - if (removedNodeIndex !== null) { - let validMutation = this.shouldProcessChildListMutation(removedNode, parent); - if (validMutation) { - this.removeShadowNode(removedNodeIndex); - } - } - } - - private applyUpdate(updatedNode: Node, attrName: string, oldValue: string): void { - let updatedNodeIndex = getNodeIndex(updatedNode); - if (updatedNodeIndex != null) { - this.updateShadowNode(updatedNodeIndex); - } - } - - // We want to determine whether we can skip this mutation without losing data. We can do so in 2 cases: - // 1. This is a mutation for a node that is marked as 'Final'. These are the new nodes for which we already know final position, - // because we have discovered them by traversing other inserted node's subtree in the real page DOM. For such final nodes, - // we already recorded the insert action to the appropriate position, so all other mutations can be ignored. - // 2. This is a mutation, which attempts to add or remove a node from the child list of the node, which is marked final. For such - // nodes, we have already processed their entire child list in the real page DOM and all children received an insert action. - // This means that any other mutations are either temporary (insert something that will end up being removed) or redundant, so - // we can skip them - private shouldProcessChildListMutation(child: Node, parent: Node): boolean { - let childNodeIndex = getNodeIndex(child); - let parentIndex = getNodeIndex(parent); - let parentShadowNode: IShadowDomNode = null; - if (childNodeIndex === null) { - parentShadowNode = this.getShadowNode(parentIndex); - } else { - let childShadowNode = this.getShadowNode(childNodeIndex); - parentShadowNode = childShadowNode && childShadowNode.parentNode as IShadowDomNode; - } - return parentShadowNode && !this.hasClass(parentShadowNode, FinalClassName); - } - - // When we apply a mutation batch, we assign a new index to every node that is added to the DOM as we parse mutations - // in their natural order. However, some of those nodes end up being again removed from DOM with some follow-up mutation - // thus 'stealing' an index with them. Since we don't instrument nodes that were added and removed within the same - // mutation batch, we have no records of these stolen indices. To maintain consistency on the backend, at the end of the mutation, - // we can re-assign indices for all nodes that remained attached to the DOM such that they all go in increasing order without gaps - private reIndexNewNodes(newNodes: IShadowDomNode[], nextIndex: number): void { - newNodes.map((shadowNode: IShadowDomNode) => { - let index = getNodeIndex(shadowNode.node); - delete this.nodeMap[index]; - }); - newNodes.map((shadowNode: IShadowDomNode) => { - shadowNode.node[NodeIndex] = nextIndex; - shadowNode.id = `${nextIndex}`; - this.nodeMap[nextIndex] = shadowNode; - nextIndex++; - }); - this.nextIndex = nextIndex; - } - - private assignNodeIndex(node: Node): number { - let index = getNodeIndex(node); - if (index === null) { - index = this.nextIndex; - this.nextIndex++; - } - node[NodeIndex] = index; - return index; - } -} diff --git a/src/plugins/layout/states/doctype.ts b/src/plugins/layout/states/doctype.ts deleted file mode 100644 index 75d4d9a2..00000000 --- a/src/plugins/layout/states/doctype.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IDoctypeLayoutState } from "@clarity-types/layout"; -import { createGenericLayoutState, Tags } from "./generic"; - -export function createDoctypeLayoutState(doctypeNode: DocumentType): IDoctypeLayoutState { - let doctypeState = createGenericLayoutState(doctypeNode, Tags.Doc) as IDoctypeLayoutState; - doctypeState.attributes = { - name: doctypeNode.name, - publicId: doctypeNode.publicId, - systemId: doctypeNode.systemId - }; - return doctypeState; -} diff --git a/src/plugins/layout/states/element.ts b/src/plugins/layout/states/element.ts deleted file mode 100644 index f185eec4..00000000 --- a/src/plugins/layout/states/element.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { IAttributes, IElementLayoutState, ILayoutRectangle, ILayoutStyle, INodeInfo } from "@clarity-types/layout"; -import { config } from "@src/config"; -import { mask } from "@src/utils"; -import { createGenericLayoutState, Tags } from "./generic"; - -enum Styles { - Color = "color", - BackgroundColor = "backgroundColor", - BackgroundImage = "backgroundImage", - OverflowX = "overflowX", - OverflowY = "overflowY", - Visibility = "visibility" -} - -let defaultColor: string; -let attributeMaskList: string[]; - -const DefaultAttributeMaskList = ["value", "placeholder", "alt", "title"]; - -export function createElementLayoutState(element: Element, info: INodeInfo): IElementLayoutState { - let tagName = element.tagName; - let elementState = createGenericLayoutState(element, tagName) as IElementLayoutState; - if (tagName === Tags.Script || tagName === Tags.Meta) { - elementState.tag = Tags.Ignore; - return elementState; - } - - // Get attributes for the element - elementState.attributes = getAttributes(element, info); - - // Get layout bounding box for the element - elementState.layout = getLayout(element); - - // Get computed systems for the element with valid layout - elementState.style = elementState.layout ? getStyles(element) : null; - - // Check if scroll is possible - if (elementState.layout && elementState.style && (Styles.OverflowX in elementState.style || Styles.OverflowX in elementState.style)) { - elementState.layout.scrollX = Math.round(element.scrollLeft); - elementState.layout.scrollY = Math.round(element.scrollTop); - } - - // Certain elements might contain 'value' field, which means different things for different types of elements, - // but is ultimately important for each of them, so we must capture it as well. It is expected to be a string. - // https://www.w3schools.com/tags/att_value.asp (+ HTMLTextAreaElement) - if ("value" in element && typeof (element as any).value === "string") { - elementState.value = getElementValue(element, info); - } - - return elementState; -} - -export function resetElementStateProvider(): void { - attributeMaskList = DefaultAttributeMaskList.concat(config.sensitiveAttributes); - defaultColor = ""; -} - -export function getAttributeValue(element: Element, info: INodeInfo, attrName: string): string { - const sensitiveAttribute = attributeMaskList.indexOf(attrName) > -1; - const maskAttribute = sensitiveAttribute && !info.unmask; - const attrValue = element.attributes[attrName].value; - return maskAttribute ? mask(attrValue) : attrValue; -} - -export function getElementValue(element: Element, info: INodeInfo): string { - const valueStr = (element as any).value; - return info.unmask ? valueStr : mask(valueStr); -} - -function getLayout(element: Element): ILayoutRectangle { - let layout: ILayoutRectangle = null; - // In IE, calling getBoundingClientRect on a node that is disconnected - // from a DOM tree, sometimes results in a 'Unspecified Error' - // Wrapping this in try/catch is faster than checking whether element is connected to DOM - let rect = null; - let doc = document.documentElement; - try { - rect = element.getBoundingClientRect(); - } catch (e) { - // Ignore - } - - if (rect) { - // getBoundingClientRect returns relative positioning to viewport and therefore needs - // addition of window scroll position to get position relative to document - // Also: using Math.floor() instead of Math.round() below because in Edge, - // getBoundingClientRect returns partial pixel values (e.g. 162.5px) and Chrome already - // floors the value (e.g. 162px). Keeping behavior consistent across - layout = { - x: Math.floor(rect.left) + ("pageXOffset" in window ? window.pageXOffset : doc.scrollLeft), - y: Math.floor(rect.top) + ("pageYOffset" in window ? window.pageYOffset : doc.scrollTop), - width: Math.round(rect.width), - height: Math.round(rect.height) - }; - } - return layout; -} - -function getAttributes(element: Element, info: INodeInfo): IAttributes { - let elementAttributes = element.attributes; - let stateAttributes: IAttributes = {}; - - for (let i = 0; i < elementAttributes.length; i++) { - let attrName = elementAttributes[i].name; - stateAttributes[attrName] = getAttributeValue(element, info, attrName); - } - - return stateAttributes; -} - -function getStyles(element: Element): ILayoutStyle { - let computed = window.getComputedStyle(element); - let style = {}; - - if (defaultColor.length === 0) { - defaultColor = computed[Styles.Color]; - } - - // Send computed styles, if relevant, back to server - if (match(computed[Styles.Visibility], ["hidden", "collapse"])) { - style[Styles.Visibility] = computed[Styles.Visibility]; - } - - if (match(computed[Styles.OverflowX], ["auto", "scroll", "hidden"])) { - style[Styles.OverflowX] = computed[Styles.OverflowX]; - } - - if (match(computed[Styles.OverflowY], ["auto", "scroll", "hidden"])) { - style[Styles.OverflowY] = computed[Styles.OverflowY]; - } - - if (computed[Styles.BackgroundImage] !== "none") { - style[Styles.BackgroundImage] = computed[Styles.BackgroundImage]; - } - - if (!match(computed[Styles.BackgroundColor], ["rgba(0, 0, 0, 0)", "transparent"])) { - style[Styles.BackgroundColor] = computed[Styles.BackgroundColor]; - } - - if (computed[Styles.Color] !== defaultColor) { - style[Styles.Color] = computed[Styles.Color]; - } - - return Object.keys(style).length > 0 ? style : null; -} - -function match(variable: string, values: string[]): boolean { - return values.indexOf(variable) > -1; -} diff --git a/src/plugins/layout/states/generic.ts b/src/plugins/layout/states/generic.ts deleted file mode 100644 index 0ead2e7f..00000000 --- a/src/plugins/layout/states/generic.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ILayoutState } from "@clarity-types/layout"; - -export const NodeIndex = "clarity-index"; - -export enum Tags { - Meta = "META", - Script = "SCRIPT", - Doc = "*DOC*", - Text = "*TXT*", - Ignore = "*IGNORE*" -} - -export function getNodeIndex(node: Node): number { - return (node && NodeIndex in node) ? node[NodeIndex] : null; -} - -export function createGenericLayoutState(node: Node, tag: string): ILayoutState { - let layoutIndex = getNodeIndex(node); - let state: ILayoutState = { - index: layoutIndex, - parent: getNodeIndex(node.parentNode), - previous: getNodeIndex(node.previousSibling), - next: getNodeIndex(node.nextSibling), - source: null, - action: null, - tag - }; - return state; -} diff --git a/src/plugins/layout/states/ignore.ts b/src/plugins/layout/states/ignore.ts deleted file mode 100644 index 6a601293..00000000 --- a/src/plugins/layout/states/ignore.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { IIgnoreLayoutState, INodeInfo } from "@clarity-types/layout"; -import { createGenericLayoutState, Tags } from "./generic"; - -export function createIgnoreLayoutState(node: Node): IIgnoreLayoutState { - let layoutState = createGenericLayoutState(node, Tags.Ignore) as IIgnoreLayoutState; - layoutState.nodeType = node.nodeType; - if (node.nodeType === Node.ELEMENT_NODE) { - layoutState.elementTag = (node as Element).tagName; - } - return layoutState; -} - -export function shouldIgnoreNode(node: Node, parentInfo: INodeInfo): boolean { - if (parentInfo && parentInfo.ignore) { - return true; - } - - let ignore = false; - switch (node.nodeType) { - case Node.ELEMENT_NODE: - let tagName = (node as Element).tagName; - if (tagName === Tags.Script || tagName === Tags.Meta) { - ignore = true; - } - break; - case Node.COMMENT_NODE: - ignore = true; - break; - case Node.TEXT_NODE: - // If we capture CSSRules on style elements, ignore its text children nodes - // Since iterating over css rules can be 100X slower on certain browsers, - // we limit ignoring text nodes to STYLE elements with empty text content - if (parentInfo && parentInfo.captureCssRules) { - ignore = true; - } - break; - default: - break; - } - return ignore; -} diff --git a/src/plugins/layout/states/stateprovider.ts b/src/plugins/layout/states/stateprovider.ts deleted file mode 100644 index ecd69e31..00000000 --- a/src/plugins/layout/states/stateprovider.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ILayoutState, INodeInfo } from "@clarity-types/layout"; -import { createDoctypeLayoutState } from "./doctype"; -import { createElementLayoutState, resetElementStateProvider } from "./element"; -import { createIgnoreLayoutState } from "./ignore"; -import { createStyleLayoutState } from "./style"; -import { createTextLayoutState } from "./text"; - -export function createLayoutState(node: Node, info: INodeInfo): ILayoutState { - if (info.ignore) { - return createIgnoreLayoutState(node); - } - let state: ILayoutState = null; - switch (node.nodeType) { - case Node.DOCUMENT_TYPE_NODE: - state = createDoctypeLayoutState(node as DocumentType); - break; - case Node.TEXT_NODE: - state = createTextLayoutState(node as Text, info); - break; - case Node.ELEMENT_NODE: - let elem = node as Element; - switch (elem.tagName) { - case "STYLE": - state = createStyleLayoutState(elem as HTMLStyleElement, info); - break; - default: - state = createElementLayoutState(elem, info); - break; - } - break; - default: - state = createIgnoreLayoutState(node); - break; - } - return state; -} - -export function resetStateProvider(): void { - resetElementStateProvider(); -} diff --git a/src/plugins/layout/states/style.ts b/src/plugins/layout/states/style.ts deleted file mode 100644 index f7f9db56..00000000 --- a/src/plugins/layout/states/style.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { INodeInfo, IStyleLayoutState } from "@clarity-types/layout"; -import { config } from "@src/config"; -import { createElementLayoutState } from "./element"; - -export function createStyleLayoutState(styleNode: HTMLStyleElement, info: INodeInfo): IStyleLayoutState { - let layoutState = createElementLayoutState(styleNode, info) as IStyleLayoutState; - if (info.captureCssRules) { - layoutState.cssRules = getCssRules(styleNode); - } - return layoutState; -} - -export function getCssRules(element: HTMLStyleElement): string[] { - let cssRules = null; - let rules = []; - // Firefox throws a SecurityError when trying to access cssRules of a stylesheet from a different domain - try { - let sheet = element.sheet as CSSStyleSheet; - cssRules = sheet ? sheet.cssRules : []; - } catch (e) { - if (e.name !== "SecurityError") { - throw e; - } - } - - if (cssRules !== null) { - rules = []; - for (let i = 0; i < cssRules.length; i++) { - rules.push(cssRules[i].cssText); - } - } - - return rules; -} - -export function shouldCaptureCssRules(node: Node): boolean { - let captureCssRules = false; - if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === "STYLE") { - // Capturing CSS rules associated with a style node is more precise than relying on its childlist CSS text, - // because rules can be added/modified without affecting child text nodes, but it is also costly for performance. - // As a compromise, we are going to capture CSS rules in two cases: - // 1. If the config explicitly tells us to do so - // 2. If style node has no children. A rather common technique is to control page styles by inserting rules - // directly into the 'sheet holder' style node. So, when we encounter a style node with no inner text, - // chances are high that there are non-child-text rules associated with it and if we don't capture them, - // page visualization is likely to be broken. A popular example library that uses such technique is 'styled-components'. - // https://www.npmjs.com/package/styled-components - captureCssRules = config.cssRules || node.textContent.length === 0; - } - return captureCssRules; -} diff --git a/src/plugins/layout/states/text.ts b/src/plugins/layout/states/text.ts deleted file mode 100644 index 68074e88..00000000 --- a/src/plugins/layout/states/text.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { INodeInfo, ITextLayoutState } from "@clarity-types/layout"; -import { mask } from "@src/utils"; -import { createGenericLayoutState, Tags } from "./generic"; - -export function createTextLayoutState(textNode: Text, info: INodeInfo): ITextLayoutState { - const textState = createGenericLayoutState(textNode, Tags.Text) as ITextLayoutState; - - // Text nodes that are children of the STYLE elements contain CSS code, so we need to unmask it - const unmask = info.isCss || info.unmask; - textState.content = unmask ? textNode.nodeValue : mask(textNode.nodeValue); - return textState; -} - -export function isCssText(node: Node): boolean { - let isCss = false; - if (node.nodeType === Node.TEXT_NODE) { - // Checking parentNode, instead of parentElement, because in IE textNode.parentElement returns 'undefined'. - let parent = node.parentNode; - isCss = parent && parent.nodeType === Node.ELEMENT_NODE && (parent as Element).tagName === "STYLE"; - } - return isCss; -} diff --git a/src/plugins/performance.ts b/src/plugins/performance.ts deleted file mode 100644 index d1a51f55..00000000 --- a/src/plugins/performance.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { IPlugin } from "@clarity-types/core"; -import { IPerformanceResourceTimingState, IPerformanceTimingState } from "@clarity-types/performance"; -import { config } from "@src/config"; -import { addEvent } from "@src/core"; -import { mapProperties } from "@src/utils"; - -export const NavigationTimingEventType = "NavigationTiming"; -export const ResourceTimingEventType = "ResourceTiming"; -export const PerformanceStateErrorEventType = "PerformanceStateError"; - -export default class PerformanceProfiler implements IPlugin { - - private readonly timeoutLength: number = 1000; - - private urlBlacklist: string[]; - private lastInspectedEntryIndex: number; - private logTimingTimeout: number; - private logResourceTimingTimeout: number; - - // For performance reasons this module relies on the fact that array returned from the - // getEntries function is a superset of the array returned from that function on previous inspection. - // If that is not the case (e.g. performance.clearResourceTimings() was used), then - // some entries may be missed and consistency is not guaranteed. - // Set this flag to 'true' if we detect that something went wrong (e.g. new array length < lastInspectedEntryIndex) - private stateError: boolean = false; - - // IE has a very 'unique' way of working with performance entries - // Each time you invoke getEntries() it returns a list of brand new objects, - // even if those objects describe the same network entries, but at least it maintains the order. - // Besides that, IE returns performance entries for resources that are not finished loading yet, - // so duration, responseEnd and other values can be '0' until you re-inspect getEntries() later. - // To be able to come back to those incomplete entries, we will store their indices for revisiting. - private incompleteEntryIndices: number[] = []; - - private timing: PerformanceTiming; - private getEntriesByType: (type: string) => PerformanceEntry[]; - - public activate(): void { - if (this.timing) { - this.logTimingTimeout = window.setTimeout(this.logTiming.bind(this), this.timeoutLength); - } - if (this.getEntriesByType) { - this.logResourceTimingTimeout = window.setTimeout(this.logResourceTiming.bind(this), this.timeoutLength); - } - } - - public reset(): void { - this.lastInspectedEntryIndex = -1; - this.stateError = false; - this.incompleteEntryIndices = []; - this.urlBlacklist = config.urlBlacklist.map(this.getFullUrl); - - // We need to blacklist our own uploadUrl, otherwise we get into an infinite loop of instrumenting the calls we make - // to the uploadUrl. Each of our instrumentation calls gets instrumented and we ended up POSTing multiple times per second. - this.urlBlacklist.push(this.getFullUrl(config.uploadUrl)); - - // Potentially these don't need resets because performance object doesn't normally change within the page - // The reason for resetting these values on each activation is for easier testing - // This way this component would pick up a dummy performance object for reliable unit testing - this.timing = window.performance && performance.timing; - this.getEntriesByType = window.performance - && typeof performance.getEntriesByType === "function" - && performance.getEntriesByType.bind(performance); - } - - public teardown(): void { - clearTimeout(this.logTimingTimeout); - clearTimeout(this.logResourceTimingTimeout); - } - - private logTiming(): void { - if (this.timing.loadEventEnd > 0) { - let formattedTiming = this.timing.toJSON ? this.timing.toJSON() : this.timing; - formattedTiming = mapProperties(formattedTiming, (name: string) => { - return (formattedTiming[name] === 0) ? 0 : Math.round(formattedTiming[name] - formattedTiming.navigationStart); - }, false); - let navigationTimingEventState: IPerformanceTimingState = { - timing: formattedTiming - }; - addEvent({type: NavigationTimingEventType, state: navigationTimingEventState}); - } else { - this.logTimingTimeout = window.setTimeout(this.logTiming.bind(this), this.timeoutLength); - } - } - - private logResourceTiming(): void { - let entries = this.getEntriesByType("resource") as PerformanceResourceTiming[]; - - // If entries.length < lastInspectedEntry + 1, most likely some entries have - // been cleared, which would cause inconsistencies in further entries inspection. - // In case this happens, log an error once and reset the module to its initial state - if (entries.length < this.lastInspectedEntryIndex + 1) { - if (!this.stateError) { - this.stateError = true; - addEvent({type: PerformanceStateErrorEventType, state: {}}); - } - - this.lastInspectedEntryIndex = -1; - this.incompleteEntryIndices = []; - } - - let incompleteEntryIndicesCopy = this.incompleteEntryIndices.slice(); - - this.incompleteEntryIndices = []; - - // First, revisit the entries that were identified as incomplete upon last inspection - for (let i = 0; i < incompleteEntryIndicesCopy.length; i++) { - let entryIndex = incompleteEntryIndicesCopy[i]; - let networkData = this.inspectEntry(entries[entryIndex], entryIndex); - if (networkData) { - addEvent({type: ResourceTimingEventType, state: networkData}); - } - } - - // Then, inspect fresh entries - for (let i = this.lastInspectedEntryIndex + 1; i < entries.length; i++) { - let networkData = this.inspectEntry(entries[i], i); - if (networkData) { - addEvent({type: ResourceTimingEventType, state: networkData}); - } - this.lastInspectedEntryIndex = i; - } - - this.logResourceTimingTimeout = window.setTimeout(this.logResourceTiming.bind(this), this.timeoutLength); - } - - private inspectEntry(entry: PerformanceResourceTiming, entryIndex: number): IPerformanceResourceTimingState { - let networkData: IPerformanceResourceTimingState = null; - if (entry && entry.responseEnd > 0) { - if (this.urlBlacklist.indexOf(entry.name) < 0) { - networkData = { - duration: entry.duration, - initiatorType: entry.initiatorType, - startTime: entry.startTime, - connectStart: entry.connectStart, - connectEnd: entry.connectEnd, - requestStart: entry.requestStart, - responseStart: entry.responseStart, - responseEnd: entry.responseEnd, - name: entry.name - }; - - // These properties only exist in new browsers - // They also don't need rounding, because they are measured in bytes - if ("transferSize" in entry) { - networkData.transferSize = entry.transferSize; - } - if ("encodedBodySize" in entry) { - networkData.encodedBodySize = entry.encodedBodySize; - } - if ("decodedBodySize" in entry) { - networkData.decodedBodySize = entry.decodedBodySize; - } - if ("nextHopProtocol" in entry) { - networkData.protocol = entry.nextHopProtocol; - } - - networkData = mapProperties(networkData, (name: string, value: any) => { - return (typeof value === "number") ? Math.round(value) : value; - }, true) as IPerformanceResourceTimingState; - } - } else { - this.incompleteEntryIndices.push(entryIndex); - } - - return networkData; - } - - private getFullUrl(partialUrl: string): string { - let dummyLink = document.createElement("a"); - dummyLink.href = partialUrl; - return dummyLink.href; - } -} diff --git a/src/plugins/pointer/mouse.ts b/src/plugins/pointer/mouse.ts deleted file mode 100644 index c50aece9..00000000 --- a/src/plugins/pointer/mouse.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IPointerState } from "@clarity-types/pointer"; -import { NodeIndex } from "@src/plugins/layout/states/generic"; - -// Accessing any evt property can sometimes (rarely) throw exception "Permission denied to access property..." -// Not adding try/catch by design for perf reasons -export function transform(evt: MouseEvent): IPointerState[] { - let de = document.documentElement; - return [{ - index: 1, /* Pointer ID */ - event: evt.type, - pointer: "mouse", - x: "pageX" in evt ? Math.round(evt.pageX) : ("clientX" in evt ? Math.round(evt["clientX"] + de.scrollLeft) : null), - y: "pageY" in evt ? Math.round(evt.pageY) : ("clientY" in evt ? Math.round(evt["clientY"] + de.scrollTop) : null), - width: 1, - height: 1, - pressure: 1, - tiltX: 0, - tiltY: 0, - target: (evt.target && NodeIndex in evt.target) ? evt.target[NodeIndex] : null, - buttons: evt.buttons - }]; -} diff --git a/src/plugins/pointer/pointer.ts b/src/plugins/pointer/pointer.ts deleted file mode 100644 index 115af2bb..00000000 --- a/src/plugins/pointer/pointer.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as mouse from "./mouse"; -import * as touch from "./touch"; - -import { IPlugin } from "@clarity-types/core"; -import { IPointerModule, IPointerState } from "@clarity-types/pointer"; -import { addEvent, bind } from "@src/core"; - -export default class Pointer implements IPlugin { - private eventName: string = "Pointer"; - private distanceThreshold: number = 20; - private timeThreshold: number = 500; - private lastMoveState: IPointerState; - private lastMoveTime: number; - - public activate(): void { - bind(document, "mousedown", this.pointerHandler.bind(this, mouse)); - bind(document, "mouseup", this.pointerHandler.bind(this, mouse)); - bind(document, "mousemove", this.pointerHandler.bind(this, mouse)); - bind(document, "mousewheel", this.pointerHandler.bind(this, mouse)); - bind(document, "click", this.pointerHandler.bind(this, mouse)); - bind(document, "touchstart", this.pointerHandler.bind(this, touch)); - bind(document, "touchend", this.pointerHandler.bind(this, touch)); - bind(document, "touchmove", this.pointerHandler.bind(this, touch)); - bind(document, "touchcancel", this.pointerHandler.bind(this, touch)); - } - - public teardown(): void { - // Nothing to teardown - } - - public reset(): void { - this.lastMoveState = null; - this.lastMoveTime = 0; - } - - private pointerHandler(handler: IPointerModule, evt: Event): void { - let states = handler.transform(evt); - for (let state of states) { - this.processState(state, evt.timeStamp); - } - } - - private processState(state: IPointerState, time: number): void { - switch (state.event) { - case "mousemove": - case "touchmove": - if (this.lastMoveState == null - || this.checkDistance(this.lastMoveState, state) - || this.checkTime(time)) { - this.lastMoveState = state; - this.lastMoveTime = time; - addEvent({type: this.eventName, state}); - } - break; - default: - addEvent({type: this.eventName, state}); - break; - } - } - - private checkDistance(stateOne: IPointerState, stateTwo: IPointerState): boolean { - let dx = stateOne.x - stateTwo.x; - let dy = stateOne.y - stateTwo.y; - return (dx * dx + dy * dy > this.distanceThreshold * this.distanceThreshold); - } - - private checkTime(time: number): boolean { - return time - this.lastMoveTime > this.timeThreshold; - } -} diff --git a/src/plugins/pointer/touch.ts b/src/plugins/pointer/touch.ts deleted file mode 100644 index f3e32f98..00000000 --- a/src/plugins/pointer/touch.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { IPointerState } from "@clarity-types/pointer"; -import { NodeIndex } from "@src/plugins/layout/states/generic"; - -// Accessing any evt property can sometimes (rarely) throw exception "Permission denied to access property..." -// Not adding try/catch by design for perf reasons -export function transform(evt: TouchEvent): IPointerState[] { - let states: IPointerState[] = []; - let de = document.documentElement; - let buttons = (evt.type === "touchstart" || evt.type === "touchmove") ? 1 : 0; - let touches = evt.changedTouches; - if (touches) { - for (let i = 0; i < touches.length; i++) { - let touch = touches[i]; - states.push({ - index: touch.identifier + 2, /* Avoid conflict with mouse index of 1 */ - event: evt.type, - pointer: "touch", - x: "clientX" in touch ? Math.round(touch.clientX + de.scrollLeft) : null, - y: "clientY" in touch ? Math.round(touch.clientY + de.scrollTop) : null, - width: "radiusX" in touch ? Math.round(touch["radiusX"]) : ("webkitRadiusX" in touch ? Math.round(touch["webkitRadiusX"]) : 0), - height: "radiusY" in touch ? Math.round(touch["radiusY"]) : ("webkitRadiusY" in touch ? Math.round(touch["webkitRadiusY"]) : 0), - pressure: "force" in touch ? touch["force"] : ("webkitForce" in touch ? touch["webkitForce"] : 0.5), - tiltX: 0, - tiltY: 0, - target: (evt.target && NodeIndex in evt.target) ? evt.target[NodeIndex] : null, - buttons - }); - } - } - return states; -} diff --git a/src/plugins/viewport.ts b/src/plugins/viewport.ts deleted file mode 100644 index bd89c274..00000000 --- a/src/plugins/viewport.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { IPlugin } from "@clarity-types/core"; -import { IViewportState } from "@clarity-types/viewport"; -import { addEvent, bind } from "@src/core"; - -export default class Viewport implements IPlugin { - private eventName: string = "Viewport"; - private distanceThreshold: number = 20; - private lastViewportState: IViewportState; - private body: HTMLElement = document.body; - private documentElement: HTMLElement = document.documentElement; - - public activate(): void { - this.processState(this.getViewport("discover")); - bind(window, "scroll", this.viewportHandler.bind(this)); - bind(window, "resize", this.viewportHandler.bind(this)); - bind(window, "pageshow", this.viewportHandler.bind(this)); - bind(window, "pagehide", this.viewportHandler.bind(this)); - bind(document, "visibilitychange", this.viewportHandler.bind(this)); - } - - public teardown(): void { - // Nothing to teardown - } - - public reset(): void { - this.lastViewportState = null; - } - - private viewportHandler(evt: Event): void { - let viewportState = this.getViewport(evt.type); - this.processState(viewportState); - } - - private getViewport(type: string): IViewportState { - let viewport: IViewportState = { - viewport: { - x: "pageXOffset" in window ? window.pageXOffset : this.documentElement.scrollLeft, - y: "pageYOffset" in window ? window.pageYOffset : this.documentElement.scrollTop, - width: "innerWidth" in window ? window.innerWidth : this.documentElement.clientWidth, - height: "innerHeight" in window ? window.innerHeight : this.documentElement.clientHeight - }, - document: { - width: this.body ? this.body.clientWidth : null, - height: this.getDocumentHeight() - }, - dpi: "devicePixelRatio" in window ? window.devicePixelRatio : -1, - visibility: "visibilityState" in document ? document.visibilityState : "default", - event: type - }; - return viewport; - } - - // body.clientHeight gets set to viewport height when doctype is not set for a document. - // The more accurate way to calculate browser height is to get the maximum of body and documentElement heights - private getDocumentHeight(): number { - let bodyClientHeight = this.body ? this.body.clientHeight : null; - let bodyScrollHeight = this.body ? this.body.scrollHeight : null; - let bodyOffsetHeight = this.body ? this.body.offsetHeight : null; - let documentClientHeight = this.documentElement ? this.documentElement.clientHeight : null; - let documentScrollHeight = this.documentElement ? this.documentElement.scrollHeight : null; - let documentOffsetHeight = this.documentElement ? this.documentElement.offsetHeight : null; - let documentHeight = Math.max(bodyClientHeight, bodyScrollHeight, bodyOffsetHeight, - documentClientHeight, documentScrollHeight, documentOffsetHeight); - return documentHeight; - } - - private processState(state: IViewportState): void { - let recordState = true; - if (state.event === "scroll" - && this.lastViewportState !== null - && !this.checkDistance(this.lastViewportState, state)) { - recordState = false; - } - if (recordState) { - this.lastViewportState = state; - addEvent({type: this.eventName, state}); - } - } - - private checkDistance(stateOne: IViewportState, stateTwo: IViewportState): boolean { - let dx = stateOne.viewport.x - stateTwo.viewport.x; - let dy = stateOne.viewport.y - stateTwo.viewport.y; - return (dx * dx + dy * dy > this.distanceThreshold * this.distanceThreshold); - } -} diff --git a/src/upload.ts b/src/upload.ts deleted file mode 100644 index 696dc87e..00000000 --- a/src/upload.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { IPayload, IPayloadInfo, State, UploadCallback } from "@clarity-types/core"; -import { Instrumentation, ITotalByteLimitExceededEventState, IXhrErrorEventState } from "@clarity-types/instrumentation"; -import { config } from "./config"; -import { ClarityAttribute, instrument, state, teardown } from "./core"; -import { debug, getEventId } from "./utils"; - -// Counters -let sentBytesCount: number; -let uploadCount: number; - -// Storage for payloads that are compressed and are ready to be sent, but are waiting for Clarity trigger. -// Once trigger is fired, all payloads from this queue will be sent in the order they were generated. -let payloadQueue: IPayloadInfo[]; - -// Map from a sequence number to an UploadInfo object for payloads that were attempted to be sent, but were not succesfully delivered yet -let payloadInfos: { [key: number]: IPayloadInfo }; - -let reUploadQueue: number[]; - -export function upload(compressed: string, raw: IPayload, onSuccessCustom?: UploadCallback, onFailureCustom?: UploadCallback): void { - let uploadHandler = config.uploadHandler || defaultUploadHandler; - let sequenceNo = raw.envelope.sequenceNumber; - let onSuccess = getOnUploadCompletedHandler(true, sequenceNo, onSuccessCustom); - let onFailure = getOnUploadCompletedHandler(false, sequenceNo, onFailureCustom); - payloadInfos[sequenceNo] = { compressed, raw, failureCount: 0 }; - uploadHandler(compressed, onSuccess, onFailure); - - if (config.debug) { - let rawLength = JSON.stringify(raw).length; - debug(`** Upload #${uploadCount}: Batch #${sequenceNo}` - + ` ${Math.round(compressed.length / 1024.0)}KB` - + ` (${Math.round(rawLength / 1024.0)}KB Raw). **`); - } - uploadCount++; -} - -export function enqueuePayload(compressed: string, raw: IPayload): void { - let payloadInfo: IPayloadInfo = { compressed, raw, failureCount: 0 }; - payloadQueue.push(payloadInfo); -} - -export function flushPayloadQueue(): void { - uploadMultiplePayloads(payloadQueue); -} - -export function resetUploads(): void { - sentBytesCount = 0; - uploadCount = 0; - payloadQueue = []; - payloadInfos = {}; - reUploadQueue = []; -} - -function uploadMultiplePayloads(payloads: IPayloadInfo[]): void { - if (payloads.length > 0) { - let nextPayload = payloads.shift(); - let uploadNextPayload = (): void => { - uploadMultiplePayloads(payloads); - }; - upload(nextPayload.compressed, nextPayload.raw, uploadNextPayload, uploadNextPayload); - } -} - -function defaultUploadHandler(payload: string, onSuccess?: UploadCallback, onFailure?: UploadCallback): void { - if (config.uploadUrl.length > 0) { - payload = JSON.stringify(payload); - let xhr = new XMLHttpRequest(); - let headers = Object.keys(config.uploadHeaders); - xhr.open("POST", config.uploadUrl); - for (let i = 0; i < headers.length; i++) { - xhr.setRequestHeader(headers[i], config.uploadHeaders[headers[i]]); - } - xhr.onreadystatechange = (): void => { - onXhrReadyStatusChange(xhr, onSuccess, onFailure); - }; - xhr.send(payload); - } -} - -function onXhrReadyStatusChange(xhr: XMLHttpRequest, onSuccess: UploadCallback, onFailure: UploadCallback): void { - if (xhr.readyState === XMLHttpRequest.DONE) { - // HTTP response status documentation: - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status - if (xhr.status < 200 || xhr.status > 208) { - onFailure(xhr.status); - } else { - onSuccess(xhr.status); - } - } -} - -function onSuccessDefault(sequenceNumber: number): void { - debug(`SUCCESS: Delivered batch #${sequenceNumber}`); - let sentData = payloadInfos[sequenceNumber]; - delete payloadInfos[sequenceNumber]; - sentBytesCount += sentData.compressed.length; - - if (state === State.Activated && sentBytesCount > config.totalLimit) { - let totalByteLimitExceededEventState: ITotalByteLimitExceededEventState = { - type: Instrumentation.TotalByteLimitExceeded, - bytes: sentBytesCount - }; - instrument(totalByteLimitExceededEventState); - teardown(); - return; - } - - retryFailedUploads(); -} - -function onFailureDefault(sequenceNumber: number, status: number): void { - debug(`FAILED: Delivery failed for batch #${sequenceNumber} Status: ${status}`); - let uploadInfo = payloadInfos[sequenceNumber]; - uploadInfo.failureCount++; - logXhrError(sequenceNumber, status, uploadInfo); - if (uploadInfo.failureCount <= config.reUploadLimit) { - reUploadQueue.push(sequenceNumber); - } -} - -function logXhrError(sequenceNumber: number, status: number, payloadInfo: IPayloadInfo): void { - let events = payloadInfo.raw.events; - let xhrErrorState: IXhrErrorEventState = { - type: Instrumentation.XhrError, - requestStatus: status, - sequenceNumber, - compressedLength: payloadInfo.compressed.length, - rawLength: JSON.stringify(payloadInfo.raw).length, - firstEventId: getEventId(events[0]), - lastEventId: getEventId(events[events.length - 1]), - attemptNumber: payloadInfo.failureCount - }; - instrument(xhrErrorState); -} - -function retryFailedUploads(): void { - let payloads: IPayloadInfo[] = []; - while (reUploadQueue.length > 0) { - let nextPayloadSequenceNo = reUploadQueue.shift(); - payloads.push(payloadInfos[nextPayloadSequenceNo]); - } - uploadMultiplePayloads(payloads); -} - -function getOnUploadCompletedHandler(success: boolean, sequenceNo: number, customHandler: UploadCallback): UploadCallback { - let defaultHandler = success ? onSuccessDefault : onFailureDefault; - let sourceImpressionId = document[ClarityAttribute]; - return (status: number): void => { - let currentImpressionId = document[ClarityAttribute]; - if (state === State.Activated && currentImpressionId === sourceImpressionId) { - defaultHandler(sequenceNo, status); - if (customHandler) { - customHandler(status); - } - } - }; -} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 6112d2e8..00000000 --- a/src/utils.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { IEventArray } from "@clarity-types/core"; -import { IClarityAssertFailedEventState, Instrumentation } from "@clarity-types/instrumentation"; -import { config } from "./config"; -import { instrument } from "./core"; - -// Credit: http://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript -// Excluding 3rd party code from tslint -// tslint:disable -export function guid() { - let d = new Date().getTime(); - if (window.performance && performance.now) { - // Use high-precision timer if available - d += performance.now(); - } - let uuid = "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, function(c) { - let r = (d + Math.random() * 16) % 16 | 0; - d = Math.floor(d / 16); - return (c == "x" ? r : (r & 0x3 | 0x8)).toString(16); - }); - return uuid; -} -// tslint:enable - -export function setCookie(cookieName: string, value: string, expDays?: number): void { - let expDate = null; - if (expDays) { - expDate = new Date(); - expDate.setDate(expDate.getDate() + expDays); - } - let expires = expDate ? "expires=" + expDate.toUTCString() : ""; - let cookieValue = value + ";" + expires + ";path=/"; - document.cookie = cookieName + "=" + cookieValue; -} - -export function getCookie(cookieName: string): string { - let arrayOfCookies: string[] = document.cookie.split(";"); - if (arrayOfCookies) { - for (let i = 0; i < arrayOfCookies.length; i++) { - let cookiePair: string[] = arrayOfCookies[i].split("="); - if (cookiePair && cookiePair.length > 1 && cookiePair[0].indexOf(cookieName) >= 0) { - return cookiePair[1]; - } - } - } - return null; -} - -export function mapProperties(sourceObj: object, - mapFunction: (name: string, value: any) => any, - ownPropertiesOnly: boolean, - outObj?: object): object { - outObj = outObj || {}; - for (let property in sourceObj) { - if (!ownPropertiesOnly || sourceObj.hasOwnProperty(property)) { - let sourceValue = sourceObj[property]; - let outValue = mapFunction ? mapFunction(property, sourceValue) : sourceValue; - if (typeof outValue !== "undefined") { - outObj[property] = outValue; - } - } - } - return outObj; -} - -export function traverseNodeTree(root: Node, processingFunc: (node: Node) => void, includeRoot: boolean = true): void { - let queue = [root]; - while (queue.length > 0) { - let next = queue.shift(); - let nextChild: Node = next.firstChild; - while (nextChild) { - queue.push(nextChild); - nextChild = nextChild.nextSibling; - } - if (next !== root || includeRoot) { - processingFunc(next); - } - } -} - -export function roundingMapFunction(name: string, value: any): any { - return (isNumber(value)) ? Math.round(value) : value; -} - -export function isNumber(value: any): boolean { - return (typeof value === "number" && !isNaN(value)); -} - -export function assert(condition: boolean, source: string, comment: string): void { - if (condition === false) { - debug(`>>> Clarity Assert failed\nSource: ${source}\nComment: ${comment}`); - let eventState: IClarityAssertFailedEventState = { - type: Instrumentation.ClarityAssertFailed, - source, - comment - }; - instrument(eventState); - } -} - -export function debug(text: string): void { - if (config.debug && console.log) { - console.log(`(Clarity) ${text}`); - } -} - -export function getEventId(eventArray: IEventArray): number { - return eventArray[0]; -} - -export function getEventType(eventArray: IEventArray): string { - return eventArray[1]; -} - -export function mask(text: string): string { - return text.replace(/\S/gi, "*"); -} diff --git a/tsconfig.json b/tsconfig.json index 59038c1b..d5e3cdf3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,12 +9,14 @@ "baseUrl": ".", "paths": { "@src/*": ["src/*"], + "@decode/*": ["decode/*"], "@karma/*": ["karma/*"], "@clarity-types/*": ["types/*"] } }, "include":[ "src/**/*.ts", + "decode/**/*.ts", "types/**/*.d.ts", "karma/**/*.ts", ], diff --git a/tslint.json b/tslint.json index c9420b70..ae7b0432 100644 --- a/tslint.json +++ b/tslint.json @@ -17,6 +17,7 @@ false ], "variable-name": false, + "interface-name": false, "interface-over-type-literal": false, "only-arrow-functions": false, "typedef": [ diff --git a/types/compressionworker.d.ts b/types/compressionworker.d.ts deleted file mode 100644 index bc30074b..00000000 --- a/types/compressionworker.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { IEventArray, IPayload } from "./core"; - -export const enum WorkerMessageType { - /* Main thread to Worker messages */ - AddEvent, - ForceCompression, - - /* Worker to main thread messages */ - CompressedBatch -} - -export interface IWorkerMessage { - type: WorkerMessageType; -} - -export interface ITimestampedWorkerMessage extends IWorkerMessage { - time: number; -} - -export interface IAddEventMessage extends ITimestampedWorkerMessage { - event: IEventArray; - isXhrErrorEvent?: boolean; -} - -export interface ICompressedBatchMessage extends IWorkerMessage { - compressedData: string; - rawData: IPayload; -} diff --git a/types/config.d.ts b/types/config.d.ts deleted file mode 100644 index 160e562b..00000000 --- a/types/config.d.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { IClarityFields, UploadHandler } from "./core"; - -export interface IConfig { - // Active plugins - plugins?: string[]; - - // Endpoint, to which data will be uploaded - uploadUrl?: string; - - // A list of URLs to ignore when instrumenting network resource entries - // This is useful to prevent Clarity from instrumenting its own activity - urlBlacklist?: string[]; - - // Each new event is going to delay data upload to server by this number of milliseconds - delay?: number; - - // Maximum number of event bytes that Clarity can send in a single upload - batchLimit?: number; - - // Maximum number of bytes that Clarity can send per page overall - totalLimit?: number; - - // Maximum number of XHR re-delivery attempts for a single payload - reUploadLimit?: number; - - // If set to 'true', clarity-js will NOT write CID (Clarity ID) cookie - disableCookie?: boolean; - - // Names of the attributes that need to be masked (on top of default ones), when showText is false - sensitiveAttributes?: string[]; - - // Send back instrumentation data, if set to true - instrument?: boolean; - - // Inspect CSSRuleList for style elements and send CSSRules data instead of style's text children - cssRules?: boolean; - - // Pointer to the function which would be responsible for sending the data - // If left unspecified, raw payloads will be uploaded to the uploadUrl endpoint - uploadHandler?: UploadHandler; - - // XHLHttpRequest headers to be added to every upload request with the default upload handler - // Object is a map from header names to header values - uploadHeaders?: { [key: string]: string; }; - - // Pointer to the function which will be responsible for giving Clarity - // a dictionary of strings that the user wants logged in each Clarity payload - customInstrumentation?: (fields: IClarityFields) => { [key: string]: string; }; - - // Setting to enable debug features (e.g. console.log statements) - debug?: boolean; - - // Setting to enable consistency verifications between real DOM and shadow DOM - // Validating consistency can be costly performance-wise, because it requires - // re-traversing entire DOM and ShadowDom to compare them against each other. - // The upside is knowing deterministically that all activity on the page was - // interpreted correctly and data is reliable. - validateConsistency?: boolean; - - // If this flag is enabled, Clarity will not send any data until trigger function is called. - // Clarity will still run in the background collecting events and compressing them into batches, - // but actual sending will only be done one the trigger is fired. - backgroundMode?: boolean; - - // Identifier of the project to which this impression will be merged on the backend - projectId?: string; -} diff --git a/types/core.d.ts b/types/core.d.ts index bd6647e2..0c6779dd 100644 --- a/types/core.d.ts +++ b/types/core.d.ts @@ -1,82 +1,41 @@ -export const enum State { - Loaded, - Activated, - Unloaded, - Activating, - Unloading -} - -export interface IPlugin { - activate(): void; - teardown(): void; - reset(): void; -} - -export interface IPayload { - envelope: IEnvelope; - events: IEventArray[]; -} - -export interface IPayloadInfo { - compressed: string; - raw: IPayload; - failureCount: number; -} +import { ClarityInfo, Payload, Token } from "./data"; -export interface IEnvelope { - clarityId: string; - impressionId: string; - projectId: string; - url: string; - version: string; - time?: number; - sequenceNumber?: number; - extraInfo?: object; -} +type TaskFunction = () => Promise; +type TaskResolve = () => void; -export interface IEventData { - type: string; - state: any; - time?: number; -} +/* Helper Interfaces */ -export interface IEvent extends IEventData { - id: number; /* Event ID */ - time: number; /* Time relative to page start */ +export interface AsyncTask { + task: TaskFunction; + resolve: TaskResolve; } -export interface IEventBindingPair { - target: EventTarget; - listener: EventListener; +export interface BrowserEvent { + event: string; + target: EventTarget; + listener: EventListener; + capture: boolean; } -export interface IBindingContainer { - [key: string]: IEventBindingPair[]; +export interface Config { + projectId?: string; + longtask?: number; + lookahead?: number; + distance?: number; + interval?: number; + delay?: number; + expire?: number; + ping?: number; + timeout?: number; + shutdown?: number; + cssRules?: boolean; + lean?: boolean; + tokens?: string[]; + url?: string; + onstart?: (data: ClarityInfo) => void; + upload?: (data: string, sequence?: number, last?: boolean) => void; } -export interface IClarityFields { - impressionId: string; - clientId: string; - projectId: string; +export interface TaskTiming { + [key: number]: number; } - -export type UploadCallback = (status: number) => void; -export type UploadHandler = (payload: string, onSuccess?: UploadCallback, onFailure?: UploadCallback) => void; - -// IEvent object converted to a value array representation -export type IEventArray = [ - number, // id - string, // type - number, // time - any[], // state - ClarityDataSchema // data schema, if it's a new one, otherwise - schema hashcode -]; - -export const enum ObjectType { - Object, - Array -} - -// For details on schema generation, see schema.md: -// https://github.com/Microsoft/clarity-js/blob/master/converters/schema.md -export type ClarityDataSchema = null | string | any[]; diff --git a/types/data.d.ts b/types/data.d.ts new file mode 100644 index 00000000..da97b9c4 --- /dev/null +++ b/types/data.d.ts @@ -0,0 +1,158 @@ +export type Token = (string | number | number[] | string[]); +export type DecodedToken = (any | any[]); + +/* Enum */ + +export const enum Event { + Metric = 0, + Discover = 1, + Mutation = 2, + BoxModel = 3, + Hash = 4, + Resize = 5, + Document = 6, + Scroll = 7, + Click = 8, + MouseMove = 9, + MouseDown = 10, + MouseUp = 11, + MouseWheel = 12, + DoubleClick = 13, + RightClick = 14, + TouchStart = 15, + TouchEnd = 16, + TouchMove = 17, + TouchCancel = 18, + Selection = 19, + Page = 20, + Tag = 21, + Ping = 22, + Unload = 23, + InputChange = 24, + Visibility = 25, + Network = 26, + Performance = 27, + ScriptError = 28, + ImageError = 29, + Resource = 30, + Summary = 31, + Upload = 32, + Target = 33 +} + +export const enum Metric { + Nodes = 0, + LayoutBytes = 1, + InteractionBytes = 2, + NetworkBytes = 3, + DiagnosticBytes = 4, + Mutations = 5, + Interactions = 6, + Clicks = 7, + Selections = 8, + Changes = 9, + ScriptErrors = 10, + ImageErrors = 11, + DiscoverTime = 12, + MutationTime = 13, + BoxModelTime = 14, + StartTime = 15, + ActiveTime = 16, + EndTime = 17, + ViewportWidth = 18, + ViewportHeight = 19, + DocumentWidth = 20, + DocumentHeight = 21 +} + +export const enum Upload { + Async = 0, + Beacon = 1 +} + +export const enum BooleanFlag { + False = 0, + True = 1 +} + +/* Helper Interfaces */ + +export interface Payload { + e: Token[]; + d: Token[][]; +} + +export interface EncodedPayload { + e: string; + d: string; +} + +export interface CookieInfo { + userId: string; + sessionId: string; + timestamp: number; +} + +export interface ClarityInfo { + userId: string; + sessionId: string; + pageId: string; +} + +export interface Metadata { + page: PageData; + envelope: Envelope; +} + +export interface Envelope { + sequence: number; + version: string; + projectId: string; + userId: string; + sessionId: string; + pageId: string; + upload: Upload; + end: BooleanFlag; +} + +export interface Transit { + [key: number]: { + data: string; + attempts: number; + }; +} + +/* Event Data */ + +export interface MetricData { + [key: number]: number; +} + +export interface PageData { + timestamp: number; + ua: string; + url: string; + referrer: string; + lean: BooleanFlag; +} + +export interface PingData { + gap: number; +} + +export interface TagData { + key: string; + value: string; +} + +export interface UploadData { + sequence: number; + attempts: number; + status: number; +} + +export interface SummaryData { + event: Event; + start: number; + end: number; +} diff --git a/types/decode/core.d.ts b/types/decode/core.d.ts new file mode 100644 index 00000000..5c8e37b5 --- /dev/null +++ b/types/decode/core.d.ts @@ -0,0 +1,6 @@ +import { Event } from "../data"; + +export interface PartialEvent { + time: number; + event: Event; +} diff --git a/types/decode/data.d.ts b/types/decode/data.d.ts new file mode 100644 index 00000000..9a48ecf3 --- /dev/null +++ b/types/decode/data.d.ts @@ -0,0 +1,13 @@ +import { MetricData, PageData, PingData, SummaryData, TagData, UploadData } from "../data"; +import { PartialEvent } from "./core"; + +/* Data Events */ +export interface MetricEvent extends PartialEvent { data: MetricData; } +export interface PageEvent extends PartialEvent { data: PageData; } +export interface PingEvent extends PartialEvent { data: PingData; } +export interface SummaryEvent extends PartialEvent { data: SummaryData[]; } +export interface TagEvent extends PartialEvent { data: TagData; } +export interface UploadEvent extends PartialEvent { data: UploadData; } +export interface DataEvent extends PartialEvent { + data: MetricData | PageData | PingData | SummaryData[] | TagData | UploadData; +} diff --git a/types/decode/decode.d.ts b/types/decode/decode.d.ts new file mode 100644 index 00000000..8fe35b5e --- /dev/null +++ b/types/decode/decode.d.ts @@ -0,0 +1,35 @@ +import { Envelope, Event, MetricData, PageData, PingData, SummaryData, TagData, UploadData } from "../data"; +import { DataEvent, MetricEvent, PageEvent, PingEvent, SummaryEvent, TagEvent, UploadEvent } from "./data"; +import { DiagnosticEvent, ImageErrorEvent, ScriptErrorEvent } from "./diagnostic"; +import { InputChangeEvent, InteractionEvent, PointerEvent, ResizeEvent } from "./interaction"; +import { ScrollEvent, SelectionEvent, UnloadEvent, VisibilityEvent } from "./interaction"; +import { BoxModelEvent, DocumentEvent, DomEvent, HashEvent, LayoutEvent, ResourceEvent, TargetEvent } from "./layout"; + +export type DecodedEvent = DataEvent | DiagnosticEvent | InteractionEvent | LayoutEvent; + +export interface DecodedPayload { + timestamp: number; + envelope: Envelope; + ua?: string; + metric?: MetricEvent[]; + page?: PageEvent[]; + ping?: PingEvent[]; + tag?: TagEvent[]; + image?: ImageErrorEvent[]; + script?: ScriptErrorEvent[]; + input?: InputChangeEvent[]; + pointer?: PointerEvent[]; + resize?: ResizeEvent[]; + scroll?: ScrollEvent[]; + selection?: SelectionEvent[]; + summary?: SummaryEvent[]; + unload?: UnloadEvent[]; + upload?: UploadEvent[]; + visibility?: VisibilityEvent[]; + boxmodel?: BoxModelEvent[]; + hash?: HashEvent[]; + resource?: ResourceEvent[]; + dom?: DomEvent[]; + doc?: DocumentEvent[]; + target?: TargetEvent[]; +} diff --git a/types/decode/diagnostic.d.ts b/types/decode/diagnostic.d.ts new file mode 100644 index 00000000..d7d7df36 --- /dev/null +++ b/types/decode/diagnostic.d.ts @@ -0,0 +1,8 @@ +import { ImageErrorData, ScriptErrorData } from "../diagnostic"; +import { PartialEvent } from "./core"; + +export interface ImageErrorEvent extends PartialEvent { data: ImageErrorData; } +export interface ScriptErrorEvent extends PartialEvent { data: ScriptErrorData; } +export interface DiagnosticEvent extends PartialEvent { + data: ImageErrorData | ScriptErrorData; +} diff --git a/types/decode/index.d.ts b/types/decode/index.d.ts new file mode 100644 index 00000000..1d6f1b65 --- /dev/null +++ b/types/decode/index.d.ts @@ -0,0 +1,16 @@ +import { Payload } from "../data"; +import { DecodedPayload } from "./decode"; + +interface Decode { + decode: (data: string) => DecodedPayload; +} + +declare const decode: Decode; + +export * from "./data"; +export * from "./decode"; +export * from "./diagnostic"; +export * from "./layout"; +export * from "./interaction"; + +export { decode }; diff --git a/types/decode/interaction.d.ts b/types/decode/interaction.d.ts new file mode 100644 index 00000000..450f07ef --- /dev/null +++ b/types/decode/interaction.d.ts @@ -0,0 +1,13 @@ +import { InputChangeData, PointerData, ResizeData, ScrollData, SelectionData, UnloadData, VisibilityData } from "../interaction"; +import { PartialEvent } from "./core"; + +export interface InputChangeEvent extends PartialEvent { data: InputChangeData; } +export interface PointerEvent extends PartialEvent { data: PointerData; } +export interface ResizeEvent extends PartialEvent { data: ResizeData; } +export interface ScrollEvent extends PartialEvent { data: ScrollData; } +export interface SelectionEvent extends PartialEvent { data: SelectionData; } +export interface UnloadEvent extends PartialEvent { data: UnloadData; } +export interface VisibilityEvent extends PartialEvent { data: VisibilityData; } +export interface InteractionEvent extends PartialEvent { + data: InputChangeData | PointerData | ResizeData | ScrollData | SelectionData | UnloadData | VisibilityData; +} diff --git a/types/decode/layout.d.ts b/types/decode/layout.d.ts new file mode 100644 index 00000000..c58363fb --- /dev/null +++ b/types/decode/layout.d.ts @@ -0,0 +1,23 @@ +import { Attributes, BoxModelData, DocumentData, HashData, ResourceData, TargetData } from "../layout"; +import { PartialEvent } from "./core"; + +export interface BoxModelEvent extends PartialEvent { data: BoxModelData[]; } +export interface HashEvent extends PartialEvent { data: HashData[]; } +export interface DocumentEvent extends PartialEvent { data: DocumentData; } +export interface DomEvent extends PartialEvent { data: DomData[]; } +export interface ResourceEvent extends PartialEvent { data: ResourceData[]; } +export interface TargetEvent extends PartialEvent { data: TargetData[]; } +export interface LayoutEvent extends PartialEvent { + data: BoxModelData[] | HashData[] | DocumentData | DomData[] | ResourceData[] | TargetData[]; +} + +/* Event Data */ +export interface DomData { + id: number; + parent: number; + next: number; + tag: string; + position: number; + attributes?: Attributes; + value?: string; +} diff --git a/types/diagnostic.d.ts b/types/diagnostic.d.ts new file mode 100644 index 00000000..e6dac9cd --- /dev/null +++ b/types/diagnostic.d.ts @@ -0,0 +1,14 @@ +/* Event Data */ + +export interface ScriptErrorData { + source: string; + message: string; + line: number; + column: number; + stack: string; +} + +export interface ImageErrorData { + source: string; + target: number; +} diff --git a/types/index.d.ts b/types/index.d.ts index 49e6bc78..33105aa5 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,38 +1,22 @@ -import { IConfig } from "./config"; -import { ClarityDataSchema, IEvent, IEventArray } from "./core"; +import { Config } from "./core"; -declare class SchemaManager { - constructor(); - public reset(): void; - public addSchema(schema: ClarityDataSchema): boolean; - public createSchema(data: any, name?: string): ClarityDataSchema; - public getSchema(schemaId: number): ClarityDataSchema; - public getSchemaId(schema: ClarityDataSchema): number | undefined; -} - -interface IClarityJs { +interface Clarity { version: string; - start(config?: IConfig): void; - stop(): void; - trigger(key: string): void; -} - -interface IPayloadEncoder { - SchemaManager: typeof SchemaManager; - encode(event: IEvent): IEventArray; - decode(eventArray: IEventArray, schemas?: SchemaManager): IEvent; + config: (config?: Config) => boolean; + start: (config?: Config) => void; + pause: () => void; + resume: () => void; + end: () => void; + active: () => boolean; + tag: (key: string, value: string) => void; } -declare const ClarityJs: IClarityJs; -declare const PayloadEncoder: IPayloadEncoder; +declare const clarity: Clarity; -export * from "./compressionworker"; -export * from "./config"; -export * from "./core"; -export * from "./instrumentation"; +export * from "./data"; +export * from "./diagnostic"; export * from "./layout"; -export * from "./performance"; -export * from "./pointer"; -export * from "./viewport"; +export * from "./interaction"; +export * from "./decode/index"; -export { ClarityJs, PayloadEncoder }; +export { clarity }; diff --git a/types/instrumentation.d.ts b/types/instrumentation.d.ts deleted file mode 100644 index d60f75f0..00000000 --- a/types/instrumentation.d.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { ILayoutRoutineInfo, NumberJson } from "./layout"; - -export const enum Instrumentation { - JsError, - MissingFeature, - XhrError, - TotalByteLimitExceeded, - Teardown, - ClarityAssertFailed, - ClarityDuplicated, - ShadowDomInconsistent, - ClarityActivateError, - Trigger, - Performance -} - -export interface IInstrumentationEventState { - type: Instrumentation; -} - -export interface IJsErrorEventState extends IInstrumentationEventState { - source: string; - message: string; - stack: string; - lineno: number; - colno: number; -} - -export interface IMissingFeatureEventState extends IInstrumentationEventState { - missingFeatures: string[]; -} - -export interface IXhrErrorEventState extends IInstrumentationEventState { - requestStatus: number; - sequenceNumber: number; - compressedLength: number; - rawLength: number; - firstEventId: number; - lastEventId: number; - attemptNumber: number; -} - -export interface ITotalByteLimitExceededEventState extends IInstrumentationEventState { - bytes: number; -} - -export interface IClarityAssertFailedEventState extends IInstrumentationEventState { - source: string; - comment: string; -} - -export interface IClarityDuplicatedEventState extends IInstrumentationEventState { - currentImpressionId: string; -} - -export interface IShadowDomInconsistentEventState extends IInstrumentationEventState { - // JSON of node indicies, representing the DOM - dom: NumberJson; - - // JSON of ShadowNode IDs, representing the inconsistent ShadowDom - shadowDom: NumberJson; - - // JSON of ShadowNode IDs, representing the last consistent ShadowDom - lastConsistentShadowDom: NumberJson; - - // Last action that happened before we found out that ShadowDom is inconsistent - lastAction: ILayoutRoutineInfo; - - // To handle specific MutationObserver behavior in IE, we wait for ShadowDom to become inconsistent twice in a row, - // before we stop processing mutations and send ShadowDomInconsistentEvent. This means that the actual transition - // from consistent to inconsistent state happened on some previous action and there was also an event created for it. - // That first event is sent in this property. - firstEvent?: IShadowDomInconsistentEventState; -} - -export interface IClarityActivateErrorState extends IInstrumentationEventState { - error: string; -} - -export interface ITriggerState extends IInstrumentationEventState { - key: string; -} - -export interface IPerformanceState extends IInstrumentationEventState { - procedure: string; - duration: number; -} - -export interface INodeStateGenPerformanceState extends IPerformanceState { - nodeCount: number; -} - -export interface IMutationPerformanceState extends IPerformanceState { - mutationCount: number; - mutationSequence: number; - stateGenDuration: number; - summaryCounts: { - inserts: number; - moves: number; - updates: number; - removes: number; - }; -} diff --git a/types/interaction.d.ts b/types/interaction.d.ts new file mode 100644 index 00000000..ffa88f8c --- /dev/null +++ b/types/interaction.d.ts @@ -0,0 +1,39 @@ +/* Event Data */ +export interface InputChangeData { + target: number; + value: string; +} + +export interface PointerData { + target: number; + x: number; + y: number; + time?: number; +} + +export interface ResizeData { + width: number; + height: number; +} + +export interface ScrollData { + target: number; + x: number; + y: number; + time?: number; +} + +export interface SelectionData { + start: number; + startOffset: number; + end: number; + endOffset: number; +} + +export interface UnloadData { + name: string; +} + +export interface VisibilityData { + visible: string; +} diff --git a/types/layout.d.ts b/types/layout.d.ts index 22b43b0b..ab083eb2 100644 --- a/types/layout.d.ts +++ b/types/layout.d.ts @@ -1,133 +1,83 @@ -export type NumberJson = Array; - -export type InsertRuleHandler = (rule: string, index?: number) => number; - -export interface IShadowDomNode extends HTMLDivElement { - node: Node; - info: INodeInfo; - state: ILayoutState; - computeInfo: () => INodeInfo; - computeState: () => ILayoutState; -} - -/* Computed CSS styles associated with a layout element */ -export interface ILayoutStyle { - visibility?: string; - color?: string; - backgroundColor?: string; - backgroundImage?: string; - overflowX?: string; - overflowY?: string; -} - -export interface ILayoutRectangle { - x: number; /* X coordinate of the element */ - y: number; /* Y coordinate of the element */ - width: number; /* Width of the element */ - height: number; /* Height of the element */ - scrollX?: number; /* Scroll left of the element */ - scrollY?: number; /* Scroll top of the element */ -} +/* Enum */ export const enum Source { - Discover, - Mutation, - Scroll, - Input, - Css + Discover, + ChildListAdd, + ChildListRemove, + Attributes, + CharacterData } -export const enum Action { - Insert, - Update, - Remove, - Move +export const enum Constant { + SVG_PREFIX = "svg:", + SVG_NAMESPACE = "http://www.w3.org/2000/svg", + DEVTOOLS_HOOK = "__CLARITY_DEVTOOLS_HOOK__", + ID_ATTRIBUTE = "data-clarity", + MASK_ATTRIBUTE = "data-clarity-mask", + UNMASK_ATTRIBUTE = "data-clarity-unmask" } -export interface IAttributes { - [key: string]: string; -} - -export interface INodeInfo { - ignore: boolean; - unmask: boolean; - isCss: boolean; - captureCssRules: boolean; -} +/* Helper Interfaces */ -export interface ILayoutState { - index: number; /* Index of the layout element */ - tag: string; /* Tag name of the element */ - source: Source; /* Source of discovery */ - action: Action; /* Reflect the action with respect to DOM */ - parent: number; /* Index of the parent element */ - previous: number; /* Index of the previous sibling, if known */ - next: number; /* Index of the next sibling, if known */ - mutationSequence?: number; /* Sequence number of the mutation batch */ +export interface Attributes { + [key: string]: string; } -export interface IDoctypeLayoutState extends ILayoutState { - attributes: { - name: string; - publicId: string; - systemId: string; - }; +export interface NodeInfo { + tag: string; + path?: string; + attributes?: Attributes; + value?: string; } -export interface IElementLayoutState extends ILayoutState { - attributes: IAttributes; /* Attributes associated with an element */ - layout: ILayoutRectangle; /* Layout rectangle */ - style: ILayoutStyle; /* Layout computed styles */ - value?: string; /* Applies to certain elements only https://www.w3schools.com/tags/att_value.asp + HTMLTextAreaElement */ +export interface NodeValue { + id: number; + parent: number; + next: number; + position: number; + children: number[]; + data: NodeInfo; + selector: string; + metadata: NodeMeta; } -export interface IStyleLayoutState extends IElementLayoutState { - cssRules: string[]; +export interface NodeMeta { + active: boolean; + boxmodel: boolean; + masked: boolean; } -export interface ITextLayoutState extends ILayoutState { - content: string; +export interface NodeChange { + time: number; + source: Source; + value: NodeValue; } -export interface IIgnoreLayoutState extends ILayoutState { - nodeType: number; - elementTag?: string; -} - -export interface IMutationEntry { - node: Node; - action: Action; - parent?: Node; - previous?: Node; - next?: Node; -} +/* Event Data */ -export const enum LayoutRoutine { - DiscoverDom, - Mutation +export interface DocumentData { + width: number; + height: number; } -export interface ILayoutRoutineInfo { - action: LayoutRoutine; +export interface BoxModelData { + id: number; + box: number[]; } -export interface IMutationRoutineInfo extends ILayoutRoutineInfo { - mutationSequence: number; /* Sequence number of the mutation batch */ - batchSize: number; /* Number of mutation records in the mutation callback */ +export interface TargetData { + id: number; + hash: string; + box: number[]; } -// export interface to store some information about the initial state of the node -// in cases where mutation happens before we process the node for the first time. -// Stroring original values of the properties that can were mutated allows us to -// re-construct the initial state of the node even after mutation has happened. -export interface INodePreUpdateInfo { - characterData?: string; - attributes?: IAttributes; +export interface HashData { + id: number; + hash: string; + selector?: string; } -export interface IShadowDomMutationSummary { - newNodes: IShadowDomNode[]; - movedNodes: IShadowDomNode[]; - removedNodes: IShadowDomNode[]; - updatedNodes: IShadowDomNode[]; +export interface ResourceData { + tag: string; + url: string; } diff --git a/types/performance.d.ts b/types/performance.d.ts deleted file mode 100644 index 414e2b88..00000000 --- a/types/performance.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -// We send back all properties from performance.timing object -export type IPerformanceTiming = PerformanceTiming; - -export interface IPerformanceTimingState { - timing: IPerformanceTiming; -} - -export interface IPerformanceResourceTimingState { - duration: number; - initiatorType: string; - startTime: number; - connectStart: number; - connectEnd: number; - requestStart: number; - responseStart: number; - responseEnd: number; - name: string; - transferSize?: number; - encodedBodySize?: number; - decodedBodySize?: number; - protocol?: string; -} diff --git a/types/pointer.d.ts b/types/pointer.d.ts deleted file mode 100644 index 53e3aea0..00000000 --- a/types/pointer.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { IEvent } from "./core"; - -export interface IPointerEvent extends IEvent { - state: IPointerState; -} - -export interface IPointerModule { - transform(evt: Event): IPointerState[]; -} - -/* Spec: https://www.w3.org/TR/pointerevents/#pointerevent-export interface */ -export interface IPointerState { - index: number; /* Pointer ID */ - event: string; /* Original event that is mapped to pointer event */ - pointer: string; /* pointerType: mouse, pen, touch */ - x: number; /* X-Axis */ - y: number; /* Y-Axis */ - width: number; - height: number; - pressure: number; - tiltX: number; - tiltY: number; - target: number; /* Layout index of the target element */ - buttons: number; -} diff --git a/types/viewport.d.ts b/types/viewport.d.ts deleted file mode 100644 index 2cc19b66..00000000 --- a/types/viewport.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { IEvent } from "./core"; - -export interface IViewportEvent extends IEvent { - state: IViewportState; -} - -export interface IViewportRectangle { - x: number; /* X coordinate of the element */ - y: number; /* Y coordinate of the element */ - width: number; /* Width of the element */ - height: number; /* Height of the element */ -} - -export interface IDocumentSize { - width: number; /* Document width */ - height: number; /* Document height */ -} - -export interface IViewportState { - viewport: IViewportRectangle; /* Viewport rectangle */ - document: IDocumentSize; /* Document size */ - dpi: number; /* DPI */ - visibility: string; /* Visibility state of the page */ - event: string; /* Source event */ -} diff --git a/webpack/configs/base.ts b/webpack/configs/base.ts index a70f2740..b9ff2735 100644 --- a/webpack/configs/base.ts +++ b/webpack/configs/base.ts @@ -6,8 +6,6 @@ import { TsConfigPathsPlugin } from "awesome-typescript-loader"; // https://webpack.js.org/configuration const CommongConfig: webpack.Configuration = { - entry: "./webpack/globalize.ts", - output: { path: `${__dirname}/../../build` }, diff --git a/webpack/configs/dev.ts b/webpack/configs/dev.ts index 790c774c..8ce5db2d 100644 --- a/webpack/configs/dev.ts +++ b/webpack/configs/dev.ts @@ -9,8 +9,13 @@ const DevConfig: webpack.Configuration = { mode: "development", + entry: { + clarity: "./webpack/globalize.ts", + decode: "./decode/clarity.ts" + }, + output: { - filename: "clarity.js" + filename: "[name].dev.js" }, devtool: "inline-source-map" diff --git a/webpack/configs/index.ts b/webpack/configs/index.ts index 8f508cb3..13c824cc 100644 --- a/webpack/configs/index.ts +++ b/webpack/configs/index.ts @@ -9,15 +9,23 @@ const IndexConfig: webpack.Configuration = { mode: "production", - entry: "./src/index.ts", + entry: { + clarity: "./src/index.ts", + decode: "./decode/clarity.ts" + }, output: { libraryTarget: "commonjs", - filename: "index.js" + filename: "[name].js", + path: `${__dirname}/../../` }, optimization: { - minimize: false + minimize: false, + splitChunks: { + // include all types of chunks + chunks: "all" + } } }; diff --git a/webpack/configs/prod.ts b/webpack/configs/prod.ts index 38cda54d..4cb87fe4 100644 --- a/webpack/configs/prod.ts +++ b/webpack/configs/prod.ts @@ -10,8 +10,14 @@ const ProdConfig: webpack.Configuration = { mode: "production", + entry: { + clarity: "./webpack/globalize.ts", + decode: "./decode/clarity.ts" + }, + output: { - filename: "clarity.min.js" + path: `${__dirname}/../../build`, + filename: "[name].min.js" }, optimization: {