Add the Navigation Timing panel

Provides the navigation timing toolbar. This records key moments in the page load experience.

Additionally:

Updates Karma config to capture tests in folders
Updates the demo to have styles and use a real panel
Gives buttons a click action and a parent
Adds Formatter to handle formatting durations
Adds a few extra interfaces to handle panels being constructed with configurations
Adds PanelConfigMerger to merge user configs and default configs
Adds PanelFrame to hold and show/hide panels
Updates Toolbar to construct from panel classes and configurations
Cleans up a bug where the wrong container was cleared in the toolbar render
Removes a few tslint rules that were causing more problems than they were worth
This commit is contained in:
Adam Reineke 2018-01-08 14:44:20 -08:00 коммит произвёл GitHub
Родитель 683b09584f 4982c78e2b
Коммит ecea25b431
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
14 изменённых файлов: 454 добавлений и 58 удалений

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

@ -2,6 +2,55 @@
<html>
<head>
<title>Demo for WebPerfToolbar</title>
<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. */
#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;
}
#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>
</head>
<body>
<script defer>
@ -15,34 +64,16 @@
}
var startFunc = function() {
/** Fake a panel with a single button */
var fakePanel = {
getButtons: function () {
return [new PerfToolbar.Button({
emoji: '#',
getValue: function () { return '1'},
getColor: function () { return 'gray'}
})];
}
};
/** Fake another panel with a single button */
var fakePanel2 = {
getButtons: function () {
return [new PerfToolbar.Button({
emoji: '#',
getValue: function () { return '2'},
getColor: function () { return 'lightgray'}
})];
}
};
/** Configure this to include the panels you need */
(new PerfToolbar.Toolbar([
fakePanel,
fakePanel2
])).render();
/** Configure this to include the panels you need */
{
panel: PerfToolbar.NavigationTimingsPanel,
config: {
goalMs: 25
}
},
/** End configuration */
])).render();
}
if('requestIdleCallback' in window)

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

@ -19,7 +19,7 @@ module.exports = function(config) {
// list of files / patterns to load in the browser
files: [
'test/*.ts',
'test/**/*.ts'
],

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

@ -1,7 +1,15 @@
import { IPanel } from "./ipanel";
/**
* The configuration options for constructing a button.
*/
export interface IButtonConfiguration {
/** The icon for the button. The intention is to use a single character emoji but it's just a string, so anything goes */
/** The icon for the button. The intention is to use a single character emoji but it's just a string, so anything goes. */
emoji?: string;
/** The panel that owns this button. */
parent?: IPanel;
/** Gets the background color for the button. */
getColor?(): string;
@ -11,7 +19,7 @@ export interface IButtonConfiguration {
/** Describes a button to be displayed in the collapsed toolbar. */
export class Button {
/** The icon for the button. The intention is to use a single character emoji but it's just a string, so anything goes */
/** The icon for the button. The intention is to use a single character emoji but it's just a string, so anything goes. */
public readonly emoji: string;
/** Gets the background color for the button. */
@ -20,11 +28,15 @@ export class Button {
/** Gets the displayed value for the button. */
public readonly getValue: (() => string);
/** The panel that provides this button. */
public readonly parent: IPanel | undefined;
/**
* Create the button.
*/
public constructor(config: IButtonConfiguration = {}) {
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 => "";
@ -40,6 +52,12 @@ export class Button {
li.setAttribute("style", `background-color:${this.getColor()}`);
li.innerText = `${this.emoji} ${this.getValue()}`;
li.addEventListener("click", () => {
if (this.parent) {
this.parent.toggle();
}
});
container.appendChild(li);
}
}

18
src/formatter.ts Normal file
Просмотреть файл

@ -0,0 +1,18 @@
/** The level of precision we want to see for numbers */
export const DECIMAL_PLACES: number = 2;
/**
* 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.
* @param end The end timestamp
* @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)) {
return "-";
}
return (end - start).toFixed(decimalPlaces);
};

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

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

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

@ -1,7 +1,25 @@
import { Button } from "./button";
import { PanelFrame } from "./panelframe";
export interface IPanelWithConfiguration<C, P> {
config: C;
panel: IPanelConstructor<C, P>;
}
export interface IPanelConstructor<C, P> {
new (frame: PanelFrame, config: C): P;
}
// tslint:disable-next-line:no-empty-interface
export interface IPanelConfig {
}
/** Describes a panel within the opened toolbar. */
export interface IPanel {
/**
* The name of the panel.
*/
name: string;
/**
@ -14,4 +32,7 @@ export interface IPanel {
* @param target The HTML element to contain this panel.
*/
render(target: HTMLElement): void;
/** Toggles the visibility of this panel */
toggle(): void;
}

33
src/panelframe.ts Normal file
Просмотреть файл

@ -0,0 +1,33 @@
import { IPanel } from "./ipanel";
/**
* Responsible for holding and displaying panels
*/
export class PanelFrame {
/** The element that represents the frame in the DOM. */
private readonly frame: HTMLDivElement;
/** The root of the toolbar */
private readonly toolbarRoot: HTMLElement;
/**
* Creates the panel frame.
* @param toolbarRoot The DOM element to contain the frame. Should be the root of the toolbar.
*/
public constructor(toolbarRoot: HTMLElement) {
this.toolbarRoot = toolbarRoot;
this.frame = document.createElement("div");
this.frame.setAttribute("id", "PTB_frame");
}
/** Show the provided panel, or hide the displayed panel. */
public toggle(panel: IPanel): void {
if (this.frame.parentNode === null) {
panel.render(this.frame);
this.toolbarRoot.appendChild(this.frame);
} else {
this.frame.parentNode.removeChild(this.frame);
}
}
}

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

@ -0,0 +1,117 @@
import { Button } from "../button";
import * as Formatter from "../formatter";
import { IPanel, IPanelConfig } from "../ipanel";
import { PanelFrame } from "../panelframe";
/** Describes the configuration options available for the network panel */
export interface INavigationTimingsPanelConfig extends IPanelConfig {
/** The goal for the load duration */
goalMs: number;
/** The performance timing object, can be included in the config to enable injection of a mock object for testing */
timings: PerformanceTiming;
}
/** A set of default configuration options for the navigation timings panel */
const navigationTimingsPanelDefaultConfig: INavigationTimingsPanelConfig = {
goalMs: 500,
timings: performance.timing,
};
/**
* 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;
/** The frame that displays this panel. */
private readonly frame: PanelFrame;
public constructor(frame: PanelFrame, config?: INavigationTimingsPanelConfig) {
this.frame = frame;
this.config = { ...navigationTimingsPanelDefaultConfig, ...config };
}
/**
* Gets the buttons to be displayed
* @see IPanel.getButtons
*/
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",
})];
}
/**
* Renders the contents of the panel
* @see IPanel.render
*/
public render(target: HTMLElement): void {
const t: PerformanceTiming = this.config.timings;
target.innerHTML = `
<table>
<tr>
<th>Get Connected</th>
<td>${Formatter.duration(t.connectEnd, t.domainLookupStart)} ms</td>
</tr>
<tr>
<td>DNS Lookup</td>
<td>${Formatter.duration(t.domainLookupEnd, t.domainLookupStart)} ms</td>
</tr>
<tr>
<td>SSL</td>
<td>${Formatter.duration(t.connectEnd, t.connectStart)} ms</td>
</tr>
<tr>
<th>Get Content</th>
<td>${Formatter.duration(t.responseEnd, t.requestStart)} ms</td>
</tr>
<tr>
<td>Waiting for Server</td>
<td>${Formatter.duration(t.responseStart, t.requestStart)} ms</td>
</tr>
<tr>
<td>Time To Download</td>
<td>${Formatter.duration(t.responseEnd, t.responseStart)} ms</td>
</tr>
<tr>
<th colspan=2>Get Ready</th>
</tr>
<tr>
<td>Parse Content</td>
<td>${Formatter.duration(t.domInteractive, t.responseEnd)} ms</td>
</tr>
<tr>
<td>Deferred Scripts</td>
<td>${Formatter.duration(t.domContentLoadedEventEnd, t.domInteractive)} ms</td>
</tr>
<tr>
<td>DOM Complete</td>
<td>${Formatter.duration(t.domComplete, t.domContentLoadedEventEnd)} ms</td>
</tr>
<tr>
<td>Load Event</td>
<td>${Formatter.duration(t.loadEventEnd, t.loadEventStart)} ms</td>
</tr>
<tr>
<th>Total Load</th>
<td>${Formatter.duration(t.loadEventEnd, t.navigationStart)} ms</td>
</tr>
</table>
`;
}
/**
* Toggles the display of this panel.
*/
public toggle(): void {
this.frame.toggle(this);
}
}

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

@ -1,42 +1,42 @@
import { IPanel } from "./ipanel";
import { IPanel, IPanelConfig, IPanelWithConfiguration } from "./ipanel";
import { PanelFrame } from "./panelframe";
/** Describes the toolbar. */
export class Toolbar {
/** The container that will hold the toolbar */
private container: HTMLElement;
/** The panels that will be displayed in the toolbar */
private panels: IPanel[];
/** The root element of the toolbar. */
private root: HTMLElement;
private toolbarRoot: HTMLElement;
/**
* Creates the toolbar.
* @param panels The panels to be displayed when the toolbar is opened.
* @param container Optional parameter that defaults to the body of the HTML page.
* @param panels Classes for the panels to be displayed when the toolbar is opened.
* @param container Optional parameter for the element that contains the toolbar. It defaults to the body of the HTML page.
*/
public constructor(panels: IPanel[], container: HTMLElement = window.document.body) {
this.panels = panels;
this.container = container;
this.root = document.createElement("div");
container.appendChild(this.root);
public constructor(panels: Array<IPanelWithConfiguration<IPanelConfig, IPanel>>, container: HTMLElement = window.document.body) {
this.toolbarRoot = document.createElement("div");
this.toolbarRoot.setAttribute("id", "PTB_root");
container.appendChild(this.toolbarRoot);
// 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));
}
/**
* Renders the toolbar.
*/
public render(): void {
// Clear all children
this.container.innerHTML = "";
const ul: HTMLUListElement = document.createElement("ul");
for (const p of this.panels) {
for (const b of p.getButtons()) {
b.render(ul);
const listOfButtons: HTMLUListElement = document.createElement("ul");
listOfButtons.setAttribute("id", "PTB_buttons");
for (const panel of this.panels) {
for (const button of panel.getButtons()) {
button.render(listOfButtons);
}
}
this.container.appendChild(ul);
this.toolbarRoot.appendChild(listOfButtons);
}
}

30
test/formatter.spec.ts Normal file
Просмотреть файл

@ -0,0 +1,30 @@
import { assert, expect } from "chai";
import "mocha";
import * as sinon from "sinon";
import * as Formatter from "../src/formatter";
describe("Formatter class", () => {
describe("duration", () => {
it("should return a dash for invalid input in the first parameter", () => {
expect(Formatter.duration(undefined, 0)).to.equal("-");
});
it("should return a dash for invalid input in the second parameter", () => {
expect(Formatter.duration(0, undefined)).to.equal("-");
});
it("should default to two decimal places", () => {
expect(Formatter.duration(0, 0)).to.equal("0.00");
});
it("should allow custom number of decimals", () => {
expect(Formatter.duration(0, 0, 1)).to.equal("0.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");
});
});
});

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

@ -1,18 +1,31 @@
import { Button } from "../../src/button";
import { IPanel } from "../../src/ipanel";
import { PanelFrame } from "../../src/panelframe";
export interface IMockPanelConfig {
getButtons(): Button[];
}
export const mockPanelConfig: IMockPanelConfig = {
getButtons: (): Button[] => [new Button({})],
};
export class MockPanel implements IPanel {
public name: string = "Mock Panel";
public constructor(getButtons: () => Button[]) {
this.getButtons = getButtons;
public constructor(frame: PanelFrame, config: IMockPanelConfig) {
this.getButtons = config.getButtons;
}
public getButtons(): Button[] {
return undefined;
throw new Error("Method must be overridden by passing a new getter in the constructor.");
}
public render(target: HTMLElement): void {
throw new Error("Method not implemented.");
}
public toggle(): void {
throw new Error("Method not implemented.");
}
}

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

@ -0,0 +1,108 @@
import { assert, 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";
describe("Navigation timing panel class", () => {
it("should provide a single button that is green when below the goal", () => {
const rootElement: HTMLElement = document.createElement("div");
const frame: PanelFrame = new PanelFrame(rootElement);
const panel: NavigationTimingsPanel = new NavigationTimingsPanel(frame, {
goalMs: 200,
timings: getMockTimings({
navigationStart: 0,
loadEventEnd: 100,
}),
});
const buttons: Button[] = panel.getButtons();
expect(buttons.length).to.equal(1, "there should only be one button for this panel");
expect(buttons[0].getColor()).to.equal("green", "the button should be green");
});
it("should provide a single button that is green when at the goal", () => {
const rootElement: HTMLElement = document.createElement("div");
const frame: PanelFrame = new PanelFrame(rootElement);
const panel: NavigationTimingsPanel = new NavigationTimingsPanel(frame, {
goalMs: 100,
timings: getMockTimings({
navigationStart: 0,
loadEventEnd: 100,
}),
});
const buttons: Button[] = panel.getButtons();
expect(buttons.length).to.equal(1, "there should only be one button for this panel");
expect(buttons[0].getColor()).to.equal("green", "the button should be green");
});
it("should provide a button that is red when above the goal", () => {
const rootElement: HTMLElement = document.createElement("div");
const frame: PanelFrame = new PanelFrame(rootElement);
const panel: NavigationTimingsPanel = new NavigationTimingsPanel(frame, {
goalMs: 50,
timings: getMockTimings({
navigationStart: 0,
loadEventEnd: 100,
}),
});
const buttons: Button[] = panel.getButtons();
expect(buttons.length).to.equal(1, "there should only be one button for this panel");
expect(buttons[0].getColor()).to.equal("red", "the button should be red");
});
it("should render", () => {
const rootElement: HTMLElement = document.createElement("div");
const frame: PanelFrame = new PanelFrame(rootElement);
const panel: NavigationTimingsPanel = new NavigationTimingsPanel(frame, {
goalMs: 90,
timings: getMockTimings(),
});
const target: HTMLElement = document.createElement("div");
panel.render(target);
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 => "",
};
};
});

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

@ -3,9 +3,9 @@ import "mocha";
import * as sinon from "sinon";
import { Button } from "../src/button";
import { IPanel } from "../src/ipanel";
import { IPanel, IPanelConstructor, IPanelWithConfiguration } from "../src/ipanel";
import { Toolbar } from "../src/toolbar";
import { MockPanel } from "./mock/panel.mock";
import { IMockPanelConfig, MockPanel, mockPanelConfig } from "./mock/panel.mock";
describe("Toolbar class", () => {
@ -17,13 +17,18 @@ describe("Toolbar class", () => {
});
it("can render buttons", () => {
const panel: IPanel = new MockPanel((): Button[] => [new Button({})]);
const container: HTMLElement = document.createElement("div");
const toolbar: Toolbar = new Toolbar([panel], container);
const mockPanelWithConfig: IPanelWithConfiguration<IMockPanelConfig, MockPanel> = {
panel: MockPanel,
config: mockPanelConfig,
};
const toolbar: Toolbar = new Toolbar([mockPanelWithConfig], container);
toolbar.render();
const expectedList: Element = container.firstElementChild;
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");
});

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

@ -25,7 +25,8 @@
"prefer-function-over-method": false,
"no-implicit-dependencies": [true, "dev"],
"no-import-side-effect": false,
"strict-boolean-expressions": [true, "allow-undefined-union"]
"strict-boolean-expressions": [true, "allow-undefined-union"],
"interface-over-type-literal": false
},
"jsRules": {
"max-line-length": {