diff --git a/.gitignore b/.gitignore index 5e5b952..fd2a642 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ batarang-release-*.zip +*.build.js diff --git a/README.md b/README.md index ca16f11..194964e 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,12 @@ https://chrome.google.com/webstore/detail/ighdmehidhipcmcojjgiloacoafjmpfk ## Installing from Source -1. Clone the repository: `git clone git://github.com/angular/angularjs-batarang` -2. Navigate to `chrome://chrome/extensions/` and enable Developer Mode. -3. Choose "Load unpacked extension" -4. Open the directory you just cloned (should open with Chrome, otherwise try dragging/dropping the file into Chrome) and follow the prompts to install. +1. Clone the repository: `git clone git://github.com/angular/angularjs-batarang` +2. Install the bower dependencies: `bower install` +3. Build the inject script: `node scripts/inline.js` +4. Navigate to `chrome://chrome/extensions/` and enable Developer Mode. +5. Choose "Load unpacked extension" +6. In the dialog, open the directory you just cloned. ## Screencast diff --git a/background.html b/background.html index cdde193..87f891a 100644 --- a/background.html +++ b/background.html @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/js/background.js b/background.js similarity index 95% rename from js/background.js rename to background.js index 9e2fffc..e57f6c8 100644 --- a/js/background.js +++ b/background.js @@ -36,6 +36,10 @@ chrome.extension.onConnect.addListener(function (port) { } } + if (!scopeCache[msg.appId]) { + scopeCache[msg.appId] = {}; + } + // immediately populate the scopes tree from the cache Object.keys(scopeCache[msg.appId]).forEach(function (scopeId) { port.postMessage({ // [background] --> [devtools] diff --git a/bower.json b/bower.json index 0f9ea9a..0c3744d 100644 --- a/bower.json +++ b/bower.json @@ -9,4 +9,4 @@ "devDependencies": { "angular-mocks": "~1.0.7" } -} \ No newline at end of file +} diff --git a/content-scripts/README.md b/content-scripts/README.md new file mode 100644 index 0000000..92723d0 --- /dev/null +++ b/content-scripts/README.md @@ -0,0 +1,20 @@ +# content-scripts/inject.js + +What does this do? + +This adds a script to the page that watches DOM mutation events until it sees that `window.angular` is available. +Immediately after, before any applications have the opportunity to bootstrap, this decorates core Angular +components to expose debugging information. + +## Building debug.js + +Why does this need a build step? + +Because this script does `fn.toString()` to construct the script tags, it's impossible to use any sort of +code loading. The code needs to be inlined before being run. + +From the root of this repository, run: + +```shell +node ./scripts/inline.js +``` diff --git a/content-scripts/inject.js b/content-scripts/inject.js new file mode 100644 index 0000000..df6eb47 --- /dev/null +++ b/content-scripts/inject.js @@ -0,0 +1,356 @@ +// content-scripts/inject.js +// this file is run from the content script context (separate JS VM from the app, but same DOM) +// but injects an 'instrumentation' script tag into the app context +// confusing, right? + +var instument = function instument (window) { + // Helper to determine if the root 'ng' module has been loaded + // window.angular may be available if the app is bootstrapped asynchronously, but 'ng' might + // finish loading later. + var ngLoaded = function () { + if (!window.angular) { + return false; + } + try { + window.angular.module('ng'); + } + catch (e) { + return false; + } + return true; + }; + + if (!ngLoaded()) { + // TODO: var name + var areWeThereYet = function (ev) { + if (ev.srcElement.tagName === 'SCRIPT') { + var oldOnload = ev.srcElement.onload; + ev.srcElement.onload = function () { + if (ngLoaded()) { + document.removeEventListener('DOMNodeInserted', areWeThereYet); + instument(window); + } + if (oldOnload) { + oldOnload.apply(this, arguments); + } + }; + } + }; + document.addEventListener('DOMNodeInserted', areWeThereYet); + return; + } + + // do not patch twice + if (window.__ngDebug) { + return; + } + + + // Helpers + // ======= + + var throttle = require('./lib/throttle.js'); + var summarizeObject = require('./lib/summarizeObject.js'); + + // helper to extract dependencies from function arguments + // not all versions of AngularJS expose annotate + var annotate = angular.injector().annotate || require('./lib/annotate.js'); + + // polyfill for performance.now on older webkit + if (!performance.now) { + performance.now = performance.webkitNow; + } + + // Send notifications from app context to devtools context + // in order to do this, we need to create a DOM element across which + // the app and content script contexts can communicate + var eventProxyElement = document.createElement('div'); + eventProxyElement.id = '__ngDebugElement'; + eventProxyElement.style.display = 'none'; + document.body.appendChild(eventProxyElement); + + var customEvent = document.createEvent('Event'); + customEvent.initEvent('myCustomEvent', true, true); + + var fireCustomEvent = function (data) { + data.appId = instrumentedAppId; + eventProxyElement.innerText = JSON.stringify(data); + eventProxyElement.dispatchEvent(customEvent); + }; + + + // given a scope object, return an object with deep clones + // of the models exposed on that scope + var getScopeLocals = function (scope) { + var scopeLocals = {}, prop; + for (prop in scope) { + if (scope.hasOwnProperty(prop) && prop !== 'this' && prop[0] !== '$') { + scopeLocals[prop] = decycle(scope[prop]); + } + } + return scopeLocals; + }; + + + + // Private state + // ============= + + //var bootstrap = window.angular.bootstrap; + var debug = { + // map of scopes --> watcher function name strings + watchers: {}, + + // maps of watch/apply exp/fns to perf data + watchPerf: {}, + applyPerf: {}, + + // map of scope.$ids --> $scope objects + scopes: {}, + + // whether or not to emit profile data + profiling: false, + + // map of $ids --> [] array of things being watched + modelWatchers: {}, + // map of $id + watcher --> value + modelWatchersState: {}, + + // map of $ids --> refs to $rootScope objects + rootScopes: {}, + + deps: [] + }; + + var popover = null; + var instrumentedAppId = window.location.host + '~' + Math.random(); + + + // Utils + // ===== + + var getScopeTree = function (id) { + + var names = api.niceNames(); + + var traverse = function (sc) { + var tree = { + id: sc.$id, + name: names[sc.$id], + watchers: debug.watchers[sc.$id], + children: [] + }; + + var child = sc.$$childHead; + if (child) { + do { + tree.children.push(traverse(child)); + } while (child !== sc.$$childTail && (child = child.$$nextSibling)); + } + + return tree; + }; + + var root = debug.rootScopes[id]; + var tree = traverse(root); + + return tree; + }; + + var getWatchPerf = function () { + var changes = []; + angular.forEach(debug.watchPerf, function (info, name) { + if (info.time > 0) { + changes.push({ + name: name, + time: info.time + }); + info.time = 0; + } + }); + return changes; + }; + + // Emit stuff + // ========== + + var emit = { + modelChange: throttle(function (id, watchers) { + var scope = debug.scopes[id]; + var changes = {}; + + watchers = watchers || debug.modelWatchers[id]; + + if (scope && debug.modelWatchers[id]) { + + Object.keys(debug.modelWatchers[id]). + forEach(function (watcher) { + var newValue = api.getModel(id, watcher), + newString = JSON.stringify(newValue), + prop = id + '~' + watcher; + + if (debug.modelWatchersState[prop] !== newString) { + changes[watcher] = newValue; + debug.modelWatchersState[prop] = newString; + } + }); + } + + if (Object.keys(changes).length > 0) { + fireCustomEvent({ + action: 'modelChange', + id: id, + changes: changes + }); + } + }, 50), + + scopeChange: throttle(function (id) { + fireCustomEvent({ + action: 'scopeChange', + id: id, + scope: getScopeTree(id) + }); + }, 50), + + scopeDeleted: function (id) { + fireCustomEvent({ + action: 'scopeDeleted', + id: id + }); + }, + + watcherChange: throttle(function (id) { + if (debug.modelWatchers[id]) { + fireCustomEvent({ + action: 'watcherChange', + id: id, + watchers: debug.watchers[id] + }); + } + }, 50), + + watchPerfChange: throttle(function (str) { + if (debug.profiling) { + fireCustomEvent({ + action: 'watchPerfChange', + watcher: str, + value: debug.watchPerf[str] + }); + } + }, 50), + + applyPerfChange: throttle(function (str) { + if (debug.profiling) { + fireCustomEvent({ + action: 'applyPerfChange', + watcher: str, + value: debug.applyPerf[str] + }); + } + }, 50), + + // might be worth limiting + watchPerf: function () { + throw new Error('Implement me :c'); + } + }; + + + // Public API + // ========== + + var api = window.__ngDebug = { + + profiling: function (setting) { + debug.profiling = setting; + }, + + getDeps: function () { + return debug.deps; + }, + + getRootScopeIds: function () { + return Object.keys(debug.rootScopes); + }, + + getAppId: function () { + return instrumentedAppId; + }, + + fireCustomEvent: fireCustomEvent, + + niceNames: require('./lib/niceNames.js'), + getModel: require('./lib/summarizeModel.js'), + + setSomeModel: function (id, path, value) { + debug.scope[id].$apply(path + '=' + JSON.stringify(value)); + }, + + watchModel: function (id, path) { + debug.modelWatchers[id] = debug.modelWatchers[id] || {}; + debug.modelWatchers[id][path || ''] = true; + if (!path || path === '') { + debug.modelWatchersState = {}; + } + emit.modelChange(id); + emit.watcherChange(id); + }, + + // unwatches all children of the given path + // Ex: + // if watching 'foo.bar.baz', 'foo.bar', and 'foo' + // unwatchModel('001', 'foo.bar') + // unwatches 'foo.bar.baz' and 'foo.bar' + unwatchModel: function (id, path) { + if (!debug.modelWatchers[id]) { + return; + } + if (path === undefined) { + path = ''; + } + Object.keys(modelWatchers[id]).forEach(function (key) { + if (key.substr(0, path.length) === path) { + delete debug.modelWatchers[id][key]; + } + }); + }, + + enable: require('./lib/popover.js') + }; + + var recordDependencies = function (providerName, dependencies) { + debug.deps.push({ + name: providerName, + imports: dependencies + }); + }; + + require('./lib/decorate.js'); + +}; + +// inject into the application context from the content script context + +var inject = function () { + var script = window.document.createElement('script'); + script.innerHTML = '(' + instument.toString() + '(window))'; + document.head.appendChild(script); + + // handle forwarding the events sent from the app context to the + // background page context + var eventProxyElement = document.getElementById('__ngDebugElement'); + + if (eventProxyElement) { + eventProxyElement.addEventListener('myCustomEvent', function () { + var eventData = JSON.parse(eventProxyElement.innerText); + chrome.extension.sendMessage(eventData); + }); + document.removeEventListener('DOMContentLoaded', inject); + } +}; + +// only inject if cookie is set +if (document.cookie.indexOf('__ngDebug=true') != -1) { + document.addEventListener('DOMContentLoaded', inject); +} diff --git a/content-scripts/lib/annotate.js b/content-scripts/lib/annotate.js new file mode 100644 index 0000000..ef29864 --- /dev/null +++ b/content-scripts/lib/annotate.js @@ -0,0 +1,51 @@ + +var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; +var FN_ARG_SPLIT = /,/; +var FN_ARG = /^\s*(_?)(.+?)\1\s*$/; +var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; + +// TODO: should I keep these assertions? +function assertArg(arg, name, reason) { + if (!arg) { + throw new Error("Argument '" + (name || '?') + "' is " + (reason || "required")); + } + return arg; +} + +function assertArgFn(arg, name, acceptArrayAnnotation) { + if (acceptArrayAnnotation && angular.isArray(arg)) { + arg = arg[arg.length - 1]; + } + + assertArg(angular.isFunction(arg), name, 'not a function, got ' + + (arg && typeof arg == 'object' ? arg.constructor.name || 'Object' : typeof arg)); + return arg; +} + +module.exports = function (fn) { + var $inject, + fnText, + argDecl, + last; + + if (typeof fn == 'function') { + if (!($inject = fn.$inject)) { + $inject = []; + fnText = fn.toString().replace(STRIP_COMMENTS, ''); + argDecl = fnText.match(FN_ARGS); + argDecl[1].split(FN_ARG_SPLIT).forEach(function(arg) { + arg.replace(FN_ARG, function(all, underscore, name) { + $inject.push(name); + }); + }); + fn.$inject = $inject; + } + } else if (angular.isArray(fn)) { + last = fn.length - 1; + assertArgFn(fn[last], 'fn'); + $inject = fn.slice(0, last); + } else { + assertArgFn(fn, 'fn', true); + } + return $inject; +}; diff --git a/content-scripts/lib/decorate.js b/content-scripts/lib/decorate.js new file mode 100644 index 0000000..d434dbc --- /dev/null +++ b/content-scripts/lib/decorate.js @@ -0,0 +1,251 @@ + +var ng = angular.module('ng'); +ng.config(function ($provide) { + // methods to patch + + // $provide.provider + var temp = $provide.provider; + $provide.provider = function (name, definition) { + if (!definition) { + angular.forEach(name, function (definition, name) { + var tempGet = definition.$get; + definition.$get = function () { + recordDependencies(name, annotate(tempGet)); + return tempGet.apply(this, arguments); + }; + }); + } else if (definition instanceof Array) { + // it is a constructor with array syntax + var tempConstructor = definition[definition.length - 1]; + + definition[definition.length - 1] = function () { + recordDependencies(name, annotate(tempConstructor)); + return tempConstructor.apply(this, arguments); + }; + } else if (definition.$get instanceof Array) { + // it should have a $get + var tempGet = definition.$get[definition.$get.length - 1]; + + definition.$get[definition.$get.length - 1] = function () { + recordDependencies(name, annotate(tempGet)); + return tempGet.apply(this, arguments); + }; + } else if (typeof definition === 'object') { + // it should have a $get + var tempGet = definition.$get; + + // preserve original annotations + definition.$get = annotate(definition.$get); + definition.$get.push(function () { + recordDependencies(name, annotate(tempGet)); + return tempGet.apply(this, arguments); + }); + } else { + recordDependencies(name, annotate(definition)); + } + return temp.apply(this, arguments); + }; + + // $provide.(factory|service) + [ + 'factory', + 'service' + ].forEach(function (met) { + var temp = $provide[met]; + $provide[met] = function (name, definition) { + if (typeof name === 'object') { + angular.forEach(name, function (value, key) { + var isArray = value instanceof Array; + var originalValue = isArray ? value[value.length - 1] : value; + + var newValue = function () { + recordDependencies(key, annotate(originalValue)); + return originalValue.apply(this, arguments); + }; + + if (isArray) { + value[value.length - 1] = newValue; + } else { + name[value] = newValue; + } + }); + } else { + recordDependencies(name, annotate(definition)); + } + return temp.apply(this, arguments); + }; + }); + + $provide.decorator('$rootScope', function ($delegate) { + + var watchFnToHumanReadableString = function (fn) { + if (fn.exp) { + return fn.exp.trim(); + } + if (fn.name) { + return fn.name.trim(); + } + return fn.toString(); + }; + + var applyFnToLogString = function (fn) { + var str; + if (fn) { + if (fn.name) { + str = fn.name; + } else if (fn.toString().split('\n').length > 1) { + str = 'fn () { ' + fn.toString().split('\n')[1].trim() + ' /* ... */ }'; + } else { + str = fn.toString().trim().substr(0, 30) + '...'; + } + } else { + str = '$apply'; + } + return str; + }; + + + // patch registering watchers + // ========================== + + var _watch = $delegate.__proto__.$watch; + $delegate.__proto__.$watch = function (watchExpression, applyFunction) { + var thatScope = this; + var watchStr = watchFnToHumanReadableString(watchExpression); + + if (!debug.watchPerf[watchStr]) { + debug.watchPerf[watchStr] = { + time: 0, + calls: 0 + }; + } + if (!debug.watchers[thatScope.$id]) { + debug.watchers[thatScope.$id] = []; + } + debug.watchers[thatScope.$id].push(watchStr); + emit.watcherChange(thatScope.$id); + + // patch watchExpression + // --------------------- + var w = watchExpression; + if (typeof w === 'function') { + watchExpression = function () { + var start = performance.now(); + var ret = w.apply(this, arguments); + var end = performance.now(); + debug.watchPerf[watchStr].time += (end - start); + debug.watchPerf[watchStr].calls += 1; + emit.watchPerfChange(watchStr); + return ret; + }; + } else { + watchExpression = function () { + var start = performance.now(); + var ret = thatScope.$eval(w); + var end = performance.now(); + debug.watchPerf[watchStr].time += (end - start); + debug.watchPerf[watchStr].calls += 1; + emit.watchPerfChange(watchStr); + return ret; + }; + } + + // patch applyFunction + // ------------------- + if (typeof applyFunction === 'function') { + var applyStr = applyFunction.toString(); + var unpatchedApplyFunction = applyFunction; + applyFunction = function () { + var start = performance.now(); + var ret = unpatchedApplyFunction.apply(this, arguments); + var end = performance.now(); + + //TODO: move these checks out of here and into registering the watcher + if (!debug.applyPerf[applyStr]) { + debug.applyPerf[applyStr] = { + time: 0, + calls: 0 + }; + } + debug.applyPerf[applyStr].time += (end - start); + debug.applyPerf[applyStr].calls += 1; + emit.applyPerfChange(applyStr); + return ret; + }; + } + + return _watch.apply(this, arguments); + }; + + + // patch $destroy + // -------------- + var _destroy = $delegate.__proto__.$destroy; + $delegate.__proto__.$destroy = function () { + [ + 'watchers', + 'scopes' + ].forEach(function (prop) { + if (debug[prop][this.$id]) { + delete debug[prop][this.$id]; + } + }, this); + emit.scopeDeleted(this.$id); + return _destroy.apply(this, arguments); + }; + + + // patch $new + // ---------- + var _new = $delegate.__proto__.$new; + $delegate.__proto__.$new = function () { + + var ret = _new.apply(this, arguments); + if (ret.$root) { + debug.rootScopes[ret.$root.$id] = ret.$root; + emit.scopeChange(ret.$root.$id); + } + + // create empty watchers array for this scope + if (!debug.watchers[ret.$id]) { + debug.watchers[ret.$id] = []; + } + + debug.scopes[ret.$id] = ret; + debug.scopes[this.$id] = this; + + return ret; + }; + + + // patch $digest + // ------------- + var _digest = $delegate.__proto__.$digest; + $delegate.__proto__.$digest = function (fn) { + var ret = _digest.apply(this, arguments); + emit.modelChange(this.$id); + return ret; + }; + + + // patch $apply + // ------------ + var _apply = $delegate.__proto__.$apply; + $delegate.__proto__.$apply = function (fn) { + var start = performance.now(); + var ret = _apply.apply(this, arguments); + var end = performance.now(); + + // If the debugging option is enabled, log to console + // -------------------------------------------------- + if (debug.log) { + console.log(applyFnToLogString(fn) + '\t\t' + (end - start).toPrecision(4) + 'ms'); + } + + return ret; + }; + + + return $delegate; + }); +}); diff --git a/content-scripts/lib/niceNames.js b/content-scripts/lib/niceNames.js new file mode 100644 index 0000000..1e0c1da --- /dev/null +++ b/content-scripts/lib/niceNames.js @@ -0,0 +1,58 @@ + +module.exports = function niceNames () { + var ngScopeElts = document.getElementsByClassName('ng-scope'); + ngScopeElts = Array.prototype.slice.call(ngScopeElts); + return ngScopeElts. + reduce(function (acc, elt) { + var $elt = angular.element(elt); + var scope = $elt.scope(); + + var name = {}; + + [ + 'ng-app', + 'ng-controller', + 'ng-repeat' + ]. + forEach(function (attr) { + var val = $elt.attr(attr), + className = $elt[0].className, + match, + lhs, + valueIdentifier, + keyIdentifier; + + if (val) { + name[attr] = val; + if (attr === 'ng-repeat') { + match = /(.+) in/.exec(val); + lhs = match[1]; + + match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); + valueIdentifier = match[3] || match[1]; + keyIdentifier = match[2]; + + if (keyIdentifier) { + name.lhs = valueIdentifier + '["' + scope[keyIdentifier] + '"]' + summarizeObject(scope[valueIdentifier]); + } else { + name.lhs = valueIdentifier + summarizeObject(scope[valueIdentifier]); + } + + } + } else if (className.indexOf(attr) !== -1) { + match = (new RegExp(attr + ': ([a-zA-Z0-9]+);')).exec(className); + name[attr] = match[1]; + } + }); + + if (Object.keys(name).length === 0) { + name.tag = $elt[0].tagName.toLowerCase(); + name.classes = $elt[0].className. + replace(/(\W*ng-scope\W*)/, ' '). + split(' '). + filter(function (i) { return i; }); + } + acc[scope.$id] = name; + return acc; + }, {}); +}; diff --git a/content-scripts/lib/popover.js b/content-scripts/lib/popover.js new file mode 100644 index 0000000..d041e0a --- /dev/null +++ b/content-scripts/lib/popover.js @@ -0,0 +1,234 @@ +// TODO: this depends on global state and stuff + +module.exports = function () { + if (popover) { + return; + } + var angular = window.angular; + popover = angular.element( + '
' + + '
' + + '
{ Please select a scope }
' + + '' + + '' + + '' + + '' + + '
' + + '
'); + angular.element(window.document.body).append(popover); + var popoverContent = angular.element(angular.element(popover.children('div')[0]).children()[0]); + var dragElt = angular.element(angular.element(popover.children('div')[0]).children()[1]); + var selectElt = angular.element(angular.element(popover.children('div')[0]).children()[2]); + var closeElt = angular.element(angular.element(popover.children('div')[0]).children()[3]); + + var currentScope = null, + currentElt = null; + + function onMove (ev) { + var x = ev.clientX, + y = ev.clientY; + + if (x > window.outerWidth - 100) { + x = window.outerWidth - 100; + } else if (x < 0) { + x = 0; + } + if (y > window.outerHeight - 100) { + y = window.outerHeight - 100; + } else if (y < 0) { + y = 0; + } + + x += 5; + y += 5; + + popover.css('left', x + 'px'); + popover.css('top', y + 'px'); + } + + closeElt.bind('click', function () { + popover.remove(); + popover = null; + }); + + selectElt.bind('click', bindSelectScope); + + var selecting = false; + function bindSelectScope () { + if (selecting) { + return; + } + setTimeout(function () { + selecting = true; + selectElt.attr('disabled', true); + angular.element(document.body).css('cursor', 'crosshair'); + angular.element(document.getElementsByClassName('ng-scope')) + .bind('click', onSelectScope) + .bind('mouseover', onHoverScope); + }, 30); + } + + var hoverScopeElt = null; + + function markHoverElt () { + if (hoverScopeElt) { + hoverScopeElt.addClass('bat-selected'); + } + } + function unmarkHoverElt () { + if (hoverScopeElt) { + hoverScopeElt.removeClass('bat-selected'); + } + } + + function onSelectScope (ev) { + render(this); + angular.element(document.getElementsByClassName('ng-scope')) + .unbind('click', onSelectScope) + .unbind('mouseover', onHoverScope); + unmarkHoverElt(); + selecting = false; + selectElt.attr('disabled', false); + angular.element(document.body).css('cursor', ''); + hovering = false; + } + + var hovering = false; + function onHoverScope (ev) { + if (hovering) { + return; + } + hovering = true; + var that = this; + setTimeout(function () { + unmarkHoverElt(); + hoverScopeElt = angular.element(that); + markHoverElt(); + hovering = false; + render(that); + }, 100); + } + + function onUnhoverScope (ev) { + angular.element(this).css('border', ''); + } + + dragElt.bind('mousedown', function (ev) { + ev.preventDefault(); + rendering = true; + angular.element(document).bind('mousemove', onMove); + }); + angular.element(document).bind('mouseup', function () { + angular.element(document).unbind('mousemove', onMove); + setTimeout(function () { + rendering = false; + }, 120); + }); + + function renderTree (data) { + var tree = angular.element('
'); + angular.forEach(data, function (val, key) { + var toAppend; + if (val === undefined) { + toAppend = 'undefined'; + } else if (val === null) { + toAppend = 'null'; + } else if (val instanceof Array) { + toAppend = '[ ... ]'; + } else if (val instanceof Object) { + toAppend = '{ ... }'; + } else { + toAppend = val.toString(); + } + if (data instanceof Array) { + toAppend = '
' + + toAppend + + ((key === (data.length - 1))?'':',') + + '
'; + } else { + toAppend = '
' + + key + + ': ' + + toAppend + + (key!==0?'':',') + + '
'; + } + toAppend = angular.element(toAppend); + if (val instanceof Array || val instanceof Object) { + function recur () { + toAppend.unbind('click', recur); + toAppend.html(''); + toAppend + .append(angular.element('' + + key + ': ' + + ((val instanceof Array)?'[':'{') + + '').bind('click', collapse)) + .append(renderTree(val)) + .append('' + ((val instanceof Array)?']':'}') + ''); + } + function collapse () { + toAppend.html(''); + toAppend.append(angular.element('
' + + key + + ': ' + + ((val instanceof Array)?'[ ... ]':'{ ... }') + + '
').bind('click', recur)); + } + toAppend.bind('click', recur); + } + tree.append(toAppend); + }); + + return tree; + } + + var isEmpty = function (object) { + var prop; + for (prop in object) { + if (object.hasOwnProperty(prop)) { + return false; + } + } + return true; + }; + + var objLength = function (object) { + var prop, len = 0; + for (prop in object) { + if (object.hasOwnProperty(prop)) { + len += 1; + } + } + return len; + }; + + var rendering = false; + var render = function (elt) { + if (rendering) { + return; + } + rendering = true; + setTimeout(function () { + var scope = angular.element(elt).scope(); + rendering = false; + if (scope === currentScope) { + return; + } + currentScope = scope; + currentElt = elt; + + var models = getScopeLocals(scope); + popoverContent.children().remove(); + if (isEmpty(models)) { + popoverContent.append(angular.element('This scope has no models')); + } else { + popoverContent.append(renderTree(models)); + } + + }, 100); + }; + +}; diff --git a/content-scripts/lib/summarizeModel.js b/content-scripts/lib/summarizeModel.js new file mode 100644 index 0000000..5da2e7f --- /dev/null +++ b/content-scripts/lib/summarizeModel.js @@ -0,0 +1,51 @@ + +// TODO: handle DOM nodes, fns, etc better. +var subModel = function (obj) { + return obj instanceof Array ? + { '~array-length': obj.length } : + obj === null ? + null : + typeof obj === 'object' ? + { '~object': true } : + obj; +}; + +module.exports = function (id, path) { + + if (path === undefined || path === '') { + path = []; + } else if (typeof path === 'string') { + path = path.split('.'); + } + + var dest = debug.scopes[id], + segment; + + if (!dest) { + return; + } + + while (path.length > 0) { + segment = path.shift(); + dest = dest[segment]; + if (!dest) { + return; + } + } + + if (dest instanceof Array) { + return dest.map(subModel); + } else if (typeof dest === 'object') { + return Object. + keys(dest). + filter(function (key) { + return key[0] !== '$' || key[1] !== '$'; + }). + reduce(function (obj, prop) { + obj[prop] = subModel(dest[prop]); + return obj; + }, {}); + } else { + return dest; + } +}; diff --git a/content-scripts/lib/summarizeObject.js b/content-scripts/lib/summarizeObject.js new file mode 100644 index 0000000..3d6e67c --- /dev/null +++ b/content-scripts/lib/summarizeObject.js @@ -0,0 +1,44 @@ + +module.exports = function summarizeObject (obj) { + var summary = {}, keys; + if (obj instanceof Array) { + keys = obj.map(function (e, i) { return i; }); + } else if (typeof obj === 'object') { + keys = Object.keys(obj); + } else { + return '=' + obj.toString().substr(0, 10); + } + + var id; + + if (keys.some(function (key) { + var lowKey = key.toLowerCase(); + if (lowKey.indexOf('name') !== -1 || + lowKey.indexOf('id') !== -1) { + return id = key; + } + })) { + return '.' + id + '="' + obj[id].toString() + '"'; + } + + if (keys.length > 5) { + keys = keys.slice(0, 5); + } + + keys.forEach(function (key) { + var val = obj[key]; + if (val instanceof Array) { + summary[key] = '[ … ]'; + } else if (typeof val === 'object') { + summary[key] = '{ … }'; + } else if (typeof val === 'function') { + summary[key] = 'fn'; + } else { + summary[key] = obj[key].toString(); + if (summary[key].length > 10) { + summary[key] = summary[key].substr(0, 10) + '…'; + } + } + }); + return '=' + JSON.stringify(summary); +}; diff --git a/content-scripts/lib/throttle.js b/content-scripts/lib/throttle.js new file mode 100644 index 0000000..39a6614 --- /dev/null +++ b/content-scripts/lib/throttle.js @@ -0,0 +1,46 @@ +// throttle based on _.throttle from Lo-Dash +// https://github.com/bestiejs/lodash/blob/master/lodash.js#L4625 + +// modified so that it +// throttles based on arguments +// returns nothing + +// Ex: +// var th = throttle(fn, 50); +// fn('foo'); // not throttled +// fn('foo'); // throttled +// fn('bar'); // not throttled +module.exports = function (func, wait) { + var args, + thisArg, + timeoutId = {}, + lastCalled = {}; + + if (wait === 0) { + return func; + } + + return function() { + args = arguments; + thisArg = this; + + var argsString = Array.prototype.slice.call(args).join(';'); + + var now = new Date(); + var remaining = wait - (now - lastCalled[argsString]); + + if (remaining <= 0) { + clearTimeout(timeoutId[argsString]); + timeoutId[argsString] = null; + lastCalled[argsString] = now; + func.apply(thisArg, args); + } + else if (!timeoutId[argsString]) { + timeoutId[argsString] = setTimeout(function () { + lastCalled[argsString] = new Date(); + timeoutId[argsString] = null; + func.apply(thisArg, args); + }, remaining); + } + }; +}; diff --git a/css/components/json-tree.css b/devtools-panel/css/components/json-tree.css similarity index 100% rename from css/components/json-tree.css rename to devtools-panel/css/components/json-tree.css diff --git a/css/components/scope-tree.css b/devtools-panel/css/components/scope-tree.css similarity index 100% rename from css/components/scope-tree.css rename to devtools-panel/css/components/scope-tree.css diff --git a/css/components/slider.css b/devtools-panel/css/components/slider.css similarity index 100% rename from css/components/slider.css rename to devtools-panel/css/components/slider.css diff --git a/css/components/watcher-list.css b/devtools-panel/css/components/watcher-list.css similarity index 100% rename from css/components/watcher-list.css rename to devtools-panel/css/components/watcher-list.css diff --git a/css/d3.css b/devtools-panel/css/d3.css similarity index 100% rename from css/d3.css rename to devtools-panel/css/d3.css diff --git a/css/panel.css b/devtools-panel/css/panel.css similarity index 100% rename from css/panel.css rename to devtools-panel/css/panel.css diff --git a/css/reset.css b/devtools-panel/css/reset.css similarity index 100% rename from css/reset.css rename to devtools-panel/css/reset.css diff --git a/img/angular.png b/devtools-panel/img/angular.png similarity index 100% rename from img/angular.png rename to devtools-panel/img/angular.png diff --git a/img/angular.pxm b/devtools-panel/img/angular.pxm similarity index 100% rename from img/angular.pxm rename to devtools-panel/img/angular.pxm diff --git a/img/console.png b/devtools-panel/img/console.png similarity index 100% rename from img/console.png rename to devtools-panel/img/console.png diff --git a/img/deps.png b/devtools-panel/img/deps.png similarity index 100% rename from img/deps.png rename to devtools-panel/img/deps.png diff --git a/img/glyphicons-halflings-white.png b/devtools-panel/img/glyphicons-halflings-white.png similarity index 100% rename from img/glyphicons-halflings-white.png rename to devtools-panel/img/glyphicons-halflings-white.png diff --git a/img/glyphicons-halflings.png b/devtools-panel/img/glyphicons-halflings.png similarity index 100% rename from img/glyphicons-halflings.png rename to devtools-panel/img/glyphicons-halflings.png diff --git a/img/inspect.png b/devtools-panel/img/inspect.png similarity index 100% rename from img/inspect.png rename to devtools-panel/img/inspect.png diff --git a/img/models.png b/devtools-panel/img/models.png similarity index 100% rename from img/models.png rename to devtools-panel/img/models.png diff --git a/img/options.png b/devtools-panel/img/options.png similarity index 100% rename from img/options.png rename to devtools-panel/img/options.png diff --git a/img/perf.png b/devtools-panel/img/perf.png similarity index 100% rename from img/perf.png rename to devtools-panel/img/perf.png diff --git a/img/statusbarButtonGlyphs.png b/devtools-panel/img/statusbarButtonGlyphs.png similarity index 100% rename from img/statusbarButtonGlyphs.png rename to devtools-panel/img/statusbarButtonGlyphs.png diff --git a/img/webstore-icon.png b/devtools-panel/img/webstore-icon.png similarity index 100% rename from img/webstore-icon.png rename to devtools-panel/img/webstore-icon.png diff --git a/img/webstore-icon.pxm b/devtools-panel/img/webstore-icon.pxm similarity index 100% rename from img/webstore-icon.pxm rename to devtools-panel/img/webstore-icon.pxm diff --git a/js/controllers/DepsCtrl.js b/devtools-panel/js/controllers/DepsCtrl.js similarity index 100% rename from js/controllers/DepsCtrl.js rename to devtools-panel/js/controllers/DepsCtrl.js diff --git a/js/controllers/ModelCtrl.js b/devtools-panel/js/controllers/ModelCtrl.js similarity index 100% rename from js/controllers/ModelCtrl.js rename to devtools-panel/js/controllers/ModelCtrl.js diff --git a/js/controllers/OptionsCtrl.js b/devtools-panel/js/controllers/OptionsCtrl.js similarity index 100% rename from js/controllers/OptionsCtrl.js rename to devtools-panel/js/controllers/OptionsCtrl.js diff --git a/js/controllers/PerfCtrl.js b/devtools-panel/js/controllers/PerfCtrl.js similarity index 95% rename from js/controllers/PerfCtrl.js rename to devtools-panel/js/controllers/PerfCtrl.js index 9bf47fd..544c3cf 100644 --- a/js/controllers/PerfCtrl.js +++ b/devtools-panel/js/controllers/PerfCtrl.js @@ -29,7 +29,7 @@ angular.module('panelApp').controller('PerfCtrl', function PerfCtrl($scope, appC $scope.histogram = histogram; }); }); - appModel.getRootScopes(function (rootScopes) { + appModel.getRootScopeIds(function (rootScopes) { $scope.$apply(function () { $scope.roots = rootScopes; if ($scope.roots.length === 0) { diff --git a/js/directives/d3.js b/devtools-panel/js/directives/d3.js similarity index 100% rename from js/directives/d3.js rename to devtools-panel/js/directives/d3.js diff --git a/js/directives/jsonTree.js b/devtools-panel/js/directives/jsonTree.js similarity index 100% rename from js/directives/jsonTree.js rename to devtools-panel/js/directives/jsonTree.js diff --git a/js/directives/scopeTree.js b/devtools-panel/js/directives/scopeTree.js similarity index 100% rename from js/directives/scopeTree.js rename to devtools-panel/js/directives/scopeTree.js diff --git a/js/directives/slider.js b/devtools-panel/js/directives/slider.js similarity index 100% rename from js/directives/slider.js rename to devtools-panel/js/directives/slider.js diff --git a/js/directives/tabs.js b/devtools-panel/js/directives/tabs.js similarity index 100% rename from js/directives/tabs.js rename to devtools-panel/js/directives/tabs.js diff --git a/js/directives/verticalSplit.js b/devtools-panel/js/directives/verticalSplit.js similarity index 100% rename from js/directives/verticalSplit.js rename to devtools-panel/js/directives/verticalSplit.js diff --git a/js/directives/watcherTree.js b/devtools-panel/js/directives/watcherTree.js similarity index 100% rename from js/directives/watcherTree.js rename to devtools-panel/js/directives/watcherTree.js diff --git a/js/filters/first.js b/devtools-panel/js/filters/first.js similarity index 100% rename from js/filters/first.js rename to devtools-panel/js/filters/first.js diff --git a/js/filters/precision.js b/devtools-panel/js/filters/precision.js similarity index 100% rename from js/filters/precision.js rename to devtools-panel/js/filters/precision.js diff --git a/js/filters/sortByTime.js b/devtools-panel/js/filters/sortByTime.js similarity index 100% rename from js/filters/sortByTime.js rename to devtools-panel/js/filters/sortByTime.js diff --git a/js/lib/jquery-ui-1.8.21.custom.min.js b/devtools-panel/js/lib/jquery-ui-1.8.21.custom.min.js similarity index 100% rename from js/lib/jquery-ui-1.8.21.custom.min.js rename to devtools-panel/js/lib/jquery-ui-1.8.21.custom.min.js diff --git a/js/panelApp.js b/devtools-panel/js/panelApp.js similarity index 100% rename from js/panelApp.js rename to devtools-panel/js/panelApp.js diff --git a/js/services/appContext.js b/devtools-panel/js/services/appContext.js similarity index 100% rename from js/services/appContext.js rename to devtools-panel/js/services/appContext.js diff --git a/js/services/appCss.js b/devtools-panel/js/services/appCss.js similarity index 100% rename from js/services/appCss.js rename to devtools-panel/js/services/appCss.js diff --git a/js/services/appDeps.js b/devtools-panel/js/services/appDeps.js similarity index 100% rename from js/services/appDeps.js rename to devtools-panel/js/services/appDeps.js diff --git a/js/services/appHighlight.js b/devtools-panel/js/services/appHighlight.js similarity index 100% rename from js/services/appHighlight.js rename to devtools-panel/js/services/appHighlight.js diff --git a/js/services/appInfo.js b/devtools-panel/js/services/appInfo.js similarity index 100% rename from js/services/appInfo.js rename to devtools-panel/js/services/appInfo.js diff --git a/js/services/appModel.js b/devtools-panel/js/services/appModel.js similarity index 100% rename from js/services/appModel.js rename to devtools-panel/js/services/appModel.js diff --git a/js/services/appPerf.js b/devtools-panel/js/services/appPerf.js similarity index 100% rename from js/services/appPerf.js rename to devtools-panel/js/services/appPerf.js diff --git a/js/services/appWatch.js b/devtools-panel/js/services/appWatch.js similarity index 100% rename from js/services/appWatch.js rename to devtools-panel/js/services/appWatch.js diff --git a/js/services/chromeExtension.js b/devtools-panel/js/services/chromeExtension.js similarity index 100% rename from js/services/chromeExtension.js rename to devtools-panel/js/services/chromeExtension.js diff --git a/js/services/filesystem.js b/devtools-panel/js/services/filesystem.js similarity index 100% rename from js/services/filesystem.js rename to devtools-panel/js/services/filesystem.js diff --git a/panel.html b/devtools-panel/panel.html similarity index 92% rename from panel.html rename to devtools-panel/panel.html index afaf104..76f5c46 100644 --- a/panel.html +++ b/devtools-panel/panel.html @@ -14,10 +14,10 @@ - - + + - + diff --git a/panes/deps.html b/devtools-panel/panes/deps.html similarity index 100% rename from panes/deps.html rename to devtools-panel/panes/deps.html diff --git a/panes/help.html b/devtools-panel/panes/help.html similarity index 100% rename from panes/help.html rename to devtools-panel/panes/help.html diff --git a/panes/model.html b/devtools-panel/panes/model.html similarity index 100% rename from panes/model.html rename to devtools-panel/panes/model.html diff --git a/panes/options.html b/devtools-panel/panes/options.html similarity index 100% rename from panes/options.html rename to devtools-panel/panes/options.html diff --git a/panes/perf.html b/devtools-panel/panes/perf.html similarity index 100% rename from panes/perf.html rename to devtools-panel/panes/perf.html diff --git a/devtoolsBackground.html b/devtoolsBackground.html index cfcae41..4d9e1b8 100644 --- a/devtoolsBackground.html +++ b/devtoolsBackground.html @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/js/devtoolsBackground.js b/devtoolsBackground.js similarity index 94% rename from js/devtoolsBackground.js rename to devtoolsBackground.js index ae4057b..89f7b71 100644 --- a/js/devtoolsBackground.js +++ b/devtoolsBackground.js @@ -40,6 +40,6 @@ panels.elements.createSidebarPane( // Angular panel var angularPanel = panels.create( "AngularJS", - "img/angular.png", - "panel.html" + "devtools-panel/img/angular.png", + "devtools-panel/panel.html" ); diff --git a/js/inject/debug.js b/js/inject/debug.js deleted file mode 100644 index 716e54d..0000000 --- a/js/inject/debug.js +++ /dev/null @@ -1,1093 +0,0 @@ -// inject/debug.js -// this file is run from the content script context (separate JS VM from the app, but same DOM) -// but injects an 'instrumentation' script tag into the app context -// confusing, right? - -var instument = function instument (window) { - - // Helper to determine if the root 'ng' module has been loaded - // window.angular may be available if the app is bootstrapped asynchronously, but 'ng' might - // finish loading later. - var ngLoaded = function () { - if (!window.angular) { - return false; - } - try { - window.angular.module('ng'); - } - catch (e) { - return false; - } - return true; - }; - - if (!ngLoaded()) { - // TODO: var name - var areWeThereYet = function (ev) { - if (ev.srcElement.tagName === 'SCRIPT') { - var oldOnload = ev.srcElement.onload; - ev.srcElement.onload = function () { - if (ngLoaded()) { - document.removeEventListener('DOMNodeInserted', areWeThereYet); - instument(window); - } - if (oldOnload) { - oldOnload.apply(this, arguments); - } - }; - } - }; - document.addEventListener('DOMNodeInserted', areWeThereYet); - return; - } - - // do not patch twice - if (window.__ngDebug) { - return; - } - - - - // Helpers - // ======= - - // throttle based on _.throttle from Lo-Dash - // https://github.com/bestiejs/lodash/blob/master/lodash.js#L4625 - - // modified so that it - // throttles based on arguments - // returns nothing - - // Ex: - // var th = throttle(fn, 50); - // fn('foo'); // not throttled - // fn('foo'); // throttled - // fn('bar'); // not throttled - var throttle = function (func, wait) { - var args, - thisArg, - timeoutId = {}, - lastCalled = {}; - - return function() { - args = arguments; - thisArg = this; - - var argsString = Array.prototype.slice.call(args).join(';'); // lol javascript - - var now = new Date(); - var remaining = wait - (now - lastCalled[argsString]); - - if (remaining <= 0) { - clearTimeout(timeoutId[argsString]); - timeoutId[argsString] = null; - lastCalled[argsString] = now; - func.apply(thisArg, args); - } - else if (!timeoutId[argsString]) { - timeoutId[argsString] = setTimeout(function () { - lastCalled[argsString] = new Date(); - timeoutId[argsString] = null; - func.apply(thisArg, args); - }, remaining); - } - }; - }; - - // polyfill for performance.now on older webkit - if (!performance.now) { - performance.now = performance.webkitNow; - } - - // Send notifications from app context to devtools context - // in order to do this, we need to create a DOM element across which - // the app and content script contexts can communicate - var eventProxyElement = document.createElement('div'); - eventProxyElement.id = '__ngDebugElement'; - eventProxyElement.style.display = 'none'; - document.body.appendChild(eventProxyElement); - - var customEvent = document.createEvent('Event'); - customEvent.initEvent('myCustomEvent', true, true); - - var fireCustomEvent = function (data) { - data.appId = instrumentedAppId; - eventProxyElement.innerText = JSON.stringify(data); - eventProxyElement.dispatchEvent(customEvent); - }; - - - // given a scope object, return an object with deep clones - // of the models exposed on that scope - var getScopeLocals = function (scope) { - var scopeLocals = {}, prop; - for (prop in scope) { - if (scope.hasOwnProperty(prop) && prop !== 'this' && prop[0] !== '$') { - scopeLocals[prop] = decycle(scope[prop]); - } - } - return scopeLocals; - }; - - - - // Private state - // ============= - - //var bootstrap = window.angular.bootstrap; - var debug = { - // map of scopes --> watcher function name strings - watchers: {}, - - // maps of watch/apply exp/fns to perf data - watchPerf: {}, - applyPerf: {}, - - // map of scope.$ids --> $scope objects - scopes: {}, - - // whether or not to emit profile data - profiling: false, - - // map of $ids --> [] array of things being watched - modelWatchers: {}, - // map of $id + watcher --> value - modelWatchersState: {}, - - // map of $ids --> refs to $rootScope objects - rootScopes: {}, - - deps: [] - }; - - var popover = null; - var instrumentedAppId = window.location.host + '~' + Math.random(); - - - // Utils - // ===== - - var getScopeTree = function (id) { - - var names = api.niceNames(); - - var traverse = function (sc) { - var tree = { - id: sc.$id, - name: names[sc.$id], - watchers: debug.watchers[sc.$id], - children: [] - }; - - var child = sc.$$childHead; - if (child) { - do { - tree.children.push(traverse(child)); - } while (child !== sc.$$childTail && (child = child.$$nextSibling)); - } - - return tree; - }; - - var root = debug.rootScopes[id]; - var tree = traverse(root); - - return tree; - }; - - var getWatchPerf = function () { - var changes = []; - angular.forEach(debug.watchPerf, function (info, name) { - if (info.time > 0) { - changes.push({ - name: name, - time: info.time - }); - info.time = 0; - } - }); - return changes; - }; - - // Emit stuff - // ========== - - var emit = { - modelChange: throttle(function (id, watchers) { - var scope = debug.scopes[id]; - var changes = {}; - - watchers = watchers || debug.modelWatchers[id]; - - if (scope && debug.modelWatchers[id]) { - - Object.keys(debug.modelWatchers[id]). - forEach(function (watcher) { - var newValue = api.getModel(id, watcher), - newString = JSON.stringify(newValue), - prop = id + '~' + watcher; - - if (debug.modelWatchersState[prop] !== newString) { - changes[watcher] = newValue; - debug.modelWatchersState[prop] = newString; - } - }); - } - - if (Object.keys(changes).length > 0) { - fireCustomEvent({ - action: 'modelChange', - id: id, - changes: changes - }); - } - }, 50), - - scopeChange: throttle(function (id) { - fireCustomEvent({ - action: 'scopeChange', - id: id, - scope: getScopeTree(id) - }); - }, 50), - - scopeDeleted: function (id) { - fireCustomEvent({ - action: 'scopeDeleted', - id: id - }); - }, - - watcherChange: throttle(function (id) { - if (debug.modelWatchers[id]) { - fireCustomEvent({ - action: 'watcherChange', - id: id, - watchers: debug.watchers[id] - }); - } - }, 50), - - watchPerfChange: throttle(function (str) { - if (debug.profiling) { - fireCustomEvent({ - action: 'watchPerfChange', - watcher: str, - value: debug.watchPerf[str] - }); - } - }, 50), - - applyPerfChange: throttle(function (str) { - if (debug.profiling) { - fireCustomEvent({ - action: 'applyPerfChange', - watcher: str, - value: debug.applyPerf[str] - }); - } - }, 50), - - // might be worth limiting - watchPerf: function () { - throw new Error('Implement me :c'); - } - }; - - var summarizeObject = function (obj) { - var summary = {}, keys; - if (obj instanceof Array) { - keys = obj.map(function (e, i) { return i; }); - } else if (typeof obj === 'object') { - keys = Object.keys(obj); - } else { - return '=' + obj.toString().substr(0, 10); - } - - var id; - - if (keys.some(function (key) { - var lowKey = key.toLowerCase(); - if (lowKey.indexOf('name') !== -1 || - lowKey.indexOf('id') !== -1) { - return id = key; - } - })) { - return '.' + id + '="' + obj[id].toString() + '"'; - } - - if (keys.length > 5) { - keys = keys.slice(0, 5); - } - - keys.forEach(function (key) { - var val = obj[key]; - if (val instanceof Array) { - summary[key] = '[ … ]'; - } else if (typeof val === 'object') { - summary[key] = '{ … }'; - } else if (typeof val === 'function') { - summary[key] = 'fn'; - } else { - summary[key] = obj[key].toString() - if (summary[key].length > 10) { - summary[key] = summary[key].substr(0, 10) + '…'; - } - } - }); - return '=' + JSON.stringify(summary); - } - - - // Public API - // ========== - - var api = window.__ngDebug = { - - profiling: function (setting) { - debug.profiling = setting; - }, - - getDeps: function () { - return debug.deps; - }, - - getRootScopeIds: function () { - return Object.keys(debug.rootScopes); - }, - - getAppId: function () { - return instrumentedAppId; - }, - - fireCustomEvent: fireCustomEvent, - - niceNames: function () { - var ngScopeElts = document.getElementsByClassName('ng-scope'); - ngScopeElts = Array.prototype.slice.call(ngScopeElts); - return ngScopeElts. - reduce(function (acc, elt) { - var $elt = angular.element(elt); - var scope = $elt.scope(); - - var name = {}; - - [ - 'ng-app', - 'ng-controller', - 'ng-repeat' - ]. - forEach(function (attr) { - var val = $elt.attr(attr), - className = $elt[0].className, - match, - lhs, - valueIdentifier, - keyIdentifier; - - if (val) { - name[attr] = val; - if (attr === 'ng-repeat') { - match = /(.+) in/.exec(val); - lhs = match[1]; - - match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); - valueIdentifier = match[3] || match[1]; - keyIdentifier = match[2]; - - if (keyIdentifier) { - name.lhs = valueIdentifier + '["' + scope[keyIdentifier] + '"]' + summarizeObject(scope[valueIdentifier]); - } else { - name.lhs = valueIdentifier + summarizeObject(scope[valueIdentifier]); - } - - } - } else if (className.indexOf(attr) !== -1) { - match = (new RegExp(attr + ': ([a-zA-Z0-9]+);')).exec(className); - name[attr] = match[1]; - } - }); - - if (Object.keys(name).length === 0) { - name.tag = $elt[0].tagName.toLowerCase(); - name.classes = $elt[0].className. - replace(/(\W*ng-scope\W*)/, ' '). - split(' '). - filter(function (i) { return i; }); - } - acc[scope.$id] = name; - return acc; - }, {}); - }, - - getModel: function (id, path) { - - // lol chrome - if (path) { - try {throw new Error(''); } catch (e) {} - } - - if (path === undefined || path === '') { - path = []; - } else if (typeof path === 'string') { - path = path.split('.'); - } - - var dest = debug.scopes[id], - segment; - - if (!dest) { - return; - } - - while (path.length > 0) { - segment = path.shift(); - dest = dest[segment]; - if (!dest) { - return; - } - } - - // TODO: handle DOM nodes, fns, etc better. - var subModel = function (obj) { - return obj instanceof Array ? - { '~array-length': obj.length } : - obj === null ? - null : - typeof obj === 'object' ? - { '~object': true } : - obj; - }; - - if (dest instanceof Array) { - return dest.map(subModel); - } else if (typeof dest === 'object') { - return Object. - keys(dest). - filter(function (key) { - return key[0] !== '$' || key[1] !== '$'; - }). - reduce(function (obj, prop) { - obj[prop] = subModel(dest[prop]); - return obj; - }, {}); - } else { - return dest; - } - }, - - setSomeModel: function (id, path, value) { - debug.scope[id].$apply(path + '=' + JSON.stringify(value)); - }, - - watchModel: function (id, path) { - debug.modelWatchers[id] = debug.modelWatchers[id] || {}; - debug.modelWatchers[id][path || ''] = true; - if (!path || path === '') { - debug.modelWatchersState = {}; - } - emit.modelChange(id); - emit.watcherChange(id); - }, - - // unwatches all children of the given path - // Ex: - // if watching 'foo.bar.baz', 'foo.bar', and 'foo' - // unwatchModel('001', 'foo.bar') - // unwatches 'foo.bar.baz' and 'foo.bar' - unwatchModel: function (id, path) { - if (!debug.modelWatchers[id]) { - return; - } - if (path === undefined) { - path = ''; - } - Object.keys(modelWatchers[id]).forEach(function (key) { - if (key.substr(0, path.length) === path) { - delete debug.modelWatchers[id][key]; - //delete debug. - } - }); - }, - - enable: function () { - if (popover) { - return; - } - var angular = window.angular; - popover = angular.element( - '
' + - '
' + - '
{ Please select a scope }
' + - '' + - '' + - '' + - '' + - '
' + - '
'); - angular.element(window.document.body).append(popover); - var popoverContent = angular.element(angular.element(popover.children('div')[0]).children()[0]); - var dragElt = angular.element(angular.element(popover.children('div')[0]).children()[1]); - var selectElt = angular.element(angular.element(popover.children('div')[0]).children()[2]); - var closeElt = angular.element(angular.element(popover.children('div')[0]).children()[3]); - - var currentScope = null, - currentElt = null; - - function onMove (ev) { - var x = ev.clientX, - y = ev.clientY; - - if (x > window.outerWidth - 100) { - x = window.outerWidth - 100; - } else if (x < 0) { - x = 0; - } - if (y > window.outerHeight - 100) { - y = window.outerHeight - 100; - } else if (y < 0) { - y = 0; - } - - x += 5; - y += 5; - - popover.css('left', x + 'px'); - popover.css('top', y + 'px'); - } - - closeElt.bind('click', function () { - popover.remove(); - popover = null; - }); - - selectElt.bind('click', bindSelectScope); - - var selecting = false; - function bindSelectScope () { - if (selecting) { - return; - } - setTimeout(function () { - selecting = true; - selectElt.attr('disabled', true); - angular.element(document.body).css('cursor', 'crosshair'); - angular.element(document.getElementsByClassName('ng-scope')) - .bind('click', onSelectScope) - .bind('mouseover', onHoverScope); - }, 30); - } - - var hoverScopeElt = null; - - function markHoverElt () { - if (hoverScopeElt) { - hoverScopeElt.addClass('bat-selected'); - } - } - function unmarkHoverElt () { - if (hoverScopeElt) { - hoverScopeElt.removeClass('bat-selected'); - } - } - - function onSelectScope (ev) { - render(this); - angular.element(document.getElementsByClassName('ng-scope')) - .unbind('click', onSelectScope) - .unbind('mouseover', onHoverScope); - unmarkHoverElt(); - selecting = false; - selectElt.attr('disabled', false); - angular.element(document.body).css('cursor', ''); - hovering = false; - } - - var hovering = false; - function onHoverScope (ev) { - if (hovering) { - return; - } - hovering = true; - var that = this; - setTimeout(function () { - unmarkHoverElt(); - hoverScopeElt = angular.element(that); - markHoverElt(); - hovering = false; - render(that); - }, 100); - } - - function onUnhoverScope (ev) { - angular.element(this).css('border', ''); - } - - dragElt.bind('mousedown', function (ev) { - ev.preventDefault(); - rendering = true; - angular.element(document).bind('mousemove', onMove); - }); - angular.element(document).bind('mouseup', function () { - angular.element(document).unbind('mousemove', onMove); - setTimeout(function () { - rendering = false; - }, 120); - }); - - function renderTree (data) { - var tree = angular.element('
'); - angular.forEach(data, function (val, key) { - var toAppend; - if (val === undefined) { - toAppend = 'undefined'; - } else if (val === null) { - toAppend = 'null'; - } else if (val instanceof Array) { - toAppend = '[ ... ]'; - } else if (val instanceof Object) { - toAppend = '{ ... }'; - } else { - toAppend = val.toString(); - } - if (data instanceof Array) { - toAppend = '
' + - toAppend + - ((key === (data.length - 1))?'':',') + - '
'; - } else { - toAppend = '
' + - key + - ': ' + - toAppend + - (key!==0?'':',') + - '
'; - } - toAppend = angular.element(toAppend); - if (val instanceof Array || val instanceof Object) { - function recur () { - toAppend.unbind('click', recur); - toAppend.html(''); - toAppend - .append(angular.element('' + - key + ': ' + - ((val instanceof Array)?'[':'{') + - '').bind('click', collapse)) - .append(renderTree(val)) - .append('' + ((val instanceof Array)?']':'}') + ''); - } - function collapse () { - toAppend.html(''); - toAppend.append(angular.element('
' + - key + - ': ' + - ((val instanceof Array)?'[ ... ]':'{ ... }') + - '
').bind('click', recur)); - } - toAppend.bind('click', recur); - } - tree.append(toAppend); - }); - - return tree; - } - - var isEmpty = function (object) { - var prop; - for (prop in object) { - if (object.hasOwnProperty(prop)) { - return false; - } - } - return true; - }; - - var objLength = function (object) { - var prop, len = 0; - for (prop in object) { - if (object.hasOwnProperty(prop)) { - len += 1; - } - } - return len; - }; - - var rendering = false; - var render = function (elt) { - if (rendering) { - return; - } - rendering = true; - setTimeout(function () { - var scope = angular.element(elt).scope(); - rendering = false; - if (scope === currentScope) { - return; - } - currentScope = scope; - currentElt = elt; - - var models = getScopeLocals(scope); - popoverContent.children().remove(); - if (isEmpty(models)) { - popoverContent.append(angular.element('This scope has no models')); - } else { - popoverContent.append(renderTree(models)); - } - - }, 100); - }; - - } - }; - - - - - // helper to extract dependencies from function arguments - // not all versions of AngularJS expose annotate - var annotate = angular.injector().annotate; - if (!annotate) { - annotate = (function () { - - var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; - var FN_ARG_SPLIT = /,/; - var FN_ARG = /^\s*(_?)(.+?)\1\s*$/; - var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; - - // TODO: should I keep these assertions? - function assertArg(arg, name, reason) { - if (!arg) { - throw new Error("Argument '" + (name || '?') + "' is " + (reason || "required")); - } - return arg; - } - function assertArgFn(arg, name, acceptArrayAnnotation) { - if (acceptArrayAnnotation && angular.isArray(arg)) { - arg = arg[arg.length - 1]; - } - - assertArg(angular.isFunction(arg), name, 'not a function, got ' + - (arg && typeof arg == 'object' ? arg.constructor.name || 'Object' : typeof arg)); - return arg; - } - - return function (fn) { - var $inject, - fnText, - argDecl, - last; - - if (typeof fn == 'function') { - if (!($inject = fn.$inject)) { - $inject = []; - fnText = fn.toString().replace(STRIP_COMMENTS, ''); - argDecl = fnText.match(FN_ARGS); - argDecl[1].split(FN_ARG_SPLIT).forEach(function(arg) { - arg.replace(FN_ARG, function(all, underscore, name) { - $inject.push(name); - }); - }); - fn.$inject = $inject; - } - } else if (angular.isArray(fn)) { - last = fn.length - 1; - assertArgFn(fn[last], 'fn'); - $inject = fn.slice(0, last); - } else { - assertArgFn(fn, 'fn', true); - } - return $inject; - }; - }()); - } - - var recordDependencies = function (providerName, dependencies) { - debug.deps.push({ - name: providerName, - imports: dependencies - }); - }; - - // $provide Instrumentation - // ======================== - - var ng = angular.module('ng'); - ng.config(function ($provide) { - // methods to patch - - // $provide.provider - var temp = $provide.provider; - $provide.provider = function (name, definition) { - if (!definition) { - angular.forEach(name, function (definition, name) { - var tempGet = definition.$get; - definition.$get = function () { - recordDependencies(name, annotate(tempGet)); - return tempGet.apply(this, arguments); - }; - }); - } else if (definition instanceof Array) { - // it is a constructoctor with array syntax - var tempConstructor = definition[definition.length - 1]; - - definition[definition.length - 1] = function () { - recordDependencies(name, annotate(tempConstructor)); - return tempConstructor.apply(this, arguments); - }; - } else if (definition.$get instanceof Array) { - // it should have a $get - var tempGet = definition.$get[definition.$get.length - 1]; - - definition.$get[definition.$get.length - 1] = function () { - recordDependencies(name, annotate(tempGet)); - return tempGet.apply(this, arguments); - }; - } else if (typeof definition === 'object') { - // it should have a $get - var tempGet = definition.$get; - - // preserve original annotations - definition.$get = annotate(definition.$get); - definition.$get.push(function () { - recordDependencies(name, annotate(tempGet)); - return tempGet.apply(this, arguments); - }); - } else { - recordDependencies(name, annotate(definition)); - } - return temp.apply(this, arguments); - }; - - // $provide.(factory|service) - [ - 'factory', - 'service' - ].forEach(function (met) { - var temp = $provide[met]; - $provide[met] = function (name, definition) { - if (typeof name === 'object') { - angular.forEach(name, function (value, key) { - var isArray = value instanceof Array; - var originalValue = isArray ? value[value.length - 1] : value; - - var newValue = function () { - recordDependencies(key, annotate(originalValue)); - return originalValue.apply(this, arguments); - }; - - if (isArray) { - value[value.length - 1] = newValue; - } else { - name[value] = newValue; - } - }); - } else { - recordDependencies(name, annotate(definition)); - } - return temp.apply(this, arguments); - }; - }); - - $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(); - } - }; - - var applyFnToLogString = function (fn) { - var str; - if (fn) { - if (fn.name) { - str = fn.name; - } else if (fn.toString().split('\n').length > 1) { - str = 'fn () { ' + fn.toString().split('\n')[1].trim() + ' /* ... */ }'; - } else { - str = fn.toString().trim().substr(0, 30) + '...'; - } - } else { - str = '$apply'; - } - return str; - }; - - - // patch registering watchers - // ========================== - - var _watch = $delegate.__proto__.$watch; - $delegate.__proto__.$watch = function (watchExpression, applyFunction) { - var thatScope = this; - var watchStr = watchFnToHumanReadableString(watchExpression); - - if (!debug.watchPerf[watchStr]) { - debug.watchPerf[watchStr] = { - time: 0, - calls: 0 - }; - } - if (!debug.watchers[thatScope.$id]) { - debug.watchers[thatScope.$id] = []; - } - debug.watchers[thatScope.$id].push(watchStr); - emit.watcherChange(thatScope.$id); - - // patch watchExpression - // --------------------- - var w = watchExpression; - if (typeof w === 'function') { - watchExpression = function () { - var start = performance.now(); - var ret = w.apply(this, arguments); - var end = performance.now(); - debug.watchPerf[watchStr].time += (end - start); - debug.watchPerf[watchStr].calls += 1; - emit.watchPerfChange(watchStr); - return ret; - }; - } else { - watchExpression = function () { - var start = performance.now(); - var ret = thatScope.$eval(w); - var end = performance.now(); - debug.watchPerf[watchStr].time += (end - start); - debug.watchPerf[watchStr].calls += 1; - emit.watchPerfChange(watchStr); - return ret; - }; - } - - // patch applyFunction - // ------------------- - if (typeof applyFunction === 'function') { - var applyStr = applyFunction.toString(); - var unpatchedApplyFunction = applyFunction; - applyFunction = function () { - var start = performance.now(); - var ret = unpatchedApplyFunction.apply(this, arguments); - var end = performance.now(); - - //TODO: move these checks out of here and into registering the watcher - if (!debug.applyPerf[applyStr]) { - debug.applyPerf[applyStr] = { - time: 0, - calls: 0 - }; - } - debug.applyPerf[applyStr].time += (end - start); - debug.applyPerf[applyStr].calls += 1; - emit.applyPerfChange(applyStr); - return ret; - }; - } - - return _watch.apply(this, arguments); - }; - - - // patch $destroy - // -------------- - var _destroy = $delegate.__proto__.$destroy; - $delegate.__proto__.$destroy = function () { - [ - 'watchers', - 'scopes' - ].forEach(function (prop) { - if (debug[prop][this.$id]) { - delete debug[prop][this.$id]; - } - }, this); - emit.scopeDeleted(this.$id); - return _destroy.apply(this, arguments); - }; - - - // patch $new - // ---------- - var _new = $delegate.__proto__.$new; - $delegate.__proto__.$new = function () { - - var ret = _new.apply(this, arguments); - if (ret.$root) { - debug.rootScopes[ret.$root.$id] = ret.$root; - emit.scopeChange(ret.$root.$id); - } - - // create empty watchers array for this scope - if (!debug.watchers[ret.$id]) { - debug.watchers[ret.$id] = []; - } - - debug.scopes[ret.$id] = ret; - debug.scopes[this.$id] = this; - - return ret; - }; - - - // patch $digest - // ------------- - var _digest = $delegate.__proto__.$digest; - $delegate.__proto__.$digest = function (fn) { - var ret = _digest.apply(this, arguments); - emit.modelChange(this.$id); - return ret; - }; - - - // patch $apply - // ------------ - var _apply = $delegate.__proto__.$apply; - $delegate.__proto__.$apply = function (fn) { - var start = performance.now(); - var ret = _apply.apply(this, arguments); - var end = performance.now(); - - // If the debugging option is enabled, log to console - // -------------------------------------------------- - if (debug.log) { - console.log(applyFnToLogString(fn) + '\t\t' + (end - start).toPrecision(4) + 'ms'); - } - - return ret; - }; - - - return $delegate; - }); - }); -}; - -// inject into the application context from the content script context - -var inject = function () { - var script = window.document.createElement('script'); - script.innerHTML = '(' + instument.toString() + '(window))'; - document.head.appendChild(script); - - // handle forwarding the events sent from the app context to the - // background page context - var eventProxyElement = document.getElementById('__ngDebugElement'); - - if (eventProxyElement) { - eventProxyElement.addEventListener('myCustomEvent', function () { - var eventData = JSON.parse(eventProxyElement.innerText); - chrome.extension.sendMessage(eventData); - }); - document.removeEventListener('DOMContentLoaded', inject); - } -}; - -// only inject if cookie is set -if (document.cookie.indexOf('__ngDebug=true') != -1) { - document.addEventListener('DOMContentLoaded', inject); -} diff --git a/karma-inject.conf.js b/karma-inject.conf.js new file mode 100644 index 0000000..8b965cd --- /dev/null +++ b/karma-inject.conf.js @@ -0,0 +1,61 @@ +// Karma configuration for testing injected +// AngularJS instrumentation + +module.exports = function (config) { + config.set({ + + // base path, that will be used to resolve files and exclude + basePath: '', + + // frameworks to use + frameworks: ['jasmine'], + + // list of files / patterns to load in the browser + files: [ + 'test/inject/mock/*.js', + 'js/inject/debug.final.js', + 'bower_components/angular/angular.js', + 'bower_components/angular-mocks/angular-mocks.js', + 'test/inject/*.js' + ], + + // list of files to exclude + exclude: [ + '*.min.js' + ], + + // test results reporter to use + // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' + reporters: ['progress'], + + // web server port + port: 9876, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera + // - Safari (only Mac) + // - PhantomJS + // - IE (only Windows) + browsers: ['Chrome'], + + // If browser does not capture in given timeout [ms], kill it + captureTimeout: 60000, + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: false + }); +}; diff --git a/karma.conf b/karma.conf deleted file mode 100644 index efe647b..0000000 --- a/karma.conf +++ /dev/null @@ -1,24 +0,0 @@ - -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; - diff --git a/karma.e2e.conf b/karma.e2e.conf deleted file mode 100644 index efe647b..0000000 --- a/karma.e2e.conf +++ /dev/null @@ -1,24 +0,0 @@ - -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; - diff --git a/manifest.json b/manifest.json index 95d3232..80b1094 100644 --- a/manifest.json +++ b/manifest.json @@ -14,7 +14,7 @@ "content_scripts": [ { "matches": [""], - "js": ["js/inject/debug.js"], + "js": ["content-scripts/inject.build.js"], "run_at": "document_start" } ], diff --git a/scripts/inline.js b/scripts/inline.js new file mode 100644 index 0000000..ebead0a --- /dev/null +++ b/scripts/inline.js @@ -0,0 +1,27 @@ +// Similar to browserify, but inlines the scripts instead. +// This is a really dumb naive approach that only supports `module.exports =` + +var fs = require('fs'); + +var debug = fs.readFileSync(__dirname + '/../content-scripts/inject.js', 'utf8'); + +var r = new RegExp("require\\('(.+?)'\\)", 'g'); + +var out = debug.replace(r, function (match, file) { + return ex(file); +}); + +fs.writeFileSync(__dirname + '/../content-scripts/inject.build.js', out); + +// takes the contents of a file, wraps it in a closure +// and returns the result +function ex (file) { + contents = fs.readFileSync(__dirname + '/../content-scripts/' + file, 'utf8'); + contents = contents.replace('module.exports = ', 'return '); + contents = '(function () {\n' + + '// exported from ' + file + '\n' + + contents + '\n' + + '}())'; + + return contents; +} diff --git a/tests/angular-expose-isolate-scopes.js b/test/e2e/angular-expose-isolate-scopes.js similarity index 100% rename from tests/angular-expose-isolate-scopes.js rename to test/e2e/angular-expose-isolate-scopes.js diff --git a/tests/apply.html b/test/e2e/apply.html similarity index 100% rename from tests/apply.html rename to test/e2e/apply.html diff --git a/tests/grid.html b/test/e2e/grid.html similarity index 100% rename from tests/grid.html rename to test/e2e/grid.html diff --git a/tests/isolate.html b/test/e2e/isolate.html similarity index 100% rename from tests/isolate.html rename to test/e2e/isolate.html diff --git a/tests/transclusion.html b/test/e2e/transclusion.html similarity index 100% rename from tests/transclusion.html rename to test/e2e/transclusion.html diff --git a/test/inject/injectSpec.js b/test/inject/injectSpec.js new file mode 100644 index 0000000..d218a77 --- /dev/null +++ b/test/inject/injectSpec.js @@ -0,0 +1,57 @@ +describe('inject', function () { + + // inject/debug bootstraps asynchronously + beforeEach(function () {}); + + it('should expose a __ngDebug object to window', function () { + expect(window.__ngDebug).not.toBeUndefined(); + }); + + describe('getRootScopeIds', function () { + + it('should start empty', function () { + expect(__ngDebug.getRootScopeIds()).toEqual([]); + }); + + describe('bootstraped', function () { + it('should work', function () { + var elt, scope; + + runs(function () { + angular.module('foo', []).controller('A', function ($scope) { + $scope.model = 1; + $scope.complexModel = { foo: { bar: 'baz' } }; + }); + elt = angular.element('
'); + angular.bootstrap(elt, ['ng', 'foo']); + scope = elt.data().$scope; + }); + + runs(function () { + expect(__ngDebug.getRootScopeIds().length).toBe(1); + expect(__ngDebug.getModel(scope.$id).model).toBe(scope.model); + }); + + runs(function () { + scope.model = 2; + scope.$digest(); + expect(__ngDebug.getModel(scope.$id).model).toBe(2); + }); + + runs(function () { + __ngDebug.watchModel(scope.$id, 'complexModel'); + scope.$digest(); + }); + + waits(60); + + runs(function () { + scope.complexModel.b = 1; + scope.$digest(); + }); + }); + }); + + + }); +}); diff --git a/test/inject/mock/chromeExtension.js b/test/inject/mock/chromeExtension.js new file mode 100644 index 0000000..93747d8 --- /dev/null +++ b/test/inject/mock/chromeExtension.js @@ -0,0 +1,5 @@ +// mocks window.chrome.extension + +chrome.extension = { + sendMessage: dump +}; diff --git a/test/inject/mock/injectEnabler.js b/test/inject/mock/injectEnabler.js new file mode 100644 index 0000000..10f7d2b --- /dev/null +++ b/test/inject/mock/injectEnabler.js @@ -0,0 +1,2 @@ +// sets the __ngDebug cookie +window.document.cookie = '__ngDebug=true;';