Bug 1410214: Part 1 - Implement support for WebExtension-style dictionary add-ons. r=aswan,gandalf a=lizzard
authorKris Maglione <maglione.k@gmail.com>
Thu, 26 Apr 2018 13:32:38 -0700
changeset 450296 9e641345e2ef5366cf6678b9eab23369aa777307
parent 450295 9055726e2d89e54a135b28d9c2dc4162a129fa1b
child 450297 4997cf9a50db280a7143fb481f2337f41c451e9c
push id232
push usermaglione.k@gmail.com
push dateFri, 09 Nov 2018 00:30:52 +0000
treeherdermozilla-esr60@9e641345e2ef [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, gandalf, lizzard
bugs1410214
milestone60.3.1
Bug 1410214: Part 1 - Implement support for WebExtension-style dictionary add-ons. r=aswan,gandalf a=lizzard MozReview-Commit-ID: 3oylyEBgrzB
extensions/spellcheck/hunspell/glue/mozHunspell.cpp
extensions/spellcheck/hunspell/glue/mozHunspell.h
extensions/spellcheck/idl/mozISpellCheckingEngine.idl
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/schemas/manifest.json
toolkit/mozapps/extensions/internal/XPIInstall.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/test/xpcshell/head_addons.js
toolkit/mozapps/extensions/test/xpcshell/test_dictionary_webextension.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.ini
--- a/extensions/spellcheck/hunspell/glue/mozHunspell.cpp
+++ b/extensions/spellcheck/hunspell/glue/mozHunspell.cpp
@@ -402,28 +402,38 @@ mozHunspell::LoadDictionaryList(bool aNo
       LoadDictionariesFromDir(dictDir);
   }
 
   // find dictionaries from restartless extensions
   for (int32_t i = 0; i < mDynamicDirectories.Count(); i++) {
     LoadDictionariesFromDir(mDynamicDirectories[i]);
   }
 
+  for (auto iter = mDynamicDictionaries.Iter(); !iter.Done(); iter.Next()) {
+    mDictionaries.Put(iter.Key(), iter.Data());
+  }
+
+  DictionariesChanged(aNotifyChildProcesses);
+}
+
+void
+mozHunspell::DictionariesChanged(bool aNotifyChildProcesses)
+{
   // Now we have finished updating the list of dictionaries, update the current
   // dictionary and any editors which may use it.
   mozInlineSpellChecker::UpdateCanEnableInlineSpellChecking();
 
   if (aNotifyChildProcesses) {
     ContentParent::NotifyUpdatedDictionaries();
   }
 
   // Check if the current dictionary is still available.
   // If not, try to replace it with another dictionary of the same language.
   if (!mDictionary.IsEmpty()) {
-    rv = SetDictionary(mDictionary.get());
+    nsresult rv = SetDictionary(mDictionary.get());
     if (NS_SUCCEEDED(rv))
       return;
   }
 
   // If the current dictionary has gone, and we don't have a good replacement,
   // set no current dictionary.
   if (!mDictionary.IsEmpty()) {
     SetDictionary(EmptyString().get());
@@ -651,8 +661,31 @@ NS_IMETHODIMP mozHunspell::RemoveDirecto
   if (obs) {
     obs->NotifyObservers(nullptr,
                          SPELLCHECK_DICTIONARY_REMOVE_NOTIFICATION,
                          nullptr);
   }
 #endif
   return NS_OK;
 }
+
+NS_IMETHODIMP mozHunspell::AddDictionary(const nsAString& aLang, nsIFile *aFile)
+{
+  NS_ENSURE_TRUE(aFile, NS_ERROR_INVALID_ARG);
+
+  mDynamicDictionaries.Put(aLang, aFile);
+  mDictionaries.Put(aLang, aFile);
+  DictionariesChanged(true);
+  return NS_OK;
+}
+
+NS_IMETHODIMP mozHunspell::RemoveDictionary(const nsAString& aLang, nsIFile *aFile)
+{
+  NS_ENSURE_TRUE(aFile, NS_ERROR_INVALID_ARG);
+
+  nsCOMPtr<nsIFile> file = mDynamicDictionaries.Get(aLang);
+  bool equal;
+  if (file && NS_SUCCEEDED(file->Equals(aFile, &equal)) && equal) {
+    mDynamicDictionaries.Remove(aLang);
+    LoadDictionaryList(true);
+  }
+  return NS_OK;
+}
--- a/extensions/spellcheck/hunspell/glue/mozHunspell.h
+++ b/extensions/spellcheck/hunspell/glue/mozHunspell.h
@@ -100,25 +100,28 @@ public:
   // helper method for converting a word to the charset of the dictionary
   nsresult ConvertCharset(const char16_t* aStr, std::string* aDst);
 
   NS_DECL_NSIMEMORYREPORTER
 
 protected:
   virtual ~mozHunspell();
 
+  void DictionariesChanged(bool aNotifyChildProcesses);
+
   nsCOMPtr<mozIPersonalDictionary> mPersonalDictionary;
   mozilla::UniquePtr<mozilla::Encoder> mEncoder;
   mozilla::UniquePtr<mozilla::Decoder> mDecoder;
 
   // Hashtable matches dictionary name to .aff file
   nsInterfaceHashtable<nsStringHashKey, nsIFile> mDictionaries;
   nsString  mDictionary;
   nsString  mLanguage;
   nsCString mAffixFileName;
 
   // dynamic dirs used to search for dictionaries
   nsCOMArray<nsIFile> mDynamicDirectories;
+  nsInterfaceHashtable<nsStringHashKey, nsIFile> mDynamicDictionaries;
 
   Hunspell  *mHunspell;
 };
 
 #endif
--- a/extensions/spellcheck/idl/mozISpellCheckingEngine.idl
+++ b/extensions/spellcheck/idl/mozISpellCheckingEngine.idl
@@ -80,16 +80,28 @@ interface mozISpellCheckingEngine : nsIS
    * Add dictionaries from a directory to the spell checker
    */
   void addDirectory(in nsIFile dir);
 
   /**
    * Remove dictionaries from a directory from the spell checker
    */
   void removeDirectory(in nsIFile dir);
+
+  /**
+   * Add a dictionary with the given language code and file path.
+   */
+  void addDictionary(in AString lang, in nsIFile dir);
+
+  /**
+   * Remove a dictionary with the given language code and path. If the path does
+   * not match that of the current entry with the given language code, it is not
+   * removed.
+   */
+  void removeDictionary(in AString lang, in nsIFile dir);
 };
 
 %{C++
 #define DICTIONARY_SEARCH_DIRECTORY "DictD"
 #define DICTIONARY_SEARCH_DIRECTORY_LIST "DictDL"
 
 #define SPELLCHECK_DICTIONARY_REMOVE_NOTIFICATION \
   "spellcheck-dictionary-remove"
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -1,16 +1,16 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 /* 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 EXPORTED_SYMBOLS = ["Extension", "ExtensionData", "Langpack"];
+var EXPORTED_SYMBOLS = ["Dictionary", "Extension", "ExtensionData", "Langpack"];
 
 /* exported Extension, ExtensionData */
 /* globals Extension ExtensionData */
 
 /*
  * This file is the main entry point for extensions. When an extension
  * loads, its bootstrap.js file creates a Extension instance
  * and calls .startup() on it. It calls .shutdown() when the extension
@@ -57,26 +57,36 @@ XPCOMUtils.defineLazyModuleGetters(this,
   TelemetryStopwatch: "resource://gre/modules/TelemetryStopwatch.jsm",
 });
 
 XPCOMUtils.defineLazyGetter(
   this, "processScript",
   () => Cc["@mozilla.org/webextensions/extension-process-script;1"]
           .getService().wrappedJSObject);
 
+// This is used for manipulating jar entry paths, which always use Unix
+// separators.
+XPCOMUtils.defineLazyGetter(
+  this, "OSPath", () => {
+    let obj = {};
+    ChromeUtils.import("resource://gre/modules/osfile/ospath_unix.jsm", obj);
+    return obj;
+  });
+
 XPCOMUtils.defineLazyGetter(
   this, "resourceProtocol",
   () => Services.io.getProtocolHandler("resource")
           .QueryInterface(Ci.nsIResProtocolHandler));
 
 ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
 ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
 
 XPCOMUtils.defineLazyServiceGetters(this, {
   aomStartup: ["@mozilla.org/addons/addon-manager-startup;1", "amIAddonManagerStartup"],
+  spellCheck: ["@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine"],
   uuidGen: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"],
 });
 
 XPCOMUtils.defineLazyPreferenceGetter(this, "processCount", "dom.ipc.processCount.extension");
 
 var {
   GlobalManager,
   ParentAPIManager,
@@ -374,16 +384,19 @@ class ExtensionData {
     }
 
     let uri = this.rootURI.QueryInterface(Ci.nsIJARURI);
     let file = uri.JARFile.QueryInterface(Ci.nsIFileURL).file;
 
     // Normalize the directory path.
     path = `${uri.JAREntry}/${path}`;
     path = path.replace(/\/\/+/g, "/").replace(/^\/|\/$/g, "") + "/";
+    if (path === "/") {
+      path = "";
+    }
 
     // Escape pattern metacharacters.
     let pattern = path.replace(/[[\]()?*~|$\\]/g, "\\$&") + "*";
 
     let results = [];
     for (let name of aomStartup.enumerateZipFile(file, pattern)) {
       if (!name.startsWith(path)) {
         throw new Error("Unexpected ZipReader entry");
@@ -535,16 +548,19 @@ class ExtensionData {
 
     let manifestType = "manifest.WebExtensionManifest";
     if (this.manifest.theme) {
       this.type = "theme";
       manifestType = "manifest.ThemeManifest";
     } else if (this.manifest.langpack_id) {
       this.type = "langpack";
       manifestType = "manifest.WebExtensionLangpackManifest";
+    } else if (this.manifest.dictionaries) {
+      this.type = "dictionary";
+      manifestType = "manifest.WebExtensionDictionaryManifest";
     } else {
       this.type = "extension";
     }
 
     if (this.localeData) {
       context.preprocessors.localize = (value, context) => this.localize(value);
     }
 
@@ -726,16 +742,40 @@ class ExtensionData {
         }
       }
 
       // 4. Save the list of languages handled by this langpack.
       const languages = Object.keys(manifest.languages);
 
 
       this.startupData = {chromeEntries, langpackId, l10nRegistrySources, languages};
+    } else if (this.type == "dictionary") {
+      let dictionaries = {};
+      for (let [lang, path] of Object.entries(manifest.dictionaries)) {
+        path = path.replace(/^\/+/, "");
+
+        let dir = OSPath.dirname(path);
+        if (dir === ".") {
+          dir = "";
+        }
+        let leafName = OSPath.basename(path);
+        let affixPath = leafName.slice(0, -3) + "aff";
+
+        let entries = Array.from(await this.readDirectory(dir), entry => entry.name);
+        if (!entries.includes(leafName)) {
+          this.manifestError(`Invalid dictionary path specified for '${lang}': ${path}`);
+        }
+        if (!entries.includes(affixPath)) {
+          this.manifestError(`Invalid dictionary path specified for '${lang}': Missing affix file: ${path}`);
+        }
+
+        dictionaries[lang] = path;
+      }
+
+      this.startupData = {dictionaries};
     }
 
     if (schemaPromises.size) {
       let schemas = new Map();
       for (let [url, promise] of schemaPromises) {
         schemas.set(url, await promise);
       }
       result.schemaURLs = schemas;
@@ -1150,16 +1190,32 @@ XPCOMUtils.defineLazyGetter(BootstrapSco
     [BOOTSTRAP_REASONS.ADDON_DISABLE]: "ADDON_DISABLE",
     [BOOTSTRAP_REASONS.ADDON_INSTALL]: "ADDON_INSTALL",
     [BOOTSTRAP_REASONS.ADDON_UNINSTALL]: "ADDON_UNINSTALL",
     [BOOTSTRAP_REASONS.ADDON_UPGRADE]: "ADDON_UPGRADE",
     [BOOTSTRAP_REASONS.ADDON_DOWNGRADE]: "ADDON_DOWNGRADE",
   });
 });
 
+class DictionaryBootstrapScope extends BootstrapScope {
+  install(data, reason) {}
+  uninstall(data, reason) {}
+
+  startup(data, reason) {
+    // eslint-disable-next-line no-use-before-define
+    this.dictionary = new Dictionary(data);
+    return this.dictionary.startup(this.BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
+  }
+
+  shutdown(data, reason) {
+    this.dictionary.shutdown(this.BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
+    this.dictionary = null;
+  }
+}
+
 class LangpackBootstrapScope {
   install(data, reason) {}
   uninstall(data, reason) {}
 
   startup(data, reason) {
     // eslint-disable-next-line no-use-before-define
     this.langpack = new Langpack(data);
     return this.langpack.startup();
@@ -1825,16 +1881,48 @@ class Extension extends ExtensionData {
       let restrictSchemes = !this.hasPermission("mozillaAddons");
       let origins = this.manifest.optional_permissions.filter(perm => classifyPermission(perm, restrictSchemes).origin);
       this._optionalOrigins = new MatchPatternSet(origins, {restrictSchemes, ignorePath: true});
     }
     return this._optionalOrigins;
   }
 }
 
+class Dictionary extends ExtensionData {
+  constructor(addonData, startupReason) {
+    super(addonData.resourceURI);
+    this.id = addonData.id;
+    this.startupData = addonData.startupData;
+  }
+
+  static getBootstrapScope(id, file) {
+    return new DictionaryBootstrapScope();
+  }
+
+  async startup(reason) {
+    this.dictionaries = {};
+    for (let [lang, path] of Object.entries(this.startupData.dictionaries)) {
+      let {file} = Services.io.newURI(path, null, this.rootURI).QueryInterface(Ci.nsIFileURL);
+      this.dictionaries[lang] = file;
+
+      spellCheck.addDictionary(lang, file);
+    }
+
+    Management.emit("ready", this);
+  }
+
+  async shutdown(reason) {
+    if (reason !== "APP_SHUTDOWN") {
+      for (let [lang, file] of Object.entries(this.dictionaries)) {
+        spellCheck.removeDictionary(lang, file);
+      }
+    }
+  }
+}
+
 class Langpack extends ExtensionData {
   constructor(addonData, startupReason) {
     super(addonData.resourceURI);
     this.startupData = addonData.startupData;
     this.manifestCacheKey = [addonData.id, addonData.version];
   }
 
   static getBootstrapScope(id, file) {
--- a/toolkit/components/extensions/schemas/manifest.json
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -296,16 +296,42 @@
                   }
                 }
               }
             }
           }
         }
       },
       {
+        "id": "WebExtensionDictionaryManifest",
+        "type": "object",
+        "description": "Represents a WebExtension dictionary manifest.json file",
+
+        "$import": "ManifestBase",
+        "properties": {
+          "homepage_url": {
+            "type": "string",
+            "format": "url",
+            "optional": true,
+            "preprocess": "localize"
+          },
+
+          "dictionaries": {
+            "type": "object",
+            "patternProperties": {
+              "^[a-z]{2}[a-zA-Z-]*$": {
+                "type": "string",
+                "format": "strictRelativeUrl",
+                "pattern": "\\.dic$"
+              }
+            }
+          }
+        }
+      },
+      {
         "id": "ThemeIcons",
         "type": "object",
         "properties": {
           "light": {
             "$ref": "ExtensionURL",
             "description": "A light icon to use for dark themes"
           },
           "dark": {
--- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -150,16 +150,17 @@ const COMPATIBLE_BY_DEFAULT_TYPES = {
   dictionary: true,
 };
 
 const RESTARTLESS_TYPES = new Set([
   "apiextension",
   "dictionary",
   "experiment",
   "webextension",
+  "webextension-dictionary",
   "webextension-theme",
 ]);
 
 // This is a random number array that can be used as "salt" when generating
 // an automatic ID based on the directory path of an add-on. It will prevent
 // someone from creating an ID for a permanent add-on that could be replaced
 // by a temporary add-on (because that would be confusing, I guess).
 const TEMP_INSTALL_ID_GEN_SESSION =
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -12,16 +12,17 @@ ChromeUtils.import("resource://gre/modul
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
   AddonSettings: "resource://gre/modules/addons/AddonSettings.jsm",
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   ChromeManifestParser: "resource://gre/modules/ChromeManifestParser.jsm",
+  Dictionary: "resource://gre/modules/Extension.jsm",
   Extension: "resource://gre/modules/Extension.jsm",
   Langpack: "resource://gre/modules/Extension.jsm",
   LightweightThemeManager: "resource://gre/modules/LightweightThemeManager.jsm",
   FileUtils: "resource://gre/modules/FileUtils.jsm",
   ZipUtils: "resource://gre/modules/ZipUtils.jsm",
   NetUtil: "resource://gre/modules/NetUtil.jsm",
   PermissionsUtils: "resource://gre/modules/PermissionsUtils.jsm",
   OS: "resource://gre/modules/osfile.jsm",
@@ -200,18 +201,19 @@ const BOOTSTRAP_REASONS = {
   ADDON_DOWNGRADE: 8
 };
 
 // Some add-on types that we track internally are presented as other types
 // externally
 const TYPE_ALIASES = {
   "apiextension": "extension",
   "webextension": "extension",
+  "webextension-dictionary": "dictionary",
+  "webextension-langpack": "locale",
   "webextension-theme": "theme",
-  "webextension-langpack": "locale",
 };
 
 const CHROME_TYPES = new Set([
   "extension",
   "experiment",
 ]);
 
 const SIGNED_TYPES = new Set([
@@ -4260,16 +4262,18 @@ var XPIProvider = {
       logger.error("Attempted to load bootstrap scope from missing directory " + aFile.path);
       return;
     }
 
     if (isWebExtension(aType)) {
       activeAddon.bootstrapScope = Extension.getBootstrapScope(aId, aFile);
     } else if (aType === "webextension-langpack") {
       activeAddon.bootstrapScope = Langpack.getBootstrapScope(aId, aFile);
+    } else if (aType === "webextension-dictionary") {
+      activeAddon.bootstrapScope = Dictionary.getBootstrapScope(aId, aFile);
     } else {
       let uri = getURIForResourceInFile(aFile, "bootstrap.js").spec;
       if (aType == "dictionary")
         uri = "resource://gre/modules/addons/SpellCheckDictionaryBootstrap.js";
       else if (aType == "apiextension")
         uri = "resource://gre/modules/addons/APIExtensionBootstrap.js";
 
       activeAddon.bootstrapScope =
@@ -4945,17 +4949,17 @@ AddonInternal.prototype = {
     }
 
     if (this.signedState === AddonManager.SIGNEDSTATE_NOT_REQUIRED)
       return true;
     return this.signedState > AddonManager.SIGNEDSTATE_MISSING;
   },
 
   get unpack() {
-    return this.type === "dictionary";
+    return this.type === "dictionary" || this.type === "webextension-dictionary";
   },
 
   get isCompatible() {
     return this.isCompatibleWith();
   },
 
   get disabled() {
     return (this.userDisabled || this.appDisabled || this.softDisabled);
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -1081,22 +1081,23 @@ function installAllFiles(aFiles, aCallba
 
 const EXTENSIONS_DB = "extensions.json";
 var gExtensionsJSON = gProfD.clone();
 gExtensionsJSON.append(EXTENSIONS_DB);
 
 function promiseInstallWebExtension(aData) {
   let addonFile = createTempWebExtensionFile(aData);
 
+  let promise = promiseWebExtensionStartup();
   return promiseInstallAllFiles([addonFile]).then(installs => {
     Services.obs.notifyObservers(addonFile, "flush-cache-entry");
     // Since themes are disabled by default, it won't start up.
     if (aData.manifest.theme)
       return installs[0].addon;
-    return promiseWebExtensionStartup();
+    return promise.then(() => installs[0].addon);
   });
 }
 
 // By default use strict compatibility
 Services.prefs.setBoolPref("extensions.strictCompatibility", true);
 
 // By default, set min compatible versions to 0
 Services.prefs.setCharPref(PREF_EM_MIN_COMPAT_APP_VERSION, "0");
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_dictionary_webextension.js
@@ -0,0 +1,122 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(this, "spellCheck",
+                                   "@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine");
+
+add_task(async function setup() {
+  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "61", "61");
+
+  // The dictionary service bails out early if this provider fails, and
+  // the built-in version isn't available in xpcshell tests, so register
+  // a stub.
+  Services.dirsvc.registerProvider({
+    getFiles(prop) {
+      if (prop == "DictDL") {
+        return {
+          hasMoreElements() {
+            return false;
+          },
+          QueryInterface: XPCOMUtils.generateQI(["nsISimpleEnumerator"]),
+        };
+      }
+      return null;
+    },
+
+    QueryInterface: XPCOMUtils.generateQI(["nsIDirectoryServiceProvider",
+                                           "nsIDirectoryServiceProvider2"]),
+  });
+
+  await promiseStartupManager();
+});
+
+add_task(async function test_validation() {
+  await Assert.rejects(
+    promiseInstallWebExtension({
+      manifest: {
+        applications: {gecko: {id: "en-US-no-dic@dictionaries.mozilla.org"}},
+        "dictionaries": {
+          "en-US": "en-US.dic",
+        },
+      },
+    })
+  );
+
+  await Assert.rejects(
+    promiseInstallWebExtension({
+      manifest: {
+        applications: {gecko: {id: "en-US-no-aff@dictionaries.mozilla.org"}},
+        "dictionaries": {
+          "en-US": "en-US.dic",
+        },
+      },
+
+      files: {
+        "en-US.dic": "",
+      },
+    })
+  );
+
+  let addon = await promiseInstallWebExtension({
+    manifest: {
+      applications: {gecko: {id: "en-US-1@dictionaries.mozilla.org"}},
+      "dictionaries": {
+        "en-US": "en-US.dic",
+      },
+    },
+
+    files: {
+      "en-US.dic": "",
+      "en-US.aff": "",
+    },
+  });
+
+  let addon2 = await promiseInstallWebExtension({
+    manifest: {
+      applications: {gecko: {id: "en-US-2@dictionaries.mozilla.org"}},
+      "dictionaries": {
+        "en-US": "dictionaries/en-US.dic",
+      },
+    },
+
+    files: {
+      "dictionaries/en-US.dic": "",
+      "dictionaries/en-US.aff": "",
+    },
+  });
+
+  addon.uninstall();
+  addon2.uninstall();
+});
+
+add_task(async function test_registration() {
+  const WORD = "Flehgragh";
+
+  spellCheck.dictionary = "en-US";
+
+  ok(!spellCheck.check(WORD), "Word should not pass check before add-on loads");
+
+  let addon = await promiseInstallWebExtension({
+    manifest: {
+      applications: {gecko: {id: "en-US@dictionaries.mozilla.org"}},
+      "dictionaries": {
+        "en-US": "en-US.dic",
+      },
+    },
+
+    files: {
+      "en-US.dic": `1\n${WORD}\n`,
+      "en-US.aff": "",
+    },
+  });
+
+  ok(spellCheck.check(WORD), "Word should pass check while add-on load is loaded");
+
+  addon.uninstall();
+
+  await new Promise(executeSoon);
+
+  ok(!spellCheck.check(WORD), "Word should not pass check after add-on unloads");
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.ini
@@ -6,9 +6,10 @@ skip-if = toolkit == 'android'
 dupe-manifest =
 tags = addons
 
 [test_webextension_paths.js]
 tags = webextensions
 [test_webextension_theme.js]
 tags = webextensions
 
+[test_dictionary_webextension.js]
 [test_filepointer.js]