Bug 1410214: Part 1 - Implement support for WebExtension-style dictionary add-ons. r=aswan,gandalf
authorKris Maglione <maglione.k@gmail.com>
Thu, 26 Apr 2018 13:32:38 -0700
changeset 472075 2f1e8f0e6b5316f9119a7111100af50c4b7025af
parent 472074 6825c9cd5bd5b98009d99384999a4de4945552ba
child 472076 3dd497b3e3f15b06e3e786d00addcd5ef85b97cb
push id1728
push userjlund@mozilla.com
push dateMon, 18 Jun 2018 21:12:27 +0000
treeherdermozilla-release@c296fde26f5f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, gandalf
bugs1410214
milestone61.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 1410214: Part 1 - Implement support for WebExtension-style dictionary add-ons. r=aswan,gandalf 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/XPIDatabase.jsm
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,
@@ -368,16 +378,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");
@@ -520,16 +533,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);
     }
 
@@ -708,16 +724,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;
@@ -1120,16 +1160,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();
@@ -1799,16 +1855,48 @@ class Extension extends ExtensionData {
     if (this._optionalOrigins == null) {
       let origins = this.manifest.optional_permissions.filter(perm => classifyPermission(perm).origin);
       this._optionalOrigins = new MatchPatternSet(origins, {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/XPIDatabase.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
@@ -320,17 +320,17 @@ class AddonInternal {
     }
 
     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/internal/XPIInstall.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -191,16 +191,17 @@ const TYPES = {
 const COMPATIBLE_BY_DEFAULT_TYPES = {
   extension: true,
   dictionary: true,
 };
 
 const RESTARTLESS_TYPES = new Set([
   "dictionary",
   "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
@@ -23,16 +23,17 @@ var EXPORTED_SYMBOLS = ["XPIProvider", "
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
   AppConstants: "resource://gre/modules/AppConstants.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",
   PermissionsUtils: "resource://gre/modules/PermissionsUtils.jsm",
   OS: "resource://gre/modules/osfile.jsm",
   ConsoleAPI: "resource://gre/modules/Console.jsm",
   JSONFile: "resource://gre/modules/JSONFile.jsm",
@@ -153,18 +154,19 @@ const BOOTSTRAP_REASONS = {
   ADDON_UPGRADE: 7,
   ADDON_DOWNGRADE: 8
 };
 
 // Some add-on types that we track internally are presented as other types
 // externally
 const TYPE_ALIASES = {
   "webextension": "extension",
+  "webextension-dictionary": "dictionary",
+  "webextension-langpack": "locale",
   "webextension-theme": "theme",
-  "webextension-langpack": "locale",
 };
 
 const CHROME_TYPES = new Set([
   "extension",
 ]);
 
 const SIGNED_TYPES = new Set([
   "extension",
@@ -2572,16 +2574,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";
 
       activeAddon.bootstrapScope =
         new Cu.Sandbox(principal, { sandboxName: uri,
                                     addonId: aId,
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -1150,22 +1150,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
@@ -7,9 +7,10 @@ dupe-manifest =
 tags = addons
 
 [test_webextension_paths.js]
 tags = webextensions
 [test_webextension_theme.js]
 tags = webextensions
 
 [test_dictionary.js]
+[test_dictionary_webextension.js]
 [test_filepointer.js]