Bug 1287010 - Add pathObj parameter to Schemas draft
authorRob Wu <rob@robwu.nl>
Fri, 19 Aug 2016 00:35:07 -0700
changeset 405203 0c58d0c20220169ae7b3d5a67d5870d75da2419f
parent 405202 48db819893bf2424f75b74e6675b943796128a52
child 405204 51066cfef54f236f6956fc31c17ea4dce82a7fd6
push id27432
push userbmo:rob@robwu.nl
push dateThu, 25 Aug 2016 02:36:24 +0000
bugs1287010
milestone51.0a1
Bug 1287010 - Add pathObj parameter to Schemas Local wrappers currently look up the API object over and over again whenever a schema API is invoked. This can be optimized by re-using the lookup result from a `shouldInject` invocation, which is passed as the `pathObj` parameter to the wrapper methods. This commit adds the necessary changes and tests to allow this to happen, but does not modify the wrapper in Extension.jsm yet. Also, this construction allows the `ChildAPIManager` to use a local implementation if available and fall back to a remote implementation otherwise. MozReview-Commit-ID: C9gm7A9Zppb
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
toolkit/components/extensions/test/xpcshell/test_ext_schemas_pathObj.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -624,25 +624,25 @@ GlobalManager = {
       get cloneScope() {
         return context.cloneScope;
       },
 
       hasPermission(permission) {
         return context.extension.hasPermission(permission);
       },
 
-      callFunction(path, name, args) {
+      callFunction(pathObj, path, name, args) {
         return findPathInObject(apis, path)[name](...args);
       },
 
-      callFunctionNoReturn(path, name, args) {
+      callFunctionNoReturn(pathObj, path, name, args) {
         findPathInObject(apis, path)[name](...args);
       },
 
-      callAsyncFunction(path, name, args, callback) {
+      callAsyncFunction(pathObj, path, name, args, callback) {
         // We pass an empty stub function as a default callback for
         // the `chrome` API, so promise objects are not returned,
         // and lastError values are reported immediately.
         if (callback === null) {
           callback = defaultCallback;
         }
 
         let promise;
@@ -657,31 +657,31 @@ GlobalManager = {
 
       shouldInject(namespace, name) {
         if (namespaces && !namespaces.includes(namespace)) {
           return false;
         }
         return findPathInObject(apis, [namespace]) != null;
       },
 
-      getProperty(path, name) {
+      getProperty(pathObj, path, name) {
         return findPathInObject(apis, path)[name];
       },
 
-      setProperty(path, name, value) {
+      setProperty(pathObj, path, name, value) {
         findPathInObject(apis, path)[name] = value;
       },
 
-      addListener(path, name, listener, args) {
+      addListener(pathObj, path, name, listener, args) {
         findPathInObject(apis, path)[name].addListener.call(null, listener, ...args);
       },
-      removeListener(path, name, listener) {
+      removeListener(pathObj, path, name, listener) {
         findPathInObject(apis, path)[name].removeListener.call(null, listener);
       },
-      hasListener(path, name, listener) {
+      hasListener(pathObj, path, name, listener) {
         return findPathInObject(apis, path)[name].hasListener.call(null, listener);
       },
     };
     Schemas.inject(dest, schemaWrapper);
   },
 
   observe(document, topic, data) {
     let contentWindow = document.defaultView;
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -1475,28 +1475,28 @@ class ChildAPIManager {
   close() {
     this.messageManager.sendAsyncMessage("API:CloseProxyContext", {childId: this.id});
   }
 
   get cloneScope() {
     return this.context.cloneScope;
   }
 
-  callFunction(path, name, args) {
+  callFunction(pathObj, path, name, args) {
     throw new Error("Not implemented");
   }
 
-  callFunctionNoReturn(path, name, args) {
+  callFunctionNoReturn(pathObj, path, name, args) {
     this.messageManager.sendAsyncMessage("API:Call", {
       childId: this.id,
       path, name, args,
     });
   }
 
-  callAsyncFunction(path, name, args, callback) {
+  callAsyncFunction(pathObj, path, name, args, callback) {
     let callId = nextId++;
     let deferred = PromiseUtils.defer();
     this.callPromises.set(callId, deferred);
 
     this.messageManager.sendAsyncMessage("API:Call", {
       childId: this.id,
       callId,
       path, name, args,
@@ -1508,25 +1508,25 @@ class ChildAPIManager {
   shouldInject(namespace, name) {
     return this.namespaces.includes(namespace);
   }
 
   hasPermission(permission) {
     return this.context.extension.permissions.has(permission);
   }
 
-  getProperty(path, name) {
+  getProperty(pathObj, path, name) {
     throw new Error("Not implemented");
   }
 
-  setProperty(path, name, value) {
+  setProperty(pathObj, path, name, value) {
     throw new Error("Not implemented");
   }
 
-  addListener(path, name, listener, args) {
+  addListener(pathObj, path, name, listener, args) {
     let ref = path.concat(name).join(".");
     let set;
     if (this.listeners.has(ref)) {
       set = this.listeners.get(ref);
     } else {
       set = new Set();
       this.listeners.set(ref, set);
     }
@@ -1538,30 +1538,30 @@ class ChildAPIManager {
 
       this.messageManager.sendAsyncMessage("API:AddListener", {
         childId: this.id,
         path, name, args,
       });
     }
   }
 
-  removeListener(path, name, listener) {
+  removeListener(pathObj, path, name, listener) {
     let ref = path.concat(name).join(".");
     let set = this.listeners.get(ref) || new Set();
     set.remove(listener);
 
     if (set.size == 0) {
       this.messageManager.sendAsyncMessage("Extension:RemoveListener", {
         childId: this.id,
         path, name,
       });
     }
   }
 
-  hasListener(path, name, listener) {
+  hasListener(pathObj, path, name, listener) {
     let ref = path.concat(name).join(".");
     let set = this.listeners.get(ref) || new Set();
     return set.has(listener);
   }
 }
 
 /**
  * Convert any of several different representations of a date/time to a Date object.
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -89,16 +89,17 @@ const CONTEXT_FOR_VALIDATION = [
   "hasPermission",
   "logError",
 ];
 
 // Methods of Context that are used by Schemas.inject.
 // Callers of Schemas.inject should implement all of these methods.
 const CONTEXT_FOR_INJECTION = [
   ...CONTEXT_FOR_VALIDATION,
+  "shouldInject",
   "callFunction",
   "callFunctionNoReturn",
   "callAsyncFunction",
   "getProperty",
   "setProperty",
 
   "addListener",
   "hasListener",
@@ -335,118 +336,154 @@ class Context {
  * as defined in the schema. Otherwise an error is reported to the context.
  */
 class InjectionContext extends Context {
   constructor(params) {
     super(params, CONTEXT_FOR_INJECTION);
   }
 
   /**
+   * Called before injecting an API. The return value is used to determine
+   * whether to inject the API, and if so what the value of the `pathObj`
+   * parameter should be when the methods of this interface are called.
+   * - If falsey, the API is not injected.
+   * - If `true`, the API is injected and the `pathObj` parameter is `null`.
+   * - If an object, the `pathObj` parameter is this object. The object SHOULD
+   *   have a property `name`.
+   *
+   * With the above contract, a local API implementation can simply be
+   * implemented as follows:
+   *
+   *    callFunction(pathObj, path, name, args) {
+   *      if (pathObj)
+   *        return pathObj[name];
+   *      // else the local API does not exist, so fall back or throw an error.
+   *    }
+   *
+   * @abstract
+   * @param {string} namespace The namespace of the API. This may contain dots,
+   *     e.g. in the case of "devtools.inspectedWindow".
+   * @param {string} [name] The name of the property in the namespace.
+   * @returns {*} An object with the property `name`, `true` or a falsey value.
+   */
+  shouldInject(namespace, name) {
+    throw new Error("Not implemented");
+  }
+
+  /**
    * Calls function `path`.`name` and returns its return value.
    *
    * @abstract
+   * @param {*} pathObj See `shouldInject`.
    * @param {Array<string>} path The API path, e.g. `["storage", "local"]`.
    * @param {string} name The method name, e.g. "get".
    * @param {Array} args The parameters for the function.
    * @returns {*} The return value of the invoked function.
    */
-  callFunction(path, name, args) {
+  callFunction(pathObj, path, name, args) {
     throw new Error("Not implemented");
   }
 
   /**
    * Calls function `path`.`name` and ignores its return value.
    *
    * @abstract
+   * @param {*} pathObj See `shouldInject`.
    * @param {Array<string>} path The API path, e.g. `["storage", "local"]`.
    * @param {string} name The method name, e.g. "get".
    * @param {Array} args The parameters for the function.
    */
-  callFunctionNoReturn(path, name, args) {
+  callFunctionNoReturn(pathObj, path, name, args) {
     throw new Error("Not implemented");
   }
 
   /**
    * Call function `path`.`name` that completes asynchronously.
    *
    * @abstract
+   * @param {*} pathObj See `shouldInject`.
    * @param {Array<string>} path The API path, e.g. `["storage", "local"]`.
    * @param {string} name The method name, e.g. "get".
    * @param {Array} args The parameters for the function.
    * @param {function(*)} [callback] The callback to be called when the function
    *     completes.
    * @returns {Promise|undefined} Must be void if `callback` is set, and a
    *     promise otherwise. The promise is resolved when the function completes.
    */
-  callAsyncFunction(path, name, args, callback) {
+  callAsyncFunction(pathObj, path, name, args, callback) {
     throw new Error("Not implemented");
   }
 
   /**
    * Retrieves the value of property `path`.`name`.
    *
    * @abstract
+   * @param {*} pathObj See `shouldInject`.
    * @param {Array<string>} path The API path, e.g. `["storage", "local"]`.
    * @param {string} name The property name.
    * @returns {*} The value of the property.
    */
-  getProperty(path, name) {
+  getProperty(pathObj, path, name) {
     throw new Error("Not implemented");
   }
 
   /**
    * Assigns the value of property `path`.`name`.
    *
    * @abstract
+   * @param {*} pathObj See `shouldInject`.
    * @param {Array<string>} path The API path, e.g. `["storage", "local"]`.
    * @param {string} name The property name.
    * @param {string} value The new value of the property.
    */
-  setProperty(path, name, value) {
+  setProperty(pathObj, path, name, value) {
     throw new Error("Not implemented");
   }
 
   /**
    * Registers `listener` for event `path`.`name`.
    *
    * @abstract
+   * @param {*} pathObj See `shouldInject`.
    * @param {Array<string>} path The API path, e.g. `["storage", "local"]`.
    * @param {string} name The event name, e.g. "onChanged"
    * @param {function} listener The callback to be called when the event fires.
    * @param {Array} args Extra parameters for EventManager.addListener.
    * @see EventManager.addListener
    */
-  addListener(path, name, listener, args) {
+  addListener(pathObj, path, name, listener, args) {
     throw new Error("Not implemented");
   }
 
   /**
    * Checks whether `listener` is listening to event `path`.`name`.
    *
    * @abstract
+   * @param {*} pathObj See `shouldInject`.
    * @param {Array<string>} path The API path, e.g. `["storage", "local"]`.
    * @param {string} name The event name, e.g. "onChanged"
    * @param {function} listener The event listener.
    * @returns {boolean} Whether `listener` was added to event `path`.`name`.
    * @see EventManager.hasListener
    */
-  hasListener(path, name, listener) {
+  hasListener(pathObj, path, name, listener) {
     throw new Error("Not implemented");
   }
 
   /**
    * Unregisters `listener` from event `path`.`name`.
    *
    * @abstract
+   * @param {*} pathObj See `shouldInject`.
    * @param {Array<string>} path The API path, e.g. `["storage", "local"]`.
    * @param {string} name The event name, e.g. "onChanged"
    * @param {function} listener The event listener.
    * @see EventManager.removeListener
    */
-  removeListener(path, name, listener) {
+  removeListener(pathObj, path, name, listener) {
     throw new Error("Not implemented");
   }
 }
 
 
 /**
  * The methods in this singleton represent the "format" specifier for
  * JSON Schema string types.
@@ -607,22 +644,23 @@ class Entry {
   }
 
   /**
    * Injects JS values for the entry into the extension API
    * namespace. The default implementation is to do nothing.
    * `context` is used to call the actual implementation
    * of a given function or event.
    *
+   * @param {*} pathObj See `shouldInject`.
    * @param {Array<string>} path The API path, e.g. `["storage", "local"]`.
    * @param {string} name The method name, e.g. "get".
    * @param {object} dest The object where `path`.`name` should be stored.
    * @param {InjectionContext} context
    */
-  inject(path, name, dest, context) {
+  inject(pathObj, path, name, dest, context) {
   }
 }
 
 // Corresponds either to a type declared in the "types" section of the
 // schema or else to any type object used throughout the schema.
 class Type extends Entry {
   // Takes a value, checks that it has the correct type, and returns a
   // "normalized" version of the value. The normalized version will
@@ -803,17 +841,17 @@ class StringType extends Type {
 
     return r;
   }
 
   checkBaseType(baseType) {
     return baseType == "string";
   }
 
-  inject(path, name, dest, context) {
+  inject(pathObj, path, name, dest, context) {
     if (this.enumeration) {
       let obj = Cu.createObjectIn(dest, {defineAs: name});
       for (let e of this.enumeration) {
         let key = e.toUpperCase();
         obj[key] = e;
       }
     }
   }
@@ -1120,17 +1158,17 @@ class FunctionType extends Type {
 // particular value. Essentially this is a constant.
 class ValueProperty extends Entry {
   constructor(schema, name, value) {
     super(schema);
     this.name = name;
     this.value = value;
   }
 
-  inject(path, name, dest, context) {
+  inject(pathObj, path, name, dest, context) {
     dest[name] = this.value;
   }
 }
 
 // Represents a "property" defined in a schema namespace that is not a
 // constant.
 class TypeProperty extends Entry {
   constructor(schema, namespaceName, name, type, writable) {
@@ -1140,41 +1178,41 @@ class TypeProperty extends Entry {
     this.type = type;
     this.writable = writable;
   }
 
   throwError(context, msg) {
     throw context.makeError(`${msg} for ${this.namespaceName}.${this.name}.`);
   }
 
-  inject(path, name, dest, context) {
+  inject(pathObj, path, name, dest, context) {
     if (this.unsupported) {
       return;
     }
 
     let getStub = () => {
       this.checkDeprecated(context);
-      return context.getProperty(path, name);
+      return context.getProperty(pathObj, path, name);
     };
 
     let desc = {
       configurable: false,
       enumerable: true,
 
       get: Cu.exportFunction(getStub, dest),
     };
 
     if (this.writable) {
       let setStub = (value) => {
         let normalized = this.type.normalize(value, context);
         if (normalized.error) {
           this.throwError(context, normalized.error);
         }
 
-        context.setProperty(path, name, normalized.value);
+        context.setProperty(pathObj, path, name, normalized.value);
       };
 
       desc.set = Cu.exportFunction(setStub, dest);
     }
 
     Object.defineProperty(dest, name, desc);
   }
 }
@@ -1193,33 +1231,38 @@ class SubModuleProperty extends Entry {
   constructor(name, namespaceName, reference, properties) {
     super();
     this.name = name;
     this.namespaceName = namespaceName;
     this.reference = reference;
     this.properties = properties;
   }
 
-  inject(path, name, dest, context) {
+  inject(pathObj, path, name, dest, context) {
     let obj = Cu.createObjectIn(dest, {defineAs: name});
 
     let ns = Schemas.namespaces.get(this.namespaceName);
     let type = ns.get(this.reference);
     if (!type && this.reference.includes(".")) {
       let [namespaceName, ref] = this.reference.split(".");
       ns = Schemas.namespaces.get(namespaceName);
       type = ns.get(ref);
     }
     if (!type || !(type instanceof SubModuleType)) {
       throw new Error(`Internal error: ${this.namespaceName}.${this.reference} is not a sub-module`);
     }
 
     let functions = type.functions;
     for (let fun of functions) {
-      fun.inject(path.concat(name), fun.name, obj, context);
+      let subpath = path.concat(name);
+      let pathObj = context.shouldInject(subpath.join("."), fun.name);
+      if (pathObj) {
+        pathObj = pathObj === true ? null : pathObj;
+        fun.inject(pathObj, subpath, fun.name, obj, context);
+      }
     }
 
     // TODO: Inject this.properties.
   }
 }
 
 // This class is a base class for FunctionEntrys and Events. It takes
 // care of validating parameter lists (i.e., handling of optional
@@ -1317,17 +1360,17 @@ class FunctionEntry extends CallEntry {
     this.unsupported = unsupported;
     this.returns = returns;
     this.permissions = permissions;
 
     this.isAsync = type.isAsync;
     this.hasAsyncCallback = type.hasAsyncCallback;
   }
 
-  inject(path, name, dest, context) {
+  inject(pathObj, path, name, dest, context) {
     if (this.unsupported) {
       return;
     }
 
     if (this.permissions && !this.permissions.some(perm => context.hasPermission(perm))) {
       return;
     }
 
@@ -1335,29 +1378,29 @@ class FunctionEntry extends CallEntry {
     if (this.isAsync) {
       stub = (...args) => {
         this.checkDeprecated(context);
         let actuals = this.checkParameters(args, context);
         let callback = null;
         if (this.hasAsyncCallback) {
           callback = actuals.pop();
         }
-        return context.callAsyncFunction(path, name, actuals, callback);
+        return context.callAsyncFunction(pathObj, path, name, actuals, callback);
       };
     } else if (!this.returns) {
       stub = (...args) => {
         this.checkDeprecated(context);
         let actuals = this.checkParameters(args, context);
-        return context.callFunctionNoReturn(path, name, actuals);
+        return context.callFunctionNoReturn(pathObj, path, name, actuals);
       };
     } else {
       stub = (...args) => {
         this.checkDeprecated(context);
         let actuals = this.checkParameters(args, context);
-        return context.callFunction(path, name, actuals);
+        return context.callFunction(pathObj, path, name, actuals);
       };
     }
     Cu.exportFunction(stub, dest, {defineAs: name});
   }
 }
 
 // Represents an "event" defined in a schema namespace.
 class Event extends CallEntry {
@@ -1371,39 +1414,39 @@ class Event extends CallEntry {
   checkListener(listener, context) {
     let r = this.type.normalize(listener, context);
     if (r.error) {
       this.throwError(context, "Invalid listener");
     }
     return r.value;
   }
 
-  inject(path, name, dest, context) {
+  inject(pathObj, path, name, dest, context) {
     if (this.unsupported) {
       return;
     }
 
     if (this.permissions && !this.permissions.some(perm => context.hasPermission(perm))) {
       return;
     }
 
     let addStub = (listener, ...args) => {
       listener = this.checkListener(listener, context);
       let actuals = this.checkParameters(args, context);
-      context.addListener(this.path, name, listener, actuals);
+      context.addListener(pathObj, this.path, name, listener, actuals);
     };
 
     let removeStub = (listener) => {
       listener = this.checkListener(listener, context);
-      context.removeListener(this.path, name, listener);
+      context.removeListener(pathObj, this.path, name, listener);
     };
 
     let hasStub = (listener) => {
       listener = this.checkListener(listener, context);
-      return context.hasListener(this.path, name, listener);
+      return context.hasListener(pathObj, this.path, name, listener);
     };
 
     let obj = Cu.createObjectIn(dest, {defineAs: name});
     Cu.exportFunction(addStub, obj, {defineAs: "addListener"});
     Cu.exportFunction(removeStub, obj, {defineAs: "removeListener"});
     Cu.exportFunction(hasStub, obj, {defineAs: "hasListener"});
   }
 }
@@ -1822,18 +1865,20 @@ this.Schemas = {
 
     for (let [namespace, ns] of this.namespaces) {
       if (ns.permissions && !ns.permissions.some(perm => context.hasPermission(perm))) {
         continue;
       }
 
       let obj = Cu.createObjectIn(dest, {defineAs: namespace});
       for (let [name, entry] of ns) {
-        if (wrapperFuncs.shouldInject(namespace, name)) {
-          entry.inject([namespace], name, obj, context);
+        let pathObj = context.shouldInject(namespace, name);
+        if (pathObj) {
+          pathObj = pathObj === true ? null : pathObj;
+          entry.inject(pathObj, [namespace], name, obj, context);
         }
       }
 
       // Remove the namespace object if it is empty
       if (!Object.keys(obj).length) {
         delete dest[namespace];
         // process the next namespace.
         continue;
--- a/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
@@ -402,49 +402,49 @@ let wrapper = {
   logError(message) {
     talliedErrors.push(message);
   },
 
   hasPermission(permission) {
     return permissions.has(permission);
   },
 
-  callFunction(path, name, args) {
+  callFunction(pathObj, path, name, args) {
     let ns = path.join(".");
     tally("call", ns, name, args);
   },
 
-  callFunctionNoReturn(path, name, args) {
+  callFunctionNoReturn(pathObj, path, name, args) {
     let ns = path.join(".");
     tally("call", ns, name, args);
   },
 
   shouldInject(ns) {
     return ns != "do-not-inject";
   },
 
-  getProperty(path, name) {
+  getProperty(pathObj, path, name) {
     let ns = path.join(".");
     tally("get", ns, name);
   },
 
-  setProperty(path, name, value) {
+  setProperty(pathObj, path, name, value) {
     let ns = path.join(".");
     tally("set", ns, name, value);
   },
 
-  addListener(path, name, listener, args) {
+  addListener(pathObj, path, name, listener, args) {
     let ns = path.join(".");
     tally("addListener", ns, name, [listener, args]);
   },
-  removeListener(path, name, listener) {
+  removeListener(pathObj, path, name, listener) {
     let ns = path.join(".");
     tally("removeListener", ns, name, [listener]);
   },
-  hasListener(path, name, listener) {
+  hasListener(pathObj, path, name, listener) {
     let ns = path.join(".");
     tally("hasListener", ns, name, [listener]);
   },
 };
 
 add_task(function* () {
   let url = "data:," + JSON.stringify(json);
   yield Schemas.load(url);
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_pathObj.js
@@ -0,0 +1,153 @@
+"use strict";
+
+Components.utils.import("resource://gre/modules/Schemas.jsm");
+
+function generateNsWithPropValue(val) {
+  let count = 0;
+  return {
+    get count() { return count; },
+    get prop1() {
+      ++count;
+      return val;
+    },
+  };
+}
+
+let pathObjApiJson = [
+  {
+    namespace: "ret_false",
+    properties: {
+      // Nb. Somehow a string is a valid value for the "object" type.
+      prop1: {type: "object", "value": "value of ret-false"},
+    },
+  },
+  {
+    namespace: "ret_false_noval",
+    properties: {
+      prop1: {type: "object"},
+    },
+  },
+  {
+    namespace: "dot.obj",
+    properties: {
+      prop1: {type: "object"},
+    },
+  },
+  {
+    namespace: "ret_obj",
+    properties: {
+      prop1: {type: "object"},
+    },
+  },
+  {
+    namespace: "ret_true",
+    properties: {
+      prop1: {type: "object", value: "value of ret-true"},
+    },
+  },
+  {
+    namespace: "ret_true_noval",
+    properties: {
+      prop1: {type: "object"},
+    },
+  },
+  {
+    namespace: "with_submodule",
+    types: [{
+      id: "subtype",
+      type: "object",
+      // Properties in submodules are not supported (yet), so we use functions.
+      functions: [{
+        name: "retObj",
+        type: "function",
+        parameters: [],
+        returns: {type: "string"},
+      }, {
+        name: "retFalse",
+        type: "function",
+        parameters: [],
+        returns: {type: "string"},
+      }, {
+        name: "retTrue",
+        type: "function",
+        parameters: [],
+        returns: {type: "string"},
+      }],
+    }],
+    properties: {
+      mySub: {$ref: "subtype"},
+    },
+  },
+];
+add_task(function* testPathObj() {
+  let url = "data:," + JSON.stringify(pathObjApiJson);
+  yield Schemas.load(url);
+
+  let localApi = generateNsWithPropValue("localApi value");
+  let dotApi = generateNsWithPropValue("dotApi value");
+  let submoduleApi = {
+    _count: 0,
+    retObj() {
+      return "submoduleApi value " + (++submoduleApi._count);
+    },
+  };
+  let localWrapper = {
+    shouldInject(ns, name) {
+      if (ns == "ret_obj") {
+        return localApi;
+      } else if (ns == "dot.obj") {
+        return dotApi;
+      } else if (ns == "ret_true" || ns == "ret_true_noval") {
+        return true;
+      } else if (ns == "ret_false" || ns == "ret_false_noval") {
+        return false;
+      } else if (ns == "with_submodule") {
+        if (name == "subtype" || name == "mySub") {
+          return true;
+        }
+      } else if (ns == "with_submodule.mySub") {
+        if (name == "retTrue") {
+          return true;
+        } else if (name == "retFalse") {
+          return false;
+        } else if (name == "retObj") {
+          return submoduleApi;
+        }
+      }
+      throw new Error(`Unexpected shouldInject call: ${ns} ${name}`);
+    },
+    getProperty(pathObj, path, name) {
+      if (pathObj) {
+        return pathObj[name];
+      }
+      return "fallback,pathObj=" + pathObj;
+    },
+    callFunction(pathObj, path, name, args) {
+      if (pathObj) {
+        return pathObj[name](...args);
+      }
+      return "fallback call,pathObj=" + pathObj;
+    },
+  };
+
+  let root = {};
+  Schemas.inject(root, localWrapper);
+
+  do_check_eq(0, localApi.count);
+  do_check_eq(0, dotApi.count);
+  do_check_eq(Object.keys(root).sort().join(),
+      "dot,ret_obj,ret_true,ret_true_noval,with_submodule");
+
+  do_check_eq("fallback,pathObj=null", root.ret_true_noval.prop1);
+  do_check_eq("value of ret-true", root.ret_true.prop1);
+  do_check_eq("localApi value", root.ret_obj.prop1);
+  do_check_eq(1, localApi.count);
+  do_check_eq("dotApi value", root.dot.obj.prop1);
+  do_check_eq(1, dotApi.count);
+
+  do_check_eq(Object.keys(root.with_submodule).sort().join(), "mySub");
+  let mySub = root.with_submodule.mySub;
+  do_check_eq(Object.keys(mySub).sort().join(), "retObj,retTrue");
+  do_check_eq("fallback call,pathObj=null", mySub.retTrue());
+  do_check_eq("submoduleApi value 1", mySub.retObj());
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -39,15 +39,16 @@ skip-if = release_build
 [test_ext_onmessage_removelistener.js]
 [test_ext_runtime_connect_no_receiver.js]
 [test_ext_runtime_getPlatformInfo.js]
 [test_ext_runtime_sendMessage.js]
 [test_ext_runtime_sendMessage_errors.js]
 [test_ext_runtime_sendMessage_no_receiver.js]
 [test_ext_schemas.js]
 [test_ext_schemas_api_injection.js]
+[test_ext_schemas_pathObj.js]
 [test_ext_simple.js]
 [test_ext_storage.js]
 [test_getAPILevelForWindow.js]
 [test_locale_converter.js]
 [test_locale_data.js]
 [test_native_messaging.js]
 skip-if = os == "android"