// JSLitmus.js // // Copyright (c) 2010, Robert Kieffer, http://broofa.com // Available under MIT license (http://en.wikipedia.org/wiki/MIT_License) (function() { // Private methods and state // Get platform info but don't go crazy trying to recognize everything // that's out there. This is just for the major platforms and OSes. var platform = 'unknown platform', ua = navigator.userAgent; // Detect OS var oses = ['Windows','iPhone OS','(Intel |PPC )?Mac OS X','Linux'].join('|'); var pOS = new RegExp('((' + oses + ') [^ \);]*)').test(ua) ? RegExp.$1 : null; if (!pOS) pOS = new RegExp('((' + oses + ')[^ \);]*)').test(ua) ? RegExp.$1 : null; // Detect browser var pName = /(Chrome|MSIE|Safari|Opera|Firefox)/.test(ua) ? RegExp.$1 : null; // Detect version var vre = new RegExp('(Version|' + pName + ')[ \/]([^ ;]*)'); var pVersion = (pName && vre.test(ua)) ? RegExp.$2 : null; var platform = (pOS && pName && pVersion) ? pName + ' ' + pVersion + ' on ' + pOS : 'unknown platform'; /** * A smattering of methods that are needed to implement the JSLitmus testbed. */ var jsl = { /** * Enhanced version of escape() */ escape: function(s) { s = s.replace(/,/g, '\\,'); s = escape(s); s = s.replace(/\+/g, '%2b'); s = s.replace(/ /g, '+'); return s; }, /** * Get an element by ID. */ $: function(id) { return document.getElementById(id); }, /** * Null function */ F: function() {}, /** * Set the status shown in the UI */ status: function(msg) { var el = jsl.$('jsl_status'); if (el) el.innerHTML = msg || ''; }, /** * Convert a number to an abbreviated string like, "15K" or "10M" */ toLabel: function(n) { if (n == Infinity) { return 'Infinity'; } else if (n > 1e9) { n = Math.round(n/1e8); return n/10 + 'B'; } else if (n > 1e6) { n = Math.round(n/1e5); return n/10 + 'M'; } else if (n > 1e3) { n = Math.round(n/1e2); return n/10 + 'K'; } return n; }, /** * Copy properties from src to dst */ extend: function(dst, src) { for (var k in src) dst[k] = src[k]; return dst; }, /** * Like Array.join(), but for the key-value pairs in an object */ join: function(o, delimit1, delimit2) { if (o.join) return o.join(delimit1); // If it's an array var pairs = []; for (var k in o) pairs.push(k + delimit1 + o[k]); return pairs.join(delimit2); }, /** * Array#indexOf isn't supported in IE, so we use this as a cross-browser solution */ indexOf: function(arr, o) { if (arr.indexOf) return arr.indexOf(o); for (var i = 0; i < this.length; i++) if (arr[i] === o) return i; return -1; } }; /** * Test manages a single test (created with * JSLitmus.test()) * * @private */ var Test = function (name, f) { if (!f) throw new Error('Undefined test function'); if (!/function[^\(]*\(([^,\)]*)/.test(f.toString())) { throw new Error('"' + name + '" test: Test is not a valid Function object'); } this.loopArg = RegExp.$1; this.name = name; this.f = f; }; jsl.extend(Test, /** @lends Test */ { /** Calibration tests for establishing iteration loop overhead */ CALIBRATIONS: [ new Test('calibrating loop', function(count) {while (count--);}), new Test('calibrating function', jsl.F) ], /** * Run calibration tests. Returns true if calibrations are not yet * complete (in which case calling code should run the tests yet again). * onCalibrated - Callback to invoke when calibrations have finished */ calibrate: function(onCalibrated) { for (var i = 0; i < Test.CALIBRATIONS.length; i++) { var cal = Test.CALIBRATIONS[i]; if (cal.running) return true; if (!cal.count) { cal.isCalibration = true; cal.onStop = onCalibrated; //cal.MIN_TIME = .1; // Do calibrations quickly cal.run(2e4); return true; } } return false; } }); jsl.extend(Test.prototype, {/** @lends Test.prototype */ /** Initial number of iterations */ INIT_COUNT: 10, /** Max iterations allowed (i.e. used to detect bad looping functions) */ MAX_COUNT: 1e9, /** Minimum time a test should take to get valid results (secs) */ MIN_TIME: .5, /** Callback invoked when test state changes */ onChange: jsl.F, /** Callback invoked when test is finished */ onStop: jsl.F, /** * Reset test state */ reset: function() { delete this.count; delete this.time; delete this.running; delete this.error; }, /** * Run the test (in a timeout). We use a timeout to make sure the browser * has a chance to finish rendering any UI changes we've made, like * updating the status message. */ run: function(count) { count = count || this.INIT_COUNT; jsl.status(this.name + ' x ' + count); this.running = true; var me = this; setTimeout(function() {me._run(count);}, 200); }, /** * The nuts and bolts code that actually runs a test */ _run: function(count) { var me = this; // Make sure calibration tests have run if (!me.isCalibration && Test.calibrate(function() {me.run(count);})) return; this.error = null; try { var start, f = this.f, now, i = count; // Start the timer start = new Date(); // Now for the money shot. If this is a looping function ... if (this.loopArg) { // ... let it do the iteration itself f(count); } else { // ... otherwise do the iteration for it while (i--) f(); } // Get time test took (in secs) this.time = Math.max(1,new Date() - start)/1000; // Store iteration count and per-operation time taken this.count = count; this.period = this.time/count; // Do we need to do another run? this.running = this.time <= this.MIN_TIME; // ... if so, compute how many times we should iterate if (this.running) { // Bump the count to the nearest power of 2 var x = this.MIN_TIME/this.time; var pow = Math.pow(2, Math.max(1, Math.ceil(Math.log(x)/Math.log(2)))); count *= pow; if (count > this.MAX_COUNT) { throw new Error('Max count exceeded. If this test uses a looping function, make sure the iteration loop is working properly.'); } } } catch (e) { // Exceptions are caught and displayed in the test UI this.reset(); this.error = e; } // Figure out what to do next if (this.running) { me.run(count); } else { jsl.status(''); me.onStop(me); } // Finish up this.onChange(this); }, /** * Get the number of operations per second for this test. * * @param normalize if true, iteration loop overhead taken into account */ getHz: function(/**Boolean*/ normalize) { var p = this.period; // Adjust period based on the calibration test time if (normalize && !this.isCalibration) { var cal = Test.CALIBRATIONS[this.loopArg ? 0 : 1]; // If the period is within 20% of the calibration time, then zero the // it out p = p < cal.period*1.2 ? 0 : p - cal.period; } return Math.round(1/p); }, /** * Get a friendly string describing the test */ toString: function() { return this.name + ' - ' + this.time/this.count + ' secs'; } }); // CSS we need for the UI var STYLESHEET = ''; // HTML markup for the UI var MARKUP = '
\ \ \
\
\ Normalize results \ \ \ \ \ \ \ \ \ \ \ \
' + platform + '
TestOps/sec
\
\ \ Powered by JSLitmus \
'; /** * The public API for creating and running tests */ window.JSLitmus = { /** The list of all tests that have been registered with JSLitmus.test */ _tests: [], /** The queue of tests that need to be run */ _queue: [], /** * The parsed query parameters the current page URL. This is provided as a * convenience for test functions - it's not used by JSLitmus proper */ params: {}, /** * Initialize */ _init: function() { // Parse query params into JSLitmus.params[] hash var match = (location + '').match(/([^?#]*)(#.*)?$/); if (match) { var pairs = match[1].split('&'); for (var i = 0; i < pairs.length; i++) { var pair = pairs[i].split('='); if (pair.length > 1) { var key = pair.shift(); var value = pair.length > 1 ? pair.join('=') : pair[0]; this.params[key] = value; } } } // Write out the stylesheet. We have to do this here because IE // doesn't honor sheets written after the document has loaded. document.write(STYLESHEET); // Setup the rest of the UI once the document is loaded if (window.addEventListener) { window.addEventListener('load', this._setup, false); } else if (document.addEventListener) { document.addEventListener('load', this._setup, false); } else if (window.attachEvent) { window.attachEvent('onload', this._setup); } return this; }, /** * Set up the UI */ _setup: function() { var el = jsl.$('jslitmus_container'); if (!el) document.body.appendChild(el = document.createElement('div')); el.innerHTML = MARKUP; // Render the UI for all our tests for (var i=0; i < JSLitmus._tests.length; i++) JSLitmus.renderTest(JSLitmus._tests[i]); }, /** * (Re)render all the test results */ renderAll: function() { for (var i = 0; i < JSLitmus._tests.length; i++) JSLitmus.renderTest(JSLitmus._tests[i]); JSLitmus.renderChart(); }, /** * (Re)render the chart graphics */ renderChart: function() { var url = JSLitmus.chartUrl(); jsl.$('chart_link').href = url; jsl.$('chart_image').src = url; jsl.$('chart').style.display = ''; // Update the tiny URL jsl.$('tiny_url').src = 'http://tinyurl.com/api-create.php?url='+escape(url); }, /** * (Re)render the results for a specific test */ renderTest: function(test) { // Make a new row if needed if (!test._row) { var trow = jsl.$('test_row_template'); if (!trow) return; test._row = trow.cloneNode(true); test._row.style.display = ''; test._row.id = ''; test._row.onclick = function() {JSLitmus._queueTest(test);}; test._row.title = 'Run ' + test.name + ' test'; trow.parentNode.appendChild(test._row); test._row.cells[0].innerHTML = test.name; } var cell = test._row.cells[1]; var cns = [test.loopArg ? 'test_looping' : 'test_nonlooping']; if (test.error) { cns.push('test_error'); cell.innerHTML = '
' + test.error + '
' + ''; } else { if (test.running) { cns.push('test_running'); cell.innerHTML = 'running'; } else if (jsl.indexOf(JSLitmus._queue, test) >= 0) { cns.push('test_pending'); cell.innerHTML = 'pending'; } else if (test.count) { cns.push('test_done'); var hz = test.getHz(jsl.$('test_normalize').checked); cell.innerHTML = hz != Infinity ? hz : '∞'; cell.title = 'Looped ' + test.count + ' times in ' + test.time + ' seconds'; } else { cell.innerHTML = 'ready'; } } cell.className = cns.join(' '); }, /** * Create a new test */ test: function(name, f) { // Create the Test object var test = new Test(name, f); JSLitmus._tests.push(test); // Re-render if the test state changes test.onChange = JSLitmus.renderTest; // Run the next test if this one finished test.onStop = function(test) { if (JSLitmus.onTestFinish) JSLitmus.onTestFinish(test); JSLitmus.currentTest = null; JSLitmus._nextTest(); }; // Render the new test this.renderTest(test); }, /** * Add all tests to the run queue */ runAll: function(e) { e = e || window.event; var reverse = e && e.shiftKey, len = JSLitmus._tests.length; for (var i = 0; i < len; i++) { JSLitmus._queueTest(JSLitmus._tests[!reverse ? i : (len - i - 1)]); } }, /** * Remove all tests from the run queue. The current test has to finish on * it's own though */ stop: function() { while (JSLitmus._queue.length) { var test = JSLitmus._queue.shift(); JSLitmus.renderTest(test); } }, /** * Run the next test in the run queue */ _nextTest: function() { if (!JSLitmus.currentTest) { var test = JSLitmus._queue.shift(); if (test) { jsl.$('stop_button').disabled = false; JSLitmus.currentTest = test; test.run(); JSLitmus.renderTest(test); if (JSLitmus.onTestStart) JSLitmus.onTestStart(test); } else { jsl.$('stop_button').disabled = true; JSLitmus.renderChart(); } } }, /** * Add a test to the run queue */ _queueTest: function(test) { if (jsl.indexOf(JSLitmus._queue, test) >= 0) return; JSLitmus._queue.push(test); JSLitmus.renderTest(test); JSLitmus._nextTest(); }, /** * Generate a Google Chart URL that shows the data for all tests */ chartUrl: function() { var n = JSLitmus._tests.length, markers = [], data = []; var d, min = 0, max = -1e10; var normalize = jsl.$('test_normalize').checked; // Gather test data for (var i=0; i < JSLitmus._tests.length; i++) { var test = JSLitmus._tests[i]; if (test.count) { var hz = test.getHz(normalize); var v = hz != Infinity ? hz : 0; data.push(v); markers.push('t' + jsl.escape(test.name + '(' + jsl.toLabel(hz)+ ')') + ',000000,0,' + markers.length + ',10'); max = Math.max(v, max); } } if (markers.length <= 0) return null; // Build chart title var title = document.getElementsByTagName('title'); title = (title && title.length) ? title[0].innerHTML : null; var chart_title = []; if (title) chart_title.push(title); chart_title.push('Ops/sec (' + platform + ')'); // Build labels var labels = [jsl.toLabel(min), jsl.toLabel(max)]; var w = 250, bw = 15; var bs = 5; var h = markers.length*(bw + bs) + 30 + chart_title.length*20; var params = { chtt: escape(chart_title.join('|')), chts: '000000,10', cht: 'bhg', // chart type chd: 't:' + data.join(','), // data set chds: min + ',' + max, // max/min of data chxt: 'x', // label axes chxl: '0:|' + labels.join('|'), // labels chsp: '0,1', chm: markers.join('|'), // test names chbh: [bw, 0, bs].join(','), // bar widths // chf: 'bg,lg,0,eeeeee,0,eeeeee,.5,ffffff,1', // gradient chs: w + 'x' + h }; return 'http://chart.apis.google.com/chart?' + jsl.join(params, '=', '&'); } }; JSLitmus._init(); })();