initial revision

test-unit-sauce
Brian Ford 12 years ago
commit 171033e958

@ -0,0 +1,55 @@
# AngularJS Batarang
## Getting it
Checkout my fork of AngularJS from Github:
git clone git://github.com/btford/angular.js.git
cd angular.js
git fetch origin extension
git checkout origin/extension
## Installation
You'll need [Google Chrome Canary](https://tools.google.com/dlpage/chromesxs) to use the extension.
1. Navigate to `chrome://flags/` in Canary, and enable "Experimental Extension APIs"
2. Navigate to `chrome://chrome/extensions/` and enable Developer Mode.
3. On the same page, click "Load unpacked extension" and choose the "extension" directory inside the repository that you just checked out.
## Using the Batarang
First, navigate Chrome Canary to the AngularJS application that you want to debug. [Open the Developer Tools](https://developers.google.com/chrome-developer-tools/docs/overview#access). There should be an AngularJS icon. Click on it to open he AngularJS Batarang.
The Batarang has four tabs: Model, Performance, Options, and Help.
### Model
![Batarang screenshot](img/model-tree.png)
Starting at the top of this tab, there is the root selection. If the application has only one `ng-app` declaration (as most applications do) then you will not see the option to change roots.
Below that is a tree showing how scopes are nested, and which models are attached to them. Clicking on a scope name will take you to the Elements tab, and show you the DOM element associated with that scope. Models and methods attached to each scope are listed with bullet points on the tree. Just the name of methods attached to a scope are shown. Models with a simple value and complex objects are shown as JSON. You can edit either, and the changes will be reflected in the application being debugged.
### Performance
![Batarang performance tab screenshot](img/performance.png)
The performance tab must be enabled separately because it causes code to be injected into AngularJS to track and report performance metrics. There is also an option to output performance metrics to the console.
Below that is a tree of watched expressions, showing which expressions are attached to which scopes. Much like the model tree, you can collapse sections by clicking on "toggle" and you can inspect the element that a scope is attached to by clicking on the scope name.
Underneath that is a graph showing the relative performance of all of the application's expressions. This graph will update as you interact with the application.
### Options
![Batarang options tab screenshot](img/options.png)
Last, there is the options tab. The options tab has two checkboxes: one for "show scopes" and one for "show bindings." Each of these options, when enabled, highlights the respective feature of the application being debugged; scopes will have a red outline, and bindings will have a blue outline.
### Elements
![Batarang console screenshot](img/inspect.png)
The Batarang also hooks into some of the existing features of the Chrome developer tools. For AngularJS applications, there is now a properties pane on in the Elements tab. Much like the model tree in the AngularJS tab, you can use this to inspect the models attached to a given element's scope.
### Console
![Batarang console screenshot](img/console.png)
The Batarang exposes some convenient features to the Chrome developer tools console. To access the scope of an element selected in the Elements tab of the developer tools, in console, you can type `$scope`. If you change value of some model on `$scope` and want to have this change reflected in the running application, you need to call `$scope.$apply()` after making the change.

@ -0,0 +1,5 @@
<html>
<body>
<script src="js/background.js"></script>
</body>
</html>

4978
css/bootstrap.css vendored

File diff suppressed because it is too large Load Diff

@ -0,0 +1,18 @@
.hidden {
display: none;
}
.col {
float: left;
width: 200px;
}
.col-2 {
float: left;
width: 400px;
}
.scope-branch {
margin-left: 30px;
background-color:rgba(0,0,0,0.06);
}
body {
margin: 10px;
}

@ -0,0 +1,3 @@
.ng-scope {
border: 1px solid red;
}

@ -0,0 +1,5 @@
<html>
<body>
<script src="js/devtools.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

@ -0,0 +1,62 @@
var injectPrereqs = {};
var cbs = {};
chrome.extension.onRequest.addListener(function (request, sender, sendResponse) {
if (request.script === 'register') {
chrome.tabs.onUpdated.addListener(cbs[request.tab]);
} else if (request.script === 'debug-true') {
cbs[request.tab] = (function (req) {
return function (tabId, changeInfo, tab) {
if (tabId !== req.tab) {
return;
}
chrome.tabs.executeScript(tabId, {
file: 'js/inject/debug.js'
});
};
}(request));
chrome.tabs.onUpdated.addListener(cbs[request.tab]);
} else if (request.script === 'debug-false' && cbs[request.tab]) {
chrome.tabs.onUpdated.removeListener(cbs[request.tab]);
delete cbs[request.tab];
} else {
chrome.tabs.executeScript(request.tab, {
file: 'js/inject/css-inject.js'
}, function () {
injectPrereqs[request.tab] = true;
chrome.tabs.executeScript(request.tab, {
file: 'inject/' + request.script + '.js'
});
});
}
if (sendResponse) {
sendResponse();
}
});
// notify of page refreshes
chrome.extension.onConnect.addListener(function(port) {
port.onMessage.addListener(function (msg) {
if (msg.action === 'register') {
var respond = function (tabId, changeInfo, tab) {
if (tabId !== msg.inspectedTabId) {
return;
}
port.postMessage('refresh');
};
chrome.tabs.onUpdated.addListener(respond);
port.onDisconnect.addListener(function () {
chrome.tabs.onUpdated.removeListener(respond);
});
}
});
port.onDisconnect.addListener(function () {
console.log('disconnected');
});
});

@ -0,0 +1,90 @@
panelApp.controller('OptionsCtrl', function OptionsCtrl($scope, appContext, chromeExtension) {
$scope.debugger = {
scopes: false,
bindings: false,
app: false
};
$scope.$watch('debugger.scopes', function (newVal, oldVal) {
if (newVal) {
chromeExtension.eval(function () {
var addCssRule = function (selector, rule) {
var styleSheet = document.styleSheets[document.styleSheets.length - 1];
styleSheet.insertRule(selector + '{' + rule + '}', styleSheet.cssRules.length);
};
addCssRule('.ng-scope', 'border: 1px solid red');
});
} else {
chromeExtension.eval(function () {
removeCssRule = function (selector, rule) {
var styleSheet = document.styleSheets[document.styleSheets.length - 1];
var i;
for (i = styleSheet.cssRules.length - 1; i >= 0; i -= 1) {
if (styleSheet.cssRules[i].cssText === selector + ' { ' + rule + '; }') {
styleSheet.deleteRule(i);
}
}
};
removeCssRule('.ng-scope', 'border: 1px solid red');
});
}
});
$scope.$watch('debugger.bindings', function (newVal, oldVal) {
if (newVal) {
chromeExtension.eval(function () {
var addCssRule = function (selector, rule) {
var styleSheet = document.styleSheets[document.styleSheets.length - 1];
styleSheet.insertRule(selector + '{' + rule + '}', styleSheet.cssRules.length);
};
addCssRule('.ng-binding', 'border: 1px solid blue');
});
} else {
chromeExtension.eval(function () {
removeCssRule = function (selector, rule) {
var styleSheet = document.styleSheets[document.styleSheets.length - 1];
var i;
for (i = styleSheet.cssRules.length - 1; i >= 0; i -= 1) {
if (styleSheet.cssRules[i].cssText === selector + ' { ' + rule + '; }') {
styleSheet.deleteRule(i);
}
}
};
removeCssRule('.ng-binding', 'border: 1px solid blue');
});
}
});
$scope.$watch('debugger.app', function (newVal, oldVal) {
if (newVal) {
chromeExtension.eval(function () {
var addCssRule = function (selector, rule) {
var styleSheet = document.styleSheets[document.styleSheets.length - 1];
styleSheet.insertRule(selector + '{' + rule + '}', styleSheet.cssRules.length);
};
addCssRule('[ng-app]', 'border: 1px solid green');
//addCssRule('ng-app:', 'border: 1px solid green');
addCssRule('[app-run]', 'border: 1px solid green');
});
} else {
chromeExtension.eval(function () {
removeCssRule = function (selector, rule) {
var styleSheet = document.styleSheets[document.styleSheets.length - 1];
var i;
for (i = styleSheet.cssRules.length - 1; i >= 0; i -= 1) {
if (styleSheet.cssRules[i].cssText === selector + ' { ' + rule + '; }') {
styleSheet.deleteRule(i);
}
}
};
removeCssRule('[ng-app]', 'border: 1px solid green');
//removeCssRule('ng-app:', 'border: 1px solid green');
removeCssRule('[app-run]', 'border: 1px solid green');
});
}
});
});

@ -0,0 +1,134 @@
panelApp.filter('sortByTime', function () {
return function (input) {
return input.slice(0).sort(function (a, b) {
return b.time - a.time;
});
};
});
panelApp.controller('PerfCtrl', function PerfCtrl($scope, appContext) {
$scope.enable = false;
$scope.histogram = [];
$scope.timeline = [];
$scope.clearHistogram = function () {
appContext.clearHistogram();
};
var first = true;
$scope.$watch('enable', function (newVal, oldVal) {
// prevent refresh on initial pageload
if (first) {
first = false;
} else {
appContext.setDebug(newVal);
}
if (newVal) {
//updateTimeline();
updateHistogram();
}
});
$scope.log = false;
$scope.$watch('log', function (newVal, oldVal) {
appContext.setLog(newVal);
appContext.watchRefresh(function () {
appContext.setLog(newVal);
});
});
$scope.inspect = function () {
appContext.inspect(this.val.id);
};
var updateTimeline = function () {
var timeline = appContext.getTimeline();
if (timeline && timeline.length > $scope.timeline.length) {
$scope = $scope.concat(timeline.splice($scope.length - 1));
}
};
var updateHistogram = function () {
var info = appContext.getHistogram();
if (!info) {
return;
}
var total = 0;
info.forEach(function (elt) {
total += elt.time;
});
var i, elt, his;
for (i = 0; (i < $scope.histogram.length && i < info.length); i++) {
elt = info[i];
his = $scope.histogram[i];
his.time = elt.time.toPrecision(3);
his.percent = (100 * elt.time / total).toPrecision(3);
}
for ( ; i < info.length; i++) {
elt = info[i];
elt.time = elt.time.toPrecision(3);
elt.percent = (100 * elt.time / total).toPrecision(3);
$scope.histogram.push(elt);
}
$scope.histogram.length = info.length;
};
var updateTree = function () {
var rts = appContext.getListOfRoots();
if (!rts) {
return;
}
var roots = [];
rts.forEach(function (item) {
roots.push({
label: item,
value: item
});
});
$scope.roots = roots;
var trees = appContext.getModelTrees();
if (!$scope.trees || $scope.trees.length !== trees.length) {
$scope.trees = trees;
} else {
var syncBranch = function (oldTree, newTree) {
if (!oldTree || !newTree) {
return;
}
oldTree.locals = newTree.locals;
if (oldTree.children.length !== newTree.children.length) {
oldTree.children = newTree.children;
} else {
oldTree.children.forEach(function (oldBranch, i) {
var newBranch = newTree.children[i];
syncBranch(newBranch, oldBranch);
});
}
};
var treeId, oldTree, newTree;
for (treeId in $scope.trees) {
if ($scope.trees.hasOwnProperty(treeId)) {
oldTree = $scope.trees[treeId];
newTree = trees[treeId];
syncBranch(oldTree, newTree);
}
}
}
if (roots.length === 0) {
$scope.selectedRoot = null;
} else if (!$scope.selectedRoot) {
$scope.selectedRoot = roots[0].value;
}
$scope.$apply();
};
appContext.watchPoll(updateTree);
appContext.watchPoll(updateHistogram);
});

@ -0,0 +1,5 @@
// TODO: call this AppCtrl?
// this acts as the top level ctrl
panelApp.controller('TabCtrl', function TabCtrl($scope) {
$scope.selectedTab = 'Model';
});

@ -0,0 +1,75 @@
panelApp.controller('TreeCtrl', function TreeCtrl($scope, chromeExtension, appContext) {
$scope.inspect = function () {
appContext.inspect(this.val.id);
};
$scope.edit = function () {
appContext.executeOnScope(this.val.id, function (scope, elt, args) {
scope[args.name] = args.value;
scope.$apply();
}, {
name: this.key,
value: JSON.parse(this.item)
});
};
$scope.roots = [];
var updateTree = function () {
if ($('input:focus').length > 0) {
return;
}
var roots = appContext.getListOfRoots();
if (!roots) {
return;
}
var trees = appContext.getModelTrees();
if (!$scope.trees || $scope.trees.length !== trees.length) {
$scope.trees = trees;
} else {
var syncBranch = function (oldTree, newTree) {
if (!oldTree || !newTree) {
return;
}
oldTree.locals = newTree.locals;
if (oldTree.children.length !== newTree.children.length) {
oldTree.children = newTree.children;
} else {
oldTree.children.forEach(function (oldBranch, i) {
var newBranch = newTree.children[i];
syncBranch(oldBranch, newBranch);
});
}
};
var treeId, oldTree, newTree;
for (treeId in $scope.trees) {
if ($scope.trees.hasOwnProperty(treeId)) {
oldTree = $scope.trees[treeId];
newTree = trees[treeId];
syncBranch(oldTree, newTree);
}
}
}
$scope.roots.length = roots.length;
roots.forEach(function (item, i) {
$scope.roots[i] = {
label: item,
value: item
};
});
if (roots.length === 0) {
$scope.selectedRoot = null;
} else if (!$scope.selectedRoot) {
$scope.selectedRoot = $scope.roots[0].value;
}
$scope.$apply();
};
appContext.watchPoll(updateTree);
});

@ -0,0 +1,53 @@
// The function below is executed in the context of the inspected page.
var page_getProperties = function () {
if (window.angular && $0) {
var scope = window.angular.element($0).scope();
window.$scope = scope;
return (function (scope) {
var ret = {
__private__: {}
};
for (prop in scope) {
if (scope.hasOwnProperty(prop)) {
if (prop[0] === '$' && prop[1] === '$') {
ret.__private__[prop] = scope[prop];
} else {
ret[prop] = scope[prop];
}
}
}
return ret;
}(scope));
} else {
return {};
}
};
chrome.
devtools.
panels.
elements.
createSidebarPane(
"AngularJS Properties",
function (sidebar) {
var selectedElt;
var updateElementProperties = function () {
sidebar.setExpression("(" + page_getProperties.toString() + ")()");
}
updateElementProperties();
chrome.devtools.panels.elements.onSelectionChanged.addListener(updateElementProperties);
});
// Angular panel
var angularPanel = chrome.
devtools.
panels.
create(
"AngularJS",
"img/angular.png",
"panel.html");

@ -0,0 +1,34 @@
// model tree
panelApp.directive('mtree', function($compile) {
return {
restrict: 'E',
terminal: true,
scope: {
val: '=val',
edit: '=edit',
inspect: '=inspect'
},
link: function (scope, element, attrs) {
// this is more complicated then it should be
// see: https://github.com/angular/angular.js/issues/898
element.append(
'<div class="scope-branch">' +
'<a href ng-click="inspect()">Scope ({{val.id}})</a> | ' +
'<a href ng-click="showState = !showState">toggle</a>' +
'<div ng-class="{hidden: showState}">' +
'<ul>' +
'<li ng-repeat="(key, item) in val.locals">' +
'{{key}}' +
'<input ng-class="{hidden: !item}" ng-model="item" ng-change="edit()()">' +
'</li>' +
'</ul>' +
'<div ng-repeat="child in val.children">' +
'<mtree val="child" inspect="inspect" edit="edit"></mtree>' +
'</div>' +
'</div>' +
'</div>');
$compile(element.contents())(scope.$new());
}
};
});

@ -0,0 +1,36 @@
// watchers tree
panelApp.directive('wtree', function($compile) {
return {
restrict: 'E',
terminal: true,
scope: {
val: '=val',
inspect: '=inspect'
},
link: function (scope, element, attrs) {
// this is more complicated then it should be
// see: https://github.com/angular/angular.js/issues/898
element.append(
'<div class="scope-branch">' +
'<a href ng-click="inspect()">Scope ({{val.id}})</a> | ' +
'<a href ng-click="showState = !showState">toggle</a>' +
'<div ng-class="{hidden: showState}">' +
'<ul>' +
'<li ng-repeat="item in val.watchers">' +
'<a href ng-class="{hidden: item.split(\'\n\').length < 2}" ng-click="showState = !showState">toggle</a> ' +
'<span ng-class="{hidden: showState || item.split(\'\n\').length < 2}">{{item | first}} ...</span>' +
'<pre ng-class="{hidden: !showState && item.split(\'\n\').length > 1}">' +
'{{item}}' +
'</pre>' +
'</li>' +
'</ul>' +
'<div ng-repeat="child in val.children">' +
'<wtree val="child" inspect="inspect"></wtree>' +
'</div>' +
'</div>' +
'</div>');
$compile(element.contents())(scope.$new());
}
};
});

@ -0,0 +1,6 @@
// returns the first line of a multi-line string
panelApp.filter('first', function () {
return function (input, output) {
return input.split("\n")[0];
};
});

@ -0,0 +1,182 @@
var inject = function () {
if (document.cookie.indexOf('__ngDebug=true') == -1) {
return;
}
if (document.head) {
document.head.insertBefore(
(function () {
var fn = function (window) {
//alert('script');
var patch = function () {
if (window.angular && typeof window.angular.bootstrap === 'function') {
// do not patch twice
if (window.__ngDebug) {
return;
}
//var bootstrap = window.angular.bootstrap;
var debug = window.__ngDebug = {
watchers: {},
timeline: [],
watchExp: {},
watchList: {}
};
var ng = angular.module('ng');
ng.config(function ($provide) {
$provide.decorator('$rootScope',
function ($delegate) {
var watchFnToHumanReadableString = function (fn) {
if (fn.exp) {
return fn.exp.trim();
} else if (fn.name) {
return fn.name.trim();
} else {
return fn.toString();
}
};
// patch registering watchers
// --------------------------
var watch = $delegate.__proto__.$watch;
$delegate.__proto__.$watch = function() {
if (!debug.watchers[this.$id]) {
debug.watchers[this.$id] = [];
}
var str = watchFnToHumanReadableString(arguments[0]);
debug.watchers[this.$id].push(str);
var w = arguments[0];
if (typeof w === 'function') {
arguments[0] = function () {
var start = window.performance.webkitNow();
var ret = w.apply(this, arguments);
var end = window.performance.webkitNow();
if (!debug.watchExp[str]) {
debug.watchExp[str] = {
time: 0,
calls: 0
};
}
debug.watchExp[str].time += (end - start);
debug.watchExp[str].calls += 1;
return ret;
};
} else {
var thatScope = this;
arguments[0] = function () {
var start = window.performance.webkitNow();
var ret = thatScope.$eval(w);
var end = window.performance.webkitNow();
if (!debug.watchExp[str]) {
debug.watchExp[str] = {
time: 0,
calls: 0
};
}
debug.watchExp[str].time += (end - start);
debug.watchExp[str].calls += 1;
return ret;
};
}
var fn = arguments[1];
arguments[1] = function () {
var start = window.performance.webkitNow();
var ret = fn.apply(this, arguments);
var end = window.performance.webkitNow();
var str = fn.toString();
if (typeof debug.watchList[str] !== 'number') {
debug.watchList[str] = 0;
//debug.watchList[str].total = 0;
}
debug.watchList[str] += (end - start);
//debug.watchList[str].total += (end - start);
//debug.dirty = true;
return ret;
};
return watch.apply(this, arguments);
};
// patch destroy
// -------------
/*
var destroy = $delegate.__proto__.$destroy;
$delegate.__proto__.$destroy = function () {
if (debug.watchers[this.$id]) {
delete debug.watchers[this.$id];
}
debug.dirty = true;
return destroy.apply(this, arguments);
};
*/
// patch apply
// -----------
var firstLog = 0;
var apply = $delegate.__proto__.$apply;
$delegate.__proto__.$apply = function (fn) {
var start = window.performance.webkitNow();
var ret = apply.apply(this, arguments);
var end = window.performance.webkitNow();
if (Math.round(end - start) === 0) {
if (debug.timeline.length === 0) {
firstLog = start;
}
debug.timeline.push({
start: Math.round(start - firstLog),
end: Math.round(end - firstLog)
});
}
//debug.dirty = true;
if (debug.log) {
if (fn) {
if (fn.name) {
fn = fn.name;
} else if (fn.toString().split('\n').length > 1) {
fn = 'fn () { ' + fn.toString().split('\n')[1].trim() + ' /* ... */ }';
} else {
fn = fn.toString().trim().substr(0, 30) + '...';
}
} else {
fn = '$apply';
}
console.log(fn + '\t\t' + (end - start).toPrecision(4) + 'ms');
}
return ret;
};
return $delegate;
});
});
} else {
setTimeout(patch, 1);
}
};
patch();
};
var script = window.document.createElement('script');
script.innerHTML = '(' + fn.toString() + '(window))';
return script;
}()),
document.head.firstChild);
} else {
setTimeout(inject, 1);
}
};
inject();

File diff suppressed because it is too large Load Diff

14326
js/lib/angular.js vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -0,0 +1 @@
var panelApp = angular.module('panelApp', []);

@ -0,0 +1,306 @@
// Service for doing stuff in the context of the application being debugged
panelApp.factory('appContext', function(chromeExtension) {
// Private vars
// ============
var _debugCache = {},
_pollListeners = [],
_pollInterval = 500;
// TODO: make this private and have it automatically poll?
var getDebugData = function (callback) {
chromeExtension.eval(function (window) {
// Detect whether or not this is an AngularJS app
if (!window.angular) {
return false;
}
// cycle.js
// 2011-08-24
// https://github.com/douglascrockford/JSON-js/blob/master/cycle.js
// Make a deep copy of an object or array, assuring that there is at most
// one instance of each object or array in the resulting structure. The
// duplicate references (which might be forming cycles) are replaced with
// an object of the form
// {$ref: PATH}
// where the PATH is a JSONPath string that locates the first occurance.
var decycle = function decycle(object) {
var objects = [], // Keep a reference to each unique object or array
paths = []; // Keep the path to each unique object or array
return (function derez(value, path) {
var i, // The loop counter
name, // Property name
nu; // The new object or array
switch (typeof value) {
case 'object':
if (!value) {
return null;
}
for (i = 0; i < objects.length; i += 1) {
if (objects[i] === value) {
return {$ref: paths[i]};
}
}
objects.push(value);
paths.push(path);
if (Object.prototype.toString.apply(value) === '[object Array]') {
nu = [];
for (i = 0; i < value.length; i += 1) {
nu[i] = derez(value[i], path + '[' + i + ']');
}
} else {
nu = {};
for (name in value) {
if (Object.prototype.hasOwnProperty.call(value, name)) {
nu[name] = derez(value[name],
path + '[' + JSON.stringify(name) + ']');
}
}
}
return nu;
case 'number':
case 'string':
case 'boolean':
return value;
}
}(object, '$'));
};
var rootIds = [];
var rootScopes = [];
var elts = window.document.getElementsByClassName('ng-scope');
var i;
for (i = 0; i < elts.length; i++) {
(function (elt) {
var $scope = window.angular.element(elt).scope();
while ($scope.$parent) {
$scope = $scope.$parent;
}
if ($scope === $scope.$root && rootScopes.indexOf($scope) === -1) {
rootScopes.push($scope);
rootIds.push($scope.$id);
}
}(elts[i]));
}
var getScopeTree = function (scope) {
var tree = {};
var getScopeNode = function (scope, node) {
// copy scope's locals
node.locals = {};
for (var i in scope) {
if (i[0] !== '$' && scope.hasOwnProperty(i) && i !== 'this') {
if (typeof scope[i] === 'number' || typeof scope[i] === 'boolean') {
node.locals[i] = scope[i];
} else if (typeof scope[i] === 'string') {
node.locals[i] = '"' + scope[i] + '"';
} else {
node.locals[i] = JSON.stringify(decycle(scope[i]));
}
}
}
node.id = scope.$id;
if (window.__ngDebug) {
node.watchers = __ngDebug.watchers[scope.$id];
}
// recursively get children scopes
node.children = [];
var child;
if (scope.$$childHead) {
child = scope.$$childHead;
do {
getScopeNode(child, node.children[node.children.length] = {});
} while (child = child.$$nextSibling);
}
};
getScopeNode(scope, tree);
return tree;
};
var trees = {};
rootScopes.forEach(function (root) {
trees[root.$id] = getScopeTree(root);
});
// get histogram data
var histogram = [],
timeline;
// performance
if (window.__ngDebug) {
(function (info) {
for (exp in info) {
if (info.hasOwnProperty(exp)) {
histogram.push({
name: exp,
time: info[exp].time,
calls: info[exp].calls
});
}
}
}(window.__ngDebug.watchExp));
timeline = __ngDebug.timeline;
}
return {
roots: rootIds,
trees: trees,
histogram: histogram,
timeline: timeline
};
},
function (data) {
_debugCache = data;
_pollListeners.forEach(function (fn) {
fn();
});
// poll every 500 ms
setTimeout(getDebugData, _pollInterval);
});
};
getDebugData();
// Public API
// ==========
return {
executeOnScope: function(scopeId, fn, args, cb) {
if (typeof args === 'function') {
cb = args;
args = {};
} else if (!args) {
args = {};
}
args.scopeId = scopeId;
args.fn = fn.toString();
chromeExtension.eval("function (window, args) {" +
"var elts = window.document.getElementsByClassName('ng-scope'), i;" +
"for (i = 0; i < elts.length; i++) {" +
"(function (elt) {" +
"var $scope = window.angular.element(elt).scope();" +
"if ($scope.$id === args.scopeId) {" +
"(" + args.fn + "($scope, elt, args));" +
"}" +
"}(elts[i]));" +
"}" +
"}", args, cb);
},
// Getters
// -------
getTimeline: function () {
return _debugCache.timeline;
},
getHistogram: function () {
return _debugCache.histogram;
},
getListOfRoots: function () {
return _debugCache.roots;
},
getModelTrees: function () {
return _debugCache.trees;
},
// Actions
// -------
clearTimeline: function (cb) {
chromeExtension.eval(function (window) {
window.__ngDebug.timeline = [];
}, cb);
},
clearHistogram: function (cb) {
chromeExtension.eval(function (window) {
window.__ngDebug.watchExp = {};
}, cb);
},
refresh: function (cb) {
chromeExtension.eval(function (window) {
window.document.location.reload();
}, cb);
},
inspect: function (scopeId) {
this.executeOnScope(scopeId, function (scope, elt) {
inspect(elt);
});
},
// Settings
// --------
// takes a bool
setDebug: function (setting) {
if (setting) {
chromeExtension.eval(function (window) {
window.document.cookie = '__ngDebug=true;'
window.document.location.reload();
});
} else {
chromeExtension.eval(function (window) {
window.document.cookie = '__ngDebug=false;'
window.document.location.reload();
});
}
},
// takes a bool
setLog: function (setting) {
chromeExtension.eval('function (window) {' +
'window.__ngDebug.log = ' + setting.toString() + ';' +
'}');
},
// takes # of miliseconds
setPollInterval: function (setting) {
_pollInterval = setting;
},
// Registering events
// ------------------
// TODO: depreciate this; only poll from now on?
// TODO: move to chromeExtension?
watchRefresh: function (cb) {
var port = chrome.extension.connect();
port.postMessage({
action: 'register',
inspectedTabId: chrome.devtools.inspectedWindow.tabId
});
port.onMessage.addListener(function(msg) {
if (msg === 'refresh') {
cb();
}
});
port.onDisconnect.addListener(function (a) {
console.log(a);
});
},
watchPoll: function (fn) {
_pollListeners.push(fn);
}
};
});

@ -0,0 +1,26 @@
panelApp.value('chromeExtension', {
sendRequest: function (requestName, cb) {
chrome.extension.sendRequest({
script: requestName,
tab: chrome.devtools.inspectedWindow.tabId
}, cb || function () {});
},
// evaluates in the context of a window
//written because I don't like the API for chrome.devtools.inspectedWindow.eval;
// passing strings instead of functions are gross.
eval: function (fn, args, cb) {
// with two args
if (!cb && typeof args === 'function') {
cb = args;
args = {};
} else if (!args) {
args = {};
}
chrome.devtools.inspectedWindow.eval('(' +
fn.toString() +
'(window, ' +
JSON.stringify(args) +
'));', cb);
}
});

@ -0,0 +1,22 @@
{
"name": "AngularJS Batarang",
"version": "0.1",
"description": "Extends the Developer Tools, adding a tools for debugging and profiling AngularJS applications.",
"background": {
"page": "background.html"
},
"devtools_page": "devtools.html",
"manifest_version": 2,
"permissions": [
"experimental",
"tabs",
"<all_urls>"
],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["js/inject/debug.js"],
"run_at": "document_start"
}
]
}

@ -0,0 +1,158 @@
<!doctype html>
<html ng-csp ng-app="panelApp">
<head>
<link rel="stylesheet" type="text/css" href="css/bootstrap.css">
<link rel="stylesheet" type="text/css" href="css/panel.css">
<script src="js/lib/angular.js"></script>
<script src="js/lib/jquery-1.7.2.min.js"></script>
<script src="js/panelApp.js"></script>
<script src="js/directives/mtree.js"></script>
<script src="js/directives/wtree.js"></script>
<script src="js/filters/first.js"></script>
<script src="js/services/appContext.js"></script>
<script src="js/services/chromeExtension.js"></script>
<script src="js/controllers/OptionsCtrl.js"></script>
<script src="js/controllers/PerfCtrl.js"></script>
<script src="js/controllers/TabCtrl.js"></script>
<script src="js/controllers/TreeCtrl.js"></script>
</head>
<body ng-controller="TabCtrl">
<ul class="nav nav-tabs">
<li ng-repeat="tabId in ['Model', 'Performance', 'Options', 'Help']"
ng-class="{active: $parent.selectedTab == tabId}"
ng-click="$parent.selectedTab = tabId">
<a href="#">{{tabId}}</a>
</li>
</ul>
<div ng-class="{hidden: $parent.selectedTab != 'Model'}" ng-controller="TreeCtrl">
<h2>Model Tree</h2>
<div ng-class="{hidden: roots.length <= 1}">
<label for="select-root">Root <select id="select-root" ng-options="p.value as p.label for p in roots" ng-model="selectedRoot"></select></label>
</div>
<pre>
<mtree val="trees[selectedRoot]" inspect="inspect" edit="edit"></mtree>
</pre>
</div>
<div ng-class="{hidden: $parent.selectedTab != 'Performance'}" ng-controller="PerfCtrl">
<h2>Performance</h2>
<div>
<label class="checkbox" for="extra">
<input type="checkbox" ng-model="enable" id="extra"> Enable (Causes tab to refresh)
</label>
<label class="checkbox" for="log">
<input type="checkbox" ng-model="log" id="log"> Log to console
</label>
</div>
<div ng-class="{hidden: !enable}">
<label for="select-root" ng-class="{hidden: roots.length <= 1}">Root
<select id="select-root" ng-options="p.value as p.label for p in roots" ng-model="selectedRoot"></select>
</label>
<wtree val="trees[selectedRoot]" inspect="inspect"></wtree>
<!--
<h3>Timeline</h3>
<button ng-click="clear()">Clear Timeline</button>
<div style="height: 400px; background-color: blue; position: relative; overflow: scroll;">
<div ng-repeat="x in timeline" style="height: 50px; position: absolute; background-color: red;" ng-style="{width: (x.end - x.start) + 'px', left: x.start + 'px', top: $index * 50 + 'px'}"></div>
</div>
-->
<h3>Watch Expressions</h3>
<div class="well" style="height: 400px; overflow-y: auto;">
<div ng-repeat="watch in histogram|sortByTime">
<span style="font-family: monospace;">{{watch.name | first}} </span>
<span> | {{watch.percent}}% | {{watch.time}}ms</span>
<div class="progress">
<div ng-style="{width: (watch.percent) + '%'}" class= "bar">
</div>
</div>
</div>
</div>
<button class="btn" ng-click="clearHistogram()">Clear Data</button>
</div>
</div>
<div ng-class="{hidden: $parent.selectedTab != 'Options'}" ng-controller="OptionsCtrl">
<h2>Options</h2>
<div>
<label class="checkbox" for="app">
<input type="checkbox" ng-model="debugger.app" id="app"> Show applications
</label>
</div>
<div>
<label class="checkbox" for="bindings">
<input type="checkbox" ng-model="debugger.bindings" id="bindings"> Show bindings
</label>
</div>
<div>
<label class="checkbox" for="scopes">
<input type="checkbox" ng-model="debugger.scopes" id="scopes"> Show scopes
</label>
</div>
</div>
<div ng-class="{hidden: selectedTab != 'Help'}">
<h2>Using the Batarang</h2>
<p>First, navigate Chrome Canary to the AngularJS application that you want to debug. <a href="https://developers.google.com/chrome-developer-tools/docs/overview#access">Open the Developer Tools</a>. There should be an AngularJS icon. Click on it to open he AngularJS Batarang.</p>
<p>The Batarang has four tabs: Model, Performance, Options, and Help.</p>
<h3>Model</h3>
<p><img src="img/model-tree.png" alt="Batarang screenshot" title="" /></p>
<p>Starting at the top of this tab, there is the root selection. If the application has only one <code>ng-app</code> declaration (as most applications do) then you will not see the option to change roots.</p>
<p>Below that is a tree showing how scopes are nested, and which models are attached to them. Clicking on a scope name will take you to the Elements tab, and show you the DOM element associated with that scope. Models and methods attached to each scope are listed with bullet points on the tree. Just the name of methods attached to a scope are shown. Models with a simple value and complex objects are shown as JSON. You can edit either, and the changes will be reflected in the application being debugged.</p>
<h3>Performance</h3>
<p><img src="img/performance.png" alt="Batarang performance tab screenshot" title="" /></p>
<p>The performance tab must be enabled separately because it causes code to be injected into AngularJS to track and report performance metrics. There is also an option to output performance metrics to the console.</p>
<p>Below that is a tree of watched expressions, showing which expressions are attached to which scopes. Much like the model tree, you can collapse sections by clicking on "toggle" and you can inspect the element that a scope is attached to by clicking on the scope name.</p>
<p>Underneath that is a graph showing the relative performance of all of the application's expressions. This graph will update as you interact with the application.</p>
<h3>Options</h3>
<p><img src="img/options.png" alt="Batarang options tab screenshot" title="" /></p>
<p>Last, there is the options tab. The options tab has two checkboxes: one for "show scopes" and one for "show bindings." Each of these options, when enabled, highlights the respective feature of the application being debugged; scopes will have a red outline, and bindings will have a blue outline.</p>
<h3>Elements</h3>
<p><img src="img/inspect.png" alt="Batarang console screenshot" title="" /></p>
<p>The Batarang also hooks into some of the existing features of the Chrome developer tools. For AngularJS applications, there is now a properties pane on in the Elements tab. Much like the model tree in the AngularJS tab, you can use this to inspect the models attached to a given element's scope.</p>
<h3>Console</h3>
<p><img src="img/console.png" alt="Batarang console screenshot" title="" /></p>
<p>The Batarang exposes some convenient features to the Chrome developer tools console. To access the scope of an element selected in the Elements tab of the developer tools, in console, you can type <code>$scope</code>. If you change value of some model on <code>$scope</code> and want to have this change reflected in the running application, you need to call <code>$scope.$apply()</code> after making the change.</p>
</div>
</body>
</html>

@ -0,0 +1,70 @@
describe('panelApp:appContext', function () {
beforeEach(module('panelApp'));
beforeEach(module(function($provide) {
$provide.factory('chromeExtension', createChromeExtensionMock);
}));
describe('appContext', function() {
var chromeExtension,
appContext;
beforeEach(inject(function(_appContext_, _chromeExtension_) {
appContext = _appContext_;
chromeExtension = _chromeExtension_;
chromeExtension.__registerWindow({
angular: {
element: function () {
return {
scope: function () {
var sc = {
$id: '001',
$parent: null
};
sc.$root = sc;
return sc;
}
};
}
}
});
}));
describe('getModelTrees', function () {
it('should work in the simple case', function () {
chromeExtension.__registerQueryResult([1,2,3]);
var infoVal;
waitsFor(function () {
return infoVal = appContext.getModelTrees();
});
runs(function () {
expect(infoVal).toEqual({
"001": {
"locals": {},
"id": "001",
"children": []
}
});
})
});
});
describe('getListOfRoots', function () {
it('should work in the simple case', function () {
chromeExtension.__registerQueryResult([1,2,3]);
var infoVal;
waitsFor(function () {
return infoVal = appContext.getListOfRoots();
});
runs(function() {
expect(infoVal).toEqual([
"001",
"001",
"001"
]);
});
});
});
});
});

@ -0,0 +1,55 @@
describe('panelApp', function () {
beforeEach(module('panelApp'));
beforeEach(module(function($provide) {
$provide.factory('chromeExtension', createChromeExtensionMock);
$provide.factory('appContext', createAppContextMock);
}));
describe('OptionsCtrl', function() {
var ctrl, $scope, appContext, chromeExtension;
beforeEach(inject(function(_$rootScope_, _appContext_, _chromeExtension_, $controller) {
$scope = _$rootScope_;
chromeExtension = _chromeExtension_;
appContext = _appContext_;
ctrl = $controller('OptionsCtrl', {$scope: $scope});
}));
it('should initialize debug state to false and send request to chrome', function () {
expect($scope.debugger.scopes).toBe(false);
expect($scope.debugger.bindings).toBe(false);
$scope.$digest();
});
// TODO: test that window is mutated appropriately
/*
it('should notify chrome of state changes to the showScopes option', function () {
$scope.$digest();
chromeExtension.sendRequest.reset();
$scope.debugger.scopes = true;
$scope.$digest();
expect(chromeExtension.sendRequest).toHaveBeenCalledWith('showScopes');
});
it('should notify chrome of state changes to the showBindings option', function () {
$scope.$digest();
chromeExtension.sendRequest.reset();
$scope.debugger.bindings = true;
$scope.$digest();
expect(chromeExtension.sendRequest).toHaveBeenCalledWith('showBindings');
});
*/
it('should not refresh upon initial panel load', function () {
$scope.$digest();
expect(appContext.setDebug).not.toHaveBeenCalled();
});
});
});

@ -0,0 +1,52 @@
describe('panelApp:TreeCtrl', function () {
beforeEach(module('panelApp'));
beforeEach(module(function($provide) {
$provide.factory('appContext', createAppContextMock);
$provide.factory('chromeExtension', createChromeExtensionMock);
}));
describe('TreeCtrl', function() {
var ctrl,
$scope,
appContext,
chromeExtension;
beforeEach(inject(function(_$rootScope_, _appContext_, _chromeExtension_, $controller) {
$scope = _$rootScope_;
// mock accessor
$scope.val = {
id: "ZZZ"
};
//inspect.reset();
appContext = _appContext_;
chromeExtension = _chromeExtension_;
ctrl = $controller('TreeCtrl', {$scope: $scope});
}));
it('should call inspect when there is an element to inspect', function () {
$scope.inspect();
expect(appContext.inspect).toHaveBeenCalledWith('ZZZ');
});
it('should change the corresponding value in the scope when edit is called', function () {
// mock accessor
$scope.val = {
id: $scope.$id
};
// feel like this might be cheating
appContext.registerScope($scope);
$scope.key = 'someKey';
$scope.someKey = 'someOldValue';
$scope.item = '"someNewValue"';
$scope.edit();
expect($scope.someKey).toBe('someNewValue');
});
});
});

@ -0,0 +1,47 @@
function createAppContextMock () {
var scopeMocks = {
"ZZZ": {
id: "ZZZ",
$apply: function () {},
someKey: 'someOldValue'
}
};
var scopeEltMocks = {
"ZZZ": "elementMock"
};
var regScope;
return {
executeOnScope: function (scopeId, fn, args, cb) {
if (regScope && regScope.$id === scopeId) {
fn(regScope, 'elementMock', args);
if (cb) {
cb();
}
}
if (scopeMocks[scopeId] && scopeEltMocks[scopeId]) {
fn(scopeMocks[scopeId],
scopeEltMocks[scopeId],
args);
if (cb) {
cb();
}
}
},
registerScope: function (scope) {
regScope = scope;
},
getDebugInfo: function (cb) {
cb({
roots: [ "YYY" ],
trees: {}
});
},
watchRefresh: function (cb) {},
setDebug: jasmine.createSpy('setDebug'),
setLog: jasmine.createSpy('setLog'),
setPollInterval: jasmine.createSpy('setPollInterval'),
inspect: jasmine.createSpy('inspect'),
refresh: jasmine.createSpy('refresh')
}
}

@ -0,0 +1,54 @@
function createChromeExtensionMock() {
var extend = function(obj, source) {
for (var prop in source) {
obj[prop] = source[prop];
}
return obj;
};
// TODO: rename the "jQuery" stuff
var jQueryResult = [];
var defaultMock = {
document: {
getElementsByClassName: function (arg) {
if (arg === 'ng-scope') {
return jQueryResult;
}
throw new Error('unknown selector');
}
}
};
var windowMock = defaultMock;
return {
eval: function (fn, args, cb) {
if (!cb && typeof args === 'function') {
cb = args;
args = {};
} else if (!args) {
args = {};
}
var res = fn(windowMock, args);
if (typeof cb === 'function') {
cb(res);
}
},
__registerWindow: function (win) {
windowMock = extend(windowMock, win);
},
__registerQueryResult: function (res) {
jQueryResult = res;
jQueryResult.each = function (fn) {
var i;
for (i = 0; i < this.length; i++) {
fn(i, this[i]);
}
};
},
sendRequest: jasmine.createSpy('sendRequest')
};
}

@ -0,0 +1,24 @@
files = [
JASMINE,
JASMINE_ADAPTER,
'js/lib/angular.js',
'js/lib/angular-mocks.js',
'js/panelApp.js',
'js/controllers/*.js',
'js/directives/*.js',
'js/filters/*.js',
'js/services/*.js',
'test/mock/*.js',
'test/*.js'
];
exclude = [];
autoWatch = true;
autoWatchInterval = 1;
logLevel = LOG_INFO;
logColors = true;