diff options
Diffstat (limited to 'devtools/shared/protocol')
28 files changed, 4501 insertions, 0 deletions
diff --git a/devtools/shared/protocol/Actor.js b/devtools/shared/protocol/Actor.js new file mode 100644 index 0000000000..ea37f6fea2 --- /dev/null +++ b/devtools/shared/protocol/Actor.js @@ -0,0 +1,260 @@ +/* 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"; + +var { Pool } = require("resource://devtools/shared/protocol/Pool.js"); + +/** + * Keep track of which actorSpecs have been created. If a replica of a spec + * is created, it can be caught, and specs which inherit from other specs will + * not overwrite eachother. + */ +var actorSpecs = new WeakMap(); + +exports.actorSpecs = actorSpecs; + +/** + * An actor in the actor tree. + * + * @param optional conn + * Either a DevToolsServerConnection or a DevToolsClient. Must have + * addActorPool, removeActorPool, and poolFor. + * conn can be null if the subclass provides a conn property. + * @constructor + */ + +class Actor extends Pool { + constructor(conn, spec) { + super(conn); + + this.typeName = spec.typeName; + + // Will contain the actor's ID + this.actorID = null; + + // Ensure computing requestTypes only one time per class + const proto = Object.getPrototypeOf(this); + if (!proto.requestTypes) { + proto.requestTypes = generateRequestTypes(spec); + } + + // Forward events to the connection. + if (spec.events) { + for (const [name, request] of spec.events.entries()) { + this.on(name, (...args) => { + this._sendEvent(name, request, ...args); + }); + } + } + } + + toString() { + return "[Actor " + this.typeName + "/" + this.actorID + "]"; + } + + _sendEvent(name, request, ...args) { + if (this.isDestroyed()) { + console.error( + `Tried to send a '${name}' event on an already destroyed actor` + + ` '${this.typeName}'` + ); + return; + } + let packet; + try { + packet = request.write(args, this); + } catch (ex) { + console.error("Error sending event: " + name); + throw ex; + } + packet.from = packet.from || this.actorID; + this.conn.send(packet); + + // This can really be a hot path, even computing the marker label can + // have some performance impact. + // Guard against missing `Services.profiler` because Services is mocked to + // an empty object in the worker loader. + if (Services.profiler?.IsActive()) { + ChromeUtils.addProfilerMarker( + "DevTools:RDP Actor", + null, + `${this.typeName}.${name}` + ); + } + } + + destroy() { + super.destroy(); + this.actorID = null; + this._isDestroyed = true; + } + + /** + * Override this method in subclasses to serialize the actor. + * @param [optional] string hint + * Optional string to customize the form. + * @returns A jsonable object. + */ + form(hint) { + return { actor: this.actorID }; + } + + writeError(error, typeName, method) { + console.error( + `Error while calling actor '${typeName}'s method '${method}'`, + error.message || error + ); + // Also log the error object as-is in order to log the server side stack + // nicely in the console, while the previous log will log the client side stack only. + if (error.stack) { + console.error(error); + } + + // Do not try to send the error if the actor is destroyed + // as the connection is probably also destroyed and may throw. + if (this.isDestroyed()) { + return; + } + + this.conn.send({ + from: this.actorID, + // error.error -> errors created using the throwError() helper + // error.name -> errors created using `new Error` or Components.exception + // typeof(error)=="string" -> a method thrown like this `throw "a string"` + error: + error.error || + error.name || + (typeof error == "string" ? error : "unknownError"), + message: error.message, + // error.fileName -> regular Error instances + // error.filename -> errors created using Components.exception + fileName: error.fileName || error.filename, + lineNumber: error.lineNumber, + columnNumber: error.columnNumber, + }); + } + + _queueResponse(create) { + const pending = this._pendingResponse || Promise.resolve(null); + const response = create(pending); + this._pendingResponse = response; + } + + /** + * Throw an error with the passed message and attach an `error` property to the Error + * object so it can be consumed by the writeError function. + * @param {String} error: A string (usually a single word serving as an id) that will + * be assign to error.error. + * @param {String} message: The string that will be passed to the Error constructor. + * @throws This always throw. + */ + throwError(error, message) { + const err = new Error(message); + err.error = error; + throw err; + } +} + +exports.Actor = Actor; + +/** + * Generate the "requestTypes" object used by DevToolsServerConnection to implement RDP. + * When a RDP packet is received for calling an actor method, this lookup for + * the method name in this object and call the function holded on this attribute. + * + * @params {Object} actorSpec + * The procotol-js actor specific coming from devtools/shared/specs/*.js files + * This describes the types for methods and events implemented by all actors. + * @return {Object} requestTypes + * An object where attributes are actor method names + * and values are function implementing these methods. + * These methods receive a RDP Packet (JSON-serializable object) and a DevToolsServerConnection. + * We expect them to return a promise that reserves with the response object + * to send back to the client (JSON-serializable object). + */ +var generateRequestTypes = function (actorSpec) { + // Generate request handlers for each method definition + const requestTypes = Object.create(null); + actorSpec.methods.forEach(spec => { + const handler = function (packet, conn) { + try { + const startTime = isWorker ? null : Cu.now(); + let args; + try { + args = spec.request.read(packet, this); + } catch (ex) { + console.error("Error reading request: " + packet.type); + throw ex; + } + + if (!this[spec.name]) { + throw new Error( + `Spec for '${actorSpec.typeName}' specifies a '${spec.name}'` + + ` method that isn't implemented by the actor` + ); + } + const ret = this[spec.name].apply(this, args); + + const sendReturn = retToSend => { + if (spec.oneway) { + // No need to send a response. + return; + } + if (this.isDestroyed()) { + console.error( + `Tried to send a '${spec.name}' method reply on an already destroyed actor` + + ` '${this.typeName}'` + ); + return; + } + + let response; + try { + response = spec.response.write(retToSend, this); + } catch (ex) { + console.error("Error writing response to: " + spec.name); + throw ex; + } + response.from = this.actorID; + // If spec.release has been specified, destroy the object. + if (spec.release) { + try { + this.destroy(); + } catch (e) { + this.writeError(e, actorSpec.typeName, spec.name); + return; + } + } + + conn.send(response); + + ChromeUtils.addProfilerMarker( + "DevTools:RDP Actor", + startTime, + `${actorSpec.typeName}:${spec.name}()` + ); + }; + + this._queueResponse(p => { + return p + .then(() => ret) + .then(sendReturn) + .catch(e => this.writeError(e, actorSpec.typeName, spec.name)); + }); + } catch (e) { + this._queueResponse(p => { + return p.then(() => + this.writeError(e, actorSpec.typeName, spec.name) + ); + }); + } + }; + + requestTypes[spec.request.type] = handler; + }); + + return requestTypes; +}; +exports.generateRequestTypes = generateRequestTypes; diff --git a/devtools/shared/protocol/Actor/generateActorSpec.js b/devtools/shared/protocol/Actor/generateActorSpec.js new file mode 100644 index 0000000000..9b3b166c8b --- /dev/null +++ b/devtools/shared/protocol/Actor/generateActorSpec.js @@ -0,0 +1,62 @@ +/* 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"; + +var { Request } = require("resource://devtools/shared/protocol/Request.js"); +const { Response } = require("resource://devtools/shared/protocol/Response.js"); +var { + types, + registeredTypes, +} = require("resource://devtools/shared/protocol/types.js"); + +/** + * Generates an actor specification from an actor description. + */ +var generateActorSpec = function (actorDesc) { + const actorSpec = { + typeName: actorDesc.typeName, + methods: [], + }; + + // Find additional method specifications + if (actorDesc.methods) { + for (const name in actorDesc.methods) { + const methodSpec = actorDesc.methods[name]; + const spec = {}; + + spec.name = methodSpec.name || name; + spec.request = new Request( + Object.assign({ type: spec.name }, methodSpec.request || undefined) + ); + spec.response = new Response(methodSpec.response || undefined); + spec.release = methodSpec.release; + spec.oneway = methodSpec.oneway; + + actorSpec.methods.push(spec); + } + } + + // Find event specifications + if (actorDesc.events) { + actorSpec.events = new Map(); + for (const name in actorDesc.events) { + const eventRequest = actorDesc.events[name]; + Object.freeze(eventRequest); + actorSpec.events.set( + name, + new Request(Object.assign({ type: name }, eventRequest)) + ); + } + } + + if (!registeredTypes.has(actorSpec.typeName)) { + types.addActorType(actorSpec.typeName); + } + registeredTypes.get(actorSpec.typeName).actorSpec = actorSpec; + + return actorSpec; +}; + +exports.generateActorSpec = generateActorSpec; diff --git a/devtools/shared/protocol/Actor/moz.build b/devtools/shared/protocol/Actor/moz.build new file mode 100644 index 0000000000..3e3c46d49b --- /dev/null +++ b/devtools/shared/protocol/Actor/moz.build @@ -0,0 +1,8 @@ +# 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/. + +DevToolsModules( + "generateActorSpec.js", +) diff --git a/devtools/shared/protocol/Front.js b/devtools/shared/protocol/Front.js new file mode 100644 index 0000000000..1298f3a075 --- /dev/null +++ b/devtools/shared/protocol/Front.js @@ -0,0 +1,410 @@ +/* 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"; + +var { settleAll } = require("resource://devtools/shared/DevToolsUtils.js"); +var EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +var { Pool } = require("resource://devtools/shared/protocol/Pool.js"); +var { + getStack, + callFunctionWithAsyncStack, +} = require("resource://devtools/shared/platform/stack.js"); + +/** + * Base class for client-side actor fronts. + * + * @param [DevToolsClient|null] conn + * The conn must either be DevToolsClient or null. Must have + * addActorPool, removeActorPool, and poolFor. + * conn can be null if the subclass provides a conn property. + * @param [Target|null] target + * If we are instantiating a target-scoped front, this is a reference to the front's + * Target instance, otherwise this is null. + * @param [Front|null] parentFront + * The parent front. This is only available if the Front being initialized is a child + * of a parent front. + * @constructor + */ +class Front extends Pool { + constructor(conn = null, targetFront = null, parentFront = null) { + super(conn); + if (!conn) { + throw new Error("Front without conn"); + } + this.actorID = null; + // The targetFront attribute represents the debuggable context. Only target-scoped + // fronts and their children fronts will have the targetFront attribute set. + this.targetFront = targetFront; + // The parentFront attribute points to its parent front. Only children of + // target-scoped fronts will have the parentFront attribute set. + this.parentFront = parentFront; + this._requests = []; + + // Front listener functions registered via `watchFronts` + this._frontCreationListeners = null; + this._frontDestructionListeners = null; + + // List of optional listener for each event, that is processed immediatly on packet + // receival, before emitting event via EventEmitter on the Front. + // These listeners are register via Front.before function. + // Map(Event Name[string] => Event Listener[function]) + this._beforeListeners = new Map(); + + // This flag allows to check if the `initialize` method has resolved. + // Used to avoid notifying about initialized fronts in `watchFronts`. + this._initializeResolved = false; + } + + /** + * Return the parent front. + */ + getParent() { + return this.parentFront && this.parentFront.actorID + ? this.parentFront + : null; + } + + destroy() { + // Prevent destroying twice if a `forwardCancelling` event has already been received + // and already called `baseFrontClassDestroy` + this.baseFrontClassDestroy(); + + // Keep `clearEvents` out of baseFrontClassDestroy as we still want TargetMixin to be + // able to emit `target-destroyed` after we called baseFrontClassDestroy from DevToolsClient.purgeRequests. + this.clearEvents(); + } + + // This method is also called from `DevToolsClient`, when a connector is destroyed + // and we should: + // - reject all pending request made to the remote process/target/thread. + // - avoid trying to do new request against this remote context. + // - unmanage this front, so that DevToolsClient.getFront no longer returns it. + // + // When a connector is destroyed a `forwardCancelling` RDP event is sent by the server. + // This is done in a distinct method from `destroy` in order to do all that immediately, + // even if `Front.destroy` is overloaded by an async method. + baseFrontClassDestroy() { + // Reject all outstanding requests, they won't make sense after + // the front is destroyed. + while (this._requests.length) { + const { deferred, to, type, stack } = this._requests.shift(); + // Note: many tests are ignoring `Connection closed` promise rejections, + // via PromiseTestUtils.allowMatchingRejectionsGlobally. + // Do not update the message without updating the tests. + const msg = + "Connection closed, pending request to " + + to + + ", type " + + type + + " failed" + + "\n\nRequest stack:\n" + + stack.formattedStack; + deferred.reject(new Error(msg)); + } + + if (this.actorID) { + super.destroy(); + this.actorID = null; + } + this._isDestroyed = true; + + this.targetFront = null; + this.parentFront = null; + this._frontCreationListeners = null; + this._frontDestructionListeners = null; + this._beforeListeners = null; + } + + async manage(front, form, ctx) { + if (!front.actorID) { + throw new Error( + "Can't manage front without an actor ID.\n" + + "Ensure server supports " + + front.typeName + + "." + ); + } + + if (front.parentFront && front.parentFront !== this) { + throw new Error( + `${this.actorID} (${this.typeName}) can't manage ${front.actorID} + (${front.typeName}) since it has a different parentFront ${ + front.parentFront + ? front.parentFront.actorID + "(" + front.parentFront.typeName + ")" + : "<no parentFront>" + }` + ); + } + + super.manage(front); + + if (typeof front.initialize == "function") { + await front.initialize(); + } + front._initializeResolved = true; + + // Ensure calling form() *before* notifying about this front being just created. + // We exprect the front to be fully initialized, especially via its form attributes. + // But do that *after* calling manage() so that the front is already registered + // in Pools and can be fetched by its ID, in case a child actor, created in form() + // tries to get a reference to its parent via the actor ID. + if (form) { + front.form(form, ctx); + } + + // Call listeners registered via `watchFronts` method + // (ignore if this front has been destroyed) + if (this._frontCreationListeners) { + this._frontCreationListeners.emit(front.typeName, front); + } + } + + async unmanage(front) { + super.unmanage(front); + + // Call listeners registered via `watchFronts` method + if (this._frontDestructionListeners) { + this._frontDestructionListeners.emit(front.typeName, front); + } + } + + /* + * Listen for the creation and/or destruction of fronts matching one of the provided types. + * + * @param {String} typeName + * Actor type to watch. + * @param {Function} onAvailable (optional) + * Callback fired when a front has been just created or was already available. + * The function is called with one arguments, the front. + * @param {Function} onDestroy (optional) + * Callback fired in case of front destruction. + * The function is called with the same argument than onAvailable. + */ + watchFronts(typeName, onAvailable, onDestroy) { + if (this.isDestroyed()) { + // The front was already destroyed, bail out. + console.error( + `Tried to call watchFronts for the '${typeName}' type on an ` + + `already destroyed front '${this.typeName}'.` + ); + return; + } + + if (onAvailable) { + // First fire the callback on fronts with the correct type and which have + // been initialized. If initialize() is still in progress, the front will + // be emitted via _frontCreationListeners shortly after. + for (const front of this.poolChildren()) { + if (front.typeName == typeName && front._initializeResolved) { + onAvailable(front); + } + } + + if (!this._frontCreationListeners) { + this._frontCreationListeners = new EventEmitter(); + } + // Then register the callback for fronts instantiated in the future + this._frontCreationListeners.on(typeName, onAvailable); + } + + if (onDestroy) { + if (!this._frontDestructionListeners) { + this._frontDestructionListeners = new EventEmitter(); + } + this._frontDestructionListeners.on(typeName, onDestroy); + } + } + + /** + * Stop listening for the creation and/or destruction of a given type of fronts. + * See `watchFronts()` for documentation of the arguments. + */ + unwatchFronts(typeName, onAvailable, onDestroy) { + if (this.isDestroyed()) { + // The front was already destroyed, bail out. + console.error( + `Tried to call unwatchFronts for the '${typeName}' type on an ` + + `already destroyed front '${this.typeName}'.` + ); + return; + } + + if (onAvailable && this._frontCreationListeners) { + this._frontCreationListeners.off(typeName, onAvailable); + } + if (onDestroy && this._frontDestructionListeners) { + this._frontDestructionListeners.off(typeName, onDestroy); + } + } + + /** + * Register an event listener that will be called immediately on packer receival. + * The given callback is going to be called before emitting the event via EventEmitter + * API on the Front. Event emitting will be delayed if the callback is async. + * Only one such listener can be registered per type of event. + * + * @param String type + * Event emitted by the actor to intercept. + * @param Function callback + * Function that will process the event. + */ + before(type, callback) { + if (this._beforeListeners.has(type)) { + throw new Error( + `Can't register multiple before listeners for "${type}".` + ); + } + this._beforeListeners.set(type, callback); + } + + toString() { + return "[Front for " + this.typeName + "/" + this.actorID + "]"; + } + + /** + * Update the actor from its representation. + * Subclasses should override this. + */ + form(form) {} + + /** + * Send a packet on the connection. + */ + send(packet) { + if (packet.to) { + this.conn._transport.send(packet); + } else { + packet.to = this.actorID; + // The connection might be closed during the promise resolution + if (this.conn && this.conn._transport) { + this.conn._transport.send(packet); + } + } + } + + /** + * Send a two-way request on the connection. + */ + request(packet) { + const deferred = Promise.withResolvers(); + // Save packet basics for debugging + const { to, type } = packet; + this._requests.push({ + deferred, + to: to || this.actorID, + type, + stack: getStack(), + }); + this.send(packet); + return deferred.promise; + } + + /** + * Handler for incoming packets from the client's actor. + */ + onPacket(packet) { + if (this.isDestroyed()) { + // If the Front was already destroyed, all the requests have been purged + // and rejected with detailed error messages in baseFrontClassDestroy. + return; + } + + // Pick off event packets + const type = packet.type || undefined; + if (this._clientSpec.events && this._clientSpec.events.has(type)) { + const event = this._clientSpec.events.get(packet.type); + let args; + try { + args = event.request.read(packet, this); + } catch (ex) { + console.error("Error reading event: " + packet.type); + console.exception(ex); + throw ex; + } + // Check for "pre event" callback to be processed before emitting events on fronts + // Use event.name instead of packet.type to use specific event name instead of RDP + // packet's type. + const beforeEvent = this._beforeListeners.get(event.name); + if (beforeEvent) { + const result = beforeEvent.apply(this, args); + // Check to see if the beforeEvent returned a promise -- if so, + // wait for their resolution before emitting. Otherwise, emit synchronously. + if (result && typeof result.then == "function") { + result.then(() => { + super.emit(event.name, ...args); + ChromeUtils.addProfilerMarker( + "DevTools:RDP Front", + null, + `${this.typeName}.${event.name}` + ); + }); + return; + } + } + + super.emit(event.name, ...args); + ChromeUtils.addProfilerMarker( + "DevTools:RDP Front", + null, + `${this.typeName}.${event.name}` + ); + return; + } + + // Remaining packets must be responses. + if (this._requests.length === 0) { + const msg = + "Unexpected packet " + this.actorID + ", " + JSON.stringify(packet); + const err = Error(msg); + console.error(err); + throw err; + } + + const { deferred, stack } = this._requests.shift(); + callFunctionWithAsyncStack( + () => { + if (packet.error) { + let message; + if (packet.error && packet.message) { + message = + "Protocol error (" + packet.error + "): " + packet.message; + } else { + message = packet.error; + } + message += " from: " + this.actorID; + if (packet.fileName) { + const { fileName, columnNumber, lineNumber } = packet; + message += ` (${fileName}:${lineNumber}:${columnNumber})`; + } + const packetError = new Error(message); + deferred.reject(packetError); + } else { + deferred.resolve(packet); + } + }, + stack, + "DevTools RDP" + ); + } + + hasRequests() { + return !!this._requests.length; + } + + /** + * Wait for all current requests from this front to settle. This is especially useful + * for tests and other utility environments that may not have events or mechanisms to + * await the completion of requests without this utility. + * + * @return Promise + * Resolved when all requests have settled. + */ + waitForRequestsToSettle() { + return settleAll(this._requests.map(({ deferred }) => deferred.promise)); + } +} + +exports.Front = Front; diff --git a/devtools/shared/protocol/Front/FrontClassWithSpec.js b/devtools/shared/protocol/Front/FrontClassWithSpec.js new file mode 100644 index 0000000000..55091be3e4 --- /dev/null +++ b/devtools/shared/protocol/Front/FrontClassWithSpec.js @@ -0,0 +1,118 @@ +/* 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"; + +var { Front } = require("resource://devtools/shared/protocol/Front.js"); + +/** + * Generates request methods as described by the given actor specification on + * the given front prototype. Returns the front prototype. + */ +var generateRequestMethods = function (actorSpec, frontProto) { + if (frontProto._actorSpec) { + throw new Error("frontProto called twice on the same front prototype!"); + } + + frontProto.typeName = actorSpec.typeName; + + // Generate request methods. + const methods = actorSpec.methods; + methods.forEach(spec => { + const name = spec.name; + + frontProto[name] = function (...args) { + // If the front is destroyed, the request will not be able to complete. + if (this.isDestroyed()) { + throw new Error( + `Can not send request '${name}' because front '${this.typeName}' is already destroyed.` + ); + } + + const startTime = Cu.now(); + let packet; + try { + packet = spec.request.write(args, this); + } catch (ex) { + console.error("Error writing request: " + name); + throw ex; + } + if (spec.oneway) { + // Fire-and-forget oneway packets. + this.send(packet); + return undefined; + } + + return this.request(packet).then(response => { + let ret; + if (!this.conn) { + throw new Error("Missing conn on " + this); + } + if (this.isDestroyed()) { + throw new Error( + `Can not interpret '${name}' response because front '${this.typeName}' is already destroyed.` + ); + } + try { + ret = spec.response.read(response, this); + } catch (ex) { + console.error("Error reading response to: " + name + "\n" + ex); + throw ex; + } + ChromeUtils.addProfilerMarker( + "RDP Front", + startTime, + `${this.typeName}:${name}()` + ); + return ret; + }); + }; + + // Release methods should call the destroy function on return. + if (spec.release) { + const fn = frontProto[name]; + frontProto[name] = function (...args) { + return fn.apply(this, args).then(result => { + this.destroy(); + return result; + }); + }; + } + }); + + // Process event specifications + frontProto._clientSpec = {}; + + const actorEvents = actorSpec.events; + if (actorEvents) { + frontProto._clientSpec.events = new Map(); + + for (const [name, request] of actorEvents) { + frontProto._clientSpec.events.set(request.type, { + name, + request, + }); + } + } + + frontProto._actorSpec = actorSpec; + + return frontProto; +}; + +/** + * Create a front class for the given actor specification and front prototype. + * + * @param object actorSpec + * The actor specification you're creating a front for. + * @param object proto + * The object prototype. Must have a 'typeName' property, + * should have method definitions, can have event definitions. + */ +var FrontClassWithSpec = function (actorSpec) { + class OneFront extends Front {} + generateRequestMethods(actorSpec, OneFront.prototype); + return OneFront; +}; +exports.FrontClassWithSpec = FrontClassWithSpec; diff --git a/devtools/shared/protocol/Front/moz.build b/devtools/shared/protocol/Front/moz.build new file mode 100644 index 0000000000..75b36c5eab --- /dev/null +++ b/devtools/shared/protocol/Front/moz.build @@ -0,0 +1,8 @@ +# 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/. + +DevToolsModules( + "FrontClassWithSpec.js", +) diff --git a/devtools/shared/protocol/Pool.js b/devtools/shared/protocol/Pool.js new file mode 100644 index 0000000000..b5cb4c3eb1 --- /dev/null +++ b/devtools/shared/protocol/Pool.js @@ -0,0 +1,220 @@ +/* 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"; + +var EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +/** + * Actor and Front implementations + */ + +/** + * A protocol object that can manage the lifetime of other protocol + * objects. Pools are used on both sides of the connection to help coordinate lifetimes. + * + * @param {DevToolsServerConnection|DevToolsClient} [conn] + * Either a DevToolsServerConnection or a DevToolsClient. Must have + * addActorPool, removeActorPool, and poolFor. + * conn can be null if the subclass provides a conn property. + * @param {String} [label] + * An optional label for the Pool. + * @constructor + */ +class Pool extends EventEmitter { + constructor(conn, label) { + super(); + + if (conn) { + this.conn = conn; + } + this.label = label; + + // Will be individually flipped to true by Actor/Front classes. + // Will also only be exposed via Actor/Front::isDestroyed(). + this._isDestroyed = false; + } + + __poolMap = null; + parentPool = null; + + /** + * Return the parent pool for this client. + */ + getParent() { + return this.parentPool; + } + + /** + * A pool is at the top of its pool hierarchy if it has: + * - no parent + * - or it is its own parent + */ + isTopPool() { + const parent = this.getParent(); + return !parent || parent === this; + } + + poolFor(actorID) { + return this.conn.poolFor(actorID); + } + + /** + * Override this if you want actors returned by this actor + * to belong to a different actor by default. + */ + marshallPool() { + return this; + } + + /** + * Pool is the base class for all actors, even leaf nodes. + * If the child map is actually referenced, go ahead and create + * the stuff needed by the pool. + */ + get _poolMap() { + if (this.__poolMap) { + return this.__poolMap; + } + this.__poolMap = new Map(); + this.conn.addActorPool(this); + return this.__poolMap; + } + + /** + * Add an actor as a child of this pool. + */ + manage(actor) { + if (!actor.actorID) { + actor.actorID = this.conn.allocID(actor.typeName); + } else { + // If the actor is already registered in a pool, remove it without destroying it. + // This happens for example when an addon is reloaded. To see this behavior, take a + // look at devtools/server/tests/xpcshell/test_addon_reload.js + + const parent = actor.getParent(); + if (parent && parent !== this) { + parent.unmanage(actor); + } + } + + this._poolMap.set(actor.actorID, actor); + actor.parentPool = this; + } + + unmanageChildren(FrontType) { + for (const front of this.poolChildren()) { + if (!FrontType || front instanceof FrontType) { + this.unmanage(front); + } + } + } + + /** + * Remove an actor as a child of this pool. + */ + unmanage(actor) { + if (this.__poolMap) { + this.__poolMap.delete(actor.actorID); + } + actor.parentPool = null; + } + + // true if the given actor ID exists in the pool. + has(actorID) { + return this.__poolMap && this._poolMap.has(actorID); + } + + /** + * Search for an actor in this pool, given an actorID + * @param {String} actorID + * @returns {Actor/null} Returns null if the actor wasn't found + */ + getActorByID(actorID) { + if (this.__poolMap) { + return this._poolMap.get(actorID); + } + return null; + } + + // Generator that yields each non-self child of the pool. + *poolChildren() { + if (!this.__poolMap) { + return; + } + for (const actor of this.__poolMap.values()) { + // Self-owned actors are ok, but don't need visiting twice. + if (actor === this) { + continue; + } + yield actor; + } + } + + isDestroyed() { + // Note: _isDestroyed is only flipped from Actor and Front subclasses for + // now, so this method should not be called on pure Pool instances. + // See Bug 1717811. + return this._isDestroyed; + } + + /** + * Pools can override this method in order to opt-out of a destroy sequence. + * + * For instance, Fronts are destroyed during the toolbox destroy. However when + * the toolbox is destroyed, the document holding the toolbox is also + * destroyed. So it should not be necessary to cleanup Fronts during toolbox + * destroy. + * + * For the time being, Fronts (or Pools in general) which want to opt-out of + * toolbox destroy can override this method and check the value of + * `this.conn.isToolboxDestroy`. + */ + skipDestroy() { + return false; + } + + /** + * Destroy this item, removing it from a parent if it has one, + * and destroying all children if necessary. + */ + destroy() { + const parent = this.getParent(); + if (parent) { + parent.unmanage(this); + } + if (!this.__poolMap) { + return; + } + // Immediately clear the poolmap so that we bail out early if the code is reentrant. + const poolMap = this.__poolMap; + this.__poolMap = null; + + for (const actor of poolMap.values()) { + // Self-owned actors are ok, but don't need destroying twice. + if (actor === this) { + continue; + } + + // Some pool-managed values don't extend Pool and won't have skipDestroy + // defined. For instance, the root actor and the lazy actors. + if (typeof actor.skipDestroy === "function" && actor.skipDestroy()) { + continue; + } + + const destroy = actor.destroy; + if (destroy) { + // Disconnect destroy while we're destroying in case of (misbehaving) + // circular ownership. + actor.destroy = null; + destroy.call(actor); + actor.destroy = destroy; + } + } + this.conn.removeActorPool(this); + this.conn = null; + } +} + +exports.Pool = Pool; diff --git a/devtools/shared/protocol/Request.js b/devtools/shared/protocol/Request.js new file mode 100644 index 0000000000..20befd938a --- /dev/null +++ b/devtools/shared/protocol/Request.js @@ -0,0 +1,169 @@ +/* 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 { extend } = require("resource://devtools/shared/extend.js"); +var { + findPlaceholders, + getPath, +} = require("resource://devtools/shared/protocol/utils.js"); +var { types } = require("resource://devtools/shared/protocol/types.js"); + +/** + * Manages a request template. + * + * @param object template + * The request template. + * @construcor + */ +var Request = function (template = {}) { + this.type = template.type; + this.template = template; + this.args = findPlaceholders(template, Arg); +}; + +Request.prototype = { + /** + * Write a request. + * + * @param array fnArgs + * The function arguments to place in the request. + * @param object ctx + * The object making the request. + * @returns a request packet. + */ + write(fnArgs, ctx) { + const ret = {}; + for (const key in this.template) { + const value = this.template[key]; + if (value instanceof Arg) { + ret[key] = value.write( + value.index in fnArgs ? fnArgs[value.index] : undefined, + ctx, + key + ); + } else if (key == "type") { + ret[key] = value; + } else { + throw new Error( + "Request can only an object with `Arg` or `Option` properties" + ); + } + } + return ret; + }, + + /** + * Read a request. + * + * @param object packet + * The request packet. + * @param object ctx + * The object making the request. + * @returns an arguments array + */ + read(packet, ctx) { + const fnArgs = []; + for (const templateArg of this.args) { + const arg = templateArg.placeholder; + const path = templateArg.path; + const name = path[path.length - 1]; + arg.read(getPath(packet, path), ctx, fnArgs, name); + } + return fnArgs; + }, +}; + +exports.Request = Request; + +/** + * Request/Response templates and generation + * + * Request packets are specified as json templates with + * Arg and Option placeholders where arguments should be + * placed. + * + * Reponse packets are also specified as json templates, + * with a RetVal placeholder where the return value should be + * placed. + */ + +/** + * Placeholder for simple arguments. + * + * @param number index + * The argument index to place at this position. + * @param type type + * The argument should be marshalled as this type. + * @constructor + */ +var Arg = function (index, type) { + this.index = index; + // Prevent force loading all Arg types by accessing it only when needed + loader.lazyGetter(this, "type", function () { + return types.getType(type); + }); +}; + +Arg.prototype = { + write(arg, ctx) { + return this.type.write(arg, ctx); + }, + + read(v, ctx, outArgs) { + outArgs[this.index] = this.type.read(v, ctx); + }, +}; + +// Outside of protocol.js, Arg is called as factory method, without the new keyword. +exports.Arg = function (index, type) { + return new Arg(index, type); +}; + +/** + * Placeholder for an options argument value that should be hoisted + * into the packet. + * + * If provided in a method specification: + * + * { optionArg: Option(1)} + * + * Then arguments[1].optionArg will be placed in the packet in this + * value's place. + * + * @param number index + * The argument index of the options value. + * @param type type + * The argument should be marshalled as this type. + * @constructor + */ +var Option = function (index, type) { + Arg.call(this, index, type); +}; + +Option.prototype = extend(Arg.prototype, { + write(arg, ctx, name) { + // Ignore if arg is undefined or null; allow other falsy values + if (arg == undefined || arg[name] == undefined) { + return undefined; + } + const v = arg[name]; + return this.type.write(v, ctx); + }, + read(v, ctx, outArgs, name) { + if (outArgs[this.index] === undefined) { + outArgs[this.index] = {}; + } + if (v === undefined) { + return; + } + outArgs[this.index][name] = this.type.read(v, ctx); + }, +}); + +// Outside of protocol.js, Option is called as factory method, without the new keyword. +exports.Option = function (index, type) { + return new Option(index, type); +}; diff --git a/devtools/shared/protocol/Response.js b/devtools/shared/protocol/Response.js new file mode 100644 index 0000000000..b456fe6149 --- /dev/null +++ b/devtools/shared/protocol/Response.js @@ -0,0 +1,119 @@ +/* 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"; + +var { + findPlaceholders, + getPath, +} = require("resource://devtools/shared/protocol/utils.js"); +var { types } = require("resource://devtools/shared/protocol/types.js"); + +/** + * Manages a response template. + * + * @param object template + * The response template. + * @construcor + */ +var Response = function (template = {}) { + this.template = template; + if (this.template instanceof RetVal && this.template.isArrayType()) { + throw Error("Arrays should be wrapped in objects"); + } + + const placeholders = findPlaceholders(template, RetVal); + if (placeholders.length > 1) { + throw Error("More than one RetVal specified in response"); + } + const placeholder = placeholders.shift(); + if (placeholder) { + this.retVal = placeholder.placeholder; + this.path = placeholder.path; + } +}; + +Response.prototype = { + /** + * Write a response for the given return value. + * + * @param val ret + * The return value. + * @param object ctx + * The object writing the response. + */ + write(ret, ctx) { + // Consider that `template` is either directly a `RetVal`, + // or a dictionary with may be one `RetVal`. + if (this.template instanceof RetVal) { + return this.template.write(ret, ctx); + } + const result = {}; + for (const key in this.template) { + const value = this.template[key]; + if (value instanceof RetVal) { + result[key] = value.write(ret, ctx); + } else { + throw new Error( + "Response can only be a `RetVal` instance or an object " + + "with one property being a `RetVal` instance." + ); + } + } + return result; + }, + + /** + * Read a return value from the given response. + * + * @param object packet + * The response packet. + * @param object ctx + * The object reading the response. + */ + read(packet, ctx) { + if (!this.retVal) { + return undefined; + } + const v = getPath(packet, this.path); + return this.retVal.read(v, ctx); + }, +}; + +exports.Response = Response; + +/** + * Placeholder for return values in a response template. + * + * @param type type + * The return value should be marshalled as this type. + */ +var RetVal = function (type) { + this._type = type; + // Prevent force loading all RetVal types by accessing it only when needed + loader.lazyGetter(this, "type", function () { + return types.getType(type); + }); +}; + +RetVal.prototype = { + write(v, ctx) { + return this.type.write(v, ctx); + }, + + read(v, ctx) { + return this.type.read(v, ctx); + }, + + isArrayType() { + // `_type` should always be a string, but a few incorrect RetVal calls + // pass `0`. See Bug 1677703. + return typeof this._type === "string" && this._type.startsWith("array:"); + }, +}; + +// Outside of protocol.js, RetVal is called as factory method, without the new keyword. +exports.RetVal = function (type) { + return new RetVal(type); +}; diff --git a/devtools/shared/protocol/lazy-pool.js b/devtools/shared/protocol/lazy-pool.js new file mode 100644 index 0000000000..0829fef1e0 --- /dev/null +++ b/devtools/shared/protocol/lazy-pool.js @@ -0,0 +1,224 @@ +/* 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 { extend } = require("devtools/shared/extend"); +const { Pool } = require("devtools/shared/protocol"); + +/** + * A Special Pool for RootActor and WindowGlobalTargetActor, which allows lazy loaded + * actors to be added to the pool. + * + * Like the Pool, this is a protocol object that can manage the lifetime of other protocol + * objects. Pools are used on both sides of the connection to help coordinate lifetimes. + * + * @param conn + * Is a DevToolsServerConnection. Must have + * addActorPool, removeActorPool, and poolFor. + * @constructor + */ +function LazyPool(conn) { + this.conn = conn; +} + +LazyPool.prototype = extend(Pool.prototype, { + // The actor for a given actor id stored in this pool + getActorByID(actorID) { + if (this.__poolMap) { + const entry = this._poolMap.get(actorID); + if (entry instanceof LazyActor) { + return entry.createActor(); + } + return entry; + } + return null; + }, +}); + +exports.LazyPool = LazyPool; + +/** + * Populate |parent._extraActors| as specified by |registeredActors|, reusing whatever + * actors are already there. Add all actors in the final extra actors table to + * |pool|. _extraActors is treated as a cache for lazy actors + * + * The target actor uses this to instantiate actors that other + * parts of the browser have specified with ActorRegistry.addTargetScopedActor + * + * @param factories + * An object whose own property names are the names of properties to add to + * some reply packet (say, a target actor grip or the "listTabs" response + * form), and whose own property values are actor constructor functions, as + * documented for addTargetScopedActor + * + * @param parent + * The parent TargetActor with which the new actors + * will be associated. It should support whatever API the |factories| + * constructor functions might be interested in, as it is passed to them. + * For the sake of CommonCreateExtraActors itself, it should have at least + * the following properties: + * + * - _extraActors + * An object whose own property names are factory table (and packet) + * property names, and whose values are no-argument actor constructors, + * of the sort that one can add to a Pool. + * + * - conn + * The DevToolsServerConnection in which the new actors will participate. + * + * - actorID + * The actor's name, for use as the new actors' parentID. + * @param pool + * An object which implements the protocol.js Pool interface, and has the + * following properties + * + * - manage + * a function which adds a given actor to an actor pool + */ +function createExtraActors(registeredActors, pool, parent) { + // Walk over global actors added by extensions. + const nameMap = {}; + for (const name in registeredActors) { + let actor = parent._extraActors[name]; + if (!actor) { + // Register another factory, but this time specific to this connection. + // It creates a fake actor that looks like an regular actor in the pool, + // but without actually instantiating the actor. + // It will only be instantiated on the first request made to the actor. + actor = new LazyActor(registeredActors[name], parent, pool); + parent._extraActors[name] = actor; + } + + // If the actor already exists in the pool, it may have been instantiated, + // so make sure not to overwrite it by a non-instantiated version. + if (!pool.has(actor.actorID)) { + pool.manage(actor); + } + nameMap[name] = actor.actorID; + } + return nameMap; +} + +exports.createExtraActors = createExtraActors; + +/** + * Creates an "actor-like" object which responds in the same way as an ordinary actor + * but has fewer capabilities (ie, does not manage lifetimes or have it's own pool). + * + * + * @param factories + * An object whose own property names are the names of properties to add to + * some reply packet (say, a target actor grip or the "listTabs" response + * form), and whose own property values are actor constructor functions, as + * documented for addTargetScopedActor + * + * @param parent + * The parent TargetActor with which the new actors + * will be associated. It should support whatever API the |factories| + * constructor functions might be interested in, as it is passed to them. + * For the sake of CommonCreateExtraActors itself, it should have at least + * the following properties: + * + * - _extraActors + * An object whose own property names are factory table (and packet) + * property names, and whose values are no-argument actor constructors, + * of the sort that one can add to a Pool. + * + * - conn + * The DevToolsServerConnection in which the new actors will participate. + * + * - actorID + * The actor's name, for use as the new actors' parentID. + * @param pool + * An object which implements the protocol.js Pool interface, and has the + * following properties + * + * - manage + * a function which adds a given actor to an actor pool + */ + +function LazyActor(factory, parent, pool) { + this._options = factory.options; + this._parentActor = parent; + this._name = factory.name; + this._pool = pool; + + // needed for taking a place in a pool + this.typeName = factory.name; +} + +LazyActor.prototype = { + loadModule(id) { + const options = this._options; + try { + return require(id); + // Fetch the actor constructor + } catch (e) { + throw new Error( + `Unable to load actor module '${options.id}'\n${e.message}\n${e.stack}\n` + ); + } + }, + + getConstructor() { + const options = this._options; + if (options.constructorFun) { + // Actor definition registered by testing helpers + return options.constructorFun; + } + // Lazy actor definition, where options contains all the information + // required to load the actor lazily. + // Exposes `name` attribute in order to allow removeXXXActor to match + // the actor by its actor constructor name. + this.name = options.constructorName; + const module = this.loadModule(options.id); + const constructor = module[options.constructorName]; + if (!constructor) { + throw new Error( + `Unable to find actor constructor named '${this.name}'. (Is it exported?)` + ); + } + return constructor; + }, + + /** + * Return the parent pool for this lazy actor. + */ + getParent() { + return this.conn && this.conn.poolFor(this.actorID); + }, + + /** + * This will only happen if the actor is destroyed before it is created + * We do not want to use the Pool destruction method, because this actor + * has no pool. However, it might have a parent that should unmange this + * actor + */ + destroy() { + const parent = this.getParent(); + if (parent) { + parent.unmanage(this); + } + }, + + createActor() { + // Fetch the actor constructor + const Constructor = this.getConstructor(); + // Instantiate a new actor instance + const conn = this._parentActor.conn; + // this should be taken care of once all actors are moved to protocol.js + const instance = new Constructor(conn, this._parentActor); + instance.conn = conn; + + // We want the newly-constructed actor to completely replace the factory + // actor. Reusing the existing actor ID will make sure Pool.manage + // replaces the old actor with the new actor. + instance.actorID = this.actorID; + + this._pool.manage(instance); + + return instance; + }, +}; diff --git a/devtools/shared/protocol/moz.build b/devtools/shared/protocol/moz.build new file mode 100644 index 0000000000..a75a7ae3ae --- /dev/null +++ b/devtools/shared/protocol/moz.build @@ -0,0 +1,22 @@ +# 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/. + +DIRS += [ + "Actor", + "Front", +] + +DevToolsModules( + "Actor.js", + "Front.js", + "lazy-pool.js", + "Pool.js", + "Request.js", + "Response.js", + "types.js", + "utils.js", +) + +XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] diff --git a/devtools/shared/protocol/tests/xpcshell/.eslintrc.js b/devtools/shared/protocol/tests/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..8611c174f5 --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the common devtools xpcshell eslintrc config. + extends: "../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/shared/protocol/tests/xpcshell/head.js b/devtools/shared/protocol/tests/xpcshell/head.js new file mode 100644 index 0000000000..dd055ddb42 --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/head.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); + +function dumpn(msg) { + dump("DBG-TEST: " + msg + "\n"); +} + +function connectPipeTracing() { + return new TracingTransport(DevToolsServer.connectPipe()); +} + +/** + * Mock the `Transport` class in order to intercept all the packet + * getting in and out and then being able to assert them and dump them. + */ +function TracingTransport(childTransport) { + this.hooks = null; + this.child = childTransport; + this.child.hooks = this; + + this.expectations = []; + this.packets = []; + this.checkIndex = 0; +} + +TracingTransport.prototype = { + // Remove actor names + normalize(packet) { + return JSON.parse( + JSON.stringify(packet, (key, value) => { + if (key === "to" || key === "from" || key === "actor") { + return "<actorid>"; + } + return value; + }) + ); + }, + send(packet) { + this.packets.push({ + type: "sent", + packet: this.normalize(packet), + }); + return this.child.send(packet); + }, + close() { + return this.child.close(); + }, + ready() { + return this.child.ready(); + }, + onPacket(packet) { + this.packets.push({ + type: "received", + packet: this.normalize(packet), + }); + this.hooks.onPacket(packet); + }, + onTransportClosed() { + if (this.hooks.onTransportClosed) { + this.hooks.onTransportClosed(); + } + }, + + expectSend(expected) { + const packet = this.packets[this.checkIndex++]; + Assert.equal(packet.type, "sent"); + deepEqual(packet.packet, this.normalize(expected)); + }, + + expectReceive(expected) { + const packet = this.packets[this.checkIndex++]; + Assert.equal(packet.type, "received"); + deepEqual(packet.packet, this.normalize(expected)); + }, + + // Write your tests, call dumpLog at the end, inspect the output, + // then sprinkle the calls through the right places in your test. + dumpLog() { + for (const entry of this.packets) { + if (entry.type === "sent") { + dumpn("trace.expectSend(" + entry.packet + ");"); + } else { + dumpn("trace.expectReceive(" + entry.packet + ");"); + } + } + }, +}; diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_abort.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_abort.js new file mode 100644 index 0000000000..ce237e1c00 --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_abort.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Outstanding requests should be rejected when the connection aborts + * unexpectedly. + */ + +var protocol = require("resource://devtools/shared/protocol.js"); +var { RetVal } = protocol; + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + methods: { + simpleReturn: { + response: { value: RetVal() }, + }, + }, +}); + +class RootActor extends protocol.Actor { + constructor(conn) { + super(conn, rootSpec); + + // Root actor owns itself. + this.manage(this); + this.actorID = "root"; + this.sequence = 0; + } + + sayHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; + } + + simpleReturn() { + return this.sequence++; + } +} + +class RootFront extends protocol.FrontClassWithSpec(rootSpec) { + constructor(client) { + super(client); + this.actorID = "root"; + // Root owns itself. + this.manage(this); + } +} +protocol.registerFront(RootFront); + +add_task(async function () { + DevToolsServer.createRootActor = conn => new RootActor(conn); + DevToolsServer.init(); + + const trace = connectPipeTracing(); + const client = new DevToolsClient(trace); + await client.connect(); + + const rootFront = client.mainRoot; + + const onSimpleReturn = rootFront.simpleReturn(); + trace.close(); + + try { + await onSimpleReturn; + ok(false, "Connection was aborted, request shouldn't resolve"); + } catch (e) { + const error = e.toString(); + ok(true, "Connection was aborted, request rejected correctly"); + ok(error.includes("Request stack:"), "Error includes request stack"); + ok(error.includes("test_protocol_abort.js"), "Stack includes this test"); + } +}); diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_async.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_async.js new file mode 100644 index 0000000000..dd7196710b --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_async.js @@ -0,0 +1,192 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Make sure we get replies in the same order that we sent their + * requests even when earlier requests take several event ticks to + * complete. + */ + +const { waitForTick } = require("resource://devtools/shared/DevToolsUtils.js"); +const protocol = require("resource://devtools/shared/protocol.js"); +const { Arg, RetVal } = protocol; + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + methods: { + simpleReturn: { + response: { value: RetVal() }, + }, + promiseReturn: { + request: { toWait: Arg(0, "number") }, + response: { value: RetVal("number") }, + }, + simpleThrow: { + response: { value: RetVal("number") }, + }, + promiseThrow: { + request: { toWait: Arg(0, "number") }, + response: { value: RetVal("number") }, + }, + }, +}); + +class RootActor extends protocol.Actor { + constructor(conn) { + super(conn, rootSpec); + + // Root actor owns itself. + this.manage(this); + this.actorID = "root"; + this.sequence = 0; + } + + sayHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; + } + + simpleReturn() { + return this.sequence++; + } + + // Guarantee that this resolves after simpleReturn returns. + async promiseReturn(toWait) { + const sequence = this.sequence++; + + // Wait until the number of requests specified by toWait have + // happened, to test queuing. + while (this.sequence - sequence < toWait) { + await waitForTick(); + } + + return sequence; + } + + simpleThrow() { + throw new Error(this.sequence++); + } + + // Guarantee that this resolves after simpleReturn returns. + promiseThrow(toWait) { + return this.promiseReturn(toWait).then(Promise.reject); + } +} + +class RootFront extends protocol.FrontClassWithSpec(rootSpec) { + constructor(client) { + super(client); + this.actorID = "root"; + // Root owns itself. + this.manage(this); + } +} +protocol.registerFront(RootFront); + +add_task(async function () { + DevToolsServer.createRootActor = conn => new RootActor(conn); + DevToolsServer.init(); + + const trace = connectPipeTracing(); + const client = new DevToolsClient(trace); + await client.connect(); + + const rootFront = client.mainRoot; + + const calls = []; + let sequence = 0; + + // Execute a call that won't finish processing until 2 + // more calls have happened + calls.push( + rootFront.promiseReturn(2).then(ret => { + // Check right return order + Assert.equal(sequence, 0); + // Check request handling order + Assert.equal(ret, sequence++); + }) + ); + + // Put a few requests into the backlog + + calls.push( + rootFront.simpleReturn().then(ret => { + // Check right return order + Assert.equal(sequence, 1); + // Check request handling order + Assert.equal(ret, sequence++); + }) + ); + + calls.push( + rootFront.simpleReturn().then(ret => { + // Check right return order + Assert.equal(sequence, 2); + // Check request handling order + Assert.equal(ret, sequence++); + }) + ); + + calls.push( + rootFront.simpleThrow().then( + () => { + Assert.ok(false, "simpleThrow shouldn't succeed!"); + }, + error => { + // Check right return order + Assert.equal(sequence++, 3); + } + ) + ); + + calls.push( + rootFront.promiseThrow(2).then( + () => { + Assert.ok(false, "promiseThrow shouldn't succeed!"); + }, + error => { + // Check right return order + Assert.equal(sequence++, 4); + Assert.ok(true, "simple throw should throw"); + } + ) + ); + + calls.push( + rootFront.simpleReturn().then(ret => { + // Check right return order + Assert.equal(sequence, 5); + // Check request handling order + Assert.equal(ret, sequence++); + }) + ); + + // Break up the backlog with a long request that waits + // for another simpleReturn before completing + calls.push( + rootFront.promiseReturn(1).then(ret => { + // Check right return order + Assert.equal(sequence, 6); + // Check request handling order + Assert.equal(ret, sequence++); + }) + ); + + calls.push( + rootFront.simpleReturn().then(ret => { + // Check right return order + Assert.equal(sequence, 7); + // Check request handling order + Assert.equal(ret, sequence++); + }) + ); + + await Promise.all(calls); + await client.close(); +}); diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_children.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_children.js new file mode 100644 index 0000000000..728e58c6b9 --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_children.js @@ -0,0 +1,700 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable max-nested-callbacks */ + +"use strict"; + +/** + * Test simple requests using the protocol helpers. + */ +const protocol = require("resource://devtools/shared/protocol.js"); +const { types, Arg, RetVal } = protocol; + +// Predeclaring the actor type so that it can be used in the +// implementation of the child actor. +types.addActorType("childActor"); +types.addActorType("otherChildActor"); +types.addPolymorphicType("polytype", ["childActor", "otherChildActor"]); + +const childSpec = protocol.generateActorSpec({ + typeName: "childActor", + + events: { + event1: { + a: Arg(0), + b: Arg(1), + c: Arg(2), + }, + event2: { + a: Arg(0), + b: Arg(1), + c: Arg(2), + }, + "named-event": { + type: "namedEvent", + a: Arg(0), + b: Arg(1), + c: Arg(2), + }, + "object-event": { + type: "objectEvent", + detail: Arg(0, "childActor#actorid"), + }, + "array-object-event": { + type: "arrayObjectEvent", + detail: Arg(0, "array:childActor#actorid"), + }, + }, + + methods: { + echo: { + request: { str: Arg(0) }, + response: { str: RetVal("string") }, + }, + getDetail1: { + response: { + child: RetVal("childActor#actorid"), + }, + }, + getDetail2: { + response: { + child: RetVal("childActor#actorid"), + }, + }, + getIDDetail: { + response: { + idDetail: RetVal("childActor#actorid"), + }, + }, + getIntArray: { + request: { inputArray: Arg(0, "array:number") }, + response: { + intArray: RetVal("array:number"), + }, + }, + getSibling: { + request: { id: Arg(0) }, + response: { sibling: RetVal("childActor") }, + }, + emitEvents: { + response: { value: RetVal("string") }, + }, + release: { + release: true, + }, + }, +}); + +class ChildActor extends protocol.Actor { + constructor(conn, id) { + super(conn, childSpec); + this.childID = id; + } + + // Actors returned by this actor should be owned by the root actor. + marshallPool() { + return this.getParent(); + } + + toString() { + return "[ChildActor " + this.childID + "]"; + } + + destroy() { + super.destroy(); + this.destroyed = true; + } + + form() { + return { + actor: this.actorID, + childID: this.childID, + }; + } + + echo(str) { + return str; + } + + getDetail1() { + return this; + } + + getDetail2() { + return this; + } + + getIDDetail() { + return this; + } + + getIntArray(inputArray) { + // Test that protocol.js converts an iterator to an array. + const f = function* () { + for (const i of inputArray) { + yield 2 * i; + } + }; + return f(); + } + + getSibling(id) { + return this.getParent().getChild(id); + } + + emitEvents() { + this.emit("event1", 1, 2, 3); + this.emit("event2", 4, 5, 6); + this.emit("named-event", 1, 2, 3); + this.emit("object-event", this); + this.emit("array-object-event", [this]); + return "correct response"; + } + + release() {} +} + +class ChildFront extends protocol.FrontClassWithSpec(childSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this._parentFront = parentFront; + + this.before("event1", this.onEvent1.bind(this)); + this.before("event2", this.onEvent2a.bind(this)); + this.on("event2", this.onEvent2b.bind(this)); + } + + destroy() { + this.destroyed = true; + // Call parent's destroy, which may be re-entrant and recall this function + this._parentFront.destroy(); + super.destroy(); + } + + marshallPool() { + return this.getParent(); + } + + toString() { + return "[child front " + this.childID + "]"; + } + + form(form) { + this.childID = form.childID; + } + + onEvent1(a, b, c) { + this.event1arg3 = c; + } + + onEvent2a(a, b, c) { + return Promise.resolve().then(() => { + this.event2arg3 = c; + }); + } + + onEvent2b(a, b, c) { + this.event2arg2 = b; + } +} +protocol.registerFront(ChildFront); + +const otherChildSpec = protocol.generateActorSpec({ + typeName: "otherChildActor", + methods: { + getOtherChild: { + request: {}, + response: { sibling: RetVal("otherChildActor") }, + }, + }, + events: {}, +}); + +class OtherChildActor extends protocol.Actor { + constructor(conn) { + super(conn, otherChildSpec); + } + + getOtherChild() { + return new OtherChildActor(this.conn); + } +} + +class OtherChildFront extends protocol.FrontClassWithSpec(otherChildSpec) {} +protocol.registerFront(OtherChildFront); + +types.addDictType("manyChildrenDict", { + child5: "childActor", + more: "array:childActor", +}); + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + methods: { + getChild: { + request: { str: Arg(0) }, + response: { actor: RetVal("childActor") }, + }, + getOtherChild: { + request: {}, + response: { sibling: RetVal("otherChildActor") }, + }, + getChildren: { + request: { ids: Arg(0, "array:string") }, + response: { children: RetVal("array:childActor") }, + }, + getChildren2: { + request: { ids: Arg(0, "array:childActor") }, + response: { children: RetVal("array:childActor") }, + }, + getManyChildren: { + response: RetVal("manyChildrenDict"), + }, + getPolymorphism: { + request: { id: Arg(0, "number") }, + response: { child: RetVal("polytype") }, + }, + requestPolymorphism: { + request: { + id: Arg(0, "number"), + actor: Arg(1, "polytype"), + }, + response: { child: RetVal("polytype") }, + }, + }, +}); + +let rootActor = null; +class RootActor extends protocol.Actor { + constructor(conn) { + super(conn, rootSpec); + + rootActor = this; + this.actorID = "root"; + this._children = {}; + } + + toString() { + return "[root actor]"; + } + + sayHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; + } + + getChild(id) { + if (id in this._children) { + return this._children[id]; + } + const child = new ChildActor(this.conn, id); + this._children[id] = child; + return child; + } + + // Other child actor won't all be own by the root actor + // and can have their own children + getOtherChild() { + return new OtherChildActor(this.conn); + } + + getChildren(ids) { + return ids.map(id => this.getChild(id)); + } + + getChildren2(ids) { + const f = function* () { + for (const c of ids) { + yield c; + } + }; + return f(); + } + + getManyChildren() { + return { + // note that this isn't in the specialization array. + foo: "bar", + child5: this.getChild("child5"), + more: [this.getChild("child6"), this.getChild("child7")], + }; + } + + getPolymorphism(id) { + if (id == 0) { + return new ChildActor(this.conn, id); + } else if (id == 1) { + return new OtherChildActor(this.conn); + } + throw new Error("Unexpected id"); + } + + requestPolymorphism(id, actor) { + if (id == 0 && actor instanceof ChildActor) { + return actor; + } else if (id == 1 && actor instanceof OtherChildActor) { + return actor; + } + throw new Error("Unexpected id or actor"); + } +} + +class RootFront extends protocol.FrontClassWithSpec(rootSpec) { + constructor(client, targetFront, parentFront) { + super(client, targetFront, parentFront); + this.actorID = "root"; + // Root actor owns itself. + this.manage(this); + } + + toString() { + return "[root front]"; + } +} + +let rootFront, childFront; +function expectRootChildren(size) { + Assert.equal(rootActor._poolMap.size, size); + Assert.equal(rootFront._poolMap.size, size + 1); + if (childFront) { + Assert.equal(childFront._poolMap.size, 0); + } +} +protocol.registerFront(RootFront); + +function childrenOfType(pool, type) { + const children = [...rootFront.poolChildren()]; + return children.filter(child => child instanceof type); +} + +add_task(async function () { + DevToolsServer.createRootActor = conn => { + return new RootActor(conn); + }; + DevToolsServer.init(); + + const trace = connectPipeTracing(); + const client = new DevToolsClient(trace); + const [applicationType] = await client.connect(); + trace.expectReceive({ + from: "<actorid>", + applicationType: "xpcshell-tests", + traits: [], + }); + Assert.equal(applicationType, "xpcshell-tests"); + + rootFront = client.mainRoot; + + await testSimpleChildren(trace); + await testDetail(trace); + await testSibling(trace); + await testEvents(trace); + await testManyChildren(trace); + await testGenerator(trace); + await testPolymorphism(trace); + await testUnmanageChildren(trace); + // Execute that assertion very last as it destroy the root front and actor + await testDestroy(trace); + + await client.close(); +}); + +async function testSimpleChildren(trace) { + childFront = await rootFront.getChild("child1"); + trace.expectSend({ type: "getChild", str: "child1", to: "<actorid>" }); + trace.expectReceive({ actor: "<actorid>", from: "<actorid>" }); + + Assert.ok(childFront instanceof ChildFront); + Assert.equal(childFront.childID, "child1"); + expectRootChildren(1); + + // Request the child again, make sure the same is returned. + let ret = await rootFront.getChild("child1"); + trace.expectSend({ type: "getChild", str: "child1", to: "<actorid>" }); + trace.expectReceive({ actor: "<actorid>", from: "<actorid>" }); + + expectRootChildren(1); + Assert.ok(ret === childFront); + + ret = await childFront.echo("hello"); + trace.expectSend({ type: "echo", str: "hello", to: "<actorid>" }); + trace.expectReceive({ str: "hello", from: "<actorid>" }); + + Assert.equal(ret, "hello"); +} + +async function testDetail(trace) { + let ret = await childFront.getDetail1(); + trace.expectSend({ type: "getDetail1", to: "<actorid>" }); + trace.expectReceive({ child: childFront.actorID, from: "<actorid>" }); + Assert.ok(ret === childFront); + + ret = await childFront.getDetail2(); + trace.expectSend({ type: "getDetail2", to: "<actorid>" }); + trace.expectReceive({ child: childFront.actorID, from: "<actorid>" }); + Assert.ok(ret === childFront); + + ret = await childFront.getIDDetail(); + trace.expectSend({ type: "getIDDetail", to: "<actorid>" }); + trace.expectReceive({ + idDetail: childFront.actorID, + from: "<actorid>", + }); + Assert.ok(ret === childFront); +} + +async function testSibling(trace) { + await childFront.getSibling("siblingID"); + trace.expectSend({ + type: "getSibling", + id: "siblingID", + to: "<actorid>", + }); + trace.expectReceive({ + sibling: { actor: "<actorid>", childID: "siblingID" }, + from: "<actorid>", + }); + + expectRootChildren(2); +} + +async function testEvents(trace) { + const ret = await rootFront.getChildren(["child1", "child2"]); + trace.expectSend({ + type: "getChildren", + ids: ["child1", "child2"], + to: "<actorid>", + }); + trace.expectReceive({ + children: [ + { actor: "<actorid>", childID: "child1" }, + { actor: "<actorid>", childID: "child2" }, + ], + from: "<actorid>", + }); + + expectRootChildren(3); + Assert.ok(ret[0] === childFront); + Assert.ok(ret[1] !== childFront); + Assert.ok(ret[1] instanceof ChildFront); + + // On both children, listen to events. We're only + // going to trigger events on the first child, so an event + // triggered on the second should cause immediate failures. + + const set = new Set([ + "event1", + "event2", + "named-event", + "object-event", + "array-object-event", + ]); + + childFront.on("event1", (a, b, c) => { + Assert.equal(a, 1); + Assert.equal(b, 2); + Assert.equal(c, 3); + // Verify that the pre-event handler was called. + Assert.equal(childFront.event1arg3, 3); + set.delete("event1"); + }); + childFront.on("event2", (a, b, c) => { + Assert.equal(a, 4); + Assert.equal(b, 5); + Assert.equal(c, 6); + // Verify that the async pre-event handler was called, + // setting the property before this handler was called. + Assert.equal(childFront.event2arg3, 6); + // And check that the sync preEvent with the same name is also + // executed + Assert.equal(childFront.event2arg2, 5); + set.delete("event2"); + }); + childFront.on("named-event", (a, b, c) => { + Assert.equal(a, 1); + Assert.equal(b, 2); + Assert.equal(c, 3); + set.delete("named-event"); + }); + childFront.on("object-event", obj => { + Assert.ok(obj === childFront); + set.delete("object-event"); + }); + childFront.on("array-object-event", array => { + Assert.ok(array[0] === childFront); + set.delete("array-object-event"); + }); + + const fail = function () { + do_throw("Unexpected event"); + }; + ret[1].on("event1", fail); + ret[1].on("event2", fail); + ret[1].on("named-event", fail); + ret[1].on("object-event", fail); + ret[1].on("array-object-event", fail); + + await childFront.emitEvents(); + trace.expectSend({ type: "emitEvents", to: "<actorid>" }); + trace.expectReceive({ + type: "event1", + a: 1, + b: 2, + c: 3, + from: "<actorid>", + }); + trace.expectReceive({ + type: "event2", + a: 4, + b: 5, + c: 6, + from: "<actorid>", + }); + trace.expectReceive({ + type: "namedEvent", + a: 1, + b: 2, + c: 3, + from: "<actorid>", + }); + trace.expectReceive({ + type: "objectEvent", + detail: childFront.actorID, + from: "<actorid>", + }); + trace.expectReceive({ + type: "arrayObjectEvent", + detail: [childFront.actorID], + from: "<actorid>", + }); + trace.expectReceive({ value: "correct response", from: "<actorid>" }); + + Assert.equal(set.size, 0); +} + +async function testManyChildren(trace) { + const ret = await rootFront.getManyChildren(); + trace.expectSend({ type: "getManyChildren", to: "<actorid>" }); + trace.expectReceive({ + foo: "bar", + child5: { actor: "<actorid>", childID: "child5" }, + more: [ + { actor: "<actorid>", childID: "child6" }, + { actor: "<actorid>", childID: "child7" }, + ], + from: "<actorid>", + }); + + // Check all the crazy stuff we did in getManyChildren + Assert.equal(ret.foo, "bar"); + Assert.equal(ret.child5.childID, "child5"); + Assert.equal(ret.more[0].childID, "child6"); + Assert.equal(ret.more[1].childID, "child7"); +} + +async function testGenerator(trace) { + // Test accepting a generator. + const f = function* () { + for (const i of [1, 2, 3, 4, 5]) { + yield i; + } + }; + let ret = await childFront.getIntArray(f()); + Assert.equal(ret.length, 5); + const expected = [2, 4, 6, 8, 10]; + for (let i = 0; i < 5; ++i) { + Assert.equal(ret[i], expected[i]); + } + + const ids = await rootFront.getChildren(["child1", "child2"]); + const f2 = function* () { + for (const id of ids) { + yield id; + } + }; + ret = await rootFront.getChildren2(f2()); + Assert.equal(ret.length, 2); + Assert.ok(ret[0] === childFront); + Assert.ok(ret[1] !== childFront); + Assert.ok(ret[1] instanceof ChildFront); +} + +async function testPolymorphism(trace) { + // Check polymorphic types returned by an actor + const firstChild = await rootFront.getPolymorphism(0); + Assert.ok(firstChild instanceof ChildFront); + + // Check polymorphic types passed to a front + const sameFirstChild = await rootFront.requestPolymorphism(0, firstChild); + Assert.ok(sameFirstChild instanceof ChildFront); + Assert.equal(sameFirstChild, firstChild); + + // Same with the second possible type + const secondChild = await rootFront.getPolymorphism(1); + Assert.ok(secondChild instanceof OtherChildFront); + + const sameSecondChild = await rootFront.requestPolymorphism(1, secondChild); + Assert.ok(sameSecondChild instanceof OtherChildFront); + Assert.equal(sameSecondChild, secondChild); + + // Check that any other type is rejected + Assert.throws(() => { + rootFront.requestPolymorphism(0, null); + }, /Was expecting one of these actors 'childActor,otherChildActor' but instead got an empty value/); + Assert.throws(() => { + rootFront.requestPolymorphism(0, 42); + }, /Was expecting one of these actors 'childActor,otherChildActor' but instead got value: '42'/); + Assert.throws(() => { + rootFront.requestPolymorphism(0, rootFront); + }, /Was expecting one of these actors 'childActor,otherChildActor' but instead got an actor of type: 'root'/); +} + +async function testUnmanageChildren(trace) { + // There is already one front of type OtherChildFront + Assert.equal(childrenOfType(rootFront, OtherChildFront).length, 1); + + // Create another front of type OtherChildFront + const front = await rootFront.getPolymorphism(1); + Assert.ok(front instanceof OtherChildFront); + Assert.equal(childrenOfType(rootFront, OtherChildFront).length, 2); + + // Remove all fronts of type OtherChildFront + rootFront.unmanageChildren(OtherChildFront); + Assert.ok( + !front.isDestroyed(), + "Unmanaged front is not considered as destroyed" + ); + Assert.equal(childrenOfType(rootFront, OtherChildFront).length, 0); +} + +async function testDestroy(trace) { + const front = await rootFront.getOtherChild(); + const otherChildFront = await front.getOtherChild(); + Assert.equal( + otherChildFront.getParent(), + front, + "the child is a children of first front" + ); + + front.destroy(); + Assert.ok(front.isDestroyed(), "sibling is correctly reported as destroyed"); + Assert.ok(!front.getParent(), "sibling has no more parent declared"); + Assert.ok(otherChildFront.isDestroyed(), "the child is also destroyed"); + Assert.ok( + !otherChildFront.getParent(), + "the child also has no more parent declared" + ); + Assert.ok( + !otherChildFront.parentPool, + "the child also has its parentPool attribute nullified" + ); + + // Verify that re-entrant Front.destroy doesn't throw, nor loop + // Execute that very last as it will destroy the root actor and front + const sibling = await childFront.getSibling("siblingID"); + sibling.destroy(); +} diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_index.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_index.js new file mode 100644 index 0000000000..ef566d6b97 --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_index.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const { lazyLoadFront } = require("resource://devtools/shared/specs/index.js"); +const Types = + require("resource://devtools/shared/specs/index.js").__TypesForTests; +const { getType } = require("resource://devtools/shared/protocol.js").types; + +function run_test() { + test_index_is_alphabetically_sorted(); + test_specs(); + test_fronts(); +} + +// Check alphabetic order of specs defined in devtools/shared/specs/index.js, +// in order to ease its maintenance and readability. +function test_index_is_alphabetically_sorted() { + let lastSpec = ""; + for (const type of Types) { + const spec = type.spec; + if (lastSpec && spec < lastSpec) { + ok(false, `Spec definition for "${spec}" should be before "${lastSpec}"`); + } + lastSpec = spec; + } + ok(true, "Specs index is alphabetically sorted"); +} + +function test_specs() { + for (const type of Types) { + for (const typeName of type.types) { + ok(!!getType(typeName), `${typeName} spec is defined`); + } + } + ok(true, "Specs are all accessible"); +} + +function test_fronts() { + for (const item of Types) { + if (!item.front) { + continue; + } + for (const typeName of item.types) { + lazyLoadFront(typeName); + const type = getType(typeName); + ok(!!type, `Front for ${typeName} has a spec`); + ok(type.frontClass, `${typeName} has a front correctly defined`); + } + } + ok(true, "Front are all accessible"); +} diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_invalid_response.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_invalid_response.js new file mode 100644 index 0000000000..6b530f0a61 --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_invalid_response.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const protocol = require("resource://devtools/shared/protocol.js"); +const { RetVal } = protocol; + +// Test invalid response specs throw when generating the Actor specification. + +// Test top level array response +add_task(async function () { + Assert.throws(() => { + protocol.generateActorSpec({ + typeName: "invalidArrayResponse", + methods: { + invalidMethod: { + response: RetVal("array:string"), + }, + }, + }); + }, /Arrays should be wrapped in objects/); + + protocol.generateActorSpec({ + typeName: "validArrayResponse", + methods: { + validMethod: { + response: { + someArray: RetVal("array:string"), + }, + }, + }, + }); + ok(true, "Arrays wrapped in object are valid response packets"); +}); + +// Test response with several placeholders +add_task(async function () { + Assert.throws(() => { + protocol.generateActorSpec({ + typeName: "tooManyPlaceholdersResponse", + methods: { + invalidMethod: { + response: { + prop1: RetVal("json"), + prop2: RetVal("json"), + }, + }, + }, + }); + }, /More than one RetVal specified in response/); +}); diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_lifecycle.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_lifecycle.js new file mode 100644 index 0000000000..bd887ba88a --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_lifecycle.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Actor } = require("resource://devtools/shared/protocol/Actor.js"); +const { Front } = require("resource://devtools/shared/protocol/Front.js"); + +add_task(async function () { + // Front constructor expect to be provided a client object + const client = {}; + const front = new Front(client); + ok( + !front.isDestroyed(), + "Blank front with no actor ID is not considered as destroyed" + ); + front.destroy(); + ok(front.isDestroyed(), "Front is destroyed"); + + const actor = new Actor(null, { typeName: "actor", methods: [] }); + ok( + !actor.isDestroyed(), + "Blank actor with no actor ID is not considered as destroyed" + ); + actor.destroy(); + ok(actor.isDestroyed(), "Actor is destroyed"); +}); diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_longstring.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_longstring.js new file mode 100644 index 0000000000..cda1708520 --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_longstring.js @@ -0,0 +1,310 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable max-nested-callbacks */ + +"use strict"; + +/** + * Test simple requests using the protocol helpers. + */ +var protocol = require("resource://devtools/shared/protocol.js"); +var { RetVal, Arg } = protocol; +var EventEmitter = require("resource://devtools/shared/event-emitter.js"); +var { + LongStringActor, +} = require("resource://devtools/server/actors/string.js"); + +// The test implicitly relies on this. +require("resource://devtools/client/fronts/string.js"); + +DevToolsServer.LONG_STRING_LENGTH = + DevToolsServer.LONG_STRING_INITIAL_LENGTH = + DevToolsServer.LONG_STRING_READ_LENGTH = + 5; + +var SHORT_STR = "abc"; +var LONG_STR = "abcdefghijklmnop"; + +var rootActor = null; + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + events: { + "string-event": { + str: Arg(0, "longstring"), + }, + }, + + methods: { + shortString: { + response: { value: RetVal("longstring") }, + }, + longString: { + response: { value: RetVal("longstring") }, + }, + emitShortString: { + oneway: true, + }, + emitLongString: { + oneway: true, + }, + }, +}); + +class RootActor extends protocol.Actor { + constructor(conn) { + super(conn, rootSpec); + + rootActor = this; + // Root actor owns itself. + this.manage(this); + this.actorID = "root"; + } + + sayHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; + } + + shortString() { + return new LongStringActor(this.conn, SHORT_STR); + } + + longString() { + return new LongStringActor(this.conn, LONG_STR); + } + + emitShortString() { + EventEmitter.emit( + this, + "string-event", + new LongStringActor(this.conn, SHORT_STR) + ); + } + + emitLongString() { + EventEmitter.emit( + this, + "string-event", + new LongStringActor(this.conn, LONG_STR) + ); + } +} + +class RootFront extends protocol.FrontClassWithSpec(rootSpec) { + constructor(client) { + super(client); + this.actorID = "root"; + + // Root owns itself. + this.manage(this); + } +} +protocol.registerFront(RootFront); + +function run_test() { + DevToolsServer.createRootActor = conn => { + return new RootActor(conn); + }; + + DevToolsServer.init(); + + const trace = connectPipeTracing(); + const client = new DevToolsClient(trace); + let rootFront; + + let strfront = null; + + const expectRootChildren = function (size) { + Assert.equal(rootActor.__poolMap.size, size + 1); + Assert.equal(rootFront.__poolMap.size, size + 1); + }; + + client.connect().then(([applicationType, traits]) => { + rootFront = client.mainRoot; + + // Root actor has no children yet. + expectRootChildren(0); + + trace.expectReceive({ + from: "<actorid>", + applicationType: "xpcshell-tests", + traits: [], + }); + Assert.equal(applicationType, "xpcshell-tests"); + rootFront + .shortString() + .then(ret => { + trace.expectSend({ type: "shortString", to: "<actorid>" }); + trace.expectReceive({ value: "abc", from: "<actorid>" }); + + // Should only own the one reference (itself) at this point. + expectRootChildren(0); + strfront = ret; + }) + .then(() => { + return strfront.string(); + }) + .then(ret => { + Assert.equal(ret, SHORT_STR); + }) + .then(() => { + return rootFront.longString(); + }) + .then(ret => { + trace.expectSend({ type: "longString", to: "<actorid>" }); + trace.expectReceive({ + value: { + type: "longString", + actor: "<actorid>", + length: 16, + initial: "abcde", + }, + from: "<actorid>", + }); + + strfront = ret; + // Should own a reference to itself and an extra string now. + expectRootChildren(1); + }) + .then(() => { + return strfront.string(); + }) + .then(ret => { + trace.expectSend({ + type: "substring", + start: 5, + end: 10, + to: "<actorid>", + }); + trace.expectReceive({ substring: "fghij", from: "<actorid>" }); + trace.expectSend({ + type: "substring", + start: 10, + end: 15, + to: "<actorid>", + }); + trace.expectReceive({ substring: "klmno", from: "<actorid>" }); + trace.expectSend({ + type: "substring", + start: 15, + end: 20, + to: "<actorid>", + }); + trace.expectReceive({ substring: "p", from: "<actorid>" }); + + Assert.equal(ret, LONG_STR); + }) + .then(() => { + return strfront.release(); + }) + .then(() => { + trace.expectSend({ type: "release", to: "<actorid>" }); + trace.expectReceive({ from: "<actorid>" }); + + // That reference should be removed now. + expectRootChildren(0); + }) + .then(() => { + return new Promise(resolve => { + rootFront.once("string-event", str => { + trace.expectSend({ type: "emitShortString", to: "<actorid>" }); + trace.expectReceive({ + type: "string-event", + str: "abc", + from: "<actorid>", + }); + + Assert.ok(!!str); + strfront = str; + // Shouldn't generate any new references + expectRootChildren(0); + // will generate no packets. + strfront.string().then(value => { + resolve(value); + }); + }); + rootFront.emitShortString(); + }); + }) + .then(value => { + Assert.equal(value, SHORT_STR); + }) + .then(() => { + // Will generate no packets + return strfront.release(); + }) + .then(() => { + return new Promise(resolve => { + rootFront.once("string-event", str => { + trace.expectSend({ type: "emitLongString", to: "<actorid>" }); + trace.expectReceive({ + type: "string-event", + str: { + type: "longString", + actor: "<actorid>", + length: 16, + initial: "abcde", + }, + from: "<actorid>", + }); + + Assert.ok(!!str); + // Should generate one new reference + expectRootChildren(1); + strfront = str; + strfront.string().then(value => { + trace.expectSend({ + type: "substring", + start: 5, + end: 10, + to: "<actorid>", + }); + trace.expectReceive({ substring: "fghij", from: "<actorid>" }); + trace.expectSend({ + type: "substring", + start: 10, + end: 15, + to: "<actorid>", + }); + trace.expectReceive({ substring: "klmno", from: "<actorid>" }); + trace.expectSend({ + type: "substring", + start: 15, + end: 20, + to: "<actorid>", + }); + trace.expectReceive({ substring: "p", from: "<actorid>" }); + + resolve(value); + }); + }); + rootFront.emitLongString(); + }); + }) + .then(value => { + Assert.equal(value, LONG_STR); + }) + .then(() => { + return strfront.release(); + }) + .then(() => { + trace.expectSend({ type: "release", to: "<actorid>" }); + trace.expectReceive({ from: "<actorid>" }); + expectRootChildren(0); + }) + .then(() => { + client.close().then(() => { + do_test_finished(); + }); + }) + .catch(err => { + do_report_unexpected_exception(err, "Failure executing test"); + }); + }); + do_test_pending(); +} diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_simple.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_simple.js new file mode 100644 index 0000000000..523d147f6c --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_simple.js @@ -0,0 +1,316 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test simple requests using the protocol helpers. + */ + +var protocol = require("resource://devtools/shared/protocol.js"); +var { Arg, Option, RetVal } = protocol; +var EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + events: { + oneway: { a: Arg(0) }, + falsyOptions: { + zero: Option(0), + farce: Option(0), + }, + }, + + methods: { + simpleReturn: { + response: { value: RetVal() }, + }, + promiseReturn: { + response: { value: RetVal("number") }, + }, + simpleArgs: { + request: { + firstArg: Arg(0), + secondArg: Arg(1), + }, + response: RetVal(), + }, + optionArgs: { + request: { + option1: Option(0), + option2: Option(0), + }, + response: RetVal(), + }, + optionalArgs: { + request: { + a: Arg(0), + b: Arg(1, "nullable:number"), + }, + response: { + value: RetVal("number"), + }, + }, + arrayArgs: { + request: { + a: Arg(0, "array:number"), + }, + response: { + arrayReturn: RetVal("array:number"), + }, + }, + nestedArrayArgs: { + request: { a: Arg(0, "array:array:number") }, + response: { value: RetVal("array:array:number") }, + }, + renamedEcho: { + request: { + type: "echo", + a: Arg(0), + }, + response: { + value: RetVal("string"), + }, + }, + testOneWay: { + request: { a: Arg(0) }, + oneway: true, + }, + emitFalsyOptions: { + oneway: true, + }, + }, +}); + +class RootActor extends protocol.Actor { + constructor(conn) { + super(conn, rootSpec); + + // Root actor owns itself. + this.manage(this); + this.actorID = "root"; + } + + sayHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; + } + + simpleReturn() { + return 1; + } + + promiseReturn() { + return Promise.resolve(1); + } + + simpleArgs(a, b) { + return { firstResponse: a + 1, secondResponse: b + 1 }; + } + + optionArgs(options) { + return { option1: options.option1, option2: options.option2 }; + } + + optionalArgs(a, b = 200) { + return b; + } + + arrayArgs(a) { + return a; + } + + nestedArrayArgs(a) { + return a; + } + + /** + * Test that the 'type' part of the request packet works + * correctly when the type isn't the same as the method name + */ + renamedEcho(a) { + if (this.conn.currentPacket.type != "echo") { + return "goodbye"; + } + return a; + } + + testOneWay(a) { + // Emit to show that we got this message, because there won't be a response. + EventEmitter.emit(this, "oneway", a); + } + + emitFalsyOptions() { + EventEmitter.emit(this, "falsyOptions", { zero: 0, farce: false }); + } +} + +class RootFront extends protocol.FrontClassWithSpec(rootSpec) { + constructor(client) { + super(client); + this.actorID = "root"; + // Root owns itself. + this.manage(this); + } +} +protocol.registerFront(RootFront); + +add_task(async function () { + DevToolsServer.createRootActor = conn => { + return new RootActor(conn); + }; + DevToolsServer.init(); + + protocol.types.getType("array:array:array:number"); + protocol.types.getType("array:array:array:number"); + + Assert.throws( + () => protocol.types.getType("unknown"), + /Unknown type:/, + "Should throw for unknown type" + ); + Assert.throws( + () => protocol.types.getType("array:unknown"), + /Unknown type:/, + "Should throw for unknown type" + ); + Assert.throws( + () => protocol.types.getType("unknown:number"), + /Unknown collection type:/, + "Should throw for unknown collection type" + ); + const trace = connectPipeTracing(); + const client = new DevToolsClient(trace); + + const [applicationType] = await client.connect(); + trace.expectReceive({ + from: "<actorid>", + applicationType: "xpcshell-tests", + traits: [], + }); + Assert.equal(applicationType, "xpcshell-tests"); + + const rootFront = client.mainRoot; + + let ret = await rootFront.simpleReturn(); + trace.expectSend({ type: "simpleReturn", to: "<actorid>" }); + trace.expectReceive({ value: 1, from: "<actorid>" }); + Assert.equal(ret, 1); + + ret = await rootFront.promiseReturn(); + trace.expectSend({ type: "promiseReturn", to: "<actorid>" }); + trace.expectReceive({ value: 1, from: "<actorid>" }); + Assert.equal(ret, 1); + + Assert.throws( + () => rootFront.simpleArgs(5), + /undefined passed where a value is required/, + "Should throw if simpleArgs is missing an argument." + ); + + ret = await rootFront.simpleArgs(5, 10); + trace.expectSend({ + type: "simpleArgs", + firstArg: 5, + secondArg: 10, + to: "<actorid>", + }); + trace.expectReceive({ + firstResponse: 6, + secondResponse: 11, + from: "<actorid>", + }); + Assert.equal(ret.firstResponse, 6); + Assert.equal(ret.secondResponse, 11); + + ret = await rootFront.optionArgs({ + option1: 5, + option2: 10, + }); + trace.expectSend({ + type: "optionArgs", + option1: 5, + option2: 10, + to: "<actorid>", + }); + trace.expectReceive({ option1: 5, option2: 10, from: "<actorid>" }); + Assert.equal(ret.option1, 5); + Assert.equal(ret.option2, 10); + + ret = await rootFront.optionArgs({}); + trace.expectSend({ type: "optionArgs", to: "<actorid>" }); + trace.expectReceive({ from: "<actorid>" }); + Assert.ok(typeof ret.option1 === "undefined"); + Assert.ok(typeof ret.option2 === "undefined"); + + // Explicitly call an optional argument... + ret = await rootFront.optionalArgs(5, 10); + trace.expectSend({ + type: "optionalArgs", + a: 5, + b: 10, + to: "<actorid>", + }); + trace.expectReceive({ value: 10, from: "<actorid>" }); + Assert.equal(ret, 10); + + // Now don't pass the optional argument, expect the default. + ret = await rootFront.optionalArgs(5); + trace.expectSend({ type: "optionalArgs", a: 5, to: "<actorid>" }); + trace.expectReceive({ value: 200, from: "<actorid>" }); + Assert.equal(ret, 200); + + ret = await rootFront.arrayArgs([0, 1, 2, 3, 4, 5]); + trace.expectSend({ + type: "arrayArgs", + a: [0, 1, 2, 3, 4, 5], + to: "<actorid>", + }); + trace.expectReceive({ + arrayReturn: [0, 1, 2, 3, 4, 5], + from: "<actorid>", + }); + Assert.equal(ret[0], 0); + Assert.equal(ret[5], 5); + + ret = await rootFront.arrayArgs([[5]]); + trace.expectSend({ type: "arrayArgs", a: [[5]], to: "<actorid>" }); + trace.expectReceive({ arrayReturn: [[5]], from: "<actorid>" }); + Assert.equal(ret[0][0], 5); + + const str = await rootFront.renamedEcho("hello"); + trace.expectSend({ type: "echo", a: "hello", to: "<actorid>" }); + trace.expectReceive({ value: "hello", from: "<actorid>" }); + Assert.equal(str, "hello"); + + const onOneWay = rootFront.once("oneway"); + Assert.ok(typeof rootFront.testOneWay("hello") === "undefined"); + const response = await onOneWay; + trace.expectSend({ type: "testOneWay", a: "hello", to: "<actorid>" }); + trace.expectReceive({ + type: "oneway", + a: "hello", + from: "<actorid>", + }); + Assert.equal(response, "hello"); + + const onFalsyOptions = rootFront.once("falsyOptions"); + rootFront.emitFalsyOptions(); + const res = await onFalsyOptions; + trace.expectSend({ type: "emitFalsyOptions", to: "<actorid>" }); + trace.expectReceive({ + type: "falsyOptions", + farce: false, + zero: 0, + from: "<actorid>", + }); + + Assert.ok(res.zero === 0); + Assert.ok(res.farce === false); + + await client.close(); +}); diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_stack.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_stack.js new file mode 100644 index 0000000000..faf8402ea6 --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_stack.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Client request stacks should span the entire process from before making the + * request to handling the reply from the server. The server frames are not + * included, nor can they be in most cases, since the server can be a remote + * device. + */ + +var protocol = require("resource://devtools/shared/protocol.js"); +var { RetVal } = protocol; + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + methods: { + simpleReturn: { + response: { value: RetVal() }, + }, + }, +}); + +class RootActor extends protocol.Actor { + constructor(conn) { + super(conn, rootSpec); + + // Root actor owns itself. + this.manage(this); + this.actorID = "root"; + this.sequence = 0; + } + + sayHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; + } + + simpleReturn() { + return this.sequence++; + } +} + +class RootFront extends protocol.FrontClassWithSpec(rootSpec) { + constructor(client) { + super(client); + this.actorID = "root"; + // Root owns itself. + this.manage(this); + } +} +protocol.registerFront(RootFront); + +function run_test() { + DevToolsServer.createRootActor = conn => new RootActor(conn); + DevToolsServer.init(); + + const trace = connectPipeTracing(); + const client = new DevToolsClient(trace); + let rootFront; + + client.connect().then(function onConnect() { + rootFront = client.mainRoot; + + rootFront + .simpleReturn() + .then( + () => { + let stack = Components.stack; + while (stack) { + info(stack.name); + if (stack.name.includes("onConnect")) { + // Reached back to outer function before request + ok(true, "Complete stack"); + return; + } + stack = stack.asyncCaller || stack.caller; + } + ok(false, "Incomplete stack"); + }, + () => { + ok(false, "Request failed unexpectedly"); + } + ) + .then(() => { + client.close().then(() => { + do_test_finished(); + }); + }); + }); + + do_test_pending(); +} diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_types.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_types.js new file mode 100644 index 0000000000..4a62c5e073 --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_types.js @@ -0,0 +1,65 @@ +"use strict"; + +const { types } = require("resource://devtools/shared/protocol.js"); + +function run_test() { + types.addActorType("myActor1"); + types.addActorType("myActor2"); + types.addActorType("myActor3"); + + types.addPolymorphicType("ptype1", ["myActor1", "myActor2"]); + const ptype1 = types.getType("ptype1"); + Assert.equal(ptype1.name, "ptype1"); + Assert.equal(ptype1.category, "polymorphic"); + + types.addPolymorphicType("ptype2", ["myActor1", "myActor2", "myActor3"]); + const ptype2 = types.getType("ptype2"); + Assert.equal(ptype2.name, "ptype2"); + Assert.equal(ptype2.category, "polymorphic"); + + // Polymorphic types only accept actor types + try { + types.addPolymorphicType("ptype", ["myActor1", "myActor4"]); + Assert.ok(false, "getType should fail"); + } catch (ex) { + Assert.equal(ex.toString(), "Error: Unknown type: myActor4"); + } + try { + types.addPolymorphicType("ptype", ["myActor1", "string"]); + Assert.ok(false, "getType should fail"); + } catch (ex) { + Assert.equal( + ex.toString(), + "Error: In polymorphic type 'myActor1,string', the type 'string' isn't an actor" + ); + } + try { + types.addPolymorphicType("ptype", ["myActor1", "boolean"]); + Assert.ok(false, "getType should fail"); + } catch (ex) { + Assert.equal( + ex.toString(), + "Error: In polymorphic type 'myActor1,boolean', the type 'boolean' isn't an actor" + ); + } + + // Polymorphic types are not compatible with array or nullables + try { + types.addPolymorphicType("ptype", ["array:myActor1", "myActor2"]); + Assert.ok(false, "addType should fail"); + } catch (ex) { + Assert.equal( + ex.toString(), + "Error: In polymorphic type 'array:myActor1,myActor2', the type 'array:myActor1' isn't an actor" + ); + } + try { + types.addPolymorphicType("ptype", ["nullable:myActor1", "myActor2"]); + Assert.ok(false, "addType should fail"); + } catch (ex) { + Assert.equal( + ex.toString(), + "Error: In polymorphic type 'nullable:myActor1,myActor2', the type 'nullable:myActor1' isn't an actor" + ); + } +} diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_unregister.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_unregister.js new file mode 100644 index 0000000000..060a1743b1 --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_unregister.js @@ -0,0 +1,41 @@ +"use strict"; + +const { types } = require("resource://devtools/shared/protocol.js"); + +function run_test() { + types.addType("test", { + read: v => "successful read: " + v, + write: v => "successful write: " + v, + }); + + // Verify the type registered correctly. + + const type = types.getType("test"); + const arrayType = types.getType("array:test"); + Assert.equal(type.read("foo"), "successful read: foo"); + Assert.equal(arrayType.read(["foo"])[0], "successful read: foo"); + + types.removeType("test"); + + Assert.equal(type.name, "DEFUNCT:test"); + try { + types.getType("test"); + Assert.ok(false, "getType should fail"); + } catch (ex) { + Assert.equal(ex.toString(), "Error: Unknown type: test"); + } + + try { + type.read("foo"); + Assert.ok(false, "type.read should have thrown an exception."); + } catch (ex) { + Assert.equal(ex.toString(), "Error: Using defunct type: test"); + } + + try { + arrayType.read(["foo"]); + Assert.ok(false, "array:test.read should have thrown an exception."); + } catch (ex) { + Assert.equal(ex.toString(), "Error: Using defunct type: test"); + } +} diff --git a/devtools/shared/protocol/tests/xpcshell/test_protocol_watchFronts.js b/devtools/shared/protocol/tests/xpcshell/test_protocol_watchFronts.js new file mode 100644 index 0000000000..16f98f176b --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/test_protocol_watchFronts.js @@ -0,0 +1,183 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test Front.watchFronts method. + */ + +const protocol = require("resource://devtools/shared/protocol.js"); +const { RetVal } = protocol; + +const childSpec = protocol.generateActorSpec({ + typeName: "childActor", + + methods: { + release: { + release: true, + }, + }, +}); + +class ChildActor extends protocol.Actor { + constructor(conn, id) { + super(conn, childSpec); + this.childID = id; + } + + release() {} + + form() { + return { + actor: this.actorID, + childID: this.childID, + foo: "bar", + }; + } +} + +const rootSpec = protocol.generateActorSpec({ + typeName: "root", + + methods: { + createChild: { + request: {}, + response: { actor: RetVal("childActor") }, + }, + }, +}); + +class RootActor extends protocol.Actor { + constructor(conn) { + super(conn, rootSpec); + + this.actorID = "root"; + + // Root actor owns itself. + this.manage(this); + + this.sequence = 0; + } + + sayHello() { + return { + from: "root", + applicationType: "xpcshell-tests", + traits: [], + }; + } + + createChild() { + return new ChildActor(this.conn, this.sequence++); + } +} + +class ChildFront extends protocol.FrontClassWithSpec(childSpec) { + form(form) { + this.childID = form.childID; + this.foo = form.foo; + } +} +protocol.registerFront(ChildFront); + +class RootFront extends protocol.FrontClassWithSpec(rootSpec) { + constructor(client) { + super(client); + this.actorID = "root"; + // Root owns itself. + this.manage(this); + } +} +protocol.registerFront(RootFront); + +add_task(async function run_test() { + DevToolsServer.createRootActor = conn => new RootActor(conn); + DevToolsServer.init(); + + const trace = connectPipeTracing(); + const client = new DevToolsClient(trace); + await client.connect(); + + const rootFront = client.mainRoot; + + const fronts = []; + const listener = front => { + equal( + front.foo, + "bar", + "Front's form is set before watchFronts listeners are called" + ); + fronts.push(front); + }; + rootFront.watchFronts("childActor", listener); + + const firstChild = await rootFront.createChild(); + ok( + firstChild instanceof ChildFront, + "createChild returns a ChildFront instance" + ); + equal(firstChild.childID, 0, "First child has ID=0"); + + equal( + fronts.length, + 1, + "watchFronts fires the callback, even if the front is created in the future" + ); + equal( + fronts[0], + firstChild, + "watchFronts fires the callback with the right front instance" + ); + + const watchFrontsAfter = await new Promise(resolve => { + rootFront.watchFronts("childActor", resolve); + }); + equal( + watchFrontsAfter, + firstChild, + "watchFronts fires the callback, even if the front is already created, " + + " with the same front instance" + ); + + equal( + fronts.length, + 1, + "There is still only one front reported from the first listener" + ); + + const secondChild = await rootFront.createChild(); + + equal( + fronts.length, + 2, + "After a second call to createChild, two fronts are reported" + ); + equal(fronts[1], secondChild, "And the new front is the right instance"); + + // Test unregistering a front listener + rootFront.unwatchFronts("childActor", listener); + + const thirdChild = await rootFront.createChild(); + equal( + fronts.length, + 2, + "After calling unwatchFronts, the listener is no longer called" + ); + + // Test front destruction + const destroyed = []; + rootFront.watchFronts("childActor", null, front => { + destroyed.push(front); + }); + await thirdChild.release(); + equal( + destroyed.length, + 1, + "After the destruction of the front, one destruction is reported" + ); + equal(destroyed[0], thirdChild, "And the destroyed front is the right one"); + + trace.close(); + await client.close(); +}); diff --git a/devtools/shared/protocol/tests/xpcshell/xpcshell.toml b/devtools/shared/protocol/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..f316485cb3 --- /dev/null +++ b/devtools/shared/protocol/tests/xpcshell/xpcshell.toml @@ -0,0 +1,30 @@ +[DEFAULT] +tags = "devtools" +head = "head.js" +firefox-appdir = "browser" +skip-if = ["os == 'android'"] +support-files = "" + +["test_protocol_abort.js"] + +["test_protocol_async.js"] + +["test_protocol_children.js"] + +["test_protocol_index.js"] + +["test_protocol_invalid_response.js"] + +["test_protocol_lifecycle.js"] + +["test_protocol_longstring.js"] + +["test_protocol_simple.js"] + +["test_protocol_stack.js"] + +["test_protocol_types.js"] + +["test_protocol_unregister.js"] + +["test_protocol_watchFronts.js"] diff --git a/devtools/shared/protocol/types.js b/devtools/shared/protocol/types.js new file mode 100644 index 0000000000..41764fbd79 --- /dev/null +++ b/devtools/shared/protocol/types.js @@ -0,0 +1,587 @@ +/* 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"; + +var { Actor } = require("resource://devtools/shared/protocol/Actor.js"); +var { + lazyLoadSpec, + lazyLoadFront, +} = require("resource://devtools/shared/specs/index.js"); + +/** + * Types: named marshallers/demarshallers. + * + * Types provide a 'write' function that takes a js representation and + * returns a protocol representation, and a "read" function that + * takes a protocol representation and returns a js representation. + * + * The read and write methods are also passed a context object that + * represent the actor or front requesting the translation. + * + * Types are referred to with a typestring. Basic types are + * registered by name using addType, and more complex types can + * be generated by adding detail to the type name. + */ + +var types = Object.create(null); +exports.types = types; + +var registeredTypes = (types.registeredTypes = new Map()); + +exports.registeredTypes = registeredTypes; + +/** + * Return the type object associated with a given typestring. + * If passed a type object, it will be returned unchanged. + * + * Types can be registered with addType, or can be created on + * the fly with typestrings. Examples: + * + * boolean + * threadActor + * threadActor#detail + * array:threadActor + * array:array:threadActor#detail + * + * @param [typestring|type] type + * Either a typestring naming a type or a type object. + * + * @returns a type object. + */ +types.getType = function (type) { + if (!type) { + return types.Primitive; + } + + if (typeof type !== "string") { + return type; + } + + // If already registered, we're done here. + let reg = registeredTypes.get(type); + if (reg) { + return reg; + } + + // Try to lazy load the spec, if not already loaded. + if (lazyLoadSpec(type)) { + // If a spec module was lazy loaded, it will synchronously call + // generateActorSpec, and set the type in `registeredTypes`. + reg = registeredTypes.get(type); + if (reg) { + return reg; + } + } + + // New type, see if it's a collection type: + const sep = type.indexOf(":"); + if (sep >= 0) { + const collection = type.substring(0, sep); + const subtype = types.getType(type.substring(sep + 1)); + + if (collection === "array") { + return types.addArrayType(subtype); + } else if (collection === "nullable") { + return types.addNullableType(subtype); + } + + throw Error("Unknown collection type: " + collection); + } + + // Not a collection, might be actor detail + const pieces = type.split("#", 2); + if (pieces.length > 1) { + if (pieces[1] != "actorid") { + throw new Error( + "Unsupported detail, only support 'actorid', got: " + pieces[1] + ); + } + return types.addActorDetail(type, pieces[0], pieces[1]); + } + + throw Error("Unknown type: " + type); +}; + +/** + * Don't allow undefined when writing primitive types to packets. If + * you want to allow undefined, use a nullable type. + */ +function identityWrite(v) { + if (v === undefined) { + throw Error("undefined passed where a value is required"); + } + // This has to handle iterator->array conversion because arrays of + // primitive types pass through here. + if (v && typeof v.next === "function") { + return [...v]; + } + return v; +} + +/** + * Add a type to the type system. + * + * When registering a type, you can provide `read` and `write` methods. + * + * The `read` method will be passed a JS object value from the JSON + * packet and must return a native representation. The `write` method will + * be passed a native representation and should provide a JSONable value. + * + * These methods will both be passed a context. The context is the object + * performing or servicing the request - on the server side it will be + * an Actor, on the client side it will be a Front. + * + * @param typestring name + * Name to register + * @param object typeObject + * An object whose properties will be stored in the type, including + * the `read` and `write` methods. + * @param object options + * Can specify `thawed` to prevent the type from being frozen. + * + * @returns a type object that can be used in protocol definitions. + */ +types.addType = function (name, typeObject = {}, options = {}) { + if (registeredTypes.has(name)) { + throw Error("Type '" + name + "' already exists."); + } + + const type = Object.assign( + { + toString() { + return "[protocol type:" + name + "]"; + }, + name, + primitive: !(typeObject.read || typeObject.write), + read: identityWrite, + write: identityWrite, + }, + typeObject + ); + + registeredTypes.set(name, type); + + return type; +}; + +/** + * Remove a type previously registered with the system. + * Primarily useful for types registered by addons. + */ +types.removeType = function (name) { + // This type may still be referenced by other types, make sure + // those references don't work. + const type = registeredTypes.get(name); + + type.name = "DEFUNCT:" + name; + type.category = "defunct"; + type.primitive = false; + type.read = type.write = function () { + throw new Error("Using defunct type: " + name); + }; + + registeredTypes.delete(name); +}; + +/** + * Add an array type to the type system. + * + * getType() will call this function if provided an "array:<type>" + * typestring. + * + * @param type subtype + * The subtype to be held by the array. + */ +types.addArrayType = function (subtype) { + subtype = types.getType(subtype); + + const name = "array:" + subtype.name; + + // Arrays of primitive types are primitive types themselves. + if (subtype.primitive) { + return types.addType(name); + } + return types.addType(name, { + category: "array", + read: (v, ctx) => { + if (v && typeof v.next === "function") { + v = [...v]; + } + return v.map(i => subtype.read(i, ctx)); + }, + write: (v, ctx) => { + if (v && typeof v.next === "function") { + v = [...v]; + } + return v.map(i => subtype.write(i, ctx)); + }, + }); +}; + +/** + * Add a dict type to the type system. This allows you to serialize + * a JS object that contains non-primitive subtypes. + * + * Properties of the value that aren't included in the specializations + * will be serialized as primitive values. + * + * @param object specializations + * A dict of property names => type + */ +types.addDictType = function (name, specializations) { + const specTypes = {}; + for (const prop in specializations) { + try { + specTypes[prop] = types.getType(specializations[prop]); + } catch (e) { + // Types may not be defined yet. Sometimes, we define the type *after* using it, but + // also, we have cyclic definitions on types. So lazily load them when they are not + // immediately available. + loader.lazyGetter(specTypes, prop, () => { + return types.getType(specializations[prop]); + }); + } + } + return types.addType(name, { + category: "dict", + specializations, + read: (v, ctx) => { + const ret = {}; + for (const prop in v) { + if (prop in specTypes) { + ret[prop] = specTypes[prop].read(v[prop], ctx); + } else { + ret[prop] = v[prop]; + } + } + return ret; + }, + + write: (v, ctx) => { + const ret = {}; + for (const prop in v) { + if (prop in specTypes) { + ret[prop] = specTypes[prop].write(v[prop], ctx); + } else { + ret[prop] = v[prop]; + } + } + return ret; + }, + }); +}; + +/** + * Register an actor type with the type system. + * + * Types are marshalled differently when communicating server->client + * than they are when communicating client->server. The server needs + * to provide useful information to the client, so uses the actor's + * `form` method to get a json representation of the actor. When + * making a request from the client we only need the actor ID string. + * + * This function can be called before the associated actor has been + * constructed, but the read and write methods won't work until + * the associated addActorImpl or addActorFront methods have been + * called during actor/front construction. + * + * @param string name + * The typestring to register. + */ +types.addActorType = function (name) { + // We call addActorType from: + // FrontClassWithSpec when registering front synchronously, + // generateActorSpec when defining specs, + // specs modules to register actor type early to use them in other types + if (registeredTypes.has(name)) { + return registeredTypes.get(name); + } + const type = types.addType(name, { + _actor: true, + category: "actor", + read: (v, ctx, detail) => { + // If we're reading a request on the server side, just + // find the actor registered with this actorID. + if (ctx instanceof Actor) { + return ctx.conn.getActor(v); + } + + // Reading a response on the client side, check for an + // existing front on the connection, and create the front + // if it isn't found. + const actorID = typeof v === "string" ? v : v.actor; + // `ctx.conn` is a DevToolsClient + let front = ctx.conn.getFrontByID(actorID); + + // When the type `${name}#actorid` is used, `v` is a string refering to the + // actor ID. We cannot read form information in this case and the actorID was + // already set when creating the front, so no need to do anything. + let form = null; + if (detail != "actorid") { + form = identityWrite(v); + } + + if (!front) { + // If front isn't instantiated yet, create one. + // Try lazy loading front if not already loaded. + // The front module will synchronously call `FrontClassWithSpec` and + // augment `type` with the `frontClass` attribute. + if (!type.frontClass) { + lazyLoadFront(name); + } + + const parentFront = ctx.marshallPool(); + const targetFront = parentFront.isTargetFront + ? parentFront + : parentFront.targetFront; + + // Use intermediate Class variable to please eslint requiring + // a capital letter for all constructors. + const Class = type.frontClass; + front = new Class(ctx.conn, targetFront, parentFront); + front.actorID = actorID; + + parentFront.manage(front, form, ctx); + } else if (form) { + front.form(form, ctx); + } + + return front; + }, + write: (v, ctx, detail) => { + // If returning a response from the server side, make sure + // the actor is added to a parent object and return its form. + if (v instanceof Actor) { + if (v.isDestroyed()) { + throw new Error( + `Attempted to write a response containing a destroyed actor` + ); + } + if (!v.actorID) { + ctx.marshallPool().manage(v); + } + if (detail == "actorid") { + return v.actorID; + } + return identityWrite(v.form(detail)); + } + + // Writing a request from the client side, just send the actor id. + return v.actorID; + }, + }); + return type; +}; + +types.addPolymorphicType = function (name, subtypes) { + // Assert that all subtypes are actors, as the marshalling implementation depends on that. + for (const subTypeName of subtypes) { + const subtype = types.getType(subTypeName); + if (subtype.category != "actor") { + throw new Error( + `In polymorphic type '${subtypes.join( + "," + )}', the type '${subTypeName}' isn't an actor` + ); + } + } + + return types.addType(name, { + category: "polymorphic", + read: (value, ctx) => { + // `value` is either a string which is an Actor ID or a form object + // where `actor` is an actor ID + const actorID = typeof value === "string" ? value : value.actor; + if (!actorID) { + throw new Error( + `Was expecting one of these actors '${subtypes}' but instead got value: '${value}'` + ); + } + + // Extract the typeName out of the actor ID, which should be composed like this + // ${DevToolsServerConnectionPrefix}.${typeName}${Number} + const typeName = actorID.match(/\.([a-zA-Z]+)\d+$/)[1]; + if (!subtypes.includes(typeName)) { + throw new Error( + `Was expecting one of these actors '${subtypes}' but instead got an actor of type: '${typeName}'` + ); + } + + const subtype = types.getType(typeName); + return subtype.read(value, ctx); + }, + write: (value, ctx) => { + if (!value) { + throw new Error( + `Was expecting one of these actors '${subtypes}' but instead got an empty value.` + ); + } + // value is either an `Actor` or a `Front` and both classes exposes a `typeName` + const typeName = value.typeName; + if (!typeName) { + throw new Error( + `Was expecting one of these actors '${subtypes}' but instead got value: '${value}'. Did you pass a form instead of an Actor?` + ); + } + + if (!subtypes.includes(typeName)) { + throw new Error( + `Was expecting one of these actors '${subtypes}' but instead got an actor of type: '${typeName}'` + ); + } + + const subtype = types.getType(typeName); + return subtype.write(value, ctx); + }, + }); +}; +types.addNullableType = function (subtype) { + subtype = types.getType(subtype); + return types.addType("nullable:" + subtype.name, { + category: "nullable", + read: (value, ctx) => { + if (value == null) { + return value; + } + return subtype.read(value, ctx); + }, + write: (value, ctx) => { + if (value == null) { + return value; + } + return subtype.write(value, ctx); + }, + }); +}; + +/** + * Register an actor detail type. This is just like an actor type, but + * will pass a detail hint to the actor's form method during serialization/ + * deserialization. + * + * This is called by getType() when passed an 'actorType#detail' string. + * + * @param string name + * The typestring to register this type as. + * @param type actorType + * The actor type you'll be detailing. + * @param string detail + * The detail to pass. + */ +types.addActorDetail = function (name, actorType, detail) { + actorType = types.getType(actorType); + if (!actorType._actor) { + throw Error( + `Details only apply to actor types, tried to add detail '${detail}' ` + + `to ${actorType.name}` + ); + } + return types.addType(name, { + _actor: true, + category: "detail", + read: (v, ctx) => actorType.read(v, ctx, detail), + write: (v, ctx) => actorType.write(v, ctx, detail), + }); +}; + +// Add a few named primitive types. +types.Primitive = types.addType("primitive"); +types.String = types.addType("string"); +types.Number = types.addType("number"); +types.Boolean = types.addType("boolean"); +types.JSON = types.addType("json"); + +exports.registerFront = function (cls) { + const { typeName } = cls.prototype; + if (!registeredTypes.has(typeName)) { + types.addActorType(typeName); + } + registeredTypes.get(typeName).frontClass = cls; +}; + +/** + * Instantiate a front of the given type. + * + * @param DevToolsClient client + * The DevToolsClient instance to use. + * @param string typeName + * The type name of the front to instantiate. This is defined in its specifiation. + * @returns Front + * The created front. + */ +function createFront(client, typeName, target = null) { + const type = types.getType(typeName); + if (!type) { + throw new Error(`No spec for front type '${typeName}'.`); + } else if (!type.frontClass) { + lazyLoadFront(typeName); + } + + // Use intermediate Class variable to please eslint requiring + // a capital letter for all constructors. + const Class = type.frontClass; + return new Class(client, target, target); +} + +/** + * Instantiate a global (preference, device) or target-scoped (webconsole, inspector) + * front of the given type by picking its actor ID out of either the target or root + * front's form. + * + * @param DevToolsClient client + * The DevToolsClient instance to use. + * @param string typeName + * The type name of the front to instantiate. This is defined in its specifiation. + * @param json form + * If we want to instantiate a global actor's front, this is the root front's form, + * otherwise we are instantiating a target-scoped front from the target front's form. + * @param [Target|null] target + * If we are instantiating a target-scoped front, this is a reference to the front's + * Target instance, otherwise this is null. + */ +async function getFront(client, typeName, form, target = null) { + const front = createFront(client, typeName, target); + const { formAttributeName } = front; + if (!formAttributeName) { + throw new Error(`Can't find the form attribute name for ${typeName}`); + } + // Retrieve the actor ID from root or target actor's form + front.actorID = form[formAttributeName]; + if (!front.actorID) { + throw new Error( + `Can't find the actor ID for ${typeName} from root or target` + + ` actor's form.` + ); + } + + if (!target) { + await front.manage(front); + } else { + await target.manage(front); + } + + return front; +} +exports.getFront = getFront; + +/** + * Create a RootFront. + * + * @param DevToolsClient client + * The DevToolsClient instance to use. + * @param Object packet + * @returns RootFront + */ +function createRootFront(client, packet) { + const rootFront = createFront(client, "root"); + rootFront.form(packet); + + // Root Front is a special case, managing itself as it doesn't have any parent. + // It will register itself to DevToolsClient as a Pool via Front._poolMap. + rootFront.manage(rootFront); + + return rootFront; +} +exports.createRootFront = createRootFront; diff --git a/devtools/shared/protocol/utils.js b/devtools/shared/protocol/utils.js new file mode 100644 index 0000000000..3433c2f446 --- /dev/null +++ b/devtools/shared/protocol/utils.js @@ -0,0 +1,44 @@ +/* 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"; + +/** + * Find Placeholders in the template and save them along with their + * paths. + */ +function findPlaceholders(template, constructor, path = [], placeholders = []) { + if (!template || typeof template != "object") { + return placeholders; + } + + if (template instanceof constructor) { + placeholders.push({ placeholder: template, path: [...path] }); + return placeholders; + } + + for (const name in template) { + path.push(name); + findPlaceholders(template[name], constructor, path, placeholders); + path.pop(); + } + + return placeholders; +} + +exports.findPlaceholders = findPlaceholders; + +/** + * Get the value at a given path, or undefined if not found. + */ +function getPath(obj, path) { + for (const name of path) { + if (!(name in obj)) { + return undefined; + } + obj = obj[name]; + } + return obj; +} +exports.getPath = getPath; |