Bug 1208257 - [webext] Add basic schema support (r=kmag)
authorBill McCloskey <billm@mozilla.com>
Thu, 19 Nov 2015 13:54:46 -0800
changeset 309947 5c255680866961999617036aa5b6658908d111f2
parent 309946 deda2ab537340b98f7cdceac54124f63821276d8
child 309948 d648b84b5aa7bad8b12111fa7d4985772e4f9989
push id5513
push userraliiev@mozilla.com
push dateMon, 25 Jan 2016 13:55:34 +0000
treeherdermozilla-beta@5ee97dd05b5c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskmag
bugs1208257
milestone45.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1208257 - [webext] Add basic schema support (r=kmag)
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/moz.build
toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -0,0 +1,736 @@
+/* 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 Ci = Components.interfaces;
+const Cc = Components.classes;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+this.EXPORTED_SYMBOLS = ["Schemas"];
+
+Cu.import("resource://gre/modules/NetUtil.jsm");
+
+function readJSON(uri)
+{
+  return new Promise((resolve, reject) => {
+    NetUtil.asyncFetch({uri, loadUsingSystemPrincipal: true}, (inputStream, status) => {
+      if (!Components.isSuccessCode(status)) {
+        reject(new Error(status));
+        return;
+      }
+      try {
+        let text = NetUtil.readInputStreamToString(inputStream, inputStream.available());
+
+        // Chrome JSON files include a license comment that we need to
+        // strip off for this to be valid JSON. As a hack, we just
+        // look for the first '[' character, which signals the start
+        // of the JSON content.
+        let index = text.indexOf("[");
+        text = text.slice(index);
+
+        resolve(JSON.parse(text));
+      } catch (e) {
+        reject(e);
+      }
+    });
+  });
+}
+
+function getValueBaseType(value)
+{
+  let t = typeof(value);
+  if (t == "object") {
+    if (value === null) {
+      return "null";
+    } else if (Array.isArray(value)) {
+      return "array";
+    } else if (Object.prototype.toString.call(value) == "[object ArrayBuffer]") {
+      return "binary";
+    }
+  } else if (t == "number") {
+    if (value % 1 == 0) {
+      return "integer";
+    }
+  }
+  return t;
+}
+
+// Schema files contain namespaces, and each namespace contains types,
+// properties, functions, and events. An Entry is a base class for
+// types, properties, functions, and events.
+class Entry {
+  // Injects JS values for the entry into the extension API
+  // namespace. The default implementation is to do
+  // nothing. |wrapperFuncs| is used to call the actual implementation
+  // of a given function or event. It's an object with properties
+  // callFunction, addListener, removeListener, and hasListener.
+  inject(name, dest, wrapperFuncs) {
+  }
+};
+
+// 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
+  // include "nulls" in place of omitted optional properties. The
+  // result of this function is either {error: "Some type error"} or
+  // {value: <normalized-value>}.
+  normalize(value) {
+    return {error: "invalid type"};
+  }
+
+  // Unlike normalize, this function does a shallow check to see if
+  // |baseType| (one of the possible getValueBaseType results) is
+  // valid for this type. It returns true or false. It's used to fill
+  // in optional arguments to functions before actually type checking
+  // the arguments.
+  checkBaseType(baseType) {
+    return false;
+  }
+
+  // Helper method that simply relies on checkBaseType to implement
+  // normalize. Subclasses can choose to use it or not.
+  normalizeBase(type, value) {
+    if (this.checkBaseType(getValueBaseType(value))) {
+      return {value};
+    }
+    return {error: `Expected ${type} instead of ${JSON.stringify(value)}`};
+  }
+};
+
+// Type that allows any value.
+class AnyType extends Type {
+  normalize(value) {
+    return {value};
+  }
+
+  checkBaseType(baseType) {
+    return true;
+  }
+};
+
+// An untagged union type.
+class ChoiceType extends Type {
+  constructor(choices) {
+    super();
+    this.choices = choices;
+  }
+
+  normalize(value) {
+    for (let choice of this.choices) {
+      let r = choice.normalize(value);
+      if (!r.error) {
+        return r;
+      }
+    }
+  }
+
+  checkBaseType(baseType) {
+    return this.choices.some(t => t.checkBaseType(baseType));
+  }
+};
+
+// This is a reference to another type--essentially a typedef.
+// FIXME
+class RefType extends Type {
+  constructor(namespaceName, reference) {
+    super();
+    this.namespaceName = namespaceName;
+    this.reference = reference;
+  }
+
+  normalize(value) {
+    let ns = Schemas.namespaces.get(this.namespaceName);
+    let type = ns.get(this.reference);
+    if (!type) {
+      throw new Error(`Internal error: Type ${this.reference} not found`);
+    }
+    return type.normalize(value);
+  }
+
+  checkBaseType(baseType) {
+    let ns = Schemas.namespaces.get(this.namespaceName);
+    let type = ns.get(this.reference);
+    if (!type) {
+      throw new Error(`Internal error: Type ${this.reference} not found`);
+    }
+    return type.checkBaseType(baseType);
+  }
+};
+
+class StringType extends Type {
+  constructor(enumeration, minLength, maxLength) {
+    super();
+    this.enumeration = enumeration;
+    this.minLength = minLength;
+    this.maxLength = maxLength;
+  }
+
+  normalize(value) {
+    let r = this.normalizeBase("string", value);
+    if (r.error) {
+      return r;
+    }
+
+    if (this.enumeration) {
+      if (this.enumeration.includes(value)) {
+        return {value};
+      }
+      return {error: `Invalid enumeration value ${JSON.stringify(value)}`};
+    }
+
+    if (value.length < this.minLength) {
+      return {error: `String ${JSON.stringify(value)} is too short (must be ${this.minLength})`};
+    }
+    if (value.length > this.maxLength) {
+      return {error: `String ${JSON.stringify(value)} is too long (must be ${this.maxLength})`};
+    }
+
+    return r;
+  }
+
+  checkBaseType(baseType) {
+    return baseType == "string";
+  }
+
+  inject(name, dest, wrapperFuncs) {
+    if (this.enumeration) {
+      let obj = Cu.createObjectIn(dest, {defineAs: name});
+      for (let e of this.enumeration) {
+        let key = e.toUpperCase();
+        obj[key] = e;
+      }
+    }
+  }
+};
+
+class UnrestrictedObjectType extends Type {
+  normalize(value) {
+    return this.normalizeBase("object", value);
+  }
+
+  checkBaseType(baseType) {
+    return baseType == "object";
+  }
+};
+
+class ObjectType extends Type {
+  constructor(properties, additionalProperties) {
+    super();
+    this.properties = properties;
+    this.additionalProperties = additionalProperties;
+  }
+
+  checkBaseType(baseType) {
+    return baseType == "object";
+  }
+
+  normalize(value) {
+    let v = this.normalizeBase("object", value);
+    if (v.error) {
+      return v;
+    }
+
+    let result = {};
+    for (let prop of Object.keys(this.properties)) {
+      let {type, optional, unsupported} = this.properties[prop];
+      if (unsupported) {
+        if (prop in value) {
+          return {error: `Property "${prop}" is unsupported by Firefox`};
+        }
+      } else if (prop in value) {
+        if (optional && (value[prop] === null || value[prop] === undefined)) {
+          result[prop] = null;
+        } else {
+          let r = type.normalize(value[prop]);
+          if (r.error) {
+            return r;
+          }
+          result[prop] = r.value;
+        }
+      } else if (!optional) {
+        return {error: `Property "${prop}" is required`};
+      } else {
+        result[prop] = null;
+      }
+    }
+
+    for (let prop of Object.keys(value)) {
+      if (!(prop in this.properties)) {
+        if (this.additionalProperties) {
+          let r = this.additionalProperties.normalize(value[prop]);
+          if (r.error) {
+            return r;
+          }
+          result[prop] = r.value;
+        } else {
+          return {error: `Unexpected property "${prop}"`};
+        }
+      }
+    }
+
+    return {value: result};
+  }
+};
+
+class NumberType extends Type {
+  normalize(value) {
+    let r = this.normalizeBase("number", value);
+    if (r.error) {
+      return r;
+    }
+
+    if (isNaN(value) || !Number.isFinite(value)) {
+      return {error: "NaN or infinity are not valid"};
+    }
+
+    return r;
+  }
+
+  checkBaseType(baseType) {
+    return baseType == "number" || baseType == "integer";
+  }
+};
+
+class IntegerType extends Type {
+  constructor(minimum, maximum) {
+    super();
+    this.minimum = minimum;
+    this.maximum = maximum;
+  }
+
+  normalize(value) {
+    let r = this.normalizeBase("integer", value);
+    if (r.error) {
+      return r;
+    }
+
+    // Ensure it's between -2**31 and 2**31-1
+    if ((value | 0) !== value) {
+      return {error: "Integer is out of range"};
+    }
+
+    if (value < this.minimum) {
+      return {error: `Integer ${value} is too small (must be at least ${this.minimum})`};
+    }
+    if (value > this.maximum) {
+      return {error: `Integer ${value} is too big (must be at most ${this.maximum})`};
+    }
+
+    return r;
+  }
+
+  checkBaseType(baseType) {
+    return baseType == "integer";
+  }
+};
+
+class BooleanType extends Type {
+  normalize(value) {
+    return this.normalizeBase("boolean", value);
+  }
+
+  checkBaseType(baseType) {
+    return baseType == "boolean";
+  }
+};
+
+class ArrayType extends Type {
+  constructor(itemType) {
+    super();
+    this.itemType = itemType;
+  }
+
+  normalize(value) {
+    let v = this.normalizeBase("array", value);
+    if (v.error) {
+      return v;
+    }
+
+    let result = [];
+    for (let element of value) {
+      element = this.itemType.normalize(element);
+      if (element.error) {
+        return element;
+      }
+      result.push(element.value);
+    }
+
+    return {value: result};
+  }
+
+  checkBaseType(baseType) {
+    return baseType == "array";
+  }
+};
+
+class FunctionType extends Type {
+  constructor(parameters) {
+    super();
+    this.parameters = parameters;
+  }
+
+  normalize(value) {
+    return this.normalizeBase("function", value);
+  }
+
+  checkBaseType(baseType) {
+    return baseType == "function";
+  }
+};
+
+// Represents a "property" defined in a schema namespace with a
+// particular value. Essentially this is a constant.
+class ValueProperty extends Entry {
+  constructor(name, value) {
+    super();
+    this.name = name;
+    this.value = value;
+  }
+
+  inject(name, dest, wrapperFuncs) {
+    dest[name] = this.value;
+  }
+};
+
+// Represents a "property" defined in a schema namespace that is not a
+// constant.
+class TypeProperty extends Entry {
+  constructor(name, type) {
+    super();
+    this.name = name;
+    this.type = type;
+  }
+};
+
+// This class is a base class for FunctionEntrys and Events. It takes
+// care of validating parameter lists (i.e., handling of optional
+// parameters and parameter type checking).
+class CallEntry extends Entry {
+  constructor(namespaceName, name, parameters) {
+    super();
+    this.namespaceName = namespaceName;
+    this.name = name;
+    this.parameters = parameters;
+  }
+
+  throwError(global, msg) {
+    global = Cu.getGlobalForObject(global);
+    throw new global.Error(`${msg} for ${this.namespaceName}.${this.name}.`);
+  }
+
+  checkParameters(args, global) {
+    let fixedArgs = [];
+
+    // First we create a new array, fixedArgs, that is the same as
+    // |args| but with null values in place of omitted optional
+    // parameters.
+    let check = (parameterIndex, argIndex) => {
+      if (parameterIndex == this.parameters.length) {
+        if (argIndex == args.length) {
+          return true;
+        }
+        return false;
+      }
+
+      let parameter = this.parameters[parameterIndex];
+      if (parameter.optional) {
+        // Try skipping it.
+        fixedArgs[parameterIndex] = null;
+        if (check(parameterIndex + 1, argIndex)) {
+          return true;
+        }
+      }
+
+      if (argIndex == args.length) {
+        return false;
+      }
+
+      let arg = args[argIndex];
+      if (!parameter.type.checkBaseType(getValueBaseType(arg))) {
+        if (parameter.optional && (arg === null || arg === undefined)) {
+          fixedArgs[parameterIndex] = null;
+        } else {
+          return false;
+        }
+      } else {
+        fixedArgs[parameterIndex] = arg;
+      }
+
+      return check(parameterIndex + 1, argIndex + 1);
+    }
+
+    let success = check(0, 0);
+    if (!success) {
+      this.throwError(global, "Incorrect argument types");
+    }
+
+    // Now we normalize (and fully type check) all non-omitted arguments.
+    fixedArgs = fixedArgs.map((arg, parameterIndex) => {
+      if (arg === null) {
+        return null;
+      } else {
+        let parameter = this.parameters[parameterIndex];
+        let r = parameter.type.normalize(arg);
+        if (r.error) {
+          this.throwError(global, `Type error for parameter ${parameter.name} (${r.error})`);
+        }
+        return r.value;
+      }
+    });
+
+    return fixedArgs;
+  }
+};
+
+// Represents a "function" defined in a schema namespace.
+class FunctionEntry extends CallEntry {
+  constructor(namespaceName, name, type, unsupported) {
+    super(namespaceName, name, type.parameters);
+    this.unsupported = unsupported;
+  }
+
+  inject(name, dest, wrapperFuncs) {
+    if (this.unsupported) {
+      return;
+    }
+
+    let stub = (...args) => {
+      let actuals = this.checkParameters(args, dest);
+      return wrapperFuncs.callFunction(this.namespaceName, name, actuals);
+    }
+    Cu.exportFunction(stub, dest, {defineAs: name});
+  }
+};
+
+// Represents an "event" defined in a schema namespace.
+class Event extends CallEntry {
+  constructor(namespaceName, name, type, extraParameters, unsupported) {
+    super(namespaceName, name, extraParameters);
+    this.type = type;
+    this.unsupported = unsupported;
+  }
+
+  checkListener(global, listener) {
+    let r = this.type.normalize(listener);
+    if (r.error) {
+      this.throwError(global, "Invalid listener");
+    }
+    return r.value;
+  }
+
+  inject(name, dest, wrapperFuncs) {
+    if (this.unsupported) {
+      return;
+    }
+
+    let addStub = (listener, ...args) => {
+      listener = this.checkListener(dest, listener);
+      let actuals = this.checkParameters(args, dest);
+      return wrapperFuncs.addListener(this.namespaceName, name, listener, actuals);
+    };
+
+    let removeStub = (listener) => {
+      listener = this.checkListener(dest, listener);
+      return wrapperFuncs.removeListener(this.namespaceName, name, listener);
+    };
+
+    let hasStub = (listener) => {
+      listener = this.checkListener(dest, listener);
+      return wrapperFuncs.hasListener(this.namespaceName, 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"});
+  }
+};
+
+this.Schemas = {
+  // Map[<schema-name> -> Map[<symbol-name> -> Entry]]
+  // This keeps track of all the schemas that have been loaded so far.
+  namespaces: new Map(),
+
+  register(namespaceName, symbol, value) {
+    let ns = this.namespaces.get(namespaceName);
+    if (!ns) {
+      ns = new Map();
+      this.namespaces.set(namespaceName, ns);
+    }
+    ns.set(symbol, value);
+  },
+
+  parseType(namespaceName, type, extraProperties = []) {
+    let allowedProperties = new Set(extraProperties);
+
+    // Do some simple validation of our own schemas.
+    function checkTypeProperties(...extra) {
+      let allowedSet = new Set([...allowedProperties, ...extra, "description"]);
+      for (let prop of Object.keys(type)) {
+        if (!allowedSet.has(prop)) {
+          throw new Error(`Internal error: Namespace ${namespaceName} has invalid type property ${prop} in type ${type.name}`);
+        }
+      }
+    };
+
+    if ("choices" in type) {
+      checkTypeProperties("choices");
+
+      let choices = type.choices.map(t => this.parseType(namespaceName, t));
+      return new ChoiceType(choices);
+    } else if ("$ref" in type) {
+      checkTypeProperties("$ref");
+      return new RefType(namespaceName, type["$ref"]);
+    }
+
+    if (!("type" in type)) {
+      throw new Error(`Unexpected value for type: ${JSON.stringify(type)}`);
+    }
+
+    allowedProperties.add("type");
+
+    // Otherwise it's a normal type...
+    if (type.type == "string") {
+      checkTypeProperties("enum", "minLength", "maxLength");
+      return new StringType(type["enum"] || null,
+                            type.minLength || 0,
+                            type.maxLength || Infinity);
+    } else if (type.type == "object") {
+      if (!type.properties) {
+        checkTypeProperties();
+        return new UnrestrictedObjectType();
+      }
+      let properties = {};
+      for (let propName of Object.keys(type.properties)) {
+        properties[propName] = {
+          type: this.parseType(namespaceName, type.properties[propName], ["optional", "unsupported"]),
+          optional: type.properties[propName].optional || false,
+          unsupported: type.properties[propName].unsupported || false,
+        };
+      }
+
+      let additionalProperties = null;
+      if ("additionalProperties" in type) {
+        additionalProperties = this.parseType(namespaceName, type.additionalProperties);
+      }
+
+      return new ObjectType(properties, additionalProperties);
+    } else if (type.type == "array") {
+      checkTypeProperties("items");
+      return new ArrayType(this.parseType(namespaceName, type.items));
+    } else if (type.type == "number") {
+      checkTypeProperties();
+      return new NumberType();
+    } else if (type.type == "integer") {
+      checkTypeProperties("minimum", "maximum");
+      return new IntegerType(type.minimum || 0, type.maximum || Infinity);
+    } else if (type.type == "boolean") {
+      checkTypeProperties();
+      return new BooleanType();
+    } else if (type.type == "function") {
+      let parameters = null;
+      if ("parameters" in type) {
+        parameters = [];
+        for (let param of type.parameters) {
+          parameters.push({
+            type: this.parseType(namespaceName, param, ["name", "optional"]),
+            name: param.name,
+            optional: param.optional || false,
+          });
+        }
+      }
+
+      checkTypeProperties("parameters");
+      return new FunctionType(parameters);
+    } else if (type.type == "any") {
+      checkTypeProperties();
+      return new AnyType();
+    } else {
+      throw new Error(`Unexpected type ${type.type}`);
+    }
+  },
+
+  loadType(namespaceName, type) {
+    this.register(namespaceName, type.id, this.parseType(namespaceName, type, ["id"]));
+  },
+
+  loadProperty(namespaceName, name, prop) {
+    if ("value" in prop) {
+      this.register(namespaceName, name, new ValueProperty(name, prop.value));
+    } else {
+      let type = this.parseType(namespaceName, prop);
+      this.register(namespaceName, name, new TypeProperty(name, type));
+    }
+  },
+
+  loadFunction(namespaceName, fun) {
+    let f = new FunctionEntry(namespaceName, fun.name,
+                              this.parseType(namespaceName, fun, ["name", "unsupported"]),
+                              fun.unsupported || false);
+    this.register(namespaceName, fun.name, f);
+  },
+
+  loadEvent(namespaceName, event) {
+    let extras = event.extraParameters || [];
+    extras = extras.map(param => {
+      return {
+        type: this.parseType(namespaceName, param, ["name", "optional"]),
+        name: param.name,
+        optional: param.optional || false,
+      };
+    });
+
+    // We ignore these properties for now.
+    let returns = event.returns;
+    let filters = event.filters;
+
+    let type = this.parseType(namespaceName, event,
+                              ["name", "unsupported", "extraParameters", "returns", "filters"]);
+
+    let e = new Event(namespaceName, event.name, type, extras,
+                      event.unsupported || false);
+    this.register(namespaceName, event.name, e);
+  },
+
+  load(uri) {
+    return readJSON(uri).then(json => {
+      for (let namespace of json) {
+        let name = namespace.namespace;
+
+        let types = namespace.types || [];
+        for (let type of types) {
+          this.loadType(name, type);
+        }
+
+        let properties = namespace.properties || {};
+        for (let propertyName of Object.keys(properties)) {
+          this.loadProperty(name, propertyName, properties[propertyName]);
+        }
+
+        let functions = namespace.functions || [];
+        for (let fun of functions) {
+          this.loadFunction(name, fun);
+        }
+
+        let events = namespace.events || [];
+        for (let event of events) {
+          this.loadEvent(name, event);
+        }
+      }
+    });
+  },
+
+  inject(dest, wrapperFuncs) {
+    for (let [namespace, ns] of this.namespaces) {
+      let obj = Cu.createObjectIn(dest, {defineAs: namespace});
+      for (let [name, entry] of ns) {
+        entry.inject(name, obj, wrapperFuncs);
+      }
+    }
+  },
+};
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -5,15 +5,15 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 EXTRA_JS_MODULES += [
     'Extension.jsm',
     'ExtensionContent.jsm',
     'ExtensionManagement.jsm',
     'ExtensionStorage.jsm',
     'ExtensionUtils.jsm',
+    'Schemas.jsm',
 ]
 
 JAR_MANIFESTS += ['jar.mn']
 
 MOCHITEST_MANIFESTS += ['test/mochitest/mochitest.ini']
-
 XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini']
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
@@ -0,0 +1,320 @@
+"use strict";
+
+Components.utils.import("resource://gre/modules/Schemas.jsm");
+Components.utils.import("resource://gre/modules/BrowserUtils.jsm");
+
+let json = [
+  {namespace: "testing",
+
+   properties: {
+     PROP1: {value: 20},
+     prop2: {type: "string"},
+   },
+
+   types: [
+     {
+       id: "type1",
+       type: "string",
+       "enum": ["value1", "value2", "value3"],
+     },
+
+     {
+       id: "type2",
+       type: "object",
+       properties: {
+         prop1: {type: "integer"},
+         prop2: {type: "array", items: {"$ref": "type1"}},
+       },
+     }
+   ],
+
+   functions: [
+     {
+       name: "foo",
+       type: "function",
+       parameters: [
+         {name: "arg1", type: "integer", optional: true},
+         {name: "arg2", type: "boolean", optional: true},
+       ],
+     },
+
+     {
+       name: "bar",
+       type: "function",
+       parameters: [
+         {name: "arg1", type: "integer", optional: true},
+         {name: "arg2", type: "boolean"},
+       ],
+     },
+
+     {
+       name: "baz",
+       type: "function",
+       parameters: [
+         {name: "arg1", type: "object", properties: {
+           prop1: {type: "string"},
+           prop2: {type: "integer", optional: true},
+           prop3: {type: "integer", unsupported: true},
+         }},
+       ],
+     },
+
+     {
+       name: "qux",
+       type: "function",
+       parameters: [
+         {name: "arg1", "$ref": "type1"},
+       ],
+     },
+
+     {
+       name: "quack",
+       type: "function",
+       parameters: [
+         {name: "arg1", "$ref": "type2"},
+       ],
+     },
+
+     {
+       name: "quora",
+       type: "function",
+       parameters: [
+         {name: "arg1", type: "function"},
+       ],
+     },
+
+     {
+       name: "quileute",
+       type: "function",
+       parameters: [
+         {name: "arg1", type: "integer", optional: true},
+         {name: "arg2", type: "integer"},
+       ],
+     },
+
+     {
+       name: "queets",
+       type: "function",
+       unsupported: true,
+       parameters: [],
+     },
+
+     {
+       name: "quintuplets",
+       type: "function",
+       parameters: [
+         {name: "obj", type: "object", properties: [], additionalProperties: {type: "integer"}},
+       ],
+     },
+
+     {
+       name: "quasar",
+       type: "function",
+       parameters: [
+         {name: "abc", type: "object", properties: {
+           func: {type: "function", parameters: [
+             {name: "x", type: "integer"}
+           ]}
+         }}
+       ],
+     },
+
+     {
+       name: "quosimodo",
+       type: "function",
+       parameters: [
+         {name: "xyz", type: "object"},
+       ],
+     },
+   ],
+
+   events: [
+     {
+       name: "onFoo",
+       type: "function",
+     },
+
+     {
+       name: "onBar",
+       type: "function",
+       extraParameters: [{
+         name: "filter",
+         type: "integer",
+       }],
+     }
+   ],
+  }
+];
+
+let tallied = null;
+
+function tally(kind, ns, name, args) {
+  tallied = [kind, ns, name, args];
+}
+
+function verify(...args) {
+  do_check_eq(JSON.stringify(tallied), JSON.stringify(args));
+  tallied = null;
+}
+
+let wrapper = {
+  callFunction(ns, name, args) {
+    tally("call", ns, name, args);
+  },
+
+  addListener(ns, name, listener, args) {
+    tally("addListener", ns, name, [listener, args]);
+  },
+  removeListener(ns, name, listener) {
+    tally("removeListener", ns, name, [listener]);
+  },
+  hasListener(ns, name, listener) {
+    tally("hasListener", ns, name, [listener]);
+  },
+};
+
+add_task(function* () {
+  let url = "data:," + JSON.stringify(json);
+  let uri = BrowserUtils.makeURI(url);
+  yield Schemas.load(uri);
+
+  let root = {};
+  Schemas.inject(root, wrapper);
+
+  do_check_eq(root.testing.PROP1, 20, "simple value property");
+  do_check_eq(root.testing.type1.VALUE1, "value1", "enum type");
+  do_check_eq(root.testing.type1.VALUE2, "value2", "enum type");
+
+  root.testing.foo(11, true);
+  verify("call", "testing", "foo", [11, true]);
+
+  root.testing.foo(true);
+  verify("call", "testing", "foo", [null, true]);
+
+  root.testing.foo(null, true);
+  verify("call", "testing", "foo", [null, true]);
+
+  root.testing.foo(undefined, true);
+  verify("call", "testing", "foo", [null, true]);
+
+  root.testing.foo(11);
+  verify("call", "testing", "foo", [11, null]);
+
+  Assert.throws(() => root.testing.bar(11),
+                /Incorrect argument types/,
+                "should throw without required arg");
+
+  Assert.throws(() => root.testing.bar(11, true, 10),
+                /Incorrect argument types/,
+                "should throw with too many arguments");
+
+  root.testing.bar(true);
+  verify("call", "testing", "bar", [null, true]);
+
+  root.testing.baz({ prop1: "hello", prop2: 22 });
+  verify("call", "testing", "baz", [{ prop1: "hello", prop2: 22 }]);
+
+  root.testing.baz({ prop1: "hello" });
+  verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]);
+
+  root.testing.baz({ prop1: "hello", prop2: null });
+  verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]);
+
+  Assert.throws(() => root.testing.baz({ prop2: 12 }),
+                /Property "prop1" is required/,
+                "should throw without required property");
+
+  Assert.throws(() => root.testing.baz({ prop1: "hi", prop3: 12 }),
+                /Property "prop3" is unsupported by Firefox/,
+                "should throw with unsupported property");
+
+  Assert.throws(() => root.testing.baz({ prop1: "hi", prop4: 12 }),
+                /Unexpected property "prop4"/,
+                "should throw with unexpected property");
+
+  Assert.throws(() => root.testing.baz({ prop1: 12 }),
+                /Expected string instead of 12/,
+                "should throw with wrong type");
+
+  root.testing.qux("value2");
+  verify("call", "testing", "qux", ["value2"]);
+
+  Assert.throws(() => root.testing.qux("value4"),
+                /Invalid enumeration value "value4"/,
+                "should throw for invalid enum value");
+
+  root.testing.quack({prop1: 12, prop2: ["value1", "value3"]});
+  verify("call", "testing", "quack", [{prop1: 12, prop2: ["value1", "value3"]}]);
+
+  Assert.throws(() => root.testing.quack({prop1: 12, prop2: ["value1", "value3", "value4"]}),
+                /Invalid enumeration value "value4"/,
+                "should throw for invalid array type");
+
+  function f() {}
+  root.testing.quora(f);
+  do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["call", "testing", "quora"]));
+  do_check_eq(tallied[3][0], f);
+  tallied = null;
+
+  let g = () => 0;
+  root.testing.quora(g);
+  do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["call", "testing", "quora"]));
+  do_check_eq(tallied[3][0], g);
+  tallied = null;
+
+  root.testing.quileute(10);
+  verify("call", "testing", "quileute", [null, 10]);
+
+  Assert.throws(() => root.testing.queets(),
+                /queets is not a function/,
+                "should throw for unsupported functions");
+
+  root.testing.quintuplets({a: 10, b: 20, c: 30});
+  verify("call", "testing", "quintuplets", [{a: 10, b: 20, c: 30}]);
+
+  Assert.throws(() => root.testing.quintuplets({a: 10, b: 20, c: 30, d: "hi"}),
+                /Expected integer instead of "hi"/,
+                "should throw for wrong additionalProperties type");
+
+  root.testing.quasar({func: f});
+  do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["call", "testing", "quasar"]));
+  do_check_eq(tallied[3][0].func, f);
+  tallied = null;
+
+  root.testing.quosimodo({a: 10, b: 20, c: 30});
+  verify("call", "testing", "quosimodo", [{a: 10, b: 20, c: 30}]);
+
+  Assert.throws(() => root.testing.quosimodo(10),
+                /Incorrect argument types/,
+                "should throw for wrong type");
+
+  root.testing.onFoo.addListener(f);
+  do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["addListener", "testing", "onFoo"]));
+  do_check_eq(tallied[3][0], f);
+  do_check_eq(JSON.stringify(tallied[3][1]), JSON.stringify([]));
+  tallied = null;
+
+  root.testing.onFoo.removeListener(f);
+  do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["removeListener", "testing", "onFoo"]));
+  do_check_eq(tallied[3][0], f);
+  tallied = null;
+
+  root.testing.onFoo.hasListener(f);
+  do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["hasListener", "testing", "onFoo"]));
+  do_check_eq(tallied[3][0], f);
+  tallied = null;
+
+  Assert.throws(() => root.testing.onFoo.addListener(10),
+                /Invalid listener/,
+                "addListener with non-function should throw");
+
+  root.testing.onBar.addListener(f, 10);
+  do_check_eq(JSON.stringify(tallied.slice(0, -1)), JSON.stringify(["addListener", "testing", "onBar"]));
+  do_check_eq(tallied[3][0], f);
+  do_check_eq(JSON.stringify(tallied[3][1]), JSON.stringify([10]));
+  tallied = null;
+
+  Assert.throws(() => root.testing.onBar.addListener(f, "hi"),
+                /Incorrect argument types/,
+                "addListener with wrong extra parameter should throw");
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -1,6 +1,7 @@
 [DEFAULT]
 head = head.js
 tail =
 skip-if = toolkit == 'android' || toolkit == 'gonk'
 
 [test_locale_converter.js]
+[test_ext_schemas.js]