Bug 1177891 - Introduce redux-style UI architecture in the debugger and refactor event listeners to use it. r=fitzgen

This commit is contained in:
James Long 2015-08-28 07:27:00 -04:00
parent 6b2b241334
commit afd8d71bc6
33 changed files with 1198 additions and 196 deletions

View File

@ -0,0 +1,7 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
exports.UPDATE_EVENT_BREAKPOINTS = 'UPDATE_EVENT_BREAKPOINTS';
exports.FETCH_EVENT_LISTENERS = 'FETCH_EVENT_LISTENERS';

View File

@ -0,0 +1,9 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const constants = require('./constants');
// No global actions right now, but I'm sure there will be soon.
module.exports = {};

View File

@ -0,0 +1,147 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const constants = require('../constants');
const promise = require('promise');
const { rdpInvoke, asPaused } = require('../utils');
const { reportException } = require("devtools/toolkit/DevToolsUtils");
const FETCH_EVENT_LISTENERS_DELAY = 200; // ms
const initialState = {
activeEventNames: [],
listeners: [],
fetchingListeners: false,
};
function update(state = initialState, action, emitChange) {
switch(action.type) {
case constants.UPDATE_EVENT_BREAKPOINTS:
state.activeEventNames = action.eventNames;
emitChange('activeEventNames', state.activeEventNames);
break;
case constants.FETCH_EVENT_LISTENERS:
if (action.status === "begin") {
state.fetchingListeners = true;
}
else if (action.status === "done") {
state.fetchingListeners = false;
state.listeners = action.listeners;
emitChange('listeners', state.listeners);
}
break;
}
return state;
};
function fetchEventListeners() {
return (dispatch, getState) => {
// Make sure we're not sending a batch of closely repeated requests.
// This can easily happen whenever new sources are fetched.
setNamedTimeout("event-listeners-fetch", FETCH_EVENT_LISTENERS_DELAY, () => {
// In case there is still a request of listeners going on (it
// takes several RDP round trips right now), make sure we wait
// on a currently running request
if (getState().eventListeners.fetchingListeners) {
dispatch({
type: services.WAIT_UNTIL,
predicate: action => (
action.type === constants.FETCH_EVENT_LISTENERS &&
action.status === "done"
),
run: dispatch => dispatch(fetchEventListeners())
});
return;
}
dispatch({
type: constants.FETCH_EVENT_LISTENERS,
status: "begin"
});
asPaused(gThreadClient, _getListeners).then(listeners => {
// Notify that event listeners were fetched and shown in the view,
// and callback to resume the active thread if necessary.
window.emit(EVENTS.EVENT_LISTENERS_FETCHED);
dispatch({
type: constants.FETCH_EVENT_LISTENERS,
status: "done",
listeners: listeners
});
});
});
};
}
const _getListeners = Task.async(function*() {
const response = yield rdpInvoke(gThreadClient, gThreadClient.eventListeners);
// Make sure all the listeners are sorted by the event type, since
// they're not guaranteed to be clustered together.
response.listeners.sort((a, b) => a.type > b.type ? 1 : -1);
// Add all the listeners in the debugger view event linsteners container.
let fetchedDefinitions = new Map();
let listeners = [];
for (let listener of response.listeners) {
let definitionSite;
if (fetchedDefinitions.has(listener.function.actor)) {
definitionSite = fetchedDefinitions.get(listener.function.actor);
} else if (listener.function.class == "Function") {
definitionSite = yield _getDefinitionSite(listener.function);
if (!definitionSite) {
// We don't know where this listener comes from so don't show it in
// the UI as breaking on it doesn't work (bug 942899).
continue;
}
fetchedDefinitions.set(listener.function.actor, definitionSite);
}
listener.function.url = definitionSite;
listeners.push(listener);
}
fetchedDefinitions.clear();
return listeners;
});
const _getDefinitionSite = Task.async(function*(aFunction) {
const grip = gThreadClient.pauseGrip(aFunction);
let response;
try {
response = yield rdpInvoke(grip, grip.getDefinitionSite);
}
catch(e) {
// Don't make this error fatal, because it would break the entire events pane.
reportException("_getDefinitionSite", e);
return null;
}
return response.source.url;
});
function updateEventBreakpoints(eventNames) {
return dispatch => {
setNamedTimeout("event-breakpoints-update", 0, () => {
gThreadClient.pauseOnDOMEvents(eventNames, function() {
// Notify that event breakpoints were added/removed on the server.
window.emit(EVENTS.EVENT_BREAKPOINTS_UPDATED);
dispatch({
type: constants.UPDATE_EVENT_BREAKPOINTS,
eventNames: eventNames
});
});
});
}
}
module.exports = {
update: update,
actions: { updateEventBreakpoints, fetchEventListeners }
}

View File

@ -0,0 +1,8 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const eventListeners = require('./event-listeners');
module.exports = { eventListeners };

View File

@ -0,0 +1,45 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { promiseInvoke } = require("devtools/async-utils");
const { reportException } = require("devtools/toolkit/DevToolsUtils");
function rdpInvoke(client, method, ...args) {
return promiseInvoke(client, method, ...args)
.then((packet) => {
let { error, message } = packet;
if (error) {
throw new Error(error + ": " + message);
}
return packet;
});
}
function asPaused(client, func) {
if (client.state != "paused") {
return Task.spawn(function*() {
yield rdpInvoke(client, client.interrupt);
let result;
try {
result = yield func();
}
catch(e) {
// Try to put the debugger back in a working state by resuming
// it
yield rdpInvoke(client, client.resume);
throw e;
}
yield rdpInvoke(client, client.resume);
return result;
});
} else {
return func();
}
}
module.exports = { rdpInvoke, asPaused };

View File

@ -3,14 +3,24 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const actions = require('../stores/event-listeners').actions;
const bindActionCreators = require('devtools/shared/fluxify/bindActionCreators');
/**
* Functions handling the event listeners UI.
*/
function EventListenersView(DebuggerController) {
function EventListenersView(dispatcher, DebuggerController) {
dumpn("EventListenersView was instantiated");
this.actions = bindActionCreators(actions, dispatcher.dispatch);
this.getState = () => dispatcher.getState().eventListeners;
this.Breakpoints = DebuggerController.Breakpoints;
dispatcher.onChange({
"eventListeners": { "listeners": this.renderListeners }
}, this);
this._onCheck = this._onCheck.bind(this);
this._onClick = this._onClick.bind(this);
}
@ -47,6 +57,15 @@ EventListenersView.prototype = Heritage.extend(WidgetMethods, {
this.widget.removeEventListener("click", this._onClick, false);
},
renderListeners: function(listeners) {
listeners.forEach(listener => {
this.addListener(listener, { staged: true });
});
// Flushes all the prepared events into the event listeners container.
this.commit();
},
/**
* Adds an event to this event listeners container.
*
@ -142,12 +161,12 @@ EventListenersView.prototype = Heritage.extend(WidgetMethods, {
}
// Create the element node for the event listener item.
let itemView = this._createItemView(type, selector, url);
const itemView = this._createItemView(type, selector, url);
// Event breakpoints survive target navigations. Make sure the newly
// inserted event item is correctly checked.
let checkboxState =
this.Breakpoints.DOM.activeEventNames.indexOf(type) != -1;
const activeEventNames = this.getState().activeEventNames;
const checkboxState = activeEventNames.indexOf(type) != -1;
// Append an event listener item to this container.
this.push([itemView.container], {
@ -241,7 +260,8 @@ EventListenersView.prototype = Heritage.extend(WidgetMethods, {
_onCheck: function({ detail: { description, checked }, target }) {
if (description == "item") {
this.getItemForElement(target).attachment.checkboxState = checked;
this.Breakpoints.DOM.scheduleEventBreakpointsUpdate();
this.actions.updateEventBreakpoints(this.getCheckedEvents());
return;
}
@ -271,4 +291,4 @@ EventListenersView.prototype = Heritage.extend(WidgetMethods, {
_inNativeCodeString: ""
});
DebuggerView.EventListeners = new EventListenersView(DebuggerController);
module.exports = EventListenersView;

View File

@ -11,7 +11,6 @@ const DBG_STRINGS_URI = "chrome://browser/locale/devtools/debugger.properties";
const NEW_SOURCE_IGNORED_URLS = ["debugger eval code", "XStringBundle"];
const NEW_SOURCE_DISPLAY_DELAY = 200; // ms
const FETCH_SOURCE_RESPONSE_DELAY = 200; // ms
const FETCH_EVENT_LISTENERS_DELAY = 200; // ms
const FRAME_STEP_CLEAR_DELAY = 100; // ms
const CALL_STACK_PAGE_SIZE = 25; // frames
@ -103,9 +102,11 @@ Cu.import("resource:///modules/devtools/VariablesView.jsm");
Cu.import("resource:///modules/devtools/VariablesViewController.jsm");
Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
const {require} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
Cu.import("resource:///modules/devtools/shared/browser-loader.js");
const require = BrowserLoader("resource:///modules/devtools/debugger/", this).require;
const {TargetFactory} = require("devtools/framework/target");
const {Toolbox} = require("devtools/framework/toolbox")
const {Toolbox} = require("devtools/framework/toolbox");
const DevToolsUtils = require("devtools/toolkit/DevToolsUtils");
const promise = require("devtools/toolkit/deprecated-sync-thenables");
const Editor = require("devtools/sourceeditor/editor");
@ -320,7 +321,9 @@ let DebuggerController = {
this.SourceScripts.connect();
if (aThreadClient.paused) {
aThreadClient.resume(this._ensureResumptionOrder);
aThreadClient.resume(res => {
this._ensureResumptionOrder(res)
});
}
deferred.resolve();
@ -1274,7 +1277,7 @@ SourceScripts.prototype = {
// Make sure the events listeners are up to date.
if (DebuggerView.instrumentsPaneTab == "events-tab") {
DebuggerController.Breakpoints.DOM.scheduleEventListenersFetch();
dispatcher.dispatch(actions.fetchEventListeners());
}
// Signal that a new source has been added.
@ -1534,151 +1537,6 @@ SourceScripts.prototype = {
}
};
/**
* Handles breaking on event listeners in the currently debugged target.
*/
function EventListeners() {
}
EventListeners.prototype = {
/**
* A list of event names on which the debuggee will automatically pause
* when invoked.
*/
activeEventNames: [],
/**
* Updates the list of events types with listeners that, when invoked,
* will automatically pause the debuggee. The respective events are
* retrieved from the UI.
*/
scheduleEventBreakpointsUpdate: function() {
// Make sure we're not sending a batch of closely repeated requests.
// This can easily happen when toggling all events of a certain type.
setNamedTimeout("event-breakpoints-update", 0, () => {
this.activeEventNames = DebuggerView.EventListeners.getCheckedEvents();
gThreadClient.pauseOnDOMEvents(this.activeEventNames);
// Notify that event breakpoints were added/removed on the server.
window.emit(EVENTS.EVENT_BREAKPOINTS_UPDATED);
});
},
/**
* Schedules fetching the currently attached event listeners from the debugee.
*/
scheduleEventListenersFetch: function() {
// Make sure we're not sending a batch of closely repeated requests.
// This can easily happen whenever new sources are fetched.
setNamedTimeout("event-listeners-fetch", FETCH_EVENT_LISTENERS_DELAY, () => {
if (gThreadClient.state != "paused") {
gThreadClient.interrupt(() => this._getListeners(() => gThreadClient.resume()));
} else {
this._getListeners();
}
});
},
/**
* A semaphore that is used to ensure only a single protocol request for event
* listeners will be ongoing at any given time.
*/
_parsingListeners: false,
/**
* A flag the indicates whether a new request to fetch updated event listeners
* has arrived, while another one was in progress.
*/
_eventListenersUpdateNeeded: false,
/**
* Fetches the currently attached event listeners from the debugee.
* The thread client state is assumed to be "paused".
*
* @param function aCallback
* Invoked once the event listeners are fetched and displayed.
*/
_getListeners: function(aCallback) {
// Don't make a new request if one is still ongoing, but schedule one for
// later.
if (this._parsingListeners) {
this._eventListenersUpdateNeeded = true;
return;
}
this._parsingListeners = true;
gThreadClient.eventListeners(Task.async(function*(aResponse) {
if (aResponse.error) {
throw "Error getting event listeners: " + aResponse.message;
}
// Make sure all the listeners are sorted by the event type, since
// they're not guaranteed to be clustered together.
aResponse.listeners.sort((a, b) => a.type > b.type ? 1 : -1);
// Add all the listeners in the debugger view event linsteners container.
let fetchedDefinitions = new Map();
for (let listener of aResponse.listeners) {
let definitionSite;
if (fetchedDefinitions.has(listener.function.actor)) {
definitionSite = fetchedDefinitions.get(listener.function.actor);
} else if (listener.function.class == "Function") {
definitionSite = yield this._getDefinitionSite(listener.function);
if (!definitionSite) {
// We don't know where this listener comes from so don't show it in
// the UI as breaking on it doesn't work (bug 942899).
continue;
}
fetchedDefinitions.set(listener.function.actor, definitionSite);
}
listener.function.url = definitionSite;
DebuggerView.EventListeners.addListener(listener, { staged: true });
}
fetchedDefinitions.clear();
// Flushes all the prepared events into the event listeners container.
DebuggerView.EventListeners.commit();
// Now that we are done, schedule a new update if necessary.
this._parsingListeners = false;
if (this._eventListenersUpdateNeeded) {
this._eventListenersUpdateNeeded = false;
this.scheduleEventListenersFetch();
}
// Notify that event listeners were fetched and shown in the view,
// and callback to resume the active thread if necessary.
window.emit(EVENTS.EVENT_LISTENERS_FETCHED);
aCallback && aCallback();
}.bind(this)));
},
/**
* Gets a function's source-mapped definiton site.
*
* @param object aFunction
* The grip of the function to get the definition site for.
* @return object
* A promise that is resolved with the function's owner source url.
*/
_getDefinitionSite: function(aFunction) {
let deferred = promise.defer();
gThreadClient.pauseGrip(aFunction).getDefinitionSite(aResponse => {
if (aResponse.error) {
// Don't make this error fatal, because it would break the entire events pane.
const msg = "Error getting function definition site: " + aResponse.message;
DevToolsUtils.reportException("_getDefinitionSite", msg);
deferred.resolve(null);
} else {
deferred.resolve(aResponse.source.url);
}
});
return deferred.promise;
}
};
/**
* Handles all the breakpoints in the current debugger.
*/
@ -2205,7 +2063,6 @@ DebuggerController.ThreadState = new ThreadState();
DebuggerController.StackFrames = new StackFrames();
DebuggerController.SourceScripts = new SourceScripts();
DebuggerController.Breakpoints = new Breakpoints();
DebuggerController.Breakpoints.DOM = new EventListeners();
/**
* Export some properties to the global scope for easier access.

View File

@ -33,6 +33,17 @@ const TOOLBAR_ORDER_POPUP_POSITION = "topcenter bottomleft";
const PROMISE_DEBUGGER_URL =
"chrome://browser/content/devtools/promisedebugger/promise-debugger.xhtml";
const createDispatcher = require('devtools/shared/create-dispatcher')();
const stores = require('./content/stores/index');
const dispatcher = createDispatcher(stores);
const waitUntilService = require('devtools/shared/fluxify/waitUntilService');
const services = {
WAIT_UNTIL: waitUntilService.name
};
const EventListenersView = require('./content/views/event-listeners-view');
const actions = require('./content/stores/event-listeners').actions;
/**
* Object defining the debugger view components.
*/
@ -598,7 +609,7 @@ let DebuggerView = {
*/
_onInstrumentsPaneTabSelect: function() {
if (this._instrumentsPane.selectedTab.id == "events-tab") {
DebuggerController.Breakpoints.DOM.scheduleEventListenersFetch();
dispatcher.dispatch(actions.fetchEventListeners());
}
},
@ -859,3 +870,5 @@ ResultsPanelContainer.prototype = Heritage.extend(WidgetMethods, {
left: 0,
top: 0
});
DebuggerView.EventListeners = new EventListenersView(dispatcher, DebuggerController);

View File

@ -8,4 +8,19 @@ EXTRA_JS_MODULES.devtools.debugger += [
'panel.js'
]
BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
EXTRA_JS_MODULES.devtools.debugger.content += [
'content/constants.js',
'content/globalActions.js',
'content/utils.js'
]
EXTRA_JS_MODULES.devtools.debugger.content.views += [
'content/views/event-listeners-view.js'
]
EXTRA_JS_MODULES.devtools.debugger.content.stores += [
'content/stores/event-listeners.js',
'content/stores/index.js'
]
BROWSER_CHROME_MANIFESTS += ['test/mochitest/browser.ini']

View File

@ -13,6 +13,8 @@ function test() {
let gDebugger = aPanel.panelWin;
let gView = gDebugger.DebuggerView;
let gEvents = gView.EventListeners;
let gDispatcher = gDebugger.dispatcher;
let constants = gDebugger.require('./content/constants');
Task.spawn(function*() {
yield waitForSourceShown(aPanel, ".html");
@ -24,7 +26,7 @@ function test() {
function testFetchOnFocus() {
return Task.spawn(function*() {
let fetched = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_LISTENERS_FETCHED);
let fetched = afterDispatch(gDispatcher, constants.FETCH_EVENT_LISTENERS);
gView.toggleInstrumentsPane({ visible: true, animated: false }, 1);
is(gView.instrumentsPaneHidden, false,
@ -43,7 +45,7 @@ function test() {
function testFetchOnReloadWhenFocused() {
return Task.spawn(function*() {
let fetched = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_LISTENERS_FETCHED);
let fetched = afterDispatch(gDispatcher, constants.FETCH_EVENT_LISTENERS);
let reloading = once(gDebugger.gTarget, "will-navigate");
let reloaded = waitForSourcesAfterReload();

View File

@ -12,11 +12,13 @@ function test() {
let gDebugger = aPanel.panelWin;
let gView = gDebugger.DebuggerView;
let gEvents = gView.EventListeners;
let gDispatcher = gDebugger.dispatcher;
let constants = gDebugger.require('./content/constants');
Task.spawn(function*() {
yield waitForSourceShown(aPanel, ".html");
let fetched = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_LISTENERS_FETCHED);
let fetched = afterDispatch(gDispatcher, constants.FETCH_EVENT_LISTENERS);
gView.toggleInstrumentsPane({ visible: true, animated: false }, 1);
yield fetched;
@ -44,7 +46,7 @@ function test() {
is(gEvents.getCheckedEvents().toString(), "",
"The getCheckedEvents() method returns the correct stuff.");
yield ensureThreadClientState(aPanel, "resumed");
yield ensureThreadClientState(aPanel, "attached");
yield closeDebuggerAndFinish(aPanel);
});

View File

@ -14,12 +14,14 @@ function test() {
let gView = gDebugger.DebuggerView;
let gController = gDebugger.DebuggerController
let gEvents = gView.EventListeners;
let gBreakpoints = gController.Breakpoints;
let gDispatcher = gDebugger.dispatcher;
let getState = gDispatcher.getState;
let constants = gDebugger.require('./content/constants');
Task.spawn(function*() {
yield waitForSourceShown(aPanel, ".html");
let fetched = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_LISTENERS_FETCHED);
let fetched = afterDispatch(gDispatcher, constants.FETCH_EVENT_LISTENERS);
gView.toggleInstrumentsPane({ visible: true, animated: false }, 1);
yield fetched;
@ -32,7 +34,7 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "");
let updated = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_BREAKPOINTS_UPDATED);
let updated = afterDispatch(gDispatcher, constants.UPDATE_EVENT_BREAKPOINTS);
EventUtils.sendMouseEvent({ type: "click" }, getItemCheckboxNode(0), gDebugger);
yield updated;
@ -45,7 +47,7 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "change");
updated = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_BREAKPOINTS_UPDATED);
updated = afterDispatch(gDispatcher, constants.UPDATE_EVENT_BREAKPOINTS);
EventUtils.sendMouseEvent({ type: "click" }, getItemCheckboxNode(0), gDebugger);
yield updated;
@ -58,7 +60,7 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "");
yield ensureThreadClientState(aPanel, "resumed");
yield ensureThreadClientState(aPanel, "attached");
yield closeDebuggerAndFinish(aPanel);
});
@ -90,7 +92,7 @@ function test() {
"The getAllEvents() method returns the correct stuff.");
is(gEvents.getCheckedEvents().toString(), checked,
"The getCheckedEvents() method returns the correct stuff.");
is(gBreakpoints.DOM.activeEventNames.toString(), checked,
is(getState().eventListeners.activeEventNames.toString(), checked,
"The correct event names are listed as being active breakpoints.");
}
});

View File

@ -15,12 +15,14 @@ function test() {
let gView = gDebugger.DebuggerView;
let gController = gDebugger.DebuggerController
let gEvents = gView.EventListeners;
let gBreakpoints = gController.Breakpoints;
let gDispatcher = gDebugger.dispatcher;
let getState = gDispatcher.getState;
let constants = gDebugger.require('./content/constants');
Task.spawn(function*() {
yield waitForSourceShown(aPanel, ".html");
let fetched = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_LISTENERS_FETCHED);
let fetched = afterDispatch(gDispatcher, constants.FETCH_EVENT_LISTENERS);
gView.toggleInstrumentsPane({ visible: true, animated: false }, 1);
yield fetched;
@ -33,7 +35,7 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "");
let updated = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_BREAKPOINTS_UPDATED);
let updated = afterDispatch(gDispatcher, constants.UPDATE_EVENT_BREAKPOINTS);
EventUtils.sendMouseEvent({ type: "click" }, getGroupCheckboxNode("interactionEvents"), gDebugger);
yield updated;
@ -46,7 +48,7 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "change");
updated = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_BREAKPOINTS_UPDATED);
updated = afterDispatch(gDispatcher, constants.UPDATE_EVENT_BREAKPOINTS);
EventUtils.sendMouseEvent({ type: "click" }, getGroupCheckboxNode("interactionEvents"), gDebugger);
yield updated;
@ -59,7 +61,7 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "");
updated = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_BREAKPOINTS_UPDATED);
updated = afterDispatch(gDispatcher, constants.UPDATE_EVENT_BREAKPOINTS);
EventUtils.sendMouseEvent({ type: "click" }, getGroupCheckboxNode("keyboardEvents"), gDebugger);
yield updated;
@ -72,7 +74,7 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "keydown,keyup");
updated = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_BREAKPOINTS_UPDATED);
updated = afterDispatch(gDispatcher, constants.UPDATE_EVENT_BREAKPOINTS);
EventUtils.sendMouseEvent({ type: "click" }, getGroupCheckboxNode("keyboardEvents"), gDebugger);
yield updated;
@ -85,7 +87,7 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "");
yield ensureThreadClientState(aPanel, "resumed");
yield ensureThreadClientState(aPanel, "attached");
yield closeDebuggerAndFinish(aPanel);
});
@ -117,7 +119,7 @@ function test() {
"The getAllEvents() method returns the correct stuff.");
is(gEvents.getCheckedEvents().toString(), checked,
"The getCheckedEvents() method returns the correct stuff.");
is(gBreakpoints.DOM.activeEventNames.toString(), checked,
is(getState().eventListeners.activeEventNames.toString(), checked,
"The correct event names are listed as being active breakpoints.");
}
});

View File

@ -15,11 +15,14 @@ function test() {
let gController = gDebugger.DebuggerController
let gEvents = gView.EventListeners;
let gBreakpoints = gController.Breakpoints;
let gDispatcher = gDebugger.dispatcher;
let getState = gDispatcher.getState;
let constants = gDebugger.require('./content/constants');
Task.spawn(function*() {
yield waitForSourceShown(aPanel, ".html");
let fetched = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_LISTENERS_FETCHED);
let fetched = afterDispatch(gDispatcher, constants.FETCH_EVENT_LISTENERS);
gView.toggleInstrumentsPane({ visible: true, animated: false }, 1);
yield fetched;
@ -32,7 +35,7 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "");
let updated = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_BREAKPOINTS_UPDATED);
let updated = afterDispatch(gDispatcher, constants.UPDATE_EVENT_BREAKPOINTS);
EventUtils.sendMouseEvent({ type: "click" }, getItemCheckboxNode(0), gDebugger);
EventUtils.sendMouseEvent({ type: "click" }, getItemCheckboxNode(1), gDebugger);
EventUtils.sendMouseEvent({ type: "click" }, getItemCheckboxNode(2), gDebugger);
@ -47,7 +50,8 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "change,click,keydown");
yield reloadActiveTab(aPanel, gDebugger.EVENTS.EVENT_LISTENERS_FETCHED);
reload(aPanel);
yield afterDispatch(gDispatcher, constants.FETCH_EVENT_LISTENERS);
testEventItem(0, true);
testEventItem(1, true);
@ -58,7 +62,7 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "change,click,keydown");
updated = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_BREAKPOINTS_UPDATED);
updated = afterDispatch(gDispatcher, constants.UPDATE_EVENT_BREAKPOINTS);
EventUtils.sendMouseEvent({ type: "click" }, getItemCheckboxNode(0), gDebugger);
EventUtils.sendMouseEvent({ type: "click" }, getItemCheckboxNode(1), gDebugger);
EventUtils.sendMouseEvent({ type: "click" }, getItemCheckboxNode(2), gDebugger);
@ -73,7 +77,8 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "");
yield reloadActiveTab(aPanel, gDebugger.EVENTS.EVENT_LISTENERS_FETCHED);
reload(aPanel);
yield afterDispatch(gDispatcher, constants.FETCH_EVENT_LISTENERS);
testEventItem(0, false);
testEventItem(1, false);
@ -84,7 +89,7 @@ function test() {
testEventGroup("mouseEvents", false);
testEventArrays("change,click,keydown,keyup", "");
yield ensureThreadClientState(aPanel, "resumed");
yield ensureThreadClientState(aPanel, "attached");
yield closeDebuggerAndFinish(aPanel);
});
@ -115,9 +120,9 @@ function test() {
is(gEvents.getAllEvents().toString(), all,
"The getAllEvents() method returns the correct stuff.");
is(gEvents.getCheckedEvents().toString(), checked,
"The getCheckedEvents() method returns the correct stuff.");
is(gBreakpoints.DOM.activeEventNames.toString(), checked,
"The correct event names are listed as being active breakpoints.");
"The getCheckedEvents() method returns the correct stuff.");
is(getState().eventListeners.activeEventNames.toString(), checked,
"The correct event names are listed as being active breakpoints.");
}
});
}

View File

@ -13,25 +13,28 @@ function test() {
let gDebugger = aPanel.panelWin;
let gView = gDebugger.DebuggerView;
let gEvents = gView.EventListeners;
let gDispatcher = gDebugger.dispatcher;
let getState = gDispatcher.getState;
let constants = gDebugger.require('./content/constants');
Task.spawn(function*() {
yield waitForSourceShown(aPanel, ".html");
yield callInTab(gTab, "addBodyClickEventListener");
let fetched = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_LISTENERS_FETCHED);
let fetched = afterDispatch(gDispatcher, constants.FETCH_EVENT_LISTENERS);
gView.toggleInstrumentsPane({ visible: true, animated: false }, 1);
yield fetched;
yield ensureThreadClientState(aPanel, "resumed");
yield ensureThreadClientState(aPanel, "attached");
is(gView.instrumentsPaneHidden, false,
"The instruments pane should be visible.");
is(gView.instrumentsPaneTab, "events-tab",
"The events tab should be selected.");
let updated = waitForDebuggerEvents(aPanel, gDebugger.EVENTS.EVENT_BREAKPOINTS_UPDATED);
let updated = afterDispatch(gDispatcher, constants.UPDATE_EVENT_BREAKPOINTS);
EventUtils.sendMouseEvent({ type: "click" }, getItemCheckboxNode(1), gDebugger);
yield updated;
yield ensureThreadClientState(aPanel, "resumed");
yield ensureThreadClientState(aPanel, "attached");
let paused = waitForCaretAndScopes(aPanel, 48);
generateMouseClickInTab(gTab, "content.document.body");

View File

@ -31,10 +31,13 @@ add_task(function* () {
let [,, panel, win] = yield initDebugger(tab);
let gDebugger = panel.panelWin;
let fetched = waitForDebuggerEvents(panel, gDebugger.EVENTS.EVENT_LISTENERS_FETCHED);
let gDispatcher = gDebugger.dispatcher;
let constants = gDebugger.require('./content/constants');
let eventListeners = gDebugger.require('./content/stores/event-listeners');
let fetched = afterDispatch(gDispatcher, constants.FETCH_EVENT_LISTENERS);
info("Scheduling event listener fetch.");
gDebugger.DebuggerController.Breakpoints.DOM.scheduleEventListenersFetch();
gDispatcher.dispatch(eventListeners.actions.fetchEventListeners());
info("Waiting for updated event listeners to arrive.");
yield fetched;

View File

@ -459,10 +459,14 @@ function ensureThreadClientState(aPanel, aState) {
}
}
function navigateActiveTabTo(aPanel, aUrl, aWaitForEventName, aEventRepeat) {
let finished = waitForDebuggerEvents(aPanel, aWaitForEventName, aEventRepeat);
function reload(aPanel, aUrl) {
let activeTab = aPanel.panelWin.DebuggerController._target.activeTab;
aUrl ? activeTab.navigateTo(aUrl) : activeTab.reload();
}
function navigateActiveTabTo(aPanel, aUrl, aWaitForEventName, aEventRepeat) {
let finished = waitForDebuggerEvents(aPanel, aWaitForEventName, aEventRepeat);
reload(aPanel, aUrl);
return finished;
}
@ -1196,7 +1200,10 @@ function afterDispatch(dispatcher, type) {
// internal name here so tests aren't forced to always pass it
// in
type: "@@service/waitUntil",
predicate: action => action.type === type,
predicate: action => (
action.type === type &&
action.status ? action.status === "done" : true
),
run: resolve
});
});

View File

@ -72,7 +72,6 @@ browser.jar:
content/browser/devtools/debugger/sources-view.js (debugger/views/sources-view.js)
content/browser/devtools/debugger/variable-bubble-view.js (debugger/views/variable-bubble-view.js)
content/browser/devtools/debugger/watch-expressions-view.js (debugger/views/watch-expressions-view.js)
content/browser/devtools/debugger/event-listeners-view.js (debugger/views/event-listeners-view.js)
content/browser/devtools/debugger/global-search-view.js (debugger/views/global-search-view.js)
content/browser/devtools/debugger/toolbar-view.js (debugger/views/toolbar-view.js)
content/browser/devtools/debugger/options-view.js (debugger/views/options-view.js)

View File

@ -0,0 +1,90 @@
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
const loaders = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {});
const devtools = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools;
const { joinURI } = devtools.require("devtools/toolkit/path");
let appConstants;
// Some of the services that the system module requires is not
// available in xpcshell tests. This is ok, we can easily polyfill the
// values that we need.
try {
const system = devtools.require("devtools/toolkit/shared/system");
appConstants = system.constants;
}
catch(e) {
// We are in a testing environment most likely. There isn't much
// risk to this defaulting to true because the dev version of React
// will be loaded if this is true, and that file doesn't get built
// into the release version of Firefox, so this will only work with
// dev environments.
appConstants = {
DEBUG_JS_MODULES: true
};
}
/*
* Create a loader to be used in a browser environment. This evaluates
* modules in their own environment, but sets window (the normal
* global object) as the sandbox prototype, so when a variable is not
* defined it checks `window` before throwing an error. This makes all
* browser APIs available to modules by default, like a normal browser
* environment, but modules are still evaluated in their own scope.
*
* Another very important feature of this loader is that it *only*
* deals with modules loaded from under `baseURI`. Anything loaded
* outside of that path will still be loaded from the devtools loader,
* so all system modules are still shared and cached across instances.
* An exception to this is anything under
* `browser/devtools/shared/content`, which is where shared libraries
* live that should be evaluated in a browser environment.
*
* @param string baseURI
* Base path to load modules from.
* @param Object window
* The window instance to evaluate modules within
* @return Object
* An object with two properties:
* - loader: the Loader instance
* - require: a function to require modules with
*/
function BrowserLoader(baseURI, window) {
const loaderOptions = devtools.require('@loader/options');
let dynamicPaths = {};
if (appConstants.DEBUG_JS_MODULES) {
// Load in the dev version of React
dynamicPaths["devtools/shared/content/react"] =
"resource:///modules/devtools/shared/content/react-dev.js";
}
const opts = {
id: "browser-loader",
sharedGlobal: true,
sandboxPrototype: window,
paths: Object.assign({}, loaderOptions.paths, dynamicPaths),
invisibleToDebugger: loaderOptions.invisibleToDebugger,
require: (id, require) => {
const uri = require.resolve(id);
if (!uri.startsWith(baseURI) &&
!uri.startsWith("resource:///modules/devtools/shared/content")) {
return devtools.require(uri);
}
return require(uri);
}
};
// The main.js file does not have to actually exist. It just
// represents the base environment, so requires will be relative to
// that path when used outside of modules.
const mainModule = loaders.Module(baseURI, joinURI(baseURI, "main.js"));
const mainLoader = loaders.Loader(opts);
return {
loader: mainLoader,
require: loaders.Require(mainLoader, mainModule)
};
}
EXPORTED_SYMBOLS = ["BrowserLoader"];

View File

@ -0,0 +1,31 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const fluxify = require('./fluxify/dispatcher');
const thunkMiddleware = require('./fluxify/thunkMiddleware');
const logMiddleware = require('./fluxify/logMiddleware');
const waitUntilService = require('./fluxify/waitUntilService')
const { compose } = require('devtools/toolkit/DevToolsUtils');
/**
* This creates a dispatcher with all the standard middleware in place
* that all code requires. It can also be optionally configured in
* various ways, such as logging and recording.
*
* @param {object} opts - boolean configuration flags
* - log: log all dispatched actions to console
*/
module.exports = (opts={}) => {
const middleware = [
thunkMiddleware,
waitUntilService.service
];
if (opts.log) {
middleware.push(logMiddleware);
}
return fluxify.applyMiddleware(...middleware)(fluxify.createDispatcher);
}

View File

@ -0,0 +1,28 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
function bindActionCreator(actionCreator, dispatch) {
return (...args) => dispatch(actionCreator(...args));
}
/**
* Wraps action creator functions into a function that automatically
* dispatches the created action with `dispatch`. Normally action
* creators simply return actions, but wrapped functions will
* automatically dispatch.
*
* @param {object} actionCreators
* An object of action creator functions (names as keys)
* @param {function} dispatch
*/
function bindActionCreators(actionCreators, dispatch) {
let actions = {};
for (let k of Object.keys(actionCreators)) {
actions[k] = bindActionCreator(actionCreators[k], dispatch);
}
return actions;
}
module.exports = bindActionCreators;

View File

@ -0,0 +1,247 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { entries, compose } = require("devtools/toolkit/DevToolsUtils");
/**
* A store creator that creates a dispatch function that runs the
* provided middlewares before actually dispatching. This allows
* simple middleware to augment the kinds of actions that can
* be dispatched. This would be used like this:
* `createDispatcher = applyMiddleware([fooMiddleware, ...])(createDispatcher)`
*
* Middlewares are simple functions that are provided `dispatch` and
* `getState` functions. They create functions that accept actions and
* can re-dispatch them in any way they want. A common scenario is
* asynchronously dispatching multiple actions. Here is a full
* middleware:
*
* function thunkMiddleware({ dispatch, getState }) {
* return next => action => {
* return typeof action === 'function' ?
* action(dispatch, getState) :
* next(action);
* }
* }
*
* `next` is essentially a `dispatch` function, but it calls the next
* middelware in the chain (or the real `dispatch` function). Using
* this middleware, you can return a function that gives you a
* dispatch function:
*
* function actionCreator(timeout) {
* return (dispatch, getState) => {
* dispatch({ type: TIMEOUT, status: "start" });
* setTimeout(() => dispatch({ type: TIMEOUT, status: "end" }),
* timeout);
* }
* }
*
*/
function applyMiddleware(...middlewares) {
return next => stores => {
const dispatcher = next(stores);
let dispatch = dispatcher.dispatch;
const api = {
getState: dispatcher.getState,
dispatch: action => dispatch(action)
};
const chain = middlewares.map(middleware => middleware(api));
dispatch = compose(...chain)(dispatcher.dispatch);
return Object.assign({}, dispatcher, { dispatch: dispatch });
}
}
/*
* The heart of the system. This creates a dispatcher which is the
* interface between views and stores. Views can use a dispatcher
* instance to dispatch actions, which know nothing about the stores.
* Actions are broadcasted to all registered stores, and stores can
* handle the action and update their state. The dispatcher gives
* stores an `emitChange` function, which signifies that a piece of
* state has changed. The dispatcher will notify all views that are
* listening to that piece of state, registered with `onChange`.
*
* Views generally are stateless, pure components (eventually React
* components). They simply take state and render it.
*
* Stores make up the entire app state, and are all grouped together
* into a single app state atom, returned by the dispatcher's
* `getState` function. The shape of the app state is determined by
* the `stores` object passed in to the dispatcher, so if
* `{ foo: fooStore }` was passed to `createDispatcher` the app state
* would be `{ foo: fooState }`
*
* Actions are just JavaScript object with a `type` property and any
* other fields pertinent to the action. Action creators should
* generally be used to create actions, which are just functions that
* return the action object. Additionally, the `bindActionCreators`
* module provides a function for automatically binding action
* creators to a dispatch function, so calling them automatically
* dispatches. For example:
*
* ```js
* // Manually dispatch
* dispatcher.dispatch({ type: constants.ADD_ITEM, item: item });
* // Using an action creator
* dispatcher.dispatch(addItem(item));
*
* // Using an action creator bound to dispatch
* actions = bindActionCreators({ addItem: addItem });
* actions.addItem(item);
* ```
*
* Our system expects stores to exist as an `update` function. You
* should define an update function in a module, and optionally
* any action creators that are useful to go along with it. Here is
* an example store file:
*
* ```js
* const initialState = { items: [] };
* function update(state = initialState, action, emitChange) {
* if (action.type === constants.ADD_ITEM) {
* state.items.push(action.item);
* emitChange("items", state.items);
* }
*
* return state;
* }
*
* function addItem(item) {
* return {
* type: constants.ADD_ITEM,
* item: item
* };
* }
*
* module.exports = {
* update: update,
* actions: { addItem }
* }
* ```
*
* Lastly, "constants" are simple strings that specify action names.
* Usually the entire set of available action types are specified in
* a `constants.js` file, so they are available globally. Use
* variables that are the same name as the string, for example
* `const ADD_ITEM = "ADD_ITEM"`.
*
* This entire system was inspired by Redux, which hopefully we will
* eventually use once things get cleaned up enough. You should read
* its docs, and keep in mind that it calls stores "reducers" and the
* dispatcher instance is called a single store.
* http://rackt.github.io/redux/
*/
function createDispatcher(stores) {
const state = {};
const listeners = {};
let enqueuedChanges = [];
let isDispatching = false;
// Validate the stores to make sure they have the right shape,
// and accumulate the initial state
entries(stores).forEach(([name, store]) => {
if (!store || typeof store.update !== "function") {
throw new Error("Error creating dispatcher: store \"" + name +
"\" does not have an `update` function");
}
state[name] = store.update(undefined, {});
});
function getState() {
return state;
}
function emitChange(storeName, dataName, payload) {
enqueuedChanges.push([storeName, dataName, payload]);
}
function onChange(paths, view) {
entries(paths).forEach(([storeName, data]) => {
if (!stores[storeName]) {
throw new Error("Error adding onChange handler to store: store " +
"\"" + storeName + "\" does not exist");
}
if (!listeners[storeName]) {
listeners[storeName] = [];
}
if (typeof data == 'function') {
listeners[storeName].push(data.bind(view));
}
else {
entries(data).forEach(([watchedName, handler]) => {
listeners[storeName].push((payload, dataName, storeName) => {
if (dataName === watchedName) {
handler.call(view, payload, dataName, storeName);
}
});
});
}
});
}
/**
* Flush any enqueued state changes from the dispatch cycle. Listeners
* are not immediately notified of changes, only after dispatching
* is completed, to ensure that all state is consistent (in the case
* of multiple stores changes at once).
*/
function flushChanges() {
enqueuedChanges.forEach(([storeName, dataName, payload]) => {
if (listeners[storeName]) {
listeners[storeName].forEach(listener => {
listener(payload, dataName, storeName);
});
}
});
enqueuedChanges = [];
}
function dispatch(action) {
if (isDispatching) {
throw new Error('Cannot dispatch in the middle of a dispatch');
}
if (!action.type) {
throw new Error(
'action type is null, ' +
'did you make a typo when publishing this action? ' +
JSON.stringify(action, null, 2)
);
}
isDispatching = true;
try {
entries(stores).forEach(([name, store]) => {
state[name] = store.update(
state[name],
action,
emitChange.bind(null, name)
);
});
}
finally {
isDispatching = false;
}
flushChanges();
}
return {
getState,
dispatch,
onChange
};
}
module.exports = {
createDispatcher: createDispatcher,
applyMiddleware: applyMiddleware
};

View File

@ -0,0 +1,17 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/**
* A middleware that logs all actions coming through the system
* to the console.
*/
function logMiddleware({ dispatch, getState }) {
return next => action => {
console.log('[DISPATCH]', JSON.stringify(action));
next(action);
}
}
module.exports = logMiddleware;

View File

@ -0,0 +1,15 @@
# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
# vim: set filetype=python:
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
EXTRA_JS_MODULES.devtools.shared.fluxify += [
'bindActionCreators.js',
'dispatcher.js',
'logMiddleware.js',
'thunkMiddleware.js',
'waitUntilService.js'
]
XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']

View File

@ -0,0 +1,28 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;
const CC = Components.Constructor;
const { require } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
const createDispatcher = require('devtools/shared/create-dispatcher')({ log: true });
const waitUntilService = require('devtools/shared/fluxify/waitUntilService');
const services = {
WAIT_UNTIL: waitUntilService.name
};
const Services = require("Services");
const { waitForTick, waitForTime } = require("devtools/toolkit/DevToolsUtils");
let loadSubScript = Cc[
'@mozilla.org/moz/jssubscript-loader;1'
].getService(Ci.mozIJSSubScriptLoader).loadSubScript;
function getFileUrl(name, allowMissing=false) {
let file = do_get_file(name, allowMissing);
return Services.io.newFileURI(file).spec;
}

View File

@ -0,0 +1,48 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
// This file should be loaded with `loadSubScript` because it contains
// stateful stores that need a new instance per test.
const NumberStore = {
update: (state = 1, action, emitChange) => {
switch(action.type) {
case constants.ADD_NUMBER: {
const newState = state + action.value;
emitChange('number', newState);
return newState;
}
case constants.DOUBLE_NUMBER: {
const newState = state * 2;
emitChange('number', newState);
return newState;
}}
return state;
},
constants: {
ADD_NUMBER: 'ADD_NUMBER',
DOUBLE_NUMBER: 'DOUBLE_NUMBER'
}
};
const itemsInitialState = {
list: []
};
const ItemStore = {
update: (state = itemsInitialState, action, emitChange) => {
switch(action.type) {
case constants.ADD_ITEM:
state.list.push(action.item);
emitChange('list', state.list);
return state;
}
return state;
},
constants: {
ADD_ITEM: 'ADD_ITEM'
}
}

View File

@ -0,0 +1,101 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
loadSubScript(getFileUrl("stores-for-testing.js"));
const constants = Object.assign(
{},
NumberStore.constants,
ItemStore.constants
);
const stores = { number: NumberStore,
items: ItemStore };
function addNumber(num) {
return {
type: constants.ADD_NUMBER,
value: num
}
}
function addItem(item) {
return {
type: constants.ADD_ITEM,
item: item
};
}
// Tests
function run_test() {
testInitialValue();
testDispatch();
testEmitChange();
run_next_test();
}
function testInitialValue() {
do_print("Testing initial value");
const dispatcher = createDispatcher(stores);
equal(dispatcher.getState().number, 1);
}
function testDispatch() {
do_print("Testing dispatch");
const dispatcher = createDispatcher(stores);
dispatcher.dispatch(addNumber(5));
equal(dispatcher.getState().number, 6);
dispatcher.dispatch(addNumber(2));
equal(dispatcher.getState().number, 8);
// It should ignore unknown action types
dispatcher.dispatch({ type: "FOO" });
equal(dispatcher.getState().number, 8);
}
function testEmitChange() {
do_print("Testing change emittters");
const dispatcher = createDispatcher(stores);
let listenerRan = false;
const numberView = {
x: 3,
renderNumber: function(num) {
ok(this.x, 3, "listener ran in context of view");
ok(num, 10);
listenerRan = true;
}
}
// Views can listen to changes in state by specifying which part of
// the state to listen to and giving a handler function. The
// function will be run with the view as `this`.
dispatcher.onChange({
"number": numberView.renderNumber
}, numberView);
dispatcher.dispatch(addNumber(9));
ok(listenerRan, "number listener actually ran");
listenerRan = false;
const itemsView = {
renderList: function(items) {
ok(items.length, 1);
ok(items[0].name = "james");
listenerRan = true;
}
}
// You can listen to deeper sections of the state by nesting objects
// to specify the path to that state. You can do this 1 level deep;
// you cannot arbitrarily nest state listeners.
dispatcher.onChange({
"items": {
"list": itemsView.renderList
}
}, itemsView);
dispatcher.dispatch(addItem({ name: "james" }));
ok(listenerRan, "items listener actually ran");
}

View File

@ -0,0 +1,114 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
loadSubScript(getFileUrl('stores-for-testing.js'));
const constants = NumberStore.constants;
const stores = { number: NumberStore };
function run_test() {
run_next_test();
}
add_task(function* testThunkDispatch() {
do_print("Testing thunk dispatch");
// The thunk middleware allows you to return a function from an
// action creator which takes `dispatch` and `getState` functions as
// arguments. This allows the action creator to fire multiple
// actions manually with `dispatch`, possibly asynchronously.
function addNumberLater(num) {
return dispatch => {
// Just do it in the next tick, no need to wait too long
waitForTick().then(() => {
dispatch({
type: constants.ADD_NUMBER,
value: num
});
});
};
}
function addNumber(num) {
return (dispatch, getState) => {
dispatch({
type: constants.ADD_NUMBER,
value: getState().number > 10 ? (num * 2) : num
});
};
}
const dispatcher = createDispatcher(stores);
equal(dispatcher.getState().number, 1);
dispatcher.dispatch(addNumberLater(5));
equal(dispatcher.getState().number, 1, "state should not have changed");
yield waitForTick();
equal(dispatcher.getState().number, 6, "state should have changed");
dispatcher.dispatch(addNumber(5));
equal(dispatcher.getState().number, 11);
dispatcher.dispatch(addNumber(2));
// 2 * 2 should have actually been added because the state is
// greater than 10 (the action creator changes the value based on
// the current state)
equal(dispatcher.getState().number, 15);
});
add_task(function* testWaitUntilService() {
do_print("Testing waitUntil service");
// The waitUntil service allows you to queue functions to be run at a
// later time, depending on a predicate. As actions comes through
// the system, you predicate will be called with each action. Once
// your predicate returns true, the queued function will be run and
// removed from the pending queue.
function addWhenDoubled(num) {
return {
type: services.WAIT_UNTIL,
predicate: action => action.type === constants.DOUBLE_NUMBER,
run: (dispatch, getState, action) => {
ok(action.type, constants.DOUBLE_NUMBER);
ok(getState(), 10);
dispatch({
type: constants.ADD_NUMBER,
value: 2
});
}
};
}
function addWhenGreaterThan(threshold, num) {
return (dispatch, getState) => {
dispatch({
type: services.WAIT_UNTIL,
predicate: () => getState().number > threshold,
run: () => {
dispatch({
type: constants.ADD_NUMBER,
value: num
});
}
});
}
}
const dispatcher = createDispatcher(stores);
// Add a pending action that adds 2 after the number is doubled
equal(dispatcher.getState().number, 1);
dispatcher.dispatch(addWhenDoubled(2));
equal(dispatcher.getState().number, 1);
dispatcher.dispatch({ type: constants.DOUBLE_NUMBER });
// Note how the pending function we added ran synchronously. It
// should have added 2 after doubling 1, so 1 * 2 + 2 = 4
equal(dispatcher.getState().number, 4);
// Add a pending action that adds 5 once the number is greater than 10
dispatcher.dispatch(addWhenGreaterThan(10, 5));
equal(dispatcher.getState().number, 4);
dispatcher.dispatch({ type: constants.ADD_NUMBER, value: 10 });
// Again, the pending function we added ran synchronously. It should
// have added 5 more after 10 was added, since the number was
// greater than 10.
equal(dispatcher.getState().number, 19);
});

View File

@ -0,0 +1,12 @@
[DEFAULT]
tags = devtools
head = head.js
tail =
firefox-appdir = browser
skip-if = toolkit == 'android' || toolkit == 'gonk'
support-files =
stores-for-testing.js
[test_dispatcher.js]
[test_middlewares.js]

View File

@ -0,0 +1,20 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/**
* A middleware that allows thunks (functions) to be dispatched.
* If it's a thunk, it is called with `dispatch` and `getState`,
* allowing the action to create multiple actions (most likely
* asynchronously).
*/
function thunkMiddleware({ dispatch, getState }) {
return next => action => {
return typeof action === "function"
? action(dispatch, getState)
: next(action);
}
}
module.exports = thunkMiddleware;

View File

@ -0,0 +1,69 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const NAME = "@@service/waitUntil";
/**
* A middleware which acts like a service, because it is stateful
* and "long-running" in the background. It provides the ability
* for actions to install a function to be run once when a specific
* condition is met by an action coming through the system. Think of
* it as a thunk that blocks until the condition is met. Example:
*
* ```js
* const services = { WAIT_UNTIL: require('waitUntilService').name };
*
* { type: services.WAIT_UNTIL,
* predicate: action => action.type === constants.ADD_ITEM,
* run: (dispatch, getState, action) => {
* // Do anything here. You only need to accept the arguments
* // if you need them. `action` is the action that satisfied
* // the predicate.
* }
* }
* ```
*/
function waitUntilService({ dispatch, getState }) {
let pending = [];
function checkPending(action) {
let readyRequests = [];
let stillPending = [];
// Find the pending requests whose predicates are satisfied with
// this action. Wait to run the requests until after we update the
// pending queue because the request handler may synchronously
// dispatch again and run this service (that use case is
// completely valid).
for (let request of pending) {
if (request.predicate(action)) {
readyRequests.push(request);
}
else {
stillPending.push(request);
}
}
pending = stillPending;
for (let request of readyRequests) {
request.run(dispatch, getState, action);
}
}
return next => action => {
if (action.type === NAME) {
pending.push(action);
}
else {
next(action);
checkPending(action);
}
}
}
module.exports = {
service: waitUntilService,
name: NAME
};

View File

@ -31,6 +31,8 @@ EXTRA_JS_MODULES.devtools += [
EXTRA_JS_MODULES.devtools.shared += [
'autocomplete-popup.js',
'browser-loader.js',
'create-dispatcher.js',
'devices.js',
'doorhanger.js',
'frame-script-utils.js',
@ -72,3 +74,5 @@ EXTRA_JS_MODULES.devtools.shared.widgets += [
'widgets/Tooltip.js',
'widgets/TreeWidget.js',
]
DIRS += ['fluxify']

View File

@ -116,6 +116,38 @@ exports.zip = function zip(a, b) {
return pairs;
};
/**
* Converts an object into an array with 2-element arrays as key/value
* pairs of the object. `{ foo: 1, bar: 2}` would become
* `[[foo, 1], [bar 2]]` (order not guaranteed);
*
* @param object obj
* @returns array
*/
exports.entries = function entries(obj) {
return Object.keys(obj).map(k => [k, obj[k]]);
}
/**
* Composes the given functions into a single function, which will
* apply the results of each function right-to-left, starting with
* applying the given arguments to the right-most function.
* `compose(foo, bar, baz)` === `args => foo(bar(baz(args)`
*
* @param ...function funcs
* @returns function
*/
exports.compose = function compose(...funcs) {
return (...args) => {
const initialValue = funcs[funcs.length - 1].apply(null, args);
const leftFuncs = funcs.slice(0, -1);
return leftFuncs.reduceRight((composed, f) => f(composed),
initialValue);
};
}
/**
* Waits for the next tick in the event loop to execute a callback.
*/