From f473118798f444040a41bdd5d0cac575b560f909 Mon Sep 17 00:00:00 2001 From: Patrick Hulce Date: Fri, 1 Sep 2017 09:15:24 -0700 Subject: [PATCH] feat(predictive-perf): add shell and base audit (#2720) * feat(predictive-perf): add shell and base audit * address feedback * add invariant comments --- lighthouse-core/audits/predictive-perf.js | 57 +++++ lighthouse-core/config/fast-config.js | 10 + .../gather/computed/dependency-graph/node.js | 180 ++++++++++++++++ .../gather/computed/page-dependency-graph.js | 122 +++++++++++ lighthouse-core/runner.js | 8 +- .../test/audits/predictive-perf-test.js | 34 +++ .../computed/dependency-graph/node-test.js | 195 ++++++++++++++++++ .../computed/page-dependency-graph-test.js | 114 ++++++++++ 8 files changed, 718 insertions(+), 2 deletions(-) create mode 100644 lighthouse-core/audits/predictive-perf.js create mode 100644 lighthouse-core/gather/computed/dependency-graph/node.js create mode 100644 lighthouse-core/gather/computed/page-dependency-graph.js create mode 100644 lighthouse-core/test/audits/predictive-perf-test.js create mode 100644 lighthouse-core/test/gather/computed/dependency-graph/node-test.js create mode 100644 lighthouse-core/test/gather/computed/page-dependency-graph-test.js diff --git a/lighthouse-core/audits/predictive-perf.js b/lighthouse-core/audits/predictive-perf.js new file mode 100644 index 000000000000..8068af3ea7b4 --- /dev/null +++ b/lighthouse-core/audits/predictive-perf.js @@ -0,0 +1,57 @@ +/** + * @license Copyright 2017 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +const Audit = require('./audit'); +const Util = require('../report/v2/renderer/util.js'); +const PageDependencyGraph = require('../gather/computed/page-dependency-graph.js'); + +// Parameters (in ms) for log-normal CDF scoring. To see the curve: +// https://www.desmos.com/calculator/rjp0lbit8y +const SCORING_POINT_OF_DIMINISHING_RETURNS = 1700; +const SCORING_MEDIAN = 10000; + +class PredictivePerf extends Audit { + /** + * @return {!AuditMeta} + */ + static get meta() { + return { + category: 'Performance', + name: 'predictive-perf', + description: 'Predicted Performance (beta)', + helpText: 'Predicted performance evaluates how your site will perform under ' + + 'a 3G connection on a mobile device.', + scoringMode: Audit.SCORING_MODES.NUMERIC, + requiredArtifacts: ['traces', 'devtoolsLogs'] + }; + } + + /** + * @param {!Artifacts} artifacts + * @return {!AuditResult} + */ + static audit(artifacts) { + const trace = artifacts.traces[Audit.DEFAULT_PASS]; + const devtoolsLogs = artifacts.devtoolsLogs[Audit.DEFAULT_PASS]; + return artifacts.requestPageDependencyGraph(trace, devtoolsLogs).then(graph => { + const rawValue = PageDependencyGraph.computeGraphDuration(graph); + const score = Audit.computeLogNormalScore( + rawValue, + SCORING_POINT_OF_DIMINISHING_RETURNS, + SCORING_MEDIAN + ); + + return { + score, + rawValue, + displayValue: Util.formatMilliseconds(rawValue), + }; + }); + } +} + +module.exports = PredictivePerf; diff --git a/lighthouse-core/config/fast-config.js b/lighthouse-core/config/fast-config.js index f6a8032caef2..a68c1d0bb9fd 100644 --- a/lighthouse-core/config/fast-config.js +++ b/lighthouse-core/config/fast-config.js @@ -35,4 +35,14 @@ module.exports = { gatherers: [], }, ], + audits: [ + 'predictive-perf', + ], + categories: { + performance: { + audits: [ + {id: 'predictive-perf', weight: 5, group: 'perf-metric'}, + ], + }, + }, }; diff --git a/lighthouse-core/gather/computed/dependency-graph/node.js b/lighthouse-core/gather/computed/dependency-graph/node.js new file mode 100644 index 000000000000..5d1539b3f093 --- /dev/null +++ b/lighthouse-core/gather/computed/dependency-graph/node.js @@ -0,0 +1,180 @@ +/** + * @license Copyright 2017 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +/** + * @fileoverview This class encapsulates logic for handling resources and tasks used to model the + * execution dependency graph of the page. A node has a unique identifier and can depend on other + * nodes/be depended on. The construction of the graph maintains some important invariants that are + * inherent to the model: + * + * 1. The graph is a DAG, there are no cycles. + * 2. There is always a root node upon which all other nodes eventually depend. + * + * This allows particular optimizations in this class so that we do no need to check for cycles as + * these methods are called and we can always start traversal at the root node. + */ +class Node { + + /** + * @param {string|number} id + */ + constructor(id) { + this._id = id; + this._dependents = []; + this._dependencies = []; + } + + /** + * @return {string|number} + */ + get id() { + return this._id; + } + + /** + * @return {!Array} + */ + getDependents() { + return this._dependents.slice(); + } + + + /** + * @return {!Array} + */ + getDependencies() { + return this._dependencies.slice(); + } + + + /** + * @return {!Node} + */ + getRootNode() { + let rootNode = this; + while (rootNode._dependencies.length) { + rootNode = rootNode._dependencies[0]; + } + + return rootNode; + } + + /** + * @param {!Node} + */ + addDependent(node) { + node.addDependency(this); + } + + /** + * @param {!Node} + */ + addDependency(node) { + if (this._dependencies.includes(node)) { + return; + } + + node._dependents.push(this); + this._dependencies.push(node); + } + + /** + * Clones the node's information without adding any dependencies/dependents. + * @return {!Node} + */ + cloneWithoutRelationships() { + return new Node(this.id); + } + + /** + * Clones the entire graph connected to this node filtered by the optional predicate. If a node is + * included by the predicate, all nodes along the paths between the two will be included. If the + * node that was called clone is not included in the resulting filtered graph, the return will be + * undefined. + * @param {function(!Node):boolean=} predicate + * @return {!Node|undefined} + */ + cloneWithRelationships(predicate) { + const rootNode = this.getRootNode(); + + let shouldIncludeNode = () => true; + if (predicate) { + const idsToInclude = new Set(); + rootNode.traverse(node => { + if (predicate(node)) { + node.traverse( + node => idsToInclude.add(node.id), + node => node._dependencies.filter(parent => !idsToInclude.has(parent)) + ); + } + }); + + shouldIncludeNode = node => idsToInclude.has(node.id); + } + + const idToNodeMap = new Map(); + rootNode.traverse(originalNode => { + if (!shouldIncludeNode(originalNode)) return; + const clonedNode = originalNode.cloneWithoutRelationships(); + idToNodeMap.set(clonedNode.id, clonedNode); + + for (const dependency of originalNode._dependencies) { + const clonedDependency = idToNodeMap.get(dependency.id); + clonedNode.addDependency(clonedDependency); + } + }); + + return idToNodeMap.get(this.id); + } + + /** + * Traverses all paths in the graph, calling iterator on each node visited. Decides which nodes to + * visit with the getNext function. + * @param {function(!Node,!Array)} iterator + * @param {function(!Node):!Array} getNext + */ + _traversePaths(iterator, getNext) { + const stack = [[this]]; + while (stack.length) { + const path = stack.shift(); + const node = path[0]; + iterator(node, path); + + const nodesToAdd = getNext(node); + for (const nextNode of nodesToAdd) { + stack.push([nextNode].concat(path)); + } + } + } + + /** + * Traverses all connected nodes exactly once, calling iterator on each. Decides which nodes to + * visit with the getNext function. + * @param {function(!Node,!Array)} iterator + * @param {function(!Node):!Array=} getNext Defaults to returning the dependents. + */ + traverse(iterator, getNext) { + if (!getNext) { + getNext = node => node.getDependents(); + } + + const visited = new Set(); + const originalGetNext = getNext; + + getNext = node => { + visited.add(node.id); + const allNodesToVisit = originalGetNext(node); + const nodesToVisit = allNodesToVisit.filter(nextNode => !visited.has(nextNode.id)); + nodesToVisit.forEach(nextNode => visited.add(nextNode.id)); + return nodesToVisit; + }; + + this._traversePaths(iterator, getNext); + } +} + +module.exports = Node; diff --git a/lighthouse-core/gather/computed/page-dependency-graph.js b/lighthouse-core/gather/computed/page-dependency-graph.js new file mode 100644 index 000000000000..58bf76377f48 --- /dev/null +++ b/lighthouse-core/gather/computed/page-dependency-graph.js @@ -0,0 +1,122 @@ +/** + * @license Copyright 2017 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +const ComputedArtifact = require('./computed-artifact'); +const Node = require('./dependency-graph/node'); +const Emulation = require('../../lib/emulation'); + +class PageDependencyGraphArtifact extends ComputedArtifact { + get name() { + return 'PageDependencyGraph'; + } + + get requiredNumberOfArtifacts() { + return 2; + } + + /** + * @param {!WebInspector.NetworkRequest} record + * @return {!Array} + */ + static getNetworkInitiators(record) { + if (!record._initiator) return []; + if (record._initiator.url) return [record._initiator.url]; + if (record._initiator.type === 'script') { + const frames = record._initiator.stack.callFrames; + return Array.from(new Set(frames.map(frame => frame.url))).filter(Boolean); + } + + return []; + } + + /** + * @param {!TraceOfTabArtifact} traceOfTab + * @param {!Array} networkRecords + * @return {!Node} + */ + static createGraph(traceOfTab, networkRecords) { + const idToNodeMap = new Map(); + const urlToNodeMap = new Map(); + + networkRecords.forEach(record => { + const node = new Node(record.requestId); + idToNodeMap.set(record.requestId, node); + + if (urlToNodeMap.has(record.url)) { + // If duplicate requests have been made to this URL we can't be certain which node is being + // referenced, so act like we don't know the URL at all. + urlToNodeMap.set(record.url, undefined); + } else { + urlToNodeMap.set(record.url, node); + } + }); + + const rootRequest = networkRecords + .reduce((min, next) => min.startTime < next.startTime ? min : next); + const rootNode = idToNodeMap.get(rootRequest.requestId); + networkRecords.forEach(record => { + const initiators = PageDependencyGraphArtifact.getNetworkInitiators(record); + const node = idToNodeMap.get(record.requestId); + if (initiators.length) { + initiators.forEach(initiator => { + const parent = urlToNodeMap.get(initiator) || rootNode; + parent.addDependent(node); + }); + } else if (record !== rootRequest) { + rootNode.addDependent(node); + } + }); + + return rootNode; + } + + /** + * @param {!Node} rootNode + * @return {number} + */ + static computeGraphDuration(rootNode) { + const depthByNodeId = new Map(); + const getMax = arr => Array.from(arr).reduce((max, next) => Math.max(max, next), 0); + + let startingMax = Infinity; + let endingMax = Infinity; + while (endingMax === Infinity || startingMax > endingMax) { + startingMax = endingMax; + endingMax = 0; + + rootNode.traverse(node => { + const dependencies = node.getDependencies(); + const dependencyDepths = dependencies.map(node => depthByNodeId.get(node.id) || Infinity); + const maxDepth = getMax(dependencyDepths); + endingMax = Math.max(endingMax, maxDepth); + depthByNodeId.set(node.id, maxDepth + 1); + }); + } + + const maxDepth = getMax(depthByNodeId.values()); + return maxDepth * Emulation.settings.TYPICAL_MOBILE_THROTTLING_METRICS.latency; + } + + /** + * @param {!Trace} trace + * @param {!DevtoolsLog} devtoolsLog + * @param {!ComputedArtifacts} artifacts + * @return {!Promise} + */ + compute_(trace, devtoolsLog, artifacts) { + const promises = [ + artifacts.requestTraceOfTab(trace), + artifacts.requestNetworkRecords(devtoolsLog), + ]; + + return Promise.all(promises).then(([traceOfTab, networkRecords]) => { + return PageDependencyGraphArtifact.createGraph(traceOfTab, networkRecords); + }); + } +} + +module.exports = PageDependencyGraphArtifact; diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js index feb4b85e5e80..42a8a8517207 100644 --- a/lighthouse-core/runner.js +++ b/lighthouse-core/runner.js @@ -256,9 +256,13 @@ class Runner { */ static instantiateComputedArtifacts() { const computedArtifacts = {}; + const filenamesToSkip = [ + 'computed-artifact.js', // the base class which other artifacts inherit + 'dependency-graph', // a folder containing dependencies, not an artifact + ]; + require('fs').readdirSync(__dirname + '/gather/computed').forEach(function(filename) { - // Skip base class. - if (filename === 'computed-artifact.js') return; + if (filenamesToSkip.includes(filename)) return; // Drop `.js` suffix to keep browserify import happy. filename = filename.replace(/\.js$/, ''); diff --git a/lighthouse-core/test/audits/predictive-perf-test.js b/lighthouse-core/test/audits/predictive-perf-test.js new file mode 100644 index 000000000000..14ba8634ffe3 --- /dev/null +++ b/lighthouse-core/test/audits/predictive-perf-test.js @@ -0,0 +1,34 @@ +/** + * @license Copyright 2017 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +const PredictivePerf = require('../../audits/predictive-perf.js'); +const Runner = require('../../runner.js'); +const assert = require('assert'); + +const acceptableTrace = require('../fixtures/traces/progressive-app-m60.json'); +const acceptableDevToolsLog = require('../fixtures/traces/progressive-app-m60.devtools.log.json'); + + +/* eslint-env mocha */ +describe('Performance: predictive performance audit', () => { + it('should compute the predicted values', () => { + const artifacts = Object.assign({ + traces: { + [PredictivePerf.DEFAULT_PASS]: acceptableTrace + }, + devtoolsLogs: { + [PredictivePerf.DEFAULT_PASS]: acceptableDevToolsLog + }, + }, Runner.instantiateComputedArtifacts()); + + return PredictivePerf.audit(artifacts).then(output => { + assert.equal(output.score, 97); + assert.equal(Math.round(output.rawValue), 2250); + assert.equal(output.displayValue, '2,250\xa0ms'); + }); + }); +}); diff --git a/lighthouse-core/test/gather/computed/dependency-graph/node-test.js b/lighthouse-core/test/gather/computed/dependency-graph/node-test.js new file mode 100644 index 000000000000..0b5cb6a21a51 --- /dev/null +++ b/lighthouse-core/test/gather/computed/dependency-graph/node-test.js @@ -0,0 +1,195 @@ +/** + * @license Copyright 2017 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +const Node = require('../../../../gather/computed/dependency-graph/node'); + +const assert = require('assert'); + +function sortedById(nodeArray) { + return nodeArray.sort((node1, node2) => node1.id.localeCompare(node2.id)); +} + +function createComplexGraph() { + // B F + // / \ / + // A D - E + // \ / \ + // C G - H + + const nodeA = new Node('A'); + const nodeB = new Node('B'); + const nodeC = new Node('C'); + const nodeD = new Node('D'); + const nodeE = new Node('E'); + const nodeF = new Node('F'); + const nodeG = new Node('G'); + const nodeH = new Node('H'); + + nodeA.addDependent(nodeB); + nodeA.addDependent(nodeC); + nodeB.addDependent(nodeD); + nodeC.addDependent(nodeD); + nodeD.addDependent(nodeE); + nodeE.addDependent(nodeF); + nodeE.addDependent(nodeG); + nodeG.addDependent(nodeH); + + return { + nodeA, + nodeB, + nodeC, + nodeD, + nodeE, + nodeF, + nodeG, + nodeH, + }; +} + +/* eslint-env mocha */ +describe('DependencyGraph/Node', () => { + describe('#constructor', () => { + it('should set the ID', () => { + const node = new Node('foo'); + assert.equal(node.id, 'foo'); + }); + }); + + describe('.addDependent', () => { + it('should add the correct edge', () => { + const nodeA = new Node(1); + const nodeB = new Node(2); + nodeA.addDependent(nodeB); + + assert.deepEqual(nodeA.getDependents(), [nodeB]); + assert.deepEqual(nodeB.getDependencies(), [nodeA]); + }); + }); + + describe('.addDependency', () => { + it('should add the correct edge', () => { + const nodeA = new Node(1); + const nodeB = new Node(2); + nodeA.addDependency(nodeB); + + assert.deepEqual(nodeA.getDependencies(), [nodeB]); + assert.deepEqual(nodeB.getDependents(), [nodeA]); + }); + }); + + describe('.getRootNode', () => { + it('should return the root node', () => { + const graph = createComplexGraph(); + + assert.equal(graph.nodeA.getRootNode(), graph.nodeA); + assert.equal(graph.nodeB.getRootNode(), graph.nodeA); + assert.equal(graph.nodeD.getRootNode(), graph.nodeA); + assert.equal(graph.nodeF.getRootNode(), graph.nodeA); + }); + }); + + describe('.cloneWithoutRelationships', () => { + it('should create a copy', () => { + const node = new Node(1); + const neighbor = new Node(2); + node.addDependency(neighbor); + const clone = node.cloneWithoutRelationships(); + + assert.equal(clone.id, 1); + assert.notEqual(node, clone); + assert.equal(clone.getDependencies().length, 0); + }); + }); + + describe('.cloneWithRelationships', () => { + it('should create a copy of a basic graph', () => { + const node = new Node(1); + const neighbor = new Node(2); + node.addDependency(neighbor); + const clone = node.cloneWithRelationships(); + + assert.equal(clone.id, 1); + assert.notEqual(node, clone); + + const dependencies = clone.getDependencies(); + assert.equal(dependencies.length, 1); + + const neighborClone = dependencies[0]; + assert.equal(neighborClone.id, neighbor.id); + assert.notEqual(neighborClone, neighbor); + assert.equal(neighborClone.getDependents()[0], clone); + }); + + it('should create a copy of a complex graph', () => { + const graph = createComplexGraph(); + const clone = graph.nodeA.cloneWithRelationships(); + + const clonedIdMap = new Map(); + clone.traverse(node => clonedIdMap.set(node.id, node)); + assert.equal(clonedIdMap.size, 8); + + graph.nodeA.traverse(node => { + const clone = clonedIdMap.get(node.id); + assert.equal(clone.id, node.id); + assert.notEqual(clone, node); + + const actualDependents = sortedById(clone.getDependents()); + const expectedDependents = sortedById(node.getDependents()); + actualDependents.forEach((cloneDependent, index) => { + const originalDependent = expectedDependents[index]; + assert.equal(cloneDependent.id, originalDependent.id); + assert.notEqual(cloneDependent, originalDependent); + }); + }); + }); + + it('should create a copy when not starting at root node', () => { + const graph = createComplexGraph(); + const cloneD = graph.nodeD.cloneWithRelationships(); + assert.equal(cloneD.id, 'D'); + assert.equal(cloneD.getRootNode().id, 'A'); + }); + + it('should create a partial copy of a complex graph', () => { + const graph = createComplexGraph(); + // create a clone with F and all its dependencies + const clone = graph.nodeA.cloneWithRelationships(node => node.id === 'F'); + + const clonedIdMap = new Map(); + clone.traverse(node => clonedIdMap.set(node.id, node)); + + assert.equal(clonedIdMap.size, 6); + assert.ok(clonedIdMap.has('F'), 'did not include target node'); + assert.ok(clonedIdMap.has('E'), 'did not include dependency'); + assert.ok(clonedIdMap.has('B'), 'did not include branched dependency'); + assert.ok(clonedIdMap.has('C'), 'did not include branched dependency'); + assert.equal(clonedIdMap.get('G'), undefined); + assert.equal(clonedIdMap.get('H'), undefined); + }); + }); + + describe('.traverse', () => { + it('should visit every dependent node', () => { + const graph = createComplexGraph(); + const ids = []; + graph.nodeA.traverse(node => ids.push(node.id)); + + assert.deepEqual(ids, ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']); + }); + + it('should respect getNext', () => { + const graph = createComplexGraph(); + const ids = []; + graph.nodeF.traverse( + node => ids.push(node.id), + node => node.getDependencies() + ); + + assert.deepEqual(ids, ['F', 'E', 'D', 'B', 'C', 'A']); + }); + }); +}); diff --git a/lighthouse-core/test/gather/computed/page-dependency-graph-test.js b/lighthouse-core/test/gather/computed/page-dependency-graph-test.js new file mode 100644 index 000000000000..a909486856fa --- /dev/null +++ b/lighthouse-core/test/gather/computed/page-dependency-graph-test.js @@ -0,0 +1,114 @@ +/** + * @license Copyright 2017 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +const PageDependencyGraph = require('../../../gather/computed/page-dependency-graph'); +const Node = require('../../../gather/computed/dependency-graph/node'); +const Runner = require('../../../runner.js'); + +const sampleTrace = require('../../fixtures/traces/progressive-app-m60.json'); +const sampleDevtoolsLog = require('../../fixtures/traces/progressive-app-m60.devtools.log.json'); + +const assert = require('assert'); + +function createRequest(requestId, url, startTime, _initiator) { + return {requestId, url, startTime, _initiator}; +} + +/* eslint-env mocha */ +describe('PageDependencyGraph computed artifact:', () => { + let computedArtifacts; + + beforeEach(() => { + computedArtifacts = Runner.instantiateComputedArtifacts(); + }); + + describe('#compute_', () => { + it('should compute the dependency graph', () => { + return computedArtifacts.requestPageDependencyGraph( + sampleTrace, + sampleDevtoolsLog + ).then(output => { + assert.ok(output instanceof Node, 'did not return a graph'); + + const dependents = output.getDependents(); + const nodeWithNestedDependents = dependents.find(node => node.getDependents().length); + assert.ok(nodeWithNestedDependents, 'did not link initiators'); + }); + }); + }); + + describe('#createGraph', () => { + it('should compute a simple graph', () => { + const request1 = createRequest(1, '1', 0); + const request2 = createRequest(2, '2', 5); + const request3 = createRequest(3, '3', 5); + const request4 = createRequest(4, '4', 10, {url: '2'}); + const networkRecords = [request1, request2, request3, request4]; + + const graph = PageDependencyGraph.createGraph({}, networkRecords); + const nodes = []; + graph.traverse(node => nodes.push(node)); + + assert.equal(nodes.length, 4); + assert.deepEqual(nodes.map(node => node.id), [1, 2, 3, 4]); + assert.deepEqual(nodes[0].getDependencies(), []); + assert.deepEqual(nodes[1].getDependencies(), [nodes[0]]); + assert.deepEqual(nodes[2].getDependencies(), [nodes[0]]); + assert.deepEqual(nodes[3].getDependencies(), [nodes[1]]); + }); + + it('should compute a graph with duplicate URLs', () => { + const request1 = createRequest(1, '1', 0); + const request2 = createRequest(2, '2', 5); + const request3 = createRequest(3, '2', 5); // duplicate URL + const request4 = createRequest(4, '4', 10, {url: '2'}); + const networkRecords = [request1, request2, request3, request4]; + + const graph = PageDependencyGraph.createGraph({}, networkRecords); + const nodes = []; + graph.traverse(node => nodes.push(node)); + + assert.equal(nodes.length, 4); + assert.deepEqual(nodes.map(node => node.id), [1, 2, 3, 4]); + assert.deepEqual(nodes[0].getDependencies(), []); + assert.deepEqual(nodes[1].getDependencies(), [nodes[0]]); + assert.deepEqual(nodes[2].getDependencies(), [nodes[0]]); + assert.deepEqual(nodes[3].getDependencies(), [nodes[0]]); // should depend on rootNode instead + }); + }); + + describe('#computeGraphDuration', () => { + it('should compute graph duration', () => { + // B - C - D - E - F + // / / \ + // A - * - * - * - * G - H + + const nodeA = new Node('A'); + const nodeB = new Node('B'); + const nodeC = new Node('C'); + const nodeD = new Node('D'); + const nodeE = new Node('E'); + const nodeF = new Node('F'); + const nodeG = new Node('G'); + const nodeH = new Node('H'); + + nodeA.addDependent(nodeB); + nodeA.addDependent(nodeE); + + nodeB.addDependent(nodeC); + nodeC.addDependent(nodeD); + nodeD.addDependent(nodeE); + nodeE.addDependent(nodeF); + nodeF.addDependent(nodeG); + + nodeG.addDependent(nodeH); + + const result = PageDependencyGraph.computeGraphDuration(nodeA); + assert.equal(result, 4500); // 7 hops * ~560ms latency/hop + }); + }); +});