Add the resource timings pane (#5)

* First stab at resource timings

* pretty numbers

* Prettier output, helper methods

* Add the pane to the user script

* Move some new types to a common place, add a formatter for paths

* Formatting fixes and added button titles

* Add and update formatter tests

* Bad import

* Test the new Button.title property

* resource-timing refactor with test stubs

* Add the remaining tests, plus supporting refactors.

- Added more `npm run ...` commands
- No more sourcemaps
- Update tslint to allow leading underscores in variable names (for unused variables in the mocks)
- Remove the unused webpack test config
- Rename the units type in Formatter
- Fix a minor typing issue in IPanel
- Move and update helpers in resource-timing
- Add a tsconfig for the tests project so it can be typechecked properly
- Fix tslint and tsc type errors in `button.spec.ts`, `formatter.spec.ts`, `toolbar.spec.ts`, `panel.mock.ts`, `navigation-timing-spec.ts`
- Add resource timing tests

* Update demo

* Address CR feedback

* Fix some typing errors

* Remove typedef rule, fix a compile type error in tests

* Improve `npm run check` to be comprehensive

* Dropped a default param when cleaning up typedef

* Fix keying with enums
This commit is contained in:
Adam Reineke 2018-01-17 13:59:31 -08:00 коммит произвёл GitHub
Родитель 25ad5d7163
Коммит acbb97435d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
24 изменённых файлов: 752 добавлений и 118 удалений

3
.gitignore поставляемый
Просмотреть файл

@ -3,4 +3,5 @@ dist/
*.js
*.js.map
*.d.ts
!injectDemoToolbar.user.js
!injectDemoToolbar.user.js
!commontypes.d.ts

Просмотреть файл

@ -2,6 +2,9 @@
<html>
<head>
<title>Demo for WebPerfToolbar</title>
<style>
body { background-color: lightgray; }
</style>
<style>
/** TODO: These need to be moved into JS (for ease of usage in an app). They're inline in the demo for ease of development. */
@ -9,12 +12,12 @@
position:fixed;
bottom: 0;
left: 50px;
border:1px solid black;
border-bottom: none;
list-style:none;
padding:0;
margin:0;
z-index: 2147483647; /* we're on top */
}
#PTB_buttons li {
display:inline-block;
@ -22,6 +25,9 @@
margin-left:0.5em;
padding:0.2em;
cursor:pointer;
border:1px solid black;
border-bottom: none;
}
#PTB_buttons li:first-child {
margin-left:0;
@ -29,15 +35,20 @@
#PTB_frame {
position:fixed;
width:30%;
min-width:300px;
right:0;
left:0;
top:0;
width:100%;
height:100%;
border-left:1px solid black;
overflow:auto;
padding:0.5em;
background:rgba(255, 255, 255, 0.95);
z-index:2147483646; /* we're one layer below the top */
}
#PTB_frame table {
margin-top:0.5em;
border-collapse: collapse;
border-spacing: 0;
border: 1px solid black;
@ -50,6 +61,10 @@
border: 1px solid black;
padding:0.2em;
}
#PTB_frame .numeric {
text-align: right;
}
</style>
</head>
<body>
@ -67,11 +82,15 @@
(new PerfToolbar.Toolbar([
/** Configure this to include the panels you need */
{
panel: PerfToolbar.NavigationTimingsPanel,
panelConstructor: PerfToolbar.NavigationTimingsPanel,
config: {
goalMs: 25
}
},
{
panelConstructor: PerfToolbar.ResourceTimingsPanel,
config: {}
}
/** End configuration */
])).render();
}

Просмотреть файл

@ -13,7 +13,7 @@
var initFunc = function() {
var style = document.createElement("style");
style.innerHTML = "#PTB_buttons {position:fixed;bottom: 0;left: 50px;border:1px solid black;border-bottom: none;list-style:none;padding:0;margin:0;}#PTB_buttons li {display:inline-block;line-height:1.6em;margin-left:0.5em;padding:0.2em;cursor:pointer;}#PTB_buttons li:first-child {margin-left:0;}#PTB_frame {position:fixed;width:30%;min-width:300px;right:0;top:0;height:100%;border-left:1px solid black;background:white;z-index:99999;overflow:scroll;}#PTB_frame table {border-collapse: collapse;border-spacing: 0;border: 1px solid black;}#PTB_frame th {font-weight: bold;}#PTB_frame th,#PTB_frame td {border: 1px solid black;padding:0.2em;}";
style.innerHTML = "#PTB_buttons {position:fixed;bottom: 0;left: 50px;list-style:none;padding:0;margin:0;z-index: 2147483647; /* we're on top */}#PTB_buttons li {display:inline-block;line-height:1.6em;margin-left:0.5em;padding:0.2em;cursor:pointer;border:1px solid black;border-bottom: none;}#PTB_buttons li:first-child {margin-left:0;}#PTB_frame {position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;padding:0.5em;background:rgba(255, 255, 255, 0.95);z-index:2147483646; /* we're one layer below the top */}#PTB_frame table {margin-top:0.5em;border-collapse: collapse;border-spacing: 0;border: 1px solid black;}#PTB_frame th {font-weight: bold;}#PTB_frame th,#PTB_frame td {border: 1px solid black;padding:0.2em;}#PTB_frame .numeric {text-align: right;}";
document.body.appendChild(style);
var s = document.createElement("script");
@ -27,10 +27,14 @@
(new PerfToolbar.Toolbar([
/** Configure this to include the panels you need */
{
panel: PerfToolbar.NavigationTimingsPanel,
panelConstructor: PerfToolbar.NavigationTimingsPanel,
config: {
goalMs: 25
}
},
{
panelConstructor: PerfToolbar.ResourceTimingsPanel,
config: {}
}
/** End configuration */
])).render();

Просмотреть файл

@ -4,11 +4,20 @@
"description": "A toolbar for monitoring web performance.",
"main": "dist/bundle.js",
"scripts": {
"test": "karma start karma.conf.js",
"build": "webpack",
"test": "karma start karma.conf.js",
"test-once": "karma start karma.conf.js --single-run",
"demo": "npm run build && http-server -o -c-1",
"tslint": "tslint -p .",
"tsc": "tsc -p tsconfig.json",
"tsc-verbose": "tsc -p tsconfig.json --traceResolution true",
"tslint": "tslint -p ."
"test-tslint": "tslint -p test/.",
"test-tsc": "tsc -p test/tsconfig.json",
"test-tsc-verbose": "tsc -p test/tsconfig.json --traceResolution true",
"check": "npm run tslint && npm run test-tslint && npm run test-tsc && npm run tsc && npm run test-once"
},
"keywords": [],
"author": "",

Просмотреть файл

@ -10,6 +10,9 @@ export interface IButtonConfiguration {
/** The panel that owns this button. */
parent?: IPanel;
/** The name of the button, exposed in the title attribute for the button. */
title?: string;
/** Gets the background color for the button. */
getColor?(): string;
@ -31,15 +34,19 @@ export class Button {
/** The panel that provides this button. */
public readonly parent: IPanel | undefined;
/** The name of the button, exposed in the title attribute for the button. */
public readonly title: string;
/**
* Create the button.
*/
public constructor(config: IButtonConfiguration = {}) {
this.title = config.title !== undefined ? config.title : "";
this.emoji = config.emoji !== undefined ? config.emoji : "";
this.parent = config.parent;
/* tslint:disable no-unbound-method */
this.getValue = config.getValue !== undefined ? config.getValue : (): string => "";
this.getColor = config.getColor !== undefined ? config.getColor : (): string => "";
this.getValue = config.getValue !== undefined ? config.getValue : () => "";
this.getColor = config.getColor !== undefined ? config.getColor : () => "";
/* tslint:enable no-unbound-method */
}
@ -50,6 +57,7 @@ export class Button {
public render(container: HTMLElement): void {
const li: HTMLLIElement = document.createElement("li");
li.setAttribute("style", `background-color:${this.getColor()}`);
li.setAttribute("title", this.title);
li.innerText = `${this.emoji} ${this.getValue()}`;
li.addEventListener("click", () => {

25
src/commontypes.d.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1,25 @@
// begin types from https://github.com/Microsoft/TypeScript/issues/15012
type Required<T> = {
[P in Purify<keyof T>]: NonNullable<T[P]>;
};
type Purify<T extends string> = {[P in T]: T; }[T];
type NonNullable<T> = T & {};
// end types from https://github.com/Microsoft/TypeScript/issues/15012
/**
* The types we expect from entry.initiatorType.
* Values from https://w3c.github.io/resource-timing/#dom-performanceresourcetiming-initiatortype
*/
type InitiatorType = "link" | "script" | "img" | "iframe" | "css" | "navigation" | "xmlhttprequest" | "fetch" | "beacon" | "other";
/**
* Finish the typings for a file gotten by `performance.getEntriesByType("resource")`
*/
interface IResourcePerformanceEntry extends PerformanceEntry, PerformanceResourceTiming {
decodedBodySize: number;
encodedBodySize: number;
initiatorType: InitiatorType;
transferSize: number;
}

Просмотреть файл

@ -1,6 +1,9 @@
/** The level of precision we want to see for numbers */
export const DECIMAL_PLACES: number = 2;
/** The maximum length of a file name */
export const MAX_FILE_NAME_LENGTH: number = 60;
/**
* Formats a duration for output. Makes sure the numbers are valid and only returns a certain number of decimal places.
* Invalid input returns a dash.
@ -8,11 +11,69 @@ export const DECIMAL_PLACES: number = 2;
* @param start The start timestamp
* @param decimalPlaces The number of decimal places to show.
*/
export const duration: (end: number, start: number, decimalPlaces?: number) => string =
(end: number, start: number, decimalPlaces: number = DECIMAL_PLACES): string => {
if (isNaN(end) || isNaN(start)) {
export const duration = (end: number, start: number, decimalPlaces: number = DECIMAL_PLACES): string => {
if (isNaN(end) ||
isNaN(start) ||
(end - start < 0) ||
(end === 0 && start === 0)) {
return "-";
}
return (end - start).toFixed(decimalPlaces);
return (end - start).toLocaleString(undefined, {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
});
};
/**
* Formats a path into a filename.
* @param path The file name to be formatted.
* @param maxLength The maximum length of the returned file name, plus three characters for periods.
*/
export const pathToFilename = (path: string, maxLength: number = MAX_FILE_NAME_LENGTH): string => {
let trimmed: string = path.substring(path.lastIndexOf("/") + 1);
if (trimmed.length > maxLength) {
trimmed = `${trimmed.substring(0, maxLength)}...`;
}
return trimmed;
};
enum FileSizeUnits {
b,
Kb,
Mb,
}
/**
* Simple object for passing to toLocaleString to configure the number of decimal places to display.
*/
const LOCALE_STRING_DECIMAL_PLACES: { maximumFractionDigits: number; minimumFractionDigits: number } = {
minimumFractionDigits: DECIMAL_PLACES,
maximumFractionDigits: DECIMAL_PLACES,
};
/**
* Converts a size in bytes to another size, with a unit.
* @param bytes The size in bytes.
* @param unit The desired unit to conver to.
*/
export const sizeToString = (bytes: number, unit: keyof typeof FileSizeUnits = "Kb"): string => {
const twoExpTen: number = 1024;
if (bytes === 0) {
return "-";
}
switch (unit) {
case FileSizeUnits[FileSizeUnits.b]:
return `${bytes.toLocaleString(undefined, LOCALE_STRING_DECIMAL_PLACES)} b`;
case FileSizeUnits[FileSizeUnits.Kb]:
return `${(bytes / twoExpTen).toLocaleString(undefined, LOCALE_STRING_DECIMAL_PLACES)} Kb`;
case FileSizeUnits[FileSizeUnits.Mb]:
return `${(bytes / (twoExpTen * twoExpTen)).toLocaleString(undefined, LOCALE_STRING_DECIMAL_PLACES)} Mb`;
default:
throw new Error("unknown unit");
}
};

Просмотреть файл

@ -1,3 +1,4 @@
export { Toolbar } from "./toolbar";
export { Button } from "./button";
export { NavigationTimingsPanel } from "./panels/navigation-timing";
export { ResourceTimingsPanel } from "./panels/resource-timing";

Просмотреть файл

@ -1,9 +1,9 @@
import { Button } from "./button";
import { PanelFrame } from "./panelframe";
export interface IPanelWithConfiguration<C, P> {
export interface IPanelWithConfiguration<C extends IPanelConfig, P extends IPanel> {
config: C;
panel: IPanelConstructor<C, P>;
panelConstructor: IPanelConstructor<C, P>;
}
export interface IPanelConstructor<C, P> {
@ -17,11 +17,6 @@ export interface IPanelConfig {
/** Describes a panel within the opened toolbar. */
export interface IPanel {
/**
* The name of the panel.
*/
name: string;
/**
* Gets the buttons provided by this panel to be displayed in the collapsed toolbar.
*/

Просмотреть файл

@ -5,8 +5,14 @@ import { PanelFrame } from "../panelframe";
/** Describes the configuration options available for the network panel */
export interface INavigationTimingsPanelConfig extends IPanelConfig {
/** The emoji for the button */
buttonEmoji?: string;
/** The title for the button */
buttonTitle?: string;
/** The goal for the load duration */
goalMs: number;
/** The name of the panel */
panelName?: string;
/** The performance timing object, can be included in the config to enable injection of a mock object for testing */
timings: PerformanceTiming;
}
@ -15,14 +21,15 @@ export interface INavigationTimingsPanelConfig extends IPanelConfig {
const navigationTimingsPanelDefaultConfig: INavigationTimingsPanelConfig = {
goalMs: 500,
timings: performance.timing,
panelName: "Navigation Timings",
buttonTitle: "Duration from navigation to end of load event",
buttonEmoji: "⏱️",
};
/**
* Provides a panel that shows the navigation timings for a page
*/
export class NavigationTimingsPanel implements IPanel {
/** The name of the panel */
public readonly name: string = "Navigation Timings";
/** The settings for this panel. */
private readonly config: INavigationTimingsPanelConfig;
@ -42,9 +49,10 @@ export class NavigationTimingsPanel implements IPanel {
public getButtons(): Button[] {
return [new Button({
parent: this,
emoji: "⏱️",
getValue: (): string => `${Formatter.duration(this.config.timings.loadEventEnd, this.config.timings.navigationStart)} ms`,
getColor: (): string => this.config.timings.loadEventEnd - this.config.timings.navigationStart <= this.config.goalMs ? "green" : "red",
title: this.config.buttonTitle,
emoji: this.config.buttonEmoji,
getValue: () => `${Formatter.duration(this.config.timings.loadEventEnd, this.config.timings.navigationStart)} ms`,
getColor: () => this.config.timings.loadEventEnd - this.config.timings.navigationStart <= this.config.goalMs ? "green" : "red",
})];
}
@ -55,7 +63,7 @@ export class NavigationTimingsPanel implements IPanel {
public render(target: HTMLElement): void {
const t: PerformanceTiming = this.config.timings;
target.innerHTML = `
target.innerHTML = `<h1>${this.config.panelName}</h1>
<table>
<tr>
<th>Get Connected</th>

Просмотреть файл

@ -0,0 +1,57 @@
import { IPanelConfig } from "../ipanel";
/** Describes the configuration for the bytes over wire button. */
export interface IBytesOverWireButtonConfig {
/** The emoji for the bytes over wire button. */
bytesOverWireButtonEmoji?: string;
/** The title of the bytes over wire button. */
bytesOverWireButtonTitle?: string;
}
/** Describes the configuration for the iamge bytes over wire button. */
export interface IImageBytesOverWireButtonConfig {
/** The emoji for the image bytes over wire button. */
imageBytesOverWireButtonEmoji?: string;
/** The title for the image bytes over wire button. */
imageBytesOverWireButtonTitle?: string;
}
/** Describes the configuration options available for the network panel */
export interface IResourceTimingsPanelConfig extends IPanelConfig, IBytesOverWireButtonConfig, IImageBytesOverWireButtonConfig {
/** The name of the panel */
panelName?: string;
/**
* The global performance object, can be included in the config to enable injection of a mock object for testing.
* This pane only requires the getEntriesByType method
*/
performance: Partial<Performance> & Required<{ getEntriesByType(entryType: string): {} }>;
}
/**
* Types that map to the string from PerformanceResourceTiming.initatorType, with the exception of "all"
* which is added to help summarization.
* @see https://www.w3.org/TR/resource-timing-1/#dom-performanceresourcetiming-initiatortype
* @note These enum values are lowercase rather than uppercase since we string match against the values
* provided by the browser API.
*/
export enum InitiatorTypes {
all,
other,
link,
script,
img,
css,
iframe,
xmlhttprequest,
}
/**
* Describes a row in the summary table
*/
export type SummaryRow = {
decodedBytes: number;
format: "all" | InitiatorType;
largestBytes: number;
numFiles: number;
overWireBytes: number;
};

Просмотреть файл

@ -0,0 +1,219 @@
import { Button } from "../button";
import * as Formatter from "../formatter";
import { IPanel } from "../ipanel";
import { PanelFrame } from "../panelframe";
import {
IBytesOverWireButtonConfig,
IImageBytesOverWireButtonConfig,
InitiatorTypes,
IResourceTimingsPanelConfig,
SummaryRow,
} from "./resource-timing-types";
/** A set of default configuration options for the Resource timings panel */
const resourceTimingsPanelDefaultConfig: IResourceTimingsPanelConfig = {
performance: window.performance,
panelName: "Resource Timings",
bytesOverWireButtonTitle: "Total bytes over wire",
bytesOverWireButtonEmoji: "🔌",
imageBytesOverWireButtonTitle: "Image bytes over wire",
imageBytesOverWireButtonEmoji: "🖼️",
};
/**
* Gets a summary table with default zeroed values.
*/
export const getZeroedSummaryTable = (): SummaryRow[] => {
const zeroValues: SummaryRow = {
format: "other",
decodedBytes: 0,
overWireBytes: 0,
numFiles: 0,
largestBytes: 0,
};
const numberOfSummaries: number = 8;
const summaryCounts: SummaryRow[] = new Array(numberOfSummaries);
/* tslint:disable no-any (Because keying into the enum gives a string and not the specific string literal.) */
summaryCounts[InitiatorTypes.all] = { ...zeroValues, format: InitiatorTypes[InitiatorTypes.all] as any };
summaryCounts[InitiatorTypes.other] = { ...zeroValues, format: InitiatorTypes[InitiatorTypes.other] as any };
summaryCounts[InitiatorTypes.link] = { ...zeroValues, format: InitiatorTypes[InitiatorTypes.link] as any };
summaryCounts[InitiatorTypes.script] = { ...zeroValues, format: InitiatorTypes[InitiatorTypes.script] as any };
summaryCounts[InitiatorTypes.img] = { ...zeroValues, format: InitiatorTypes[InitiatorTypes.img] as any };
summaryCounts[InitiatorTypes.css] = { ...zeroValues, format: InitiatorTypes[InitiatorTypes.css] as any };
summaryCounts[InitiatorTypes.iframe] = { ...zeroValues, format: InitiatorTypes[InitiatorTypes.iframe] as any };
summaryCounts[InitiatorTypes.xmlhttprequest] = { ...zeroValues, format: InitiatorTypes[InitiatorTypes.xmlhttprequest] as any };
/* tslint:enable no-any */
return summaryCounts;
};
export const getBytesOverWireButton = (
parent: ResourceTimingsPanel | undefined,
config: IBytesOverWireButtonConfig,
summaryCounts: SummaryRow[],
) => new Button({
parent,
title: config.bytesOverWireButtonTitle,
emoji: config.bytesOverWireButtonEmoji,
getValue: (): string => `${Formatter.sizeToString(summaryCounts[InitiatorTypes.all].overWireBytes, "Kb")}`,
getColor: (): string => "white",
});
export const getImageBytesOverWireButton = (
parent: ResourceTimingsPanel | undefined,
config: IImageBytesOverWireButtonConfig,
summaryCounts: SummaryRow[],
) => new Button({
parent,
title: config.imageBytesOverWireButtonTitle,
emoji: config.imageBytesOverWireButtonEmoji,
getValue: (): string => `${Formatter.sizeToString(summaryCounts[InitiatorTypes.img].overWireBytes, "Kb")}`,
getColor: (): string => "white",
});
/**
* Provides a panel that shows the Resource timings for a page.
*/
export class ResourceTimingsPanel implements IPanel {
/** The settings for this panel. */
private readonly config: IResourceTimingsPanelConfig;
/** The frame that displays this panel. */
private readonly frame: PanelFrame;
public constructor(frame: PanelFrame, config?: Partial<IResourceTimingsPanelConfig>) {
this.frame = frame;
this.config = { ...resourceTimingsPanelDefaultConfig, ...config };
}
/**
* Gets the buttons to be displayed
* @see IPanel.getButtons
*/
public getButtons(): Button[] {
const summaryCounts: SummaryRow[] = getZeroedSummaryTable();
this.populateSummaryTable(summaryCounts);
return [
getBytesOverWireButton(this, this.config, summaryCounts),
getImageBytesOverWireButton(this, this.config, summaryCounts),
];
}
/**
* Get the summarized resource data as an array of rows.
*/
public getSummaryCounts(): SummaryRow[] {
const summaryCounts: SummaryRow[] = getZeroedSummaryTable();
this.populateSummaryTable(summaryCounts);
return summaryCounts;
}
/**
* Renders the contents of the panel
* @see IPanel.render
*/
public render(target: HTMLElement): void {
target.innerHTML = `<h1>${this.config.panelName}</h1>
${this.getSummaryTable(this.getSummaryCounts())}
${this.getDetailTable()}`;
}
/**
* Toggles the display of this panel.
*/
public toggle(): void {
this.frame.toggle(this);
}
/**
* Gets the detail table.
*/
private getDetailTable(): string {
const entries: IResourcePerformanceEntry[] = this.config.performance.getEntriesByType("resource") as IResourcePerformanceEntry[];
const rows: string = entries.map((entry: IResourcePerformanceEntry) => `
<tr>
<td>${entry.initiatorType}</td>
<td title="${entry.name}">${Formatter.pathToFilename(entry.name)}</td>
<td class="numeric">${Formatter.sizeToString(entry.transferSize)}</td>
<td class="numeric">${Formatter.sizeToString(entry.decodedBodySize)}</td>
<td class="numeric">${Formatter.duration(entry.duration, 0)} ms</td>
<td class="numeric">${Formatter.duration(entry.responseStart, entry.fetchStart)} ms</td>
</tr>
`).join("");
return `
<table>
<tr>
<th>Initiated By</th>
<th>Path</th>
<th>Bytes Over Wire</th>
<th>Actual Size</th>
<th>Duration</th>
<th>Time To First Byte</th>
</tr>
${rows}
</table>`;
}
/**
* Gets the summary table.
* @param summaryCounts An array of summaries data.
*/
private getSummaryTable(summaryCounts: SummaryRow[]): string {
const rows: string = summaryCounts.map((row: SummaryRow): string => `
<tr>
<td>${row.format}</td>
<td class="numeric">${Formatter.sizeToString(row.decodedBytes)}</td>
<td class="numeric">${Formatter.sizeToString(row.overWireBytes)}</td>
<td class="numeric">${row.numFiles}</td>
<td class="numeric">${Formatter.sizeToString(row.largestBytes)}</td>
</tr>`).join("");
return `
<table>
<tr>
<th>Format</th>
<th>Decoded Size</th>
<th>Bytes Over Wire</th>
<th>Num Files</th>
<th>Largest Decoded</th>
</tr>
${rows}
</table>`;
}
/**
* Increments a row in the summary table.
* @param row The row to increment.
* @param entry The entry with values that we increment by.
*/
private incrementCount(row: SummaryRow, entry: IResourcePerformanceEntry): void {
row.decodedBytes += entry.decodedBodySize;
row.overWireBytes += entry.transferSize;
row.numFiles++;
row.largestBytes = Math.max(entry.decodedBodySize, row.largestBytes);
}
/**
* Fills a table of summary rows by looping over all of the resource performance entires.
* @param summaryCounts The table to be filled.
*/
private populateSummaryTable(summaryCounts: SummaryRow[]): void {
const entries: IResourcePerformanceEntry[] = this.config.performance.getEntriesByType("resource") as IResourcePerformanceEntry[];
for (const entry of entries) {
// Add to the All count
this.incrementCount(summaryCounts[InitiatorTypes.all], entry);
const index: number = InitiatorTypes[entry.initiatorType as keyof typeof InitiatorTypes];
if (index as number | undefined !== undefined) {
this.incrementCount(summaryCounts[index], entry);
} else {
this.incrementCount(summaryCounts[InitiatorTypes.other], entry);
}
}
}
}

Просмотреть файл

@ -22,7 +22,7 @@ export class Toolbar {
// Construct the frame and the panels that use it
const frame: PanelFrame = new PanelFrame(this.toolbarRoot);
this.panels = panels.map((panelWithConfig: IPanelWithConfiguration<IPanelConfig, IPanel>): IPanel =>
new panelWithConfig.panel(frame, panelWithConfig.config));
new panelWithConfig.panelConstructor(frame, panelWithConfig.config));
}
/**

Просмотреть файл

@ -1,7 +1,7 @@
import { assert, expect } from "chai";
import { expect } from "chai";
import "mocha";
import { Button, IButtonConfiguration } from "../src/button";
import { Toolbar } from "../src/toolbar";
import { assertNotNull } from "./test-utilities";
describe("Button class", () => {
@ -10,6 +10,7 @@ describe("Button class", () => {
const button: Button = new Button();
button.render(container);
expect(button.title).to.equal("");
expect(button.emoji).to.equal("");
expect(button.getValue()).to.equal("");
expect(button.getColor()).to.equal("");
@ -33,7 +34,7 @@ describe("Button class", () => {
button.render(container);
expect(container.firstElementChild.innerHTML)
expect(assertNotNull(container.firstElementChild, "Not in DOM").innerHTML)
.equals("EMOJI VALUE", "We expect the button to show the label and value we set");
});
@ -46,7 +47,19 @@ describe("Button class", () => {
button.render(container);
expect(container.firstElementChild.getAttribute("style"))
expect(assertNotNull(container.firstElementChild, "Not in DOM").getAttribute("style"))
.to.match(/red(;)?$/, "We expect the color to red");
});
it("should render the title as a title attribute", () => {
const container: HTMLElement = document.createElement("ul");
const config: IButtonConfiguration = {
title: "TITLE",
};
const button: Button = new Button(config);
button.render(container);
expect(assertNotNull(container.firstElementChild, "Not in DOM").getAttribute("title")).to.equal("TITLE");
});
});

Просмотреть файл

@ -1,30 +1,63 @@
import { assert, expect } from "chai";
import { expect } from "chai";
import "mocha";
import * as sinon from "sinon";
import * as Formatter from "../src/formatter";
describe("Formatter class", () => {
describe("Formatter", () => {
describe("duration", () => {
it("should return a dash for invalid input in the first parameter", () => {
expect(Formatter.duration(undefined, 0)).to.equal("-");
expect(Formatter.duration(undefined as any, 0)).to.equal("-"); // tslint:disable-line:no-any
});
it("should return a dash for invalid input in the second parameter", () => {
expect(Formatter.duration(0, undefined)).to.equal("-");
expect(Formatter.duration(0, undefined as any)).to.equal("-"); // tslint:disable-line:no-any
});
it("should default to two decimal places", () => {
expect(Formatter.duration(0, 0)).to.equal("0.00");
expect(Formatter.duration(1, 0)).to.equal("1.00");
});
it("should allow custom number of decimals", () => {
expect(Formatter.duration(0, 0, 1)).to.equal("0.0");
expect(Formatter.duration(1, 0, 1)).to.equal("1.0");
});
it("should do subtract the start from the end", () => {
expect(Formatter.duration(1, 0, 0)).to.equal("1");
expect(Formatter.duration(0, 1, 0)).to.equal("-1");
});
it("should not allow negative durations", () => {
expect(Formatter.duration(0, 1, 0)).to.equal("-");
});
it("should return - for zero duration", () => {
expect(Formatter.duration(0, 0)).to.equal("-");
});
});
describe("pathToFilename", () => {
it("should return everything after the last slash", () => {
expect(Formatter.pathToFilename("https://foo/a.b?c=d")).to.equal("a.b?c=d");
});
it("should trim long file names", () => {
expect(Formatter.pathToFilename("https://foo/abc", 1)).to.equal("a...");
});
});
describe("sizeToString", () => {
const twoToTheTenth: number = 1024;
const bigNumber: number = 10000;
it("should only format when requesting bytes", () => {
expect(Formatter.sizeToString(bigNumber, "b")).to.equal("10,000.00 b");
});
it("should reduce 2^10 bytes to 1 Kb", () => {
expect(Formatter.sizeToString(twoToTheTenth, "Kb")).to.equal("1.00 Kb");
});
it("should reduce 2^20 bytes to 1 Mb", () => {
expect(Formatter.sizeToString(twoToTheTenth * twoToTheTenth, "Mb")).to.equal("1.00 Mb");
});
});
});

Просмотреть файл

@ -1,30 +1,42 @@
import { Button } from "../../src/button";
import { IPanel } from "../../src/ipanel";
import { IPanel, IPanelConfig } from "../../src/ipanel";
import { PanelFrame } from "../../src/panelframe";
export interface IMockPanelConfig {
/** Describes the mocked panel configuration */
export interface IMockPanelConfig extends IPanelConfig {
/** The mock requires a getButtons method in the config so the getButtons provided by the class can be replaced */
getButtons(): Button[];
}
/**
* The default config for the mock panel which just creates a button with no options.
*/
export const mockPanelConfig: IMockPanelConfig = {
getButtons: (): Button[] => [new Button({})],
getButtons: () => [new Button({})],
};
/**
* Creates a mocked panel.
*/
export class MockPanel implements IPanel {
/** The name of our mock panel. */
public name: string = "Mock Panel";
public constructor(frame: PanelFrame, config: IMockPanelConfig) {
public constructor(_frame: PanelFrame, config: IMockPanelConfig) {
this.getButtons = config.getButtons;
}
/** Gets the buttons. Will be overriden by the constructor. */
public getButtons(): Button[] {
throw new Error("Method must be overridden by passing a new getter in the constructor.");
}
public render(target: HTMLElement): void {
/** Normally renders the panel. Not implemented here. */
public render(_target: HTMLElement): void {
throw new Error("Method not implemented.");
}
/** Normally toggles the panel. Not implemented here. */
public toggle(): void {
throw new Error("Method not implemented.");
}

Просмотреть файл

@ -1,12 +1,41 @@
import { assert, expect } from "chai";
import { expect } from "chai";
import "mocha";
import * as sinon from "sinon";
import { Button } from "../../src/button";
import { PanelConfigMerger } from "../../src/panelconfigmerger";
import { PanelFrame } from "../../src/panelframe";
import { NavigationTimingsPanel } from "../../src/panels/navigation-timing";
// Provides mocked performance timings for tests
const getMockTimings = (overrides?: Partial<PerformanceTiming>): PerformanceTiming => {
const zero: PerformanceTiming = {
connectEnd: 0,
connectStart: 0,
domainLookupEnd: 0,
domainLookupStart: 0,
domComplete: 0,
domContentLoadedEventEnd: 0,
domContentLoadedEventStart: 0,
domInteractive: 0,
domLoading: 0,
fetchStart: 0,
loadEventEnd: 0,
loadEventStart: 0,
msFirstPaint: 0,
navigationStart: 0,
redirectEnd: 0,
redirectStart: 0,
requestStart: 0,
responseEnd: 0,
responseStart: 0,
secureConnectionStart: 0,
unloadEventEnd: 0,
unloadEventStart: 0,
toJSON: () => "",
};
return { ...zero, ...overrides };
};
describe("Navigation timing panel class", () => {
it("should provide a single button that is green when below the goal", () => {
@ -74,35 +103,4 @@ describe("Navigation timing panel class", () => {
expect(target.childElementCount).to.be.above(0, "it should render to the DOM");
});
// Provides mocked performance timings for tests
const getMockTimings: (overrides?: Partial<PerformanceTiming>) => PerformanceTiming =
(overrides: Partial<PerformanceTiming> = {}): PerformanceTiming => { // tslint:disable-line:arrow-return-shorthand cyclomatic-complexity
return {
connectEnd: overrides.connectEnd || 0,
connectStart: overrides.connectStart || 0,
domainLookupEnd: overrides.domainLookupEnd || 0,
domainLookupStart: overrides.domainLookupStart || 0,
domComplete: overrides.domComplete || 0,
domContentLoadedEventEnd: overrides.domContentLoadedEventEnd || 0,
domContentLoadedEventStart: overrides.domContentLoadedEventStart || 0,
domInteractive: overrides.domInteractive || 0,
domLoading: overrides.domLoading || 0,
fetchStart: overrides.fetchStart || 0,
loadEventEnd: overrides.loadEventEnd || 0,
loadEventStart: overrides.loadEventStart || 0,
msFirstPaint: overrides.msFirstPaint || 0,
navigationStart: overrides.navigationStart || 0,
redirectEnd: overrides.redirectEnd || 0,
redirectStart: overrides.redirectStart || 0,
requestStart: overrides.requestStart || 0,
responseEnd: overrides.responseEnd || 0,
responseStart: overrides.responseStart || 0,
secureConnectionStart: overrides.secureConnectionStart || 0,
unloadEventEnd: overrides.unloadEventEnd || 0,
unloadEventStart: overrides.unloadEventStart || 0,
toJSON: (): string => "",
};
};
});

Просмотреть файл

@ -0,0 +1,171 @@
import { expect } from "chai";
import "mocha";
import { Button } from "../../src/button";
import { PanelFrame } from "../../src/panelframe";
import * as rt from "../../src/panels/resource-timing";
import {
InitiatorTypes,
IResourceTimingsPanelConfig,
SummaryRow,
} from "../../src/panels/resource-timing-types";
/** A resource performance entry that is basically zeroes. */
const zeroEntry: IResourcePerformanceEntry = {
connectEnd: 0,
connectStart: 0,
decodedBodySize: 0,
domainLookupEnd: 0,
domainLookupStart: 0,
duration: 0,
encodedBodySize: 0,
entryType: "",
fetchStart: 0,
initiatorType: "other",
name: "",
redirectEnd: 0,
redirectStart: 0,
requestStart: 0,
responseEnd: 0,
responseStart: 0,
startTime: 0,
transferSize: 0,
};
/** Provides a short mock of the summary data that would be computed from the performance object. */
const getMockSummaryRows = (): SummaryRow[] => {
const summary: SummaryRow[] = rt.getZeroedSummaryTable();
const allOverWireBytes: number = 1024;
const imageOverWireBytes: number = 2048;
summary[InitiatorTypes.all].overWireBytes = allOverWireBytes;
summary[InitiatorTypes.img].overWireBytes = imageOverWireBytes;
return summary;
};
/* tslint:disable:no-magic-numbers */
/**
* Get mock entries.
* We use prime numbers (greater than two) to make sure they can be added uniquely to verify test cases better.
* 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101
*/
const getMockEntries = (): IResourcePerformanceEntry[] => [
{
...zeroEntry,
initiatorType: "img",
decodedBodySize: 3,
transferSize: 5,
},
{
...zeroEntry,
initiatorType: "img",
decodedBodySize: 7,
transferSize: 0, // Zero is the transferred size for a cached file
},
{
...zeroEntry,
initiatorType: "link",
decodedBodySize: 13,
transferSize: 17,
},
];
/* tslint:enable:no-magic-numbers */
describe("Resource timing panel class", () => {
it("should provide two buttons", () => {
const rootElement: HTMLElement = document.createElement("div");
const frame: PanelFrame = new PanelFrame(rootElement);
const panel: rt.ResourceTimingsPanel = new rt.ResourceTimingsPanel(frame);
const expectedButtons: number = 2;
const buttons: Button[] = panel.getButtons();
expect(buttons.length).to.equal(expectedButtons, "there should be two buttons");
});
it("should summarize the performance data", () => {
const rootElement: HTMLElement = document.createElement("div");
const frame: PanelFrame = new PanelFrame(rootElement);
const config: IResourceTimingsPanelConfig = {
performance: {
getEntriesByType: (_entryType: string): IResourcePerformanceEntry[] => getMockEntries(),
},
};
const panel: rt.ResourceTimingsPanel = new rt.ResourceTimingsPanel(frame, config);
const counts: SummaryRow[] = panel.getSummaryCounts();
/* tslint:disable:no-magic-numbers */
const image: SummaryRow = counts[InitiatorTypes.img];
expect(image.decodedBytes).to.equal(3 + 7);
expect(image.overWireBytes).to.equal(5);
expect(image.numFiles).to.equal(2);
expect(image.largestBytes).to.equal(7);
const link: SummaryRow = counts[InitiatorTypes.link];
expect(link.decodedBytes).to.equal(13);
expect(link.overWireBytes).to.equal(17);
expect(link.numFiles).to.equal(1);
expect(link.largestBytes).to.equal(13);
const other: SummaryRow = counts[InitiatorTypes.other];
expect(other.decodedBytes).to.equal(0);
expect(other.overWireBytes).to.equal(0);
expect(other.numFiles).to.equal(0);
expect(other.largestBytes).to.equal(0);
/* tslint:enable:no-magic-numbers */
});
it("should render two tables", () => {
const rootElement: HTMLElement = document.createElement("div");
const frame: PanelFrame = new PanelFrame(rootElement);
const panel: rt.ResourceTimingsPanel = new rt.ResourceTimingsPanel(frame);
const div: HTMLDivElement = document.createElement("div");
const expectedTableCount: number = 2;
panel.render(div);
expect(div.querySelectorAll("table").length).to.equal(expectedTableCount);
});
it("should have a total bytes over wire button", () => {
const button: Button = rt.getBytesOverWireButton(undefined, {}, getMockSummaryRows());
expect(button.getValue()).to.equal("1.00 Kb");
});
it("should have an image bytes over wire button", () => {
const button: Button = rt.getImageBytesOverWireButton(undefined, {}, getMockSummaryRows());
expect(button.getValue()).to.equal("2.00 Kb");
});
it("should have zeroes for numeric data in the zero summary table", () => {
const zeroed: SummaryRow[] = rt.getZeroedSummaryTable();
for (const row of zeroed) {
expect(row.decodedBytes).to.equal(0);
expect(row.overWireBytes).to.equal(0);
expect(row.numFiles).to.equal(0);
expect(row.largestBytes).to.equal(0);
}
});
it("should have every format in the zero summary table with the right labels", () => {
const zeroed: SummaryRow[] = rt.getZeroedSummaryTable();
const numObjectKeysPerEnumValue: number = 2;
const numEnumValues: number = Object.keys(InitiatorTypes).length / numObjectKeysPerEnumValue;
expect(zeroed.length).to.equal(numEnumValues);
expect(zeroed[InitiatorTypes.all].format).to.equal("All");
expect(zeroed[InitiatorTypes.other].format).to.equal("other");
expect(zeroed[InitiatorTypes.link].format).to.equal("link");
expect(zeroed[InitiatorTypes.script].format).to.equal("script");
expect(zeroed[InitiatorTypes.img].format).to.equal("img");
expect(zeroed[InitiatorTypes.css].format).to.equal("css");
expect(zeroed[InitiatorTypes.iframe].format).to.equal("iframe");
expect(zeroed[InitiatorTypes.xmlhttprequest].format).to.equal("xmlhttprequest");
});
});

12
test/test-utilities.ts Normal file
Просмотреть файл

@ -0,0 +1,12 @@
/**
* Throw if an argument is null.
* @param arg The value to verify isn't null
* @param message The message to throw if the argument is null
*/
export const assertNotNull = <T>(arg: T | null, message: string) => {
if (arg !== null) {
return arg;
}
throw new Error(message);
};

Просмотреть файл

@ -1,17 +1,16 @@
import { assert, expect } from "chai";
import { expect } from "chai";
import "mocha";
import * as sinon from "sinon";
import { Button } from "../src/button";
import { IPanel, IPanelConstructor, IPanelWithConfiguration } from "../src/ipanel";
import { IPanel, IPanelConfig, IPanelConstructor, IPanelWithConfiguration } from "../src/ipanel";
import { Toolbar } from "../src/toolbar";
import { IMockPanelConfig, MockPanel, mockPanelConfig } from "./mock/panel.mock";
import { MockPanel, mockPanelConfig } from "./mock/panel.mock";
describe("Toolbar class", () => {
it("should construct with no panels", () => {
const container: HTMLElement = document.createElement("div");
const toolbar: Toolbar = new Toolbar([], container);
toolbar.render();
expect(container.childElementCount).to.equal(1);
});
@ -19,15 +18,19 @@ describe("Toolbar class", () => {
it("can render buttons", () => {
const container: HTMLElement = document.createElement("div");
const mockPanelWithConfig: IPanelWithConfiguration<IMockPanelConfig, MockPanel> = {
panel: MockPanel,
config: mockPanelConfig,
const mockPanelWithConfig: IPanelWithConfiguration<IPanelConfig, IPanel> = {
config: mockPanelConfig as IPanelConfig,
panelConstructor: MockPanel as any as IPanelConstructor<IPanelConfig, IPanel>, // tslint:disable-line:no-any
};
const toolbar: Toolbar = new Toolbar([mockPanelWithConfig], container);
toolbar.render();
if (container.firstElementChild === null) {
throw new Error("DOM was not set up properly");
}
const expectedList: Element = container.firstElementChild.children.item(0);
expect(expectedList).instanceof(HTMLUListElement, "We expect the toolbar to be a list");
expect(expectedList.childElementCount).equals(1, "We expect that list to have one item");

8
test/tsconfig.json Normal file
Просмотреть файл

@ -0,0 +1,8 @@
{
"extends": "../tsconfig",
"include": [
"./**/*",
"../src/**/*"
]
}

Просмотреть файл

@ -4,7 +4,7 @@
"noImplicitAny": true,
"module": "es2015",
"target": "es5",
"sourceMap": true,
"sourceMap": false,
"listFiles": true,
"baseUrl": "src",
"types": [

Просмотреть файл

@ -26,7 +26,9 @@
"no-implicit-dependencies": [true, "dev"],
"no-import-side-effect": false,
"strict-boolean-expressions": [true, "allow-undefined-union"],
"interface-over-type-literal": false
"interface-over-type-literal": false,
"variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"],
"typedef": false
},
"jsRules": {
"max-line-length": {

Просмотреть файл

@ -1,25 +0,0 @@
const path = require('path');
const webpack = require('webpack');
module.exports = {
target: 'node',
entry: "mocha\!./test/toolbar.spec.ts",
devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.ts?$/,
use: 'ts-loader',
exclude: '/node_modules/'
}
],
exprContextCritical: false
},
resolve: {
extensions: ['.ts', '.js']
},
output: {
filename: 'testBundle.js',
path: path.resolve(__dirname, 'dist')
}
}