diff --git a/src/ts/file-reader.ts b/src/ts/file-reader.ts index fa4fb5b1..6b93c1c2 100644 --- a/src/ts/file-reader.ts +++ b/src/ts/file-reader.ts @@ -8,7 +8,7 @@ zip.useWebWorkers = false; /** handle client side file upload */ export function readFile(file: File, fileName: string, - callback: (e: Error, har?: Har) => void, + callback: (e: Error | null, har?: Har) => void, onProgress?: (progress: number) => void) { if (!file) { return callback(new Error("Failed to load HAR file")); @@ -24,7 +24,7 @@ export function readFile(file: File, } /** start reading the file */ - const extension = fileName.match(/\.[0-9a-z]+$/i)[0]; + const extension = (fileName.match(/\.[0-9a-z]+$/i) || [])[0]; if ([".zhar", ".zip"].indexOf(extension) !== -1) { /** zhar */ zip.createReader(new zip.BlobReader(file), (zipReader) => { diff --git a/src/ts/helpers/dom.ts b/src/ts/helpers/dom.ts index abada835..81ed8c90 100644 --- a/src/ts/helpers/dom.ts +++ b/src/ts/helpers/dom.ts @@ -25,7 +25,7 @@ export function removeClass(el: T, className: string): T { classList.remove(className); } else { // IE doesn't support classList in SVG - el.setAttribute("class", el.getAttribute("class") + el.setAttribute("class", (el.getAttribute("class") || "") .replace(new RegExp("(\\s|^)" + className + "(\\s|$)", "g"), "$2")); } return el; @@ -44,9 +44,9 @@ export function getParentByClassName(base: Element, className: string) { if (base.classList.contains(className)) { return base; } - base = base.parentElement; + base = base.parentElement as Element; } - return undefined; + return null; } /** @@ -55,7 +55,7 @@ export function getParentByClassName(base: Element, className: string) { */ export function removeChildren(el: T): T { while (el.hasChildNodes()) { - el.removeChild(el.lastChild); + el.removeChild(el.lastChild as Element); } return el; } @@ -64,7 +64,7 @@ export function removeChildren(el: T): T { * Get last element of `NodeList` * @param list NodeListOf e.g. return value of `getElementsByClassName` */ -export function getLastItemOfNodeList(list: NodeListOf) { +export function getLastItemOfNodeList(list: NodeListOf | null) { if (!list || list.length === 0) { return undefined; } @@ -96,7 +96,7 @@ export function safeSetAttribute(el: HTMLElement | SVGElement, name: string, val console.warn(new Error(`Trying to set non-existing attribute ` + `${name} = ${value} on a <${el.tagName.toLowerCase()}>.`)); } - el.setAttributeNS(null, name, value); + el.setAttributeNS("", name, value); } /** Sets multiple CSS style properties, but only if property exists on `el` */ diff --git a/src/ts/helpers/misc.ts b/src/ts/helpers/misc.ts index 82243a34..4b3b8176 100644 --- a/src/ts/helpers/misc.ts +++ b/src/ts/helpers/misc.ts @@ -8,7 +8,7 @@ */ function parseUrl(url: string) { const pattern = RegExp("^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?"); - const matches = url.match(pattern); + const matches = url.match(pattern) || []; return { authority: matches[4], fragment: matches[9], diff --git a/src/ts/helpers/parse.ts b/src/ts/helpers/parse.ts index 3a98f6ea..d25f9573 100644 --- a/src/ts/helpers/parse.ts +++ b/src/ts/helpers/parse.ts @@ -1,6 +1,8 @@ import { ChartRenderOption } from "../typing/options"; import { roundNumber } from "./misc"; +export type MaybeStringOrNumber = string | number | undefined | null; + /** * Type safe and null safe way to transform, filter and format an input value, e.g. parse a Date from a string, * rejecting invalid dates, and formatting it as a localized string. If the input value is undefined, or the parseFn @@ -10,9 +12,9 @@ import { roundNumber } from "./misc"; * @param formatFn an optional function to format the parsed input value. * @returns {string} a formatted string representation of the input, or undefined. */ -export function parseAndFormat(input: S, +export function parseAndFormat(input: S | undefined, parseFn: ((_: S) => T), - formatFn: ((_: T) => string) = toString): string { + formatFn: ((_: T) => string | undefined) = toString): string | undefined { if (input === undefined) { return undefined; } @@ -27,15 +29,15 @@ function toString(source: T): string { if (typeof source["toString"] === "function") { return source.toString(); } else { - throw TypeError("Can't convert type ${typeof source} to string"); + throw TypeError(`Can't convert type ${typeof source} to string`); } } -export function parseNonEmpty(input: string): string { +export function parseNonEmpty(input: string): string | undefined { return input.trim().length > 0 ? input : undefined; } -export function parseDate(input: string): Date { +export function parseDate(input: string): Date | undefined { const date = new Date(input); if (isNaN(date.getTime())) { return undefined; @@ -43,17 +45,23 @@ export function parseDate(input: string): Date { return date; } -export function parseNonNegative(input: string | number): number { +export function parseNonNegative(input: MaybeStringOrNumber): number | undefined { + if (input === undefined || input === null) { + return undefined; + } const filter = (n) => (n >= 0); return parseToNumber(input, filter); } -export function parsePositive(input: string | number): number { +export function parsePositive(input: MaybeStringOrNumber): number | undefined { + if (input === undefined || input === null) { + return undefined; + } const filter = (n) => (n > 0); return parseToNumber(input, filter); } -function parseToNumber(input: string | number, filterFn: (_: number) => boolean): number { +function parseToNumber(input: string | number, filterFn: (_: number) => boolean): number | undefined { const filter = (n: number) => filterFn(n) ? n : undefined; if (typeof input === "string") { @@ -66,15 +74,19 @@ function parseToNumber(input: string | number, filterFn: (_: number) => boolean) return filter(input); } -export function formatMilliseconds(millis: number): string { - return `${roundNumber(millis, 3)} ms`; +export function formatMilliseconds(millis: number | undefined): string | undefined { + return (millis !== undefined) ? `${roundNumber(millis, 3)} ms` : undefined; } const secondsPerMinute = 60; const secondsPerHour = 60 * secondsPerMinute; const secondsPerDay = 24 * secondsPerHour; -export function formatSeconds(seconds: number): string { +export function formatSeconds(seconds: number | undefined): string | undefined { + if (seconds === undefined) { + return undefined; + } + const raw = `${roundNumber(seconds, 3)} s`; if (seconds > secondsPerDay) { return `${raw} (~${roundNumber(seconds / secondsPerDay, 0)} days)`; @@ -88,14 +100,17 @@ export function formatSeconds(seconds: number): string { return raw; } -export function formatDateLocalized(date: Date): string { - return `${date.toUTCString()}
(local time: ${date.toLocaleString()})`; +export function formatDateLocalized(date: Date | undefined): string | undefined { + return (date !== undefined) ? `${date.toUTCString()}
(local time: ${date.toLocaleString()})` : undefined; } const bytesPerKB = 1024; const bytesPerMB = 1024 * bytesPerKB; -export function formatBytes(bytes: number): string { +export function formatBytes(bytes: number | undefined): string { + if (bytes === undefined) { + return ""; + } const raw = `${bytes} bytes`; if (bytes >= bytesPerMB) { return `${raw} (~${roundNumber(bytes / bytesPerMB, 1)} MB)`; @@ -124,8 +139,8 @@ const htmlChars = new RegExp(Object.keys(htmlCharMap).join("|"), "g"); * Escapes unsafe characters in a string to render safely in HTML * @param {string} unsafe - string to be rendered in HTML */ -export function escapeHtml(unsafe: string | number | boolean = ""): string { - if (unsafe === null) { +export function escapeHtml(unsafe: MaybeStringOrNumber | boolean = ""): string { + if (unsafe === null || unsafe === undefined) { return ""; // See https://github.com/micmro/PerfCascade/issues/217 } if (typeof unsafe !== "string") { @@ -149,7 +164,7 @@ export function sanitizeUrlForLink(unsafeUrl: string) { if (cleaned.indexOf("http://") === 0 || cleaned.indexOf("https://") === 0) { return cleaned; } -// tslint:disable-next-line:no-console + // tslint:disable-next-line:no-console console.warn("skipped link, due to potentially unsafe url", unsafeUrl); return ""; } @@ -163,7 +178,7 @@ export function sanitizeAlphaNumeric(unsafe: string | number) { } /** Ensures `input` is casted to `number` */ -export function toInt(input: string | number): number { +export function toInt(input: MaybeStringOrNumber): number | undefined { if (typeof input === "number") { return input; } else if (typeof input === "string") { @@ -176,10 +191,11 @@ export function toInt(input: string | number): number { /** Validates the `ChartOptions` attributes types */ export function validateOptions(options: ChartRenderOption): ChartRenderOption { const validateInt = (name: keyof ChartRenderOption) => { - options[name] = toInt(options[name] as any); - if (options[name] === undefined) { + const val = toInt(options[name] as any); + if (val === undefined) { throw TypeError(`option "${name}" needs to be a number`); } + options[name] = val; }; const ensureBoolean = (name: keyof ChartRenderOption) => { options[name] = !!options[name]; diff --git a/src/ts/helpers/svg.ts b/src/ts/helpers/svg.ts index 96cec259..9bd08a38 100644 --- a/src/ts/helpers/svg.ts +++ b/src/ts/helpers/svg.ts @@ -122,7 +122,7 @@ const getTestSVGEl = (() => { // debounced time-deleayed cleanup, so the element can be re-used in tight loops clearTimeout(removeSvgTestElTimeout); removeSvgTestElTimeout = setTimeout(() => { - svgTestEl.parentNode.removeChild(svgTestEl); + (svgTestEl.parentNode as Node).removeChild(svgTestEl); }, 500); return svgTestEl; @@ -136,7 +136,7 @@ const getTestSVGEl = (() => { * @returns number */ export function getNodeTextWidth(textNode: SVGTextElement, skipClone: boolean = false): number { - if (textNode.textContent.length === 0) { + if ((textNode.textContent || "").length === 0) { return 0; } const tmp = getTestSVGEl(); diff --git a/src/ts/main.ts b/src/ts/main.ts index 61771f8f..2d293af1 100755 --- a/src/ts/main.ts +++ b/src/ts/main.ts @@ -44,7 +44,7 @@ function PerfCascade(waterfallDocsData: WaterfallDocs, chartOptions: Partial { - const el = doc.parentElement; + const el = doc.parentElement as HTMLElement; const newDoc = createWaterfallSvg(pageDoc, options); el.replaceChild(newDoc, doc); doc = newDoc; diff --git a/src/ts/paging/paging.ts b/src/ts/paging/paging.ts index 1815669e..252fec4a 100644 --- a/src/ts/paging/paging.ts +++ b/src/ts/paging/paging.ts @@ -62,7 +62,7 @@ export default class Paging { * @param {OnPagingCb} cb * @returns number - index of the callback */ - public onPageUpdate(cb: OnPagingCb): number { + public onPageUpdate(cb: OnPagingCb): number | undefined { if (this.getPageCount() > 1) { return this.onPageUpdateCbs.push(cb); } diff --git a/src/ts/transformers/extract-details-keys.ts b/src/ts/transformers/extract-details-keys.ts index 6983c0b1..97bd66f3 100644 --- a/src/ts/transformers/extract-details-keys.ts +++ b/src/ts/transformers/extract-details-keys.ts @@ -5,19 +5,20 @@ import { formatDateLocalized, formatMilliseconds, formatSeconds, + MaybeStringOrNumber, parseAndFormat, parseDate, parseNonEmpty, parseNonNegative, parsePositive, } from "../helpers/parse"; -import { KvTuple } from "../typing/waterfall"; +import { KvTuple, SafeKvTuple } from "../typing/waterfall"; import { flattenKvTuple } from "./helpers"; -const byteSizeProperty = (title: string, input: string |  number): KvTuple => { +const byteSizeProperty = (title: string, input: MaybeStringOrNumber): KvTuple => { return [title, parseAndFormat(input, parsePositive, formatBytes)]; }; -const countProperty = (title: string, input: string |  number): KvTuple => { +const countProperty = (title: string, input: MaybeStringOrNumber): KvTuple => { return [title, parseAndFormat(input, parsePositive)]; }; @@ -26,7 +27,7 @@ const notEmpty = (kv: KvTuple) => { return kv.length > 1 && kv[1] !== undefined && kv[1] !== ""; }; -function parseGeneralDetails(entry: Entry, startRelative: number, requestID: number): KvTuple[] { +function parseGeneralDetails(entry: Entry, startRelative: number, requestID: number): SafeKvTuple[] { return ([ ["Request Number", `#${requestID}`], ["Started", new Date(entry.startedDateTime).toLocaleString() + ((startRelative > 0) ? @@ -55,10 +56,10 @@ function parseGeneralDetails(entry: Entry, startRelative: number, requestID: num byteSizeProperty("Minify Save", entry._minify_save), byteSizeProperty("Image Total", entry._image_total), byteSizeProperty("Image Save", entry._image_save), - ] as KvTuple[]).filter(notEmpty); + ] as KvTuple[]).filter(notEmpty) as SafeKvTuple[]; } -function parseRequestDetails(harEntry: Entry): KvTuple[] { +function parseRequestDetails(harEntry: Entry): SafeKvTuple[] { const request = harEntry.request; const stringHeader = (name: string): KvTuple[] => getHeaders(request.headers, name); @@ -81,10 +82,10 @@ function parseRequestDetails(harEntry: Entry): KvTuple[] { stringHeader("If-Unmodified-Since"), countProperty("Querystring parameters count", request.queryString.length), countProperty("Cookies count", request.cookies.length), - ]).filter(notEmpty); + ]).filter(notEmpty) as SafeKvTuple[]; } -function parseResponseDetails(entry: Entry): KvTuple[] { +function parseResponseDetails(entry: Entry): SafeKvTuple[] { const response = entry.response; const content = response.content; const headers = response.headers; @@ -138,20 +139,20 @@ function parseResponseDetails(entry: Entry): KvTuple[] { stringHeader("Timing-Allow-Origin"), ["Redirect URL", parseAndFormat(response.redirectURL, parseNonEmpty)], ["Comment", parseAndFormat(response.comment, parseNonEmpty)], - ]).filter(notEmpty); + ]).filter(notEmpty) as SafeKvTuple[]; } -function parseTimings(entry: Entry, start: number, end: number): KvTuple[] { +function parseTimings(entry: Entry, start: number, end: number): SafeKvTuple[] { const timings = entry.timings; const optionalTiming = (timing?: number) => parseAndFormat(timing, parseNonNegative, formatMilliseconds); const total = (typeof start !== "number" || typeof end !== "number") ? undefined : (end - start); let connectVal = optionalTiming(timings.connect); - if (timings.ssl > 0) { + if (timings.ssl && timings.ssl > 0 && timings.connect) { // SSL time is also included in the connect field (to ensure backward compatibility with HAR 1.1). connectVal = `${connectVal} (without TLS: ${optionalTiming(timings.connect - timings.ssl)})`; } - return [ + return ([ ["Total", formatMilliseconds(total)], ["Blocked", optionalTiming(timings.blocked)], ["DNS", optionalTiming(timings.dns)], @@ -160,7 +161,7 @@ function parseTimings(entry: Entry, start: number, end: number): KvTuple[] { ["Send", formatMilliseconds(timings.send)], ["Wait", formatMilliseconds(timings.wait)], ["Receive", formatMilliseconds(timings.receive)], - ]; + ] as KvTuple[]).filter(notEmpty) as SafeKvTuple[]; } /** @@ -176,9 +177,9 @@ export function getKeys(entry: Entry, requestID: number, startRelative: number, return { general: parseGeneralDetails(entry, startRelative, requestID), request: parseRequestDetails(entry), - requestHeaders: requestHeaders.map(headerToKvTuple), + requestHeaders: requestHeaders.map(headerToKvTuple).filter(notEmpty) as SafeKvTuple[], response: parseResponseDetails(entry), - responseHeaders: responseHeaders.map(headerToKvTuple), + responseHeaders: responseHeaders.map(headerToKvTuple).filter(notEmpty) as SafeKvTuple[], timings: parseTimings(entry, startRelative, endRelative), }; } diff --git a/src/ts/transformers/har-heuristics.ts b/src/ts/transformers/har-heuristics.ts index 66784269..49e2a6ae 100644 --- a/src/ts/transformers/har-heuristics.ts +++ b/src/ts/transformers/har-heuristics.ts @@ -62,6 +62,9 @@ function isSecure(entry: Entry) { } function isPush(entry: Entry): boolean { + if (entry._was_pushed === undefined || entry._was_pushed === null) { + return false; + } function toInt(input: string | number): number { if (typeof input === "string") { return parseInt(input, 10); diff --git a/src/ts/transformers/har-tabs.ts b/src/ts/transformers/har-tabs.ts index 326eff42..67080103 100644 --- a/src/ts/transformers/har-tabs.ts +++ b/src/ts/transformers/har-tabs.ts @@ -2,8 +2,8 @@ import { Entry } from "har-format"; import { pluralize } from "../helpers/misc"; import { escapeHtml, sanitizeUrlForLink } from "../helpers/parse"; import { - KvTuple, RequestType, + SafeKvTuple, TabRenderer, WaterfallEntryIndicator, WaterfallEntryTab, @@ -59,7 +59,7 @@ function makeLazyWaterfallEntryTab(title: string, renderContent: TabRenderer, } /** General tab with warnings etc. */ -function makeGeneralTab(generalData: KvTuple[], indicators: WaterfallEntryIndicator[]): WaterfallEntryTab { +function makeGeneralTab(generalData: SafeKvTuple[], indicators: WaterfallEntryIndicator[]): WaterfallEntryTab { const mainContent = makeDefinitionList(generalData); if (indicators.length === 0) { return makeWaterfallEntryTab("General", mainContent); @@ -70,14 +70,14 @@ function makeGeneralTab(generalData: KvTuple[], indicators: WaterfallEntryIndica // Make indicator sections const errors = indicators .filter((i) => i.type === "error") - .map((i) => [i.title, i.description] as KvTuple); + .map((i) => [i.title, i.description] as SafeKvTuple); const warnings = indicators .filter((i) => i.type === "warning") - .map((i) => [i.title, i.description] as KvTuple); + .map((i) => [i.title, i.description] as SafeKvTuple); // all others const info = indicators .filter((i) => i.type !== "error" && i.type !== "warning") - .map((i) => [i.title, i.description] as KvTuple); + .map((i) => [i.title, i.description] as SafeKvTuple); if (errors.length > 0) { content += `

${pluralize("Error", errors.length)}

@@ -95,7 +95,7 @@ function makeGeneralTab(generalData: KvTuple[], indicators: WaterfallEntryIndica return makeWaterfallEntryTab("General", content + general); } -function makeRequestTab(request: KvTuple[], requestHeaders: KvTuple[]): WaterfallEntryTab { +function makeRequestTab(request: SafeKvTuple[], requestHeaders: SafeKvTuple[]): WaterfallEntryTab { const content = `
${makeDefinitionList(request)}
@@ -106,7 +106,7 @@ function makeRequestTab(request: KvTuple[], requestHeaders: KvTuple[]): Waterfal return makeWaterfallEntryTab("Request", content); } -function makeResponseTab(respose: KvTuple[], responseHeaders: KvTuple[]): WaterfallEntryTab { +function makeResponseTab(respose: SafeKvTuple[], responseHeaders: SafeKvTuple[]): WaterfallEntryTab { const content = `
${makeDefinitionList(respose)}
diff --git a/src/ts/transformers/har.ts b/src/ts/transformers/har.ts index e0e11537..254c0a95 100644 --- a/src/ts/transformers/har.ts +++ b/src/ts/transformers/har.ts @@ -40,7 +40,7 @@ export function transformDoc(harData: Har | Log, options: HarTransformerOptions) const pages = getPages(data); return { - pages: pages.map((_page, i) => this.transformPage(data, i, options)), + pages: pages.map((_page, i) => transformPage(data, i, options)), }; } @@ -153,10 +153,13 @@ export function transformPage(harData: Har | Log, * @param {Page} currPage - active page * @param {ChartOptions} options - HAR options */ -const getMarks = (pageTimings: PageTiming, currPage: Page, options: ChartOptions) => { +const getMarks = (pageTimings: PageTiming, currPage: Page, options: ChartOptions): UserTiming[] => { + if (pageTimings === undefined) { + return []; + } const sortFn = (a: Mark, b: Mark) => a.startTime - b.startTime; const marks = Object.keys(pageTimings) - .filter((k: keyof PageTiming) => (typeof pageTimings[k] === "number" && pageTimings[k] >= 0)) + .filter((k) => (typeof pageTimings[k] === "number" && pageTimings[k] >= 0)) .map((k) => ({ name: `${escapeHtml(k.replace(/^[_]/, ""))} (${roundNumber(pageTimings[k], 0)} ms)`, startTime: pageTimings[k], @@ -193,10 +196,10 @@ const getUserTimimngs = (currPage: Page, options: ChartOptions) => { const findName = /^_userTime\.((?:startTimer-)?(.+))$/; const extractUserTiming = (k: string) => { - let name: string; - let fullName: string; + let name: string | undefined; + let fullName: string | undefined; let duration: number; - [, fullName, name] = findName.exec(k); + [, fullName, name] = findName.exec(k) || [, undefined, undefined]; fullName = escapeHtml(fullName); name = escapeHtml(name); @@ -230,9 +233,8 @@ const getUserTimimngs = (currPage: Page, options: ChartOptions) => { */ const buildDetailTimingBlocks = (startRelative: number, harEntry: Entry): WaterfallEntryTiming[] => { const t = harEntry.timings; - return ["blocked", "dns", "connect", "send", "wait", "receive"].reduce((collect: WaterfallEntryTiming[], - key: TimingType) => { - + const types: TimingType[] = ["blocked", "dns", "connect", "send", "wait", "receive"]; + return types.reduce((collect: WaterfallEntryTiming[], key: TimingType) => { const time = getTimePair(key, harEntry, collect, startRelative); if (time.end && time.start >= time.end) { @@ -241,10 +243,10 @@ const buildDetailTimingBlocks = (startRelative: number, harEntry: Entry): Waterf // special case for 'connect' && 'ssl' since they share time // http://www.softwareishard.com/blog/har-12-spec/#timings - if (key === "connect" && t["ssl"] && t["ssl"] !== -1) { - const sslStart = parseInt(harEntry[`_ssl_start`].toString(), 10) || time.start; - const sslEnd = parseInt(harEntry[`_ssl_end`].toString(), 10) || time.start + t.ssl; - const connectStart = (!!parseInt(harEntry[`_ssl_start`].toString(), 10)) ? time.start : sslEnd; + if (key === "connect" && t.ssl && t.ssl !== -1) { + const sslStart = parseInt(`${harEntry[`_ssl_start`]}`, 10) || time.start; + const sslEnd = parseInt(`${harEntry[`_ssl_end`]}`, 10) || time.start + t.ssl; + const connectStart = (!!parseInt(`${harEntry[`_ssl_start`]}`, 10)) ? time.start : sslEnd; return collect .concat([createWaterfallEntryTiming("ssl", Math.round(sslStart), Math.round(sslEnd))]) .concat([createWaterfallEntryTiming(key, Math.round(connectStart), Math.round(time.end))]); @@ -291,7 +293,7 @@ const getTimePair = (key: string, harEntry: Entry, collect: WaterfallEntryTiming */ const createResponseDetails = (entry: Entry, indicators: WaterfallEntryIndicator[]): WaterfallResponseDetails => { const requestType = mimeToRequestType(entry.response.content.mimeType); - const statusClean = toInt(entry.response.status); + const statusClean = toInt(entry.response.status) || 0; return { icon: makeMimeTypeIcon(statusClean, entry.response.statusText, requestType, entry.response.redirectURL), indicators, diff --git a/src/ts/transformers/helpers.ts b/src/ts/transformers/helpers.ts index 7253cd16..1908b64b 100644 --- a/src/ts/transformers/helpers.ts +++ b/src/ts/transformers/helpers.ts @@ -1,7 +1,7 @@ /** Helpers that are not file-fromat specific */ import { isInStatusCodeRange, toCssClass } from "../helpers/misc"; import { escapeHtml, sanitizeAlphaNumeric } from "../helpers/parse"; -import { RequestType } from "../typing/waterfall"; +import { RequestType, SafeKvTuple } from "../typing/waterfall"; import { Icon, KvTuple, @@ -18,12 +18,12 @@ const escapeHtmlLight = (str: string) => escapeHtml(str).replace("<br/>", "< /** * Converts `dlKeyValues` to the contennd a definition list, without the outer `
` tags - * @param {KvTuple[]} dlKeyValues array of Key/Value pair + * @param {SafeKvTuple[]} dlKeyValues array of Key/Value pair * @param {boolean} [addClass=false] if `true` the key in `dlKeyValues` * is converted to a class name andd added to the `
` * @returns {string} stringified HTML definition list */ -export function makeDefinitionList(dlKeyValues: KvTuple[], addClass: boolean = false) { +export function makeDefinitionList(dlKeyValues: SafeKvTuple[], addClass: boolean = false) { const makeClass = (key: string) => { if (!addClass) { return ""; @@ -32,7 +32,6 @@ export function makeDefinitionList(dlKeyValues: KvTuple[], addClass: boolean = f return `class="${className}"`; }; return dlKeyValues - .filter((tuple: KvTuple) => tuple[1] !== undefined) .map((tuple) => `
${escapeHtmlLight(tuple[0])}
${escapeHtmlLight(tuple[1])}
@@ -92,7 +91,7 @@ export function createWaterfallEntry(url: string, segments: WaterfallEntryTiming[] = [], responseDetails: WaterfallResponseDetails, tabs: WaterfallEntryTab[]): WaterfallEntry { - const total = (typeof start !== "number" || typeof end !== "number") ? undefined : (end - start); + const total = (typeof start !== "number" || typeof end !== "number") ? NaN : (end - start); return { end, responseDetails, @@ -108,7 +107,7 @@ export function createWaterfallEntry(url: string, export function createWaterfallEntryTiming(type: TimingType, start: number, end: number): WaterfallEntryTiming { - const total = (typeof start !== "number" || typeof end !== "number") ? undefined : (end - start); + const total = (typeof start !== "number" || typeof end !== "number") ? NaN : (end - start); const typeClean = sanitizeAlphaNumeric(type) as TimingType; return { end, diff --git a/src/ts/typing/context.ts b/src/ts/typing/context.ts index 960baa44..10304011 100644 --- a/src/ts/typing/context.ts +++ b/src/ts/typing/context.ts @@ -6,12 +6,15 @@ import { ChartRenderOption } from "./options"; * Context object that is passed to (usually stateless) child-functions * to inject state and dependencies */ -export interface Context { - /** Publish and Subscribe instance for overlay updates */ - pubSub: PubSub; +export interface Context extends ContextCore { /** Overlay (popup) instance manager */ overlayManager: OverlayManager; - /** horizontal unit (duration in ms of 1%) */ +} + +export interface ContextCore { + /** Publish and Subscribe instance for overlay updates */ + pubSub: PubSub; + /** horizontal unit (duration in ms of 1%) */ unit: number; /** height of the requests part of the diagram in px */ diagramHeight: number; diff --git a/src/ts/typing/waterfall.ts b/src/ts/typing/waterfall.ts index 8ae47243..aca1d5c0 100644 --- a/src/ts/typing/waterfall.ts +++ b/src/ts/typing/waterfall.ts @@ -122,4 +122,6 @@ export interface Icon { } /** Key/Value pair in array `["key", "value"]` */ -export type KvTuple = [string, string]; +export type KvTuple = [string, string | undefined]; +/** Key/Value pair in array `["key", "value"]` that promises to have both strings defined */ +export type SafeKvTuple = [string, string]; diff --git a/src/ts/waterfall/details-overlay/overlay-manager.ts b/src/ts/waterfall/details-overlay/overlay-manager.ts index 2d50be86..7b6a92de 100644 --- a/src/ts/waterfall/details-overlay/overlay-manager.ts +++ b/src/ts/waterfall/details-overlay/overlay-manager.ts @@ -8,7 +8,7 @@ import { isTabDown, isTabUp, } from "../../helpers/misc"; -import { Context } from "../../typing/context"; +import { ContextCore } from "../../typing/context"; import { OpenOverlay, OverlayChangeEvent } from "../../typing/open-overlay"; import { WaterfallEntry } from "../../typing/waterfall"; import { createRowInfoOverlay } from "./svg-details-overlay"; @@ -48,12 +48,12 @@ class OverlayManager { /** Collection of currely open overlays */ private openOverlays: OpenOverlay[] = []; - constructor(private context: Context) { + constructor(private context: ContextCore) { } /** all open overlays height combined */ public getCombinedOverlayHeight(): number { - return this.openOverlays.reduce((pre, curr) => pre + curr.height, 0); + return this.openOverlays.reduce((pre, curr) => pre + (curr.height || 0), 0); } /** @@ -148,34 +148,35 @@ class OverlayManager { if (previewImg && !previewImg.src) { previewImg.setAttribute("src", previewImg.attributes.getNamedItem("data-src").value); } - infoOverlay.querySelector("a") + (infoOverlay.querySelector("a") as HTMLAnchorElement) .addEventListener("keydown", OverlayManager.firstElKeypress); - getLastItemOfNodeList(infoOverlay.querySelectorAll("button")) + (getLastItemOfNodeList(infoOverlay.querySelectorAll("button")) as HTMLButtonElement) .addEventListener("keydown", OverlayManager.lastElKeypress); overlayHolder.appendChild(infoOverlay); updateHeight(overlay, y, infoOverlay.getBoundingClientRect().height); }; const updateRow = (rowItem: SVGAElement, index: number) => { const overlay = find(this.openOverlays, (o) => o.index === index); - const overlayEl = rowItem.nextElementSibling.firstElementChild as SVGGElement; + const nextRowItem = rowItem.nextElementSibling as Element; + const overlayEl = nextRowItem.firstElementChild as SVGGElement; this.realignRow(rowItem, currY); if (overlay === undefined) { - if (overlayEl) { + if (overlayEl && nextRowItem !== null) { // remove closed overlay - rowItem.nextElementSibling.querySelector("a") + (nextRowItem.querySelector("a") as HTMLAnchorElement) .removeEventListener("keydown", OverlayManager.firstElKeypress); - getLastItemOfNodeList(rowItem.nextElementSibling.querySelectorAll("button")) + (getLastItemOfNodeList(nextRowItem.querySelectorAll("button")) as HTMLButtonElement) .removeEventListener("keydown", OverlayManager.lastElKeypress); - removeChildren(rowItem.nextElementSibling); + removeChildren(nextRowItem); } return; // not open } - if (overlayEl) { - const bg = overlayEl.querySelector(".info-overlay-bg"); - const fo = overlayEl.querySelector("foreignObject"); - const btnRect = overlayEl.querySelector(".info-overlay-close-btn rect"); - const btnText = overlayEl.querySelector(".info-overlay-close-btn text"); + if (overlayEl && overlay.actualY !== undefined) { + const bg = overlayEl.querySelector(".info-overlay-bg") as SVGElement; + const fo = overlayEl.querySelector("foreignObject") as SVGForeignObjectElement; + const btnRect = overlayEl.querySelector(".info-overlay-close-btn rect") as SVGRectElement; + const btnText = overlayEl.querySelector(".info-overlay-close-btn text") as SVGTextElement; updateHeight(overlay, overlay.defaultY + currY, overlay.height); // needs updateHeight bg.setAttribute("y", overlay.actualY.toString()); diff --git a/src/ts/waterfall/details-overlay/svg-details-overlay.ts b/src/ts/waterfall/details-overlay/svg-details-overlay.ts index a4ac78a9..2a7222c7 100644 --- a/src/ts/waterfall/details-overlay/svg-details-overlay.ts +++ b/src/ts/waterfall/details-overlay/svg-details-overlay.ts @@ -58,7 +58,7 @@ export function createRowInfoOverlay(overlay: OpenOverlay, y: number, detailsHei const buttons = body.getElementsByClassName("tab-button") as NodeListOf; const tabs = body.getElementsByClassName("tab") as NodeListOf; - const setTabStatus = (tabIndex: number) => { + const setTabStatus = (tabIndex: number | undefined) => { overlay.openTabIndex = tabIndex; forEachNodeList(tabs, (tab: HTMLDivElement, j) => { tab.style.display = (tabIndex === j) ? "block" : "none"; diff --git a/src/ts/waterfall/row/svg-indicators.ts b/src/ts/waterfall/row/svg-indicators.ts index b0129e08..50e3dfd8 100644 --- a/src/ts/waterfall/row/svg-indicators.ts +++ b/src/ts/waterfall/row/svg-indicators.ts @@ -24,7 +24,7 @@ export function getIndicatorIcons(entry: WaterfallEntry): Icon[] { return []; } - const combinedTitle = []; + const combinedTitle: string[] = []; let icon = ""; const errors = indicators.filter((i) => i.type === "error"); const warnings = indicators.filter((i) => i.type === "warning"); diff --git a/src/ts/waterfall/row/svg-row-subcomponents.ts b/src/ts/waterfall/row/svg-row-subcomponents.ts index 867b0c30..745b7e3e 100644 --- a/src/ts/waterfall/row/svg-row-subcomponents.ts +++ b/src/ts/waterfall/row/svg-row-subcomponents.ts @@ -30,16 +30,16 @@ function makeBlock(rectData: RectData, className: string) { }, className); holder.appendChild(rect); if (rectData.label) { - let showDelayTimeOut: number; - let foreignElLazy: SVGForeignObjectElement; + let showDelayTimeOut: number | null; + let foreignElLazy: SVGForeignObjectElement | null; rect.addEventListener("mouseenter", () => { if (!foreignElLazy) { - foreignElLazy = getParentByClassName(rect, "water-fall-chart") + foreignElLazy = (getParentByClassName(rect, "water-fall-chart") as Element) .querySelector(".tooltip") as SVGForeignObjectElement; } showDelayTimeOut = setTimeout(() => { showDelayTimeOut = null; - onHoverInShowTooltip(rect, rectData, foreignElLazy); + onHoverInShowTooltip(rect, rectData, foreignElLazy as SVGForeignObjectElement); }, 100); }); rect.addEventListener("mouseleave", () => { @@ -64,13 +64,13 @@ function makeBlock(rectData: RectData, className: string) { * @returns RectData */ function segmentToRectData(segment: WaterfallEntryTiming, rectData: RectData): RectData { + const total = (!isNaN(segment.total)) ? `
total: ${Math.round(segment.total)}ms` : ""; return { cssClass: timingTypeToCssClass(segment.type), height: (rectData.height - 6), hideOverlay: rectData.hideOverlay, label: `${segment.type}
` + - `${Math.round(segment.start)}ms - ${Math.round(segment.end)}ms
` + - `total: ${Math.round(segment.total)}ms`, + `${Math.round(segment.start)}ms - ${Math.round(segment.end)}ms${total}`, showOverlay: rectData.showOverlay, unit: rectData.unit, width: segment.total, @@ -121,7 +121,7 @@ export function createRect(rectData: RectData, segments: WaterfallEntryTiming[], if (segments && segments.length > 0) { segments.forEach((segment) => { - if (segment.total > 0 && typeof segment.start === "number") { + if (!isNaN(segment.total) && segment.total > 0 && typeof segment.start === "number") { const childRectData = segmentToRectData(segment, rectData); const childRect = makeBlock(childRectData, `segment ${childRectData.cssClass}`); firstX = Math.min(firstX, childRectData.x); @@ -227,7 +227,7 @@ export function appendRequestLabels(rowFixed: SVGGElement, requestNumberLabel: S /** the size adjustment only needs to happend once, this var keeps track of that */ let isAdjusted = false; /** store AnimationFrame id, to cancel it if hovering was too fast */ - let updateAnimFrame: number; + let updateAnimFrame: number | undefined; rowFixed.addEventListener("mouseenter", () => { fullLabel.style.display = "block"; shortLabel.style.display = "none"; diff --git a/src/ts/waterfall/row/svg-row.ts b/src/ts/waterfall/row/svg-row.ts index 20491d72..c6f071fc 100644 --- a/src/ts/waterfall/row/svg-row.ts +++ b/src/ts/waterfall/row/svg-row.ts @@ -35,7 +35,7 @@ export function createRow(context: Context, index: number, const y = rectData.y; const rowHeight = rectData.height; const leftColumnWith = context.options.leftColumnWith; - const rowItem = svg.newA(entry.responseDetails.rowClass); + const rowItem = svg.newA(entry.responseDetails.rowClass || "" as never); rowItem.setAttribute("tabindex", "0"); rowItem.setAttribute("xlink:href", "javascript:void(0)"); const leftFixedHolder = svg.newSvg("left-fixed-holder", { @@ -101,20 +101,30 @@ export function createRow(context: Context, index: number, evt.preventDefault(); onDetailsOverlayShow(evt); }); - rowItem.addEventListener("keydown", (evt: KeyboardEvent) => { + rowItem.addEventListener("keydown", (evt) => { + const e = evt as KeyboardEvent; // need to type this manually // space on enter - if (evt.which === 32 || evt.which === 13) { - evt.preventDefault(); - return onDetailsOverlayShow(evt); + if (e.which === 32 || e.which === 13) { + e.preventDefault(); + return onDetailsOverlayShow(e); } // tab without open overlays around - if (isTabUp(evt) && !hasPrevOpenOverlay && index > 0) { - rowItem.previousSibling.previousSibling.lastChild.lastChild.dispatchEvent(new MouseEvent("mouseenter")); + if (isTabUp(e) && !hasPrevOpenOverlay && index > 0) { + if (rowItem.previousSibling && + rowItem.previousSibling.previousSibling && + rowItem.previousSibling.previousSibling.lastChild && + rowItem.previousSibling.previousSibling.lastChild.lastChild) { + rowItem.previousSibling.previousSibling.lastChild.lastChild.dispatchEvent(new MouseEvent("mouseenter")); + } return; } - if (isTabDown(evt) && !hasOpenOverlay) { - if (rowItem.nextSibling && rowItem.nextSibling.nextSibling) { + if (isTabDown(e) && !hasOpenOverlay) { + if (rowItem.nextSibling && + rowItem.nextSibling.nextSibling && + rowItem.nextSibling.nextSibling.lastChild && + rowItem.nextSibling.nextSibling.lastChild.lastChild + ) { rowItem.nextSibling.nextSibling.lastChild.lastChild.dispatchEvent(new MouseEvent("mouseenter")); } return; diff --git a/src/ts/waterfall/row/svg-tooltip.ts b/src/ts/waterfall/row/svg-tooltip.ts index bdcbc085..07cbcd42 100644 --- a/src/ts/waterfall/row/svg-tooltip.ts +++ b/src/ts/waterfall/row/svg-tooltip.ts @@ -12,7 +12,8 @@ import { RectData } from "../../typing/rect-data"; const translateYRegEx = /(?:translate)\(.+[, ]+(.+)\)/; const tooltipMaxWidth = 200; -const getTranslateY = (str: string = "") => { +const getTranslateY = (str: string | null = "") => { + str = (str === null) ? "" : str; const res = translateYRegEx.exec(str); if (res && res.length >= 2) { return parseInt(res[1], 10); @@ -26,9 +27,9 @@ export const onHoverInShowTooltip = (base: SVGRectElement, rectData: RectData, f const row = getParentByClassName(base, "row-item") as SVGAElement; const yTransformOffsest = getTranslateY(row.getAttribute("transform")); /** Base Y */ - const yInt = parseInt(base.getAttribute("y"), 10); + const yInt = parseInt(base.getAttribute("y") || "", 10); /** Base X */ - const x = base.getAttribute("x"); + const x = base.getAttribute("x") || ""; /** X Positon of parent in Percent */ const xPercInt = parseFloat(x); let offsetY = 50; @@ -38,7 +39,7 @@ export const onHoverInShowTooltip = (base: SVGRectElement, rectData: RectData, f const pxPerPerc = rowWidthPx / (rectData.width / rectData.unit); const percPerPx = (rectData.width / rectData.unit) / rowWidthPx; const isLeftOfRow = xPercInt > 50 && ((95 - xPercInt) * pxPerPerc < tooltipMaxWidth); - innerDiv.innerHTML = rectData.label; + innerDiv.innerHTML = rectData.label || ""; // Disable animation for size-gathering addClass(innerDiv, "no-anim"); foreignEl.style.display = "block"; @@ -54,7 +55,7 @@ export const onHoverInShowTooltip = (base: SVGRectElement, rectData: RectData, f } if (isLeftOfRow) { const newLeft = xPercInt - ((innerDiv.clientWidth + 5) * percPerPx); - let leftOffset = parseInt(foreignEl.querySelector("body").style.left, 10); + let leftOffset = parseInt((foreignEl.querySelector("body") as HTMLBodyElement).style.left || "", 10); const ratio = 1 / (1 / 100 * (100 - leftOffset)); leftOffset = ratio * leftOffset; if (newLeft > -leftOffset) { // tooltip still visible diff --git a/src/ts/waterfall/sub-components/svg-marks.ts b/src/ts/waterfall/sub-components/svg-marks.ts index b8338d21..39765314 100644 --- a/src/ts/waterfall/sub-components/svg-marks.ts +++ b/src/ts/waterfall/sub-components/svg-marks.ts @@ -35,7 +35,7 @@ export function createMarks(context: Context, marks: Mark[]) { const lastMark = marks[i - 1]; const minDistance = 2.5; // minimum distance between marks - if (lastMark && mark.x - lastMark.x < minDistance) { + if (lastMark && lastMark.x !== undefined && mark.x - lastMark.x < minDistance) { lineLabel.setAttribute("x", lastMark.x + minDistance + "%"); mark.x = lastMark.x + minDistance; } @@ -72,7 +72,7 @@ export function createMarks(context: Context, marks: Mark[]) { const onLabelMouseEnter = () => { if (!isHoverActive) { // move marker to top - markHolder.parentNode.appendChild(markHolder); + (markHolder.parentNode as SVGElement).appendChild(markHolder); isHoverActive = true; // assign class later to not break animation with DOM re-order if (typeof window.requestAnimationFrame === "function") { diff --git a/src/ts/waterfall/svg-chart.ts b/src/ts/waterfall/svg-chart.ts index f5ecf6ff..c04d0e9d 100644 --- a/src/ts/waterfall/svg-chart.ts +++ b/src/ts/waterfall/svg-chart.ts @@ -1,6 +1,6 @@ import * as svg from "../helpers/svg"; import { requestTypeToCssClass } from "../transformers/styling-converters"; -import { Context } from "../typing/context"; +import { Context, ContextCore } from "../typing/context"; import { ChartRenderOption } from "../typing/options"; import { RectData } from "../typing/rect-data"; import { HoverEvtListeners } from "../typing/svg-alignment-helpers"; @@ -55,17 +55,17 @@ function createContext(data: WaterfallData, options: ChartRenderOption, entriesToShow: WaterfallEntry[]): Context { const unit = data.durationMs / 100; const diagramHeight = (entriesToShow.length + 1) * options.rowHeight; - const context = { + const context: ContextCore = { diagramHeight, options, - overlayManager: undefined, - pubSub : new PubSub(), + pubSub: new PubSub(), unit, }; // `overlayManager` needs the `context` reference, so it's attached later - context.overlayManager = new OverlayManager(context); - - return context; + return { + ...context, + overlayManager: new OverlayManager(context), + }; } /** @@ -103,7 +103,7 @@ export function createWaterfallSvg(data: WaterfallData, options: ChartRenderOpti }); /** Holder for on-hover vertical comparison bars */ - let hoverOverlayHolder: SVGGElement; + let hoverOverlayHolder: SVGGElement | undefined; let mouseListeners: HoverEvtListeners; if (options.showAlignmentHelpers) { hoverOverlayHolder = svg.newG("hover-overlays"); @@ -162,7 +162,7 @@ export function createWaterfallSvg(data: WaterfallData, options: ChartRenderOpti hideOverlay: options.showAlignmentHelpers ? mouseListeners.onMouseLeavePartial : undefined, label: `${entry.url}
` + `${Math.round(entry.start)}ms - ${Math.round(entry.end)}ms
` + - `total: ${Math.round(entry.total)}ms`, + `total: ${isNaN(entry.total) ? "n/a " : Math.round(entry.total)}ms`, showOverlay: options.showAlignmentHelpers ? mouseListeners.onMouseEnterPartial : undefined, unit: context.unit, width: entryWidth, @@ -184,7 +184,7 @@ export function createWaterfallSvg(data: WaterfallData, options: ChartRenderOpti // Main loop to render rows with blocks entriesToShow.forEach(renderRow); - if (options.showAlignmentHelpers) { + if (options.showAlignmentHelpers && hoverOverlayHolder !== undefined) { scaleAndMarksHolder.appendChild(hoverOverlayHolder); } timeLineHolder.appendChild(scaleAndMarksHolder); diff --git a/tsconfig.json b/tsconfig.json index 55938e52..30d1d536 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "noImplicitAny": false, "noImplicitReturns": true, "noUnusedParameters": true, - "noUnusedLocals": true + "noUnusedLocals": true, + "strict": true }, "exclude": [ "node_modules",