Skip to content
This repository was archived by the owner on Jul 29, 2024. It is now read-only.

Commit 5a404c2

Browse files
committed
feat(timeline): add timeline plugin
This plugin gathers test timeline information from the protractor test process, the selenium client logs (if available), and sauce labs (if available), and presents the output visually. This improves understanding of where latency issues are in tests. See #674 Usage: Add the plugin to your configuration file: ```js exports.config = { plugins: [{ path: 'node_modules/protractor/plugins/timelinePlugin.js', // Output json and html will go in this folder. outdir: 'timelines', // Optional - if sauceUser and sauceKey are specified, logs from // SauceLabs will also be parsed after test invocation. sauceUser: 'Jane', sauceKey: 'abcdefg' }], // other configuration settings }; ```
1 parent a9d83f7 commit 5a404c2

File tree

9 files changed

+1101
-0
lines changed

9 files changed

+1101
-0
lines changed

lib/plugins.js

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ var log_ = function() {
1919
* and current consists of only two functions:
2020
* <plugin>.setup - called before tests
2121
* <plugin>.teardown - called after tests
22+
* <plugin>.postResults - called after test results have been processed
2223
* More information on plugins coming in the future
2324
* @constructor
2425
* @param {Object} config parsed from the config file

plugins/timeline/index.js

+344
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
var q = require('q'),
2+
fs = require('fs'),
3+
path = require('path'),
4+
SauceLabs = require('saucelabs'),
5+
https = require('https');
6+
7+
var SAUCE_LOGS_WAIT = 5000;
8+
9+
/**
10+
* Outputs information about where your Protractor test is spending its time
11+
* to the specified folder. A JSON data file and small index.html to view
12+
* it will be created. The page uses Google Charts to show the timeline.
13+
*
14+
* You enable this plugin in your config file:
15+
*
16+
* exports.config = {
17+
* plugins: [{
18+
* path: 'node_modules/protractor/plugins/timeline',
19+
*
20+
* // Output json and html will go in this folder. Relative
21+
* // to current working directory of the process.
22+
* // TODO - it would make more sense for this to be relative
23+
* // to the config file - reconsider this setup
24+
* outdir: 'timelines',
25+
*
26+
* // Optional - if sauceUser and sauceKey are specified, logs from
27+
* // SauceLabs will also be parsed after test invocation.
28+
* sauceUser: 'Jane',
29+
* sauceKey: 'abcdefg'
30+
* }]
31+
* };
32+
*
33+
* The plugin will create timeline entries from
34+
* - The Protractor test process itself.
35+
* - The WebDriver Selenium Server (these logs are unavailable for Internet
36+
* Explorer and for Chrome test run over Sauce Labs).
37+
* - Sauce Labs job logs, if sauceUser and sauceKey are specified.
38+
*
39+
* @constructor
40+
*/
41+
var TimelinePlugin = function() {
42+
// Timelines are of the format:
43+
// Array<{
44+
// source: string,
45+
// id: number,
46+
// command: string,
47+
// start: number,
48+
// end: number
49+
// }>
50+
this.timeline = [];
51+
52+
this.clientLogAvailable = false;
53+
this.outdir;
54+
this.sessionId;
55+
this.testProcessSetTimeoutTimestamp = 0;
56+
}
57+
58+
/**
59+
* Parse a selenium log in array form. For example, the logs returned
60+
* from the selenium standalone server are returned as arrays.
61+
*
62+
* @param {Array<Object>} logArr The selenium server logs.
63+
* @param {string} sourceName Descripton of source.
64+
* @param {number} referenceStart Date in millis.
65+
*/
66+
TimelinePlugin.parseArrayLog = function(logArr, sourceName, referenceStart) {
67+
return TimelinePlugin.parseLog(logArr, sourceName, {
68+
isEventStart: function(event) {
69+
return /Executing:/.test(event.message);
70+
},
71+
isEventEnd: function(event) {
72+
return /Done:/.test(event.message);
73+
},
74+
extractCommand: function(event) {
75+
// Messages from the Selenium Standalone server are of the form
76+
// org...DriverServlet Executing: [command: details [params]] at URL /url/
77+
return /Executing: \[([^:^\]]*)/.exec(event.message)[1];
78+
},
79+
extractTimestamp: function(event) {
80+
return event.timestamp;
81+
}
82+
}, referenceStart);
83+
}
84+
85+
/**
86+
* Parse a selenium log from a string. For example, the logs returned from
87+
* Sauce Labs are available only as plain text.
88+
*
89+
* @param {string} text The text logs.
90+
* @param {string} sourceName Descripton of source.
91+
* @param {number} referenceStart Date in millis.
92+
*/
93+
TimelinePlugin.parseTextLog = function(text, sourceName, referenceStart) {
94+
var logLines = text.split('\n');
95+
var actions;
96+
97+
// Look for 'standalone server' in the first couple lines of the log.
98+
if (/standalone server/.test(logLines.slice(0, 3).join(' '))) {
99+
// This is a Selenium Standalone Server log.
100+
actions = {
101+
isEventStart: function(event) {
102+
return /INFO - Executing:/.test(event);
103+
},
104+
isEventEnd: function(event) {
105+
return /INFO - Done:/.test(event);
106+
},
107+
extractCommand: function(event) {
108+
// Messages are of the form
109+
// timestamp INFO - Executing: [command: details; [params]]
110+
return /Executing: \[([^:^\]]*)/.exec(event)[1];
111+
},
112+
extractTimestamp: function(event) {
113+
// Timestamps begin the line and are formatted as
114+
// HH:MM:SS.SSS
115+
// We don't care about the date so just set it to 0.
116+
return Date.parse('01 Jan 1970 ' + event.slice(0, 12));
117+
}
118+
};
119+
} else {
120+
// This is a ChromeDriver log.
121+
actions = {
122+
isEventStart: function(event) {
123+
return /: COMMAND/.test(event);
124+
},
125+
isEventEnd: function(event) {
126+
return /: RESPONSE/.test(event);
127+
},
128+
extractCommand: function(event) {
129+
return /: COMMAND ([^\s]*)/.exec(event)[1];
130+
},
131+
extractTimestamp: function(event) {
132+
return parseFloat(/^\[?([^\]]*)/.exec(event)[1]) * 1000;
133+
}
134+
};
135+
}
136+
137+
return TimelinePlugin.parseLog(logLines, sourceName, actions, referenceStart);
138+
};
139+
140+
141+
/**
142+
* Parse a selenium log.
143+
*
144+
* @param {Array<Object>} entries The list of entries.
145+
* @param {string} sourceName Descripton of source.
146+
* @param {isEventStart: function,
147+
isEventEnd: function,
148+
extractCommand: function,
149+
extractTimestamp: function} actions Methods to interpret entries.
150+
* @param {number} referenceStart Date in millis.
151+
*/
152+
TimelinePlugin.parseLog =
153+
function(entries, sourceName, actions, referenceStart) {
154+
var parsedTimeline = [];
155+
var currentEvent = {};
156+
var index = 0;
157+
var relativeStartTime = 0;
158+
for (var j = 0; j < entries.length; ++j) {
159+
var event = entries[j];
160+
if (actions.isEventStart(event)) {
161+
currentEvent = {
162+
source: sourceName,
163+
id: index++,
164+
command: actions.extractCommand(event),
165+
start: actions.extractTimestamp(event)
166+
};
167+
if (!relativeStartTime &&
168+
currentEvent.command.toString() == 'setScriptTimeout' ||
169+
currentEvent.command.toString() == 'set script timeout' ||
170+
// [sic], the timeoutt typo is present in the logs
171+
currentEvent.command.toString() == 'set script timeoutt' ||
172+
currentEvent.command.toString() == 'SetScriptTimeout') {
173+
relativeStartTime = currentEvent.start;
174+
}
175+
} else if (actions.isEventEnd(event)) {
176+
currentEvent.end = actions.extractTimestamp(event);
177+
currentEvent.duration = currentEvent.end - currentEvent.start;
178+
parsedTimeline.push(currentEvent);
179+
}
180+
}
181+
182+
// Make all the times relative to the first time log types is fetched.
183+
for (var k = 0; k < parsedTimeline.length; ++k) {
184+
parsedTimeline[k].start += (referenceStart - relativeStartTime);
185+
parsedTimeline[k].end += (referenceStart - relativeStartTime);
186+
}
187+
188+
return parsedTimeline;
189+
};
190+
191+
TimelinePlugin.prototype.outputResults = function(done) {
192+
try {
193+
fs.mkdirSync(this.outdir);
194+
} catch(e) {
195+
if ( e.code != 'EEXIST' ) throw e;
196+
}
197+
var stream = fs.createReadStream(
198+
path.join(__dirname, 'indextemplate.html'));
199+
var outfile = path.join(this.outdir, 'timeline.json');
200+
fs.writeFileSync(outfile, JSON.stringify(this.timeline));
201+
stream.pipe(fs.createWriteStream(path.join(this.outdir, 'index.html')))
202+
stream.on('end', done);
203+
};
204+
205+
/**
206+
* @param {Object} config The configuration file for the ngHint plugin.
207+
*/
208+
TimelinePlugin.prototype.setup = function(config) {
209+
var self = this;
210+
var deferred = q.defer();
211+
self.outdir = path.resolve(process.cwd(), config.outdir);
212+
var counter = 0;
213+
214+
// Override executor so that we get information about commands starting
215+
// and stopping.
216+
var originalExecute = browser.driver.executor_.execute;
217+
browser.driver.executor_.execute = function(command, callback) {
218+
var timelineEvent = {
219+
source: 'Test Process',
220+
id: counter++,
221+
command: command,
222+
start: new Date().getTime(),
223+
end: null
224+
};
225+
if (!self.testProcessSetTimeoutTimestamp &&
226+
timelineEvent.command.name_ =='setScriptTimeout') {
227+
self.testProcessSetTimeoutTimestamp = timelineEvent.start;
228+
}
229+
self.timeline.push(timelineEvent);
230+
var wrappedCallback = function(var_args) {
231+
timelineEvent.end = new Date().getTime();
232+
callback.apply(this, arguments);
233+
};
234+
originalExecute.apply(browser.driver.executor_, [command, wrappedCallback]);
235+
};
236+
237+
// Clear the logs here.
238+
browser.manage().logs().getAvailableLogTypes().then(function(result) {
239+
// The Selenium standalone server stores its logs in the 'client' channel.
240+
if (result.indexOf('client') !== -1) {
241+
self.clientLogAvailable = true;
242+
deferred.resolve();
243+
// browser.manage().logs().get('client').then(function() {
244+
// deferred.resolve();
245+
// });
246+
} else {
247+
deferred.resolve();
248+
}
249+
}, function(error) {
250+
// No logs are available - this will happen for Internet Explorer, which
251+
// does not implement webdriver logs. See
252+
// https://code.google.com/p/selenium/issues/detail?id=4925
253+
deferred.resolve();
254+
});
255+
return deferred.promise;
256+
};
257+
258+
/**
259+
* @param {Object} config The configuration file for the ngHint plugin.
260+
*/
261+
TimelinePlugin.prototype.teardown = function(config) {
262+
var self = this;
263+
var deferred = q.defer();
264+
// This will be needed later for grabbing data from Sauce Labs.
265+
browser.getSession().then(function(session) {
266+
self.sessionId = session.getId();
267+
});
268+
269+
// If running with a Selenium Standalone server, get the client logs.
270+
if (self.clientLogAvailable) {
271+
browser.manage().logs().get('client').then(function(result) {
272+
var serverTimeline =TimelinePlugin.parseArrayLog(
273+
result, 'Selenium Client', self.testProcessSetTimeoutTimestamp);
274+
self.timeline = self.timeline.concat(serverTimeline);
275+
deferred.resolve();
276+
});
277+
} else {
278+
deferred.resolve();
279+
}
280+
return deferred.promise;
281+
};
282+
283+
/**
284+
* @param {Object} config The configuration file for the ngHint plugin.
285+
*/
286+
TimelinePlugin.prototype.postResults = function(config) {
287+
var self = this;
288+
var deferred = q.defer();
289+
// We can't get Chrome or IE logs from Sauce Labs via the webdriver logs API
290+
// because it does not expose them.
291+
// TODO - if the feature request at
292+
// https://support.saucelabs.com/entries/60070884-Enable-grabbing-server-logs-from-the-wire-protocol
293+
// gets implemented, remove this hack.
294+
if (config.sauceUser && config.sauceKey) {
295+
// WARNING, HACK: we have a timeout to deal with the fact that there's a
296+
// delay before Sauce Labs updates logs.
297+
setTimeout(function() {
298+
var sauceServer = new SauceLabs({
299+
username: config.sauceUser,
300+
password: config.sauceKey
301+
});
302+
303+
sauceServer.showJob(self.sessionId, function(err, job) {
304+
var sauceLog = '';
305+
if (!job.log_url) {
306+
console.log('WARNING - no Sauce Labs log url found');
307+
deferred.resolve();
308+
return;
309+
}
310+
https.get(job.log_url, function(res) {
311+
res.on('data', function(data) {
312+
sauceLog += data;
313+
});
314+
315+
res.on('end', function() {
316+
var sauceTimeline =
317+
TimelinePlugin.parseTextLog(
318+
sauceLog,
319+
'SauceLabs Server',
320+
self.testProcessSetTimeoutTimestamp);
321+
self.timeline = self.timeline.concat(sauceTimeline);
322+
self.outputResults(deferred.resolve);
323+
});
324+
325+
}).on('error', function(e) {
326+
console.error(e);
327+
});
328+
});
329+
}, SAUCE_LOGS_WAIT);
330+
} else {
331+
self.outputResults(deferred.resolve);
332+
}
333+
return deferred.promise;
334+
};
335+
336+
337+
// Export
338+
339+
var timelinePlugin = new TimelinePlugin();
340+
341+
exports.setup = timelinePlugin.setup.bind(timelinePlugin);
342+
exports.teardown = timelinePlugin.teardown.bind(timelinePlugin);
343+
exports.postResults = timelinePlugin.postResults.bind(timelinePlugin);
344+
exports.TimelinePlugin = TimelinePlugin;

0 commit comments

Comments
 (0)