summaryrefslogtreecommitdiffstats
path: root/dom/manifest
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /dom/manifest
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'dom/manifest')
-rw-r--r--dom/manifest/ImageObjectProcessor.sys.mjs250
-rw-r--r--dom/manifest/Manifest.sys.mjs245
-rw-r--r--dom/manifest/ManifestFinder.sys.mjs57
-rw-r--r--dom/manifest/ManifestIcons.sys.mjs82
-rw-r--r--dom/manifest/ManifestObtainer.sys.mjs162
-rw-r--r--dom/manifest/ManifestProcessor.sys.mjs345
-rw-r--r--dom/manifest/ValueExtractor.sys.mjs97
-rw-r--r--dom/manifest/moz.build21
-rw-r--r--dom/manifest/test/blue-150.pngbin0 -> 534 bytes
-rw-r--r--dom/manifest/test/browser.toml23
-rw-r--r--dom/manifest/test/browser_ManifestFinder_browserHasManifestLink.js89
-rw-r--r--dom/manifest/test/browser_ManifestIcons_browserFetchIcon.js66
-rw-r--r--dom/manifest/test/browser_ManifestObtainer_credentials.js45
-rw-r--r--dom/manifest/test/browser_ManifestObtainer_obtain.js268
-rw-r--r--dom/manifest/test/browser_Manifest_install.js58
-rw-r--r--dom/manifest/test/common.js139
-rw-r--r--dom/manifest/test/cookie_checker.sjs24
-rw-r--r--dom/manifest/test/cookie_setter.html8
-rw-r--r--dom/manifest/test/cookie_setter_with_credentials.html6
-rw-r--r--dom/manifest/test/cookie_setter_with_credentials_cross_origin.html14
-rw-r--r--dom/manifest/test/file_testserver.sjs56
-rw-r--r--dom/manifest/test/icon.pngbin0 -> 8156 bytes
-rw-r--r--dom/manifest/test/manifestLoader.html13
-rw-r--r--dom/manifest/test/mochitest.toml43
-rw-r--r--dom/manifest/test/red-50.pngbin0 -> 141 bytes
-rw-r--r--dom/manifest/test/resource.sjs85
-rw-r--r--dom/manifest/test/test_ImageObjectProcessor_purpose.html120
-rw-r--r--dom/manifest/test/test_ImageObjectProcessor_sizes.html88
-rw-r--r--dom/manifest/test/test_ImageObjectProcessor_src.html110
-rw-r--r--dom/manifest/test/test_ImageObjectProcessor_type.html57
-rw-r--r--dom/manifest/test/test_ManifestProcessor_JSON.html48
-rw-r--r--dom/manifest/test/test_ManifestProcessor_background_color.html22
-rw-r--r--dom/manifest/test/test_ManifestProcessor_dir.html57
-rw-r--r--dom/manifest/test/test_ManifestProcessor_display.html78
-rw-r--r--dom/manifest/test/test_ManifestProcessor_icons.html30
-rw-r--r--dom/manifest/test/test_ManifestProcessor_id.html123
-rw-r--r--dom/manifest/test/test_ManifestProcessor_lang.html139
-rw-r--r--dom/manifest/test/test_ManifestProcessor_name_and_short_name.html79
-rw-r--r--dom/manifest/test/test_ManifestProcessor_orientation.html86
-rw-r--r--dom/manifest/test/test_ManifestProcessor_scope.html113
-rw-r--r--dom/manifest/test/test_ManifestProcessor_start_url.html83
-rw-r--r--dom/manifest/test/test_ManifestProcessor_theme_color.html22
-rw-r--r--dom/manifest/test/test_ManifestProcessor_warnings.html149
-rw-r--r--dom/manifest/test/test_link_relList_supports_manifest.html47
44 files changed, 3647 insertions, 0 deletions
diff --git a/dom/manifest/ImageObjectProcessor.sys.mjs b/dom/manifest/ImageObjectProcessor.sys.mjs
new file mode 100644
index 0000000000..f72c5ff9fe
--- /dev/null
+++ b/dom/manifest/ImageObjectProcessor.sys.mjs
@@ -0,0 +1,250 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+/*
+ * ImageObjectProcessor
+ * Implementation of Image Object processing algorithms from:
+ * http://www.w3.org/TR/appmanifest/#image-object-and-its-members
+ *
+ * This is intended to be used in conjunction with ManifestProcessor.jsm
+ *
+ * Creates an object to process Image Objects as defined by the
+ * W3C specification. This is used to process things like the
+ * icon member and the splash_screen member.
+ *
+ * Usage:
+ *
+ * .process(aManifest, aBaseURL, aMemberName);
+ *
+ */
+
+export function ImageObjectProcessor(aErrors, aExtractor, aBundle) {
+ this.errors = aErrors;
+ this.extractor = aExtractor;
+ this.domBundle = aBundle;
+}
+
+const iconPurposes = Object.freeze(["any", "maskable", "monochrome"]);
+
+// Static getters
+Object.defineProperties(ImageObjectProcessor, {
+ decimals: {
+ get() {
+ return /^\d+$/;
+ },
+ },
+ anyRegEx: {
+ get() {
+ return new RegExp("any", "i");
+ },
+ },
+});
+
+ImageObjectProcessor.prototype.process = function (
+ aManifest,
+ aBaseURL,
+ aMemberName
+) {
+ const spec = {
+ objectName: "manifest",
+ object: aManifest,
+ property: aMemberName,
+ expectedType: "array",
+ trim: false,
+ };
+ const { domBundle, extractor, errors } = this;
+ const images = [];
+ const value = extractor.extractValue(spec);
+ if (Array.isArray(value)) {
+ value
+ .map(toImageObject)
+ // Filter out images that resulted in "failure", per spec.
+ .filter(image => image)
+ .forEach(image => images.push(image));
+ }
+ return images;
+
+ function toImageObject(aImageSpec, index) {
+ let img; // if "failure" happens below, we return undefined.
+ try {
+ // can throw
+ const src = processSrcMember(aImageSpec, aBaseURL, index);
+ // can throw
+ const purpose = processPurposeMember(aImageSpec, index);
+ const type = processTypeMember(aImageSpec);
+ const sizes = processSizesMember(aImageSpec);
+ img = {
+ src,
+ purpose,
+ type,
+ sizes,
+ };
+ } catch (err) {
+ /* Errors are collected by each process* function */
+ }
+ return img;
+ }
+
+ function processPurposeMember(aImage, index) {
+ const spec = {
+ objectName: "image",
+ object: aImage,
+ property: "purpose",
+ expectedType: "string",
+ trim: true,
+ throwTypeError: true,
+ };
+
+ // Type errors are treated at "any"...
+ let value;
+ try {
+ value = extractor.extractValue(spec);
+ } catch (err) {
+ return ["any"];
+ }
+
+ // Was only whitespace...
+ if (!value) {
+ return ["any"];
+ }
+
+ const keywords = value.split(/\s+/);
+
+ // Emtpy is treated as "any"...
+ if (keywords.length === 0) {
+ return ["any"];
+ }
+
+ // We iterate over keywords and classify them into:
+ const purposes = new Set();
+ const unknownPurposes = new Set();
+ const repeatedPurposes = new Set();
+
+ for (const keyword of keywords) {
+ const canonicalKeyword = keyword.toLowerCase();
+
+ if (purposes.has(canonicalKeyword)) {
+ repeatedPurposes.add(keyword);
+ continue;
+ }
+
+ iconPurposes.includes(canonicalKeyword)
+ ? purposes.add(canonicalKeyword)
+ : unknownPurposes.add(keyword);
+ }
+
+ // Tell developer about unknown purposes...
+ if (unknownPurposes.size) {
+ const warn = domBundle.formatStringFromName(
+ "ManifestImageUnsupportedPurposes",
+ [aMemberName, index, [...unknownPurposes].join(" ")]
+ );
+ errors.push({ warn });
+ }
+
+ // Tell developer about repeated purposes...
+ if (repeatedPurposes.size) {
+ const warn = domBundle.formatStringFromName(
+ "ManifestImageRepeatedPurposes",
+ [aMemberName, index, [...repeatedPurposes].join(" ")]
+ );
+ errors.push({ warn });
+ }
+
+ if (purposes.size === 0) {
+ const warn = domBundle.formatStringFromName("ManifestImageUnusable", [
+ aMemberName,
+ index,
+ ]);
+ errors.push({ warn });
+ throw new TypeError(warn);
+ }
+
+ return [...purposes];
+ }
+
+ function processTypeMember(aImage) {
+ const charset = {};
+ const hadCharset = {};
+ const spec = {
+ objectName: "image",
+ object: aImage,
+ property: "type",
+ expectedType: "string",
+ trim: true,
+ };
+ let value = extractor.extractValue(spec);
+ if (value) {
+ value = Services.io.parseRequestContentType(value, charset, hadCharset);
+ }
+ return value || undefined;
+ }
+
+ function processSrcMember(aImage, aBaseURL, index) {
+ const spec = {
+ objectName: aMemberName,
+ object: aImage,
+ property: "src",
+ expectedType: "string",
+ trim: false,
+ throwTypeError: true,
+ };
+ const value = extractor.extractValue(spec);
+ let url;
+ if (typeof value === "undefined" || value === "") {
+ // We throw here as the value is unusable,
+ // but it's not an developer error.
+ throw new TypeError();
+ }
+ if (value && value.length) {
+ try {
+ url = new URL(value, aBaseURL).href;
+ } catch (e) {
+ const warn = domBundle.formatStringFromName(
+ "ManifestImageURLIsInvalid",
+ [aMemberName, index, "src", value]
+ );
+ errors.push({ warn });
+ throw e;
+ }
+ }
+ return url;
+ }
+
+ function processSizesMember(aImage) {
+ const sizes = new Set();
+ const spec = {
+ objectName: "image",
+ object: aImage,
+ property: "sizes",
+ expectedType: "string",
+ trim: true,
+ };
+ const value = extractor.extractValue(spec);
+ if (value) {
+ // Split on whitespace and filter out invalid values.
+ value
+ .split(/\s+/)
+ .filter(isValidSizeValue)
+ .reduce((collector, size) => collector.add(size), sizes);
+ }
+ return sizes.size ? Array.from(sizes) : undefined;
+ // Implementation of HTML's link@size attribute checker.
+ function isValidSizeValue(aSize) {
+ const size = aSize.toLowerCase();
+ if (ImageObjectProcessor.anyRegEx.test(aSize)) {
+ return true;
+ }
+ if (!size.includes("x") || size.indexOf("x") !== size.lastIndexOf("x")) {
+ return false;
+ }
+ // Split left of x for width, after x for height.
+ const widthAndHeight = size.split("x");
+ const w = widthAndHeight.shift();
+ const h = widthAndHeight.join("x");
+ const validStarts = !w.startsWith("0") && !h.startsWith("0");
+ const validDecimals = ImageObjectProcessor.decimals.test(w + h);
+ return validStarts && validDecimals;
+ }
+ }
+};
diff --git a/dom/manifest/Manifest.sys.mjs b/dom/manifest/Manifest.sys.mjs
new file mode 100644
index 0000000000..f6fab11277
--- /dev/null
+++ b/dom/manifest/Manifest.sys.mjs
@@ -0,0 +1,245 @@
+/* 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/. */
+
+/*
+ * Manifest.jsm is the top level api for managing installed web applications
+ * https://www.w3.org/TR/appmanifest/
+ *
+ * It is used to trigger the installation of a web application via .install()
+ * and to access the manifest data (including icons).
+ *
+ * TODO:
+ * - Trigger appropriate app installed events
+ */
+
+import { ManifestObtainer } from "resource://gre/modules/ManifestObtainer.sys.mjs";
+
+import { ManifestIcons } from "resource://gre/modules/ManifestIcons.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+});
+
+/**
+ * Generates an hash for the given string.
+ *
+ * @note The generated hash is returned in base64 form. Mind the fact base64
+ * is case-sensitive if you are going to reuse this code.
+ */
+function generateHash(aString) {
+ const cryptoHash = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ cryptoHash.init(Ci.nsICryptoHash.MD5);
+ const stringStream = Cc[
+ "@mozilla.org/io/string-input-stream;1"
+ ].createInstance(Ci.nsIStringInputStream);
+ stringStream.data = aString;
+ cryptoHash.updateFromStream(stringStream, -1);
+ // base64 allows the '/' char, but we can't use it for filenames.
+ return cryptoHash.finish(true).replace(/\//g, "-");
+}
+
+/**
+ * Trims the query parameters from a url
+ */
+function stripQuery(url) {
+ return url.split("?")[0];
+}
+
+// Folder in which we store the manifest files
+const MANIFESTS_DIR = PathUtils.join(PathUtils.profileDir, "manifests");
+
+// We maintain a list of scopes for installed webmanifests so we can determine
+// whether a given url is within the scope of a previously installed manifest
+const MANIFESTS_FILE = "manifest-scopes.json";
+
+/**
+ * Manifest object
+ */
+
+class Manifest {
+ constructor(browser, manifestUrl) {
+ this._manifestUrl = manifestUrl;
+ // The key for this is the manifests URL that is required to be unique.
+ // However arbitrary urls are not safe file paths so lets hash it.
+ const fileName = generateHash(manifestUrl) + ".json";
+ this._path = PathUtils.join(MANIFESTS_DIR, fileName);
+ this.browser = browser;
+ }
+
+ get browser() {
+ return this._browser;
+ }
+
+ set browser(aBrowser) {
+ this._browser = aBrowser;
+ }
+
+ async initialize() {
+ this._store = new lazy.JSONFile({ path: this._path, saveDelayMs: 100 });
+ await this._store.load();
+ }
+
+ async prefetch(browser) {
+ const manifestData = await ManifestObtainer.browserObtainManifest(browser);
+ const icon = await ManifestIcons.browserFetchIcon(
+ browser,
+ manifestData,
+ 192
+ );
+ const data = {
+ installed: false,
+ manifest: manifestData,
+ cached_icon: icon,
+ };
+ return data;
+ }
+
+ async install() {
+ const manifestData = await ManifestObtainer.browserObtainManifest(
+ this._browser
+ );
+ this._store.data = {
+ installed: true,
+ manifest: manifestData,
+ };
+ Manifests.manifestInstalled(this);
+ this._store.saveSoon();
+ }
+
+ async icon(expectedSize) {
+ if ("cached_icon" in this._store.data) {
+ return this._store.data.cached_icon;
+ }
+ const icon = await ManifestIcons.browserFetchIcon(
+ this._browser,
+ this._store.data.manifest,
+ expectedSize
+ );
+ // Cache the icon so future requests do not go over the network
+ this._store.data.cached_icon = icon;
+ this._store.saveSoon();
+ return icon;
+ }
+
+ get scope() {
+ const scope =
+ this._store.data.manifest.scope || this._store.data.manifest.start_url;
+ return stripQuery(scope);
+ }
+
+ get name() {
+ return (
+ this._store.data.manifest.short_name ||
+ this._store.data.manifest.name ||
+ this._store.data.manifest.short_url
+ );
+ }
+
+ get url() {
+ return this._manifestUrl;
+ }
+
+ get installed() {
+ return (this._store.data && this._store.data.installed) || false;
+ }
+
+ get start_url() {
+ return this._store.data.manifest.start_url;
+ }
+
+ get path() {
+ return this._path;
+ }
+}
+
+/*
+ * Manifests maintains the list of installed manifests
+ */
+export var Manifests = {
+ async _initialize() {
+ if (this._readyPromise) {
+ return this._readyPromise;
+ }
+
+ // Prevent multiple initializations
+ this._readyPromise = (async () => {
+ // Make sure the manifests have the folder needed to save into
+ await IOUtils.makeDirectory(MANIFESTS_DIR, { ignoreExisting: true });
+
+ // Ensure any existing scope data we have about manifests is loaded
+ this._path = PathUtils.join(PathUtils.profileDir, MANIFESTS_FILE);
+ this._store = new lazy.JSONFile({ path: this._path });
+ await this._store.load();
+
+ // If we don't have any existing data, initialize empty
+ if (!this._store.data.hasOwnProperty("scopes")) {
+ this._store.data.scopes = new Map();
+ }
+ })();
+
+ // Cache the Manifest objects creates as they are references to files
+ // and we do not want multiple file handles
+ this.manifestObjs = new Map();
+ return this._readyPromise;
+ },
+
+ // When a manifest is installed, we save its scope so we can determine if
+ // future visits fall within this manifests scope
+ manifestInstalled(manifest) {
+ this._store.data.scopes[manifest.scope] = manifest.url;
+ this._store.saveSoon();
+ },
+
+ // Given a url, find if it is within an installed manifests scope and if so
+ // return that manifests url
+ findManifestUrl(url) {
+ for (let scope in this._store.data.scopes) {
+ if (url.startsWith(scope)) {
+ return this._store.data.scopes[scope];
+ }
+ }
+ return null;
+ },
+
+ // Get the manifest given a url, or if not look for a manifest that is
+ // tied to the current page
+ async getManifest(browser, manifestUrl) {
+ // Ensure we have all started up
+ if (!this._readyPromise) {
+ await this._initialize();
+ }
+
+ // If the client does not already know its manifestUrl, we take the
+ // url of the client and see if it matches the scope of any installed
+ // manifests
+ if (!manifestUrl) {
+ const url = stripQuery(browser.currentURI.spec);
+ manifestUrl = this.findManifestUrl(url);
+ }
+
+ // No matches so no manifest
+ if (manifestUrl === null) {
+ return null;
+ }
+
+ // If we have already created this manifest return cached
+ if (this.manifestObjs.has(manifestUrl)) {
+ const manifest = this.manifestObjs.get(manifestUrl);
+ if (manifest.browser !== browser) {
+ manifest.browser = browser;
+ }
+ return manifest;
+ }
+
+ // Otherwise create a new manifest object
+ const manifest = new Manifest(browser, manifestUrl);
+ this.manifestObjs.set(manifestUrl, manifest);
+ await manifest.initialize();
+ return manifest;
+ },
+};
diff --git a/dom/manifest/ManifestFinder.sys.mjs b/dom/manifest/ManifestFinder.sys.mjs
new file mode 100644
index 0000000000..1f8a23462b
--- /dev/null
+++ b/dom/manifest/ManifestFinder.sys.mjs
@@ -0,0 +1,57 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+
+export var ManifestFinder = {
+ /**
+ * Check from content process if DOM Window has a conforming
+ * manifest link relationship.
+ * @param aContent DOM Window to check.
+ * @return {Promise<Boolean>}
+ */
+ contentHasManifestLink(aContent) {
+ if (!aContent || isXULBrowser(aContent)) {
+ throw new TypeError("Invalid input.");
+ }
+ return checkForManifest(aContent);
+ },
+
+ /**
+ * Check from a XUL browser (parent process) if it's content document has a
+ * manifest link relationship.
+ * @param aBrowser The XUL browser to check.
+ * @return {Promise}
+ */
+ async browserHasManifestLink(aBrowser) {
+ if (!isXULBrowser(aBrowser)) {
+ throw new TypeError("Invalid input.");
+ }
+
+ const actor =
+ aBrowser.browsingContext.currentWindowGlobal.getActor("ManifestMessages");
+ const reply = await actor.sendQuery("DOM:WebManifest:hasManifestLink");
+ return reply.result;
+ },
+};
+
+function isXULBrowser(aBrowser) {
+ if (!aBrowser || !aBrowser.namespaceURI || !aBrowser.localName) {
+ return false;
+ }
+ const XUL_NS =
+ "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+ return aBrowser.namespaceURI === XUL_NS && aBrowser.localName === "browser";
+}
+
+function checkForManifest(aWindow) {
+ // Only top-level browsing contexts are valid.
+ if (!aWindow || aWindow.top !== aWindow) {
+ return false;
+ }
+ const elem = aWindow.document.querySelector("link[rel~='manifest']");
+ // Only if we have an element and a non-empty href attribute.
+ if (!elem || !elem.getAttribute("href")) {
+ return false;
+ }
+ return true;
+}
diff --git a/dom/manifest/ManifestIcons.sys.mjs b/dom/manifest/ManifestIcons.sys.mjs
new file mode 100644
index 0000000000..9994c40d55
--- /dev/null
+++ b/dom/manifest/ManifestIcons.sys.mjs
@@ -0,0 +1,82 @@
+/* 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/. */
+
+export var ManifestIcons = {
+ async browserFetchIcon(aBrowser, manifest, iconSize) {
+ const msgKey = "DOM:WebManifest:fetchIcon";
+
+ const actor =
+ aBrowser.browsingContext.currentWindowGlobal.getActor("ManifestMessages");
+ const reply = await actor.sendQuery(msgKey, { manifest, iconSize });
+ if (!reply.success) {
+ throw reply.result;
+ }
+ return reply.result;
+ },
+
+ async contentFetchIcon(aWindow, manifest, iconSize) {
+ return getIcon(aWindow, toIconArray(manifest.icons), iconSize);
+ },
+};
+
+function parseIconSize(size) {
+ if (size === "any" || size === "") {
+ // We want icons without size specified to sorted
+ // as the largest available icons
+ return Number.MAX_SAFE_INTEGER;
+ }
+ // 100x100 will parse as 100
+ return parseInt(size, 10);
+}
+
+// Create an array of icons sorted by their size
+function toIconArray(icons) {
+ const iconBySize = [];
+ icons.forEach(icon => {
+ const sizes = "sizes" in icon ? icon.sizes : "";
+ sizes.forEach(size => {
+ iconBySize.push({ src: icon.src, size: parseIconSize(size) });
+ });
+ });
+ return iconBySize.sort((a, b) => a.size - b.size);
+}
+
+async function getIcon(aWindow, icons, expectedSize) {
+ if (!icons.length) {
+ throw new Error("Could not find valid icon");
+ }
+ // We start trying the smallest icon that is larger than the requested
+ // size and go up to the largest icon if they fail, if all those fail
+ // go back down to the smallest
+ let index = icons.findIndex(icon => icon.size >= expectedSize);
+ if (index === -1) {
+ index = icons.length - 1;
+ }
+
+ return fetchIcon(aWindow, icons[index].src).catch(err => {
+ // Remove all icons with the failed source, the same source
+ // may have been used for multiple sizes
+ icons = icons.filter(x => x.src !== icons[index].src);
+ return getIcon(aWindow, icons, expectedSize);
+ });
+}
+
+async function fetchIcon(aWindow, src) {
+ const iconURL = new aWindow.URL(src, aWindow.location);
+ // If this is already a data URL then no need to load it again.
+ if (iconURL.protocol === "data:") {
+ return iconURL.href;
+ }
+
+ const request = new aWindow.Request(iconURL, { mode: "cors" });
+ request.overrideContentPolicyType(Ci.nsIContentPolicy.TYPE_IMAGE);
+ const response = await aWindow.fetch(request);
+ const blob = await response.blob();
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => resolve(reader.result);
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+}
diff --git a/dom/manifest/ManifestObtainer.sys.mjs b/dom/manifest/ManifestObtainer.sys.mjs
new file mode 100644
index 0000000000..de2863442a
--- /dev/null
+++ b/dom/manifest/ManifestObtainer.sys.mjs
@@ -0,0 +1,162 @@
+/* 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/.
+ */
+/*
+ * ManifestObtainer is an implementation of:
+ * http://w3c.github.io/manifest/#obtaining
+ *
+ * Exposes 2 public method:
+ *
+ * .contentObtainManifest(aContent) - used in content process
+ * .browserObtainManifest(aBrowser) - used in browser/parent process
+ *
+ * both return a promise. If successful, you get back a manifest object.
+ *
+ * Import it with URL:
+ * 'chrome://global/content/manifestMessages.js'
+ *
+ * e10s IPC message from this components are handled by:
+ * dom/ipc/manifestMessages.js
+ *
+ * Which is injected into every browser instance via browser.js.
+ */
+
+import { ManifestProcessor } from "resource://gre/modules/ManifestProcessor.sys.mjs";
+
+export var ManifestObtainer = {
+ /**
+ * Public interface for obtaining a web manifest from a XUL browser, to use
+ * on the parent process.
+ * @param {XULBrowser} The browser to check for the manifest.
+ * @param {Object} aOptions
+ * @param {Boolean} aOptions.checkConformance If spec conformance messages should be collected.
+ * Adds proprietary moz_* members to manifest.
+ * @return {Promise<Object>} The processed manifest.
+ */
+ async browserObtainManifest(
+ aBrowser,
+ aOptions = { checkConformance: false }
+ ) {
+ if (!isXULBrowser(aBrowser)) {
+ throw new TypeError("Invalid input. Expected XUL browser.");
+ }
+
+ const actor =
+ aBrowser.browsingContext.currentWindowGlobal.getActor("ManifestMessages");
+
+ const reply = await actor.sendQuery(
+ "DOM:ManifestObtainer:Obtain",
+ aOptions
+ );
+ if (!reply.success) {
+ const error = toError(reply.result);
+ throw error;
+ }
+ return reply.result;
+ },
+ /**
+ * Public interface for obtaining a web manifest from a XUL browser.
+ * @param {Window} aContent A content Window from which to extract the manifest.
+ * @param {Object} aOptions
+ * @param {Boolean} aOptions.checkConformance If spec conformance messages should be collected.
+ * Adds proprietary moz_* members to manifest.
+ * @return {Promise<Object>} The processed manifest.
+ */
+ async contentObtainManifest(
+ aContent,
+ aOptions = { checkConformance: false }
+ ) {
+ if (!Services.prefs.getBoolPref("dom.manifest.enabled")) {
+ throw new Error(
+ "Obtaining manifest is disabled by pref: dom.manifest.enabled"
+ );
+ }
+ if (!aContent || isXULBrowser(aContent)) {
+ const err = new TypeError("Invalid input. Expected a DOM Window.");
+ return Promise.reject(err);
+ }
+ const response = await fetchManifest(aContent);
+ const result = await processResponse(response, aContent, aOptions);
+ const clone = Cu.cloneInto(result, aContent);
+ return clone;
+ },
+};
+
+function toError(aErrorClone) {
+ let error;
+ switch (aErrorClone.name) {
+ case "TypeError":
+ error = new TypeError();
+ break;
+ default:
+ error = new Error();
+ }
+ Object.getOwnPropertyNames(aErrorClone).forEach(
+ name => (error[name] = aErrorClone[name])
+ );
+ return error;
+}
+
+function isXULBrowser(aBrowser) {
+ if (!aBrowser || !aBrowser.namespaceURI || !aBrowser.localName) {
+ return false;
+ }
+ const XUL_NS =
+ "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+ return aBrowser.namespaceURI === XUL_NS && aBrowser.localName === "browser";
+}
+
+/**
+ * Asynchronously processes the result of response after having fetched
+ * a manifest.
+ * @param {Response} aResp Response from fetch().
+ * @param {Window} aContentWindow The content window.
+ * @return {Promise<Object>} The processed manifest.
+ */
+async function processResponse(aResp, aContentWindow, aOptions) {
+ const badStatus = aResp.status < 200 || aResp.status >= 300;
+ if (aResp.type === "error" || badStatus) {
+ const msg = `Fetch error: ${aResp.status} - ${aResp.statusText} at ${aResp.url}`;
+ throw new Error(msg);
+ }
+ const text = await aResp.text();
+ const args = {
+ jsonText: text,
+ manifestURL: aResp.url,
+ docURL: aContentWindow.location.href,
+ };
+ const processingOptions = Object.assign({}, args, aOptions);
+ const manifest = ManifestProcessor.process(processingOptions);
+ return manifest;
+}
+
+/**
+ * Asynchronously fetches a web manifest.
+ * @param {Window} a The content Window from where to extract the manifest.
+ * @return {Promise<Object>}
+ */
+async function fetchManifest(aWindow) {
+ if (!aWindow || aWindow.top !== aWindow) {
+ const msg = "Window must be a top-level browsing context.";
+ throw new Error(msg);
+ }
+ const elem = aWindow.document.querySelector("link[rel~='manifest']");
+ if (!elem || !elem.getAttribute("href")) {
+ // There is no actual manifest to fetch, we just return null.
+ return new aWindow.Response("null");
+ }
+ // Throws on malformed URLs
+ const manifestURL = new aWindow.URL(elem.href, elem.baseURI);
+ const reqInit = {
+ credentials: "omit",
+ mode: "cors",
+ };
+ if (elem.crossOrigin === "use-credentials") {
+ reqInit.credentials = "include";
+ }
+ const request = new aWindow.Request(manifestURL, reqInit);
+ request.overrideContentPolicyType(Ci.nsIContentPolicy.TYPE_WEB_MANIFEST);
+ // Can reject...
+ return aWindow.fetch(request);
+}
diff --git a/dom/manifest/ManifestProcessor.sys.mjs b/dom/manifest/ManifestProcessor.sys.mjs
new file mode 100644
index 0000000000..6a7ea3b159
--- /dev/null
+++ b/dom/manifest/ManifestProcessor.sys.mjs
@@ -0,0 +1,345 @@
+/* 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/. */
+/*
+ * ManifestProcessor
+ * Implementation of processing algorithms from:
+ * http://www.w3.org/2008/webapps/manifest/
+ *
+ * Creates manifest processor that lets you process a JSON file
+ * or individual parts of a manifest object. A manifest is just a
+ * standard JS object that has been cleaned up.
+ *
+ * .process({jsonText,manifestURL,docURL});
+ *
+ * Depends on ImageObjectProcessor to process things like
+ * icons and splash_screens.
+ *
+ * TODO: The constructor should accept the UA's supported orientations.
+ * TODO: The constructor should accept the UA's supported display modes.
+ */
+
+const displayModes = new Set([
+ "fullscreen",
+ "standalone",
+ "minimal-ui",
+ "browser",
+]);
+const orientationTypes = new Set([
+ "any",
+ "natural",
+ "landscape",
+ "portrait",
+ "portrait-primary",
+ "portrait-secondary",
+ "landscape-primary",
+ "landscape-secondary",
+]);
+const textDirections = new Set(["ltr", "rtl", "auto"]);
+
+// ValueExtractor is used by the various processors to get values
+// from the manifest and to report errors.
+import { ValueExtractor } from "resource://gre/modules/ValueExtractor.sys.mjs";
+
+// ImageObjectProcessor is used to process things like icons and images
+import { ImageObjectProcessor } from "resource://gre/modules/ImageObjectProcessor.sys.mjs";
+
+const domBundle = Services.strings.createBundle(
+ "chrome://global/locale/dom/dom.properties"
+);
+
+export var ManifestProcessor = {
+ get defaultDisplayMode() {
+ return "browser";
+ },
+ get displayModes() {
+ return displayModes;
+ },
+ get orientationTypes() {
+ return orientationTypes;
+ },
+ get textDirections() {
+ return textDirections;
+ },
+ // process() method processes JSON text into a clean manifest
+ // that conforms with the W3C specification. Takes an object
+ // expecting the following dictionary items:
+ // * jsonText: the JSON string to be processed.
+ // * manifestURL: the URL of the manifest, to resolve URLs.
+ // * docURL: the URL of the owner doc, for security checks
+ // * checkConformance: boolean. If true, collects any conformance
+ // errors into a "moz_validation" property on the returned manifest.
+ process(aOptions) {
+ const {
+ jsonText,
+ manifestURL: aManifestURL,
+ docURL: aDocURL,
+ checkConformance,
+ } = aOptions;
+
+ // The errors get populated by the different process* functions.
+ const errors = [];
+
+ let rawManifest = {};
+ try {
+ rawManifest = JSON.parse(jsonText);
+ } catch (e) {
+ errors.push({ type: "json", error: e.message });
+ }
+ if (rawManifest === null) {
+ return null;
+ }
+ if (typeof rawManifest !== "object") {
+ const warn = domBundle.GetStringFromName("ManifestShouldBeObject");
+ errors.push({ warn });
+ rawManifest = {};
+ }
+ const manifestURL = new URL(aManifestURL);
+ const docURL = new URL(aDocURL);
+ const extractor = new ValueExtractor(errors, domBundle);
+ const imgObjProcessor = new ImageObjectProcessor(
+ errors,
+ extractor,
+ domBundle
+ );
+ const processedManifest = {
+ dir: processDirMember.call(this),
+ lang: processLangMember(),
+ start_url: processStartURLMember(),
+ display: processDisplayMember.call(this),
+ orientation: processOrientationMember.call(this),
+ name: processNameMember(),
+ icons: imgObjProcessor.process(rawManifest, manifestURL, "icons"),
+ short_name: processShortNameMember(),
+ theme_color: processThemeColorMember(),
+ background_color: processBackgroundColorMember(),
+ };
+ processedManifest.scope = processScopeMember();
+ processedManifest.id = processIdMember();
+ if (checkConformance) {
+ processedManifest.moz_validation = errors;
+ processedManifest.moz_manifest_url = manifestURL.href;
+ }
+ return processedManifest;
+
+ function processDirMember() {
+ const spec = {
+ objectName: "manifest",
+ object: rawManifest,
+ property: "dir",
+ expectedType: "string",
+ trim: true,
+ };
+ const value = extractor.extractValue(spec);
+ if (this.textDirections.has(value)) {
+ return value;
+ }
+ return "auto";
+ }
+
+ function processNameMember() {
+ const spec = {
+ objectName: "manifest",
+ object: rawManifest,
+ property: "name",
+ expectedType: "string",
+ trim: true,
+ };
+ return extractor.extractValue(spec);
+ }
+
+ function processShortNameMember() {
+ const spec = {
+ objectName: "manifest",
+ object: rawManifest,
+ property: "short_name",
+ expectedType: "string",
+ trim: true,
+ };
+ return extractor.extractValue(spec);
+ }
+
+ function processOrientationMember() {
+ const spec = {
+ objectName: "manifest",
+ object: rawManifest,
+ property: "orientation",
+ expectedType: "string",
+ trim: true,
+ };
+ const value = extractor.extractValue(spec);
+ if (
+ value &&
+ typeof value === "string" &&
+ this.orientationTypes.has(value.toLowerCase())
+ ) {
+ return value.toLowerCase();
+ }
+ return undefined;
+ }
+
+ function processDisplayMember() {
+ const spec = {
+ objectName: "manifest",
+ object: rawManifest,
+ property: "display",
+ expectedType: "string",
+ trim: true,
+ };
+ const value = extractor.extractValue(spec);
+ if (
+ value &&
+ typeof value === "string" &&
+ displayModes.has(value.toLowerCase())
+ ) {
+ return value.toLowerCase();
+ }
+ return this.defaultDisplayMode;
+ }
+
+ function processScopeMember() {
+ const spec = {
+ objectName: "manifest",
+ object: rawManifest,
+ property: "scope",
+ expectedType: "string",
+ trim: false,
+ };
+ let scopeURL;
+ const startURL = new URL(processedManifest.start_url);
+ const defaultScope = new URL(".", startURL).href;
+ const value = extractor.extractValue(spec);
+ if (value === undefined || value === "") {
+ return defaultScope;
+ }
+ try {
+ scopeURL = new URL(value, manifestURL);
+ } catch (e) {
+ const warn = domBundle.GetStringFromName("ManifestScopeURLInvalid");
+ errors.push({ warn });
+ return defaultScope;
+ }
+ if (scopeURL.origin !== docURL.origin) {
+ const warn = domBundle.GetStringFromName("ManifestScopeNotSameOrigin");
+ errors.push({ warn });
+ return defaultScope;
+ }
+ // If start URL is not within scope of scope URL:
+ if (
+ startURL.origin !== scopeURL.origin ||
+ startURL.pathname.startsWith(scopeURL.pathname) === false
+ ) {
+ const warn = domBundle.GetStringFromName(
+ "ManifestStartURLOutsideScope"
+ );
+ errors.push({ warn });
+ return defaultScope;
+ }
+ // Drop search params and fragment
+ // https://github.com/w3c/manifest/pull/961
+ scopeURL.hash = "";
+ scopeURL.search = "";
+ return scopeURL.href;
+ }
+
+ function processStartURLMember() {
+ const spec = {
+ objectName: "manifest",
+ object: rawManifest,
+ property: "start_url",
+ expectedType: "string",
+ trim: false,
+ };
+ const defaultStartURL = new URL(docURL).href;
+ const value = extractor.extractValue(spec);
+ if (value === undefined || value === "") {
+ return defaultStartURL;
+ }
+ let potentialResult;
+ try {
+ potentialResult = new URL(value, manifestURL);
+ } catch (e) {
+ const warn = domBundle.GetStringFromName("ManifestStartURLInvalid");
+ errors.push({ warn });
+ return defaultStartURL;
+ }
+ if (potentialResult.origin !== docURL.origin) {
+ const warn = domBundle.GetStringFromName(
+ "ManifestStartURLShouldBeSameOrigin"
+ );
+ errors.push({ warn });
+ return defaultStartURL;
+ }
+ return potentialResult.href;
+ }
+
+ function processThemeColorMember() {
+ const spec = {
+ objectName: "manifest",
+ object: rawManifest,
+ property: "theme_color",
+ expectedType: "string",
+ trim: true,
+ };
+ return extractor.extractColorValue(spec);
+ }
+
+ function processBackgroundColorMember() {
+ const spec = {
+ objectName: "manifest",
+ object: rawManifest,
+ property: "background_color",
+ expectedType: "string",
+ trim: true,
+ };
+ return extractor.extractColorValue(spec);
+ }
+
+ function processLangMember() {
+ const spec = {
+ objectName: "manifest",
+ object: rawManifest,
+ property: "lang",
+ expectedType: "string",
+ trim: true,
+ };
+ return extractor.extractLanguageValue(spec);
+ }
+
+ function processIdMember() {
+ // the start_url serves as the fallback, in case the id is not specified
+ // or in error. A start_url is assured.
+ const startURL = new URL(processedManifest.start_url);
+
+ const spec = {
+ objectName: "manifest",
+ object: rawManifest,
+ property: "id",
+ expectedType: "string",
+ trim: false,
+ };
+ const extractedValue = extractor.extractValue(spec);
+
+ if (typeof extractedValue !== "string" || extractedValue === "") {
+ return startURL.href;
+ }
+
+ let appId;
+ try {
+ appId = new URL(extractedValue, startURL.origin);
+ } catch {
+ const warn = domBundle.GetStringFromName("ManifestIdIsInvalid");
+ errors.push({ warn });
+ return startURL.href;
+ }
+
+ if (appId.origin !== startURL.origin) {
+ const warn = domBundle.GetStringFromName("ManifestIdNotSameOrigin");
+ errors.push({ warn });
+ return startURL.href;
+ }
+
+ return appId.href;
+ }
+ },
+};
diff --git a/dom/manifest/ValueExtractor.sys.mjs b/dom/manifest/ValueExtractor.sys.mjs
new file mode 100644
index 0000000000..d08cd24e1c
--- /dev/null
+++ b/dom/manifest/ValueExtractor.sys.mjs
@@ -0,0 +1,97 @@
+/* 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 https://mozilla.org/MPL/2.0/. */
+/*
+ * Helper functions extract values from manifest members
+ * and reports conformance errors.
+ */
+
+export class ValueExtractor {
+ constructor(errors, aBundle) {
+ this.errors = errors;
+ this.domBundle = aBundle;
+ }
+
+ /**
+ * @param options
+ * The 'spec' object.
+ * @note This function takes a 'spec' object and destructures it to extract
+ * a value. If the value is of the wrong type, it warns the developer
+ * and returns undefined.
+ * expectedType: is the type of a JS primitive (string, number, etc.)
+ * object: is the object from which to extract the value.
+ * objectName: string used to construct the developer warning.
+ * property: the name of the property being extracted.
+ * throwTypeError: boolean, throw a TypeError if the type is incorrect.
+ * trim: boolean, if the value should be trimmed (used by string type).
+ */
+ extractValue(options) {
+ const { expectedType, object, objectName, property, throwTypeError, trim } =
+ options;
+ const value = object[property];
+ const isArray = Array.isArray(value);
+
+ // We need to special-case "array", as it's not a JS primitive.
+ const type = isArray ? "array" : typeof value;
+ if (type !== expectedType) {
+ if (type !== "undefined") {
+ const warn = this.domBundle.formatStringFromName(
+ "ManifestInvalidType",
+ [objectName, property, expectedType]
+ );
+ this.errors.push({ warn });
+ if (throwTypeError) {
+ throw new TypeError(warn);
+ }
+ }
+ return undefined;
+ }
+
+ // Trim string and returned undefined if the empty string.
+ const shouldTrim = expectedType === "string" && value && trim;
+ if (shouldTrim) {
+ return value.trim() || undefined;
+ }
+ return value;
+ }
+
+ extractColorValue(spec) {
+ const value = this.extractValue(spec);
+ let color;
+ if (InspectorUtils.isValidCSSColor(value)) {
+ const rgba = InspectorUtils.colorToRGBA(value);
+ color =
+ "#" +
+ rgba.r.toString(16).padStart(2, "0") +
+ rgba.g.toString(16).padStart(2, "0") +
+ rgba.b.toString(16).padStart(2, "0") +
+ Math.round(rgba.a * 255)
+ .toString(16)
+ .padStart(2, "0");
+ } else if (value) {
+ const warn = this.domBundle.formatStringFromName(
+ "ManifestInvalidCSSColor",
+ [spec.property, value]
+ );
+ this.errors.push({ warn });
+ }
+ return color;
+ }
+
+ extractLanguageValue(spec) {
+ let langTag;
+ const value = this.extractValue(spec);
+ if (value !== undefined) {
+ try {
+ langTag = Intl.getCanonicalLocales(value)[0];
+ } catch (err) {
+ const warn = this.domBundle.formatStringFromName(
+ "ManifestLangIsInvalid",
+ [spec.property, value]
+ );
+ this.errors.push({ warn });
+ }
+ }
+ return langTag;
+ }
+}
diff --git a/dom/manifest/moz.build b/dom/manifest/moz.build
new file mode 100644
index 0000000000..9f25d2273d
--- /dev/null
+++ b/dom/manifest/moz.build
@@ -0,0 +1,21 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Core", "DOM: Core & HTML")
+
+EXTRA_JS_MODULES += [
+ "ImageObjectProcessor.sys.mjs",
+ "Manifest.sys.mjs",
+ "ManifestFinder.sys.mjs",
+ "ManifestIcons.sys.mjs",
+ "ManifestObtainer.sys.mjs",
+ "ManifestProcessor.sys.mjs",
+ "ValueExtractor.sys.mjs",
+]
+
+MOCHITEST_MANIFESTS += ["test/mochitest.toml"]
+BROWSER_CHROME_MANIFESTS += ["test/browser.toml"]
diff --git a/dom/manifest/test/blue-150.png b/dom/manifest/test/blue-150.png
new file mode 100644
index 0000000000..f4a62faddf
--- /dev/null
+++ b/dom/manifest/test/blue-150.png
Binary files differ
diff --git a/dom/manifest/test/browser.toml b/dom/manifest/test/browser.toml
new file mode 100644
index 0000000000..f5c28fcfb3
--- /dev/null
+++ b/dom/manifest/test/browser.toml
@@ -0,0 +1,23 @@
+[DEFAULT]
+support-files = [
+ "cookie_setter_with_credentials_cross_origin.html",
+ "cookie_setter_with_credentials.html",
+ "blue-150.png",
+ "cookie_checker.sjs",
+ "cookie_setter.html",
+ "file_testserver.sjs",
+ "manifestLoader.html",
+ "red-50.png",
+ "resource.sjs",
+]
+
+["browser_ManifestFinder_browserHasManifestLink.js"]
+
+["browser_ManifestIcons_browserFetchIcon.js"]
+
+["browser_ManifestObtainer_credentials.js"]
+
+["browser_ManifestObtainer_obtain.js"]
+
+["browser_Manifest_install.js"]
+skip-if = ["verify"]
diff --git a/dom/manifest/test/browser_ManifestFinder_browserHasManifestLink.js b/dom/manifest/test/browser_ManifestFinder_browserHasManifestLink.js
new file mode 100644
index 0000000000..360c98220b
--- /dev/null
+++ b/dom/manifest/test/browser_ManifestFinder_browserHasManifestLink.js
@@ -0,0 +1,89 @@
+"use strict";
+const { ManifestFinder } = ChromeUtils.importESModule(
+ "resource://gre/modules/ManifestFinder.sys.mjs"
+);
+const defaultURL = new URL(
+ "http://example.org/browser/dom/manifest/test/resource.sjs"
+);
+defaultURL.searchParams.set("Content-Type", "text/html; charset=utf-8");
+
+const tests = [
+ {
+ body: `
+ <link rel="manifesto" href='${defaultURL}?body={"name":"fail"}'>
+ <link rel="foo bar manifest bar test" href='${defaultURL}?body={"name":"value"}'>
+ <link rel="manifest" href='${defaultURL}?body={"name":"fail"}'>
+ `,
+ run(result) {
+ ok(result, "Document has a web manifest.");
+ },
+ },
+ {
+ body: `
+ <link rel="amanifista" href='${defaultURL}?body={"name":"fail"}'>
+ <link rel="foo bar manifesto bar test" href='${defaultURL}?body={"name":"pass-1"}'>
+ <link rel="manifesto" href='${defaultURL}?body={"name":"fail"}'>`,
+ run(result) {
+ ok(!result, "Document does not have a web manifest.");
+ },
+ },
+ {
+ body: `
+ <link rel="manifest" href="">
+ <link rel="manifest" href='${defaultURL}?body={"name":"fail"}'>`,
+ run(result) {
+ ok(!result, "Manifest link is has empty href.");
+ },
+ },
+ {
+ body: `
+ <link rel="manifest">
+ <link rel="manifest" href='${defaultURL}?body={"name":"fail"}'>`,
+ run(result) {
+ ok(!result, "Manifest link is missing.");
+ },
+ },
+];
+
+function makeTestURL({ body }) {
+ const url = new URL(defaultURL);
+ url.searchParams.set("body", encodeURIComponent(body));
+ return url.href;
+}
+
+/**
+ * Test basic API error conditions
+ */
+add_task(async function () {
+ const expected = "Invalid types should throw a TypeError.";
+ for (let invalidValue of [undefined, null, 1, {}, "test"]) {
+ try {
+ await ManifestFinder.contentManifestLink(invalidValue);
+ ok(false, expected);
+ } catch (e) {
+ is(e.name, "TypeError", expected);
+ }
+ try {
+ await ManifestFinder.browserManifestLink(invalidValue);
+ ok(false, expected);
+ } catch (e) {
+ is(e.name, "TypeError", expected);
+ }
+ }
+});
+
+add_task(async function () {
+ const runningTests = tests
+ .map(test => ({
+ gBrowser,
+ test,
+ url: makeTestURL(test),
+ }))
+ .map(tabOptions =>
+ BrowserTestUtils.withNewTab(tabOptions, async function (browser) {
+ const result = await ManifestFinder.browserHasManifestLink(browser);
+ tabOptions.test.run(result);
+ })
+ );
+ await Promise.all(runningTests);
+});
diff --git a/dom/manifest/test/browser_ManifestIcons_browserFetchIcon.js b/dom/manifest/test/browser_ManifestIcons_browserFetchIcon.js
new file mode 100644
index 0000000000..e56e1e25c2
--- /dev/null
+++ b/dom/manifest/test/browser_ManifestIcons_browserFetchIcon.js
@@ -0,0 +1,66 @@
+"use strict";
+
+Services.prefs.setBoolPref("dom.manifest.enabled", true);
+
+const { ManifestIcons } = ChromeUtils.importESModule(
+ "resource://gre/modules/ManifestIcons.sys.mjs"
+);
+const { ManifestObtainer } = ChromeUtils.importESModule(
+ "resource://gre/modules/ManifestObtainer.sys.mjs"
+);
+
+const defaultURL = new URL(
+ "https://example.org/browser/dom/manifest/test/resource.sjs"
+);
+defaultURL.searchParams.set("Content-Type", "application/manifest+json");
+
+const manifestMock = JSON.stringify({
+ icons: [
+ {
+ sizes: "50x50",
+ src: "red-50.png?Content-type=image/png",
+ },
+ {
+ sizes: "150x150",
+ src: "blue-150.png?Content-type=image/png",
+ },
+ ],
+});
+
+function makeTestURL() {
+ const url = new URL(defaultURL);
+ const body = `<link rel="manifest" href='${defaultURL}&body=${manifestMock}'>`;
+ url.searchParams.set("Content-Type", "text/html; charset=utf-8");
+ url.searchParams.set("body", encodeURIComponent(body));
+ return url.href;
+}
+
+function getIconColor(icon) {
+ return new Promise((resolve, reject) => {
+ const canvas = content.document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ const image = new content.Image();
+ image.onload = function () {
+ ctx.drawImage(image, 0, 0);
+ resolve(ctx.getImageData(1, 1, 1, 1).data);
+ };
+ image.onerror = function () {
+ reject(new Error("could not create image"));
+ };
+ image.src = icon;
+ });
+}
+
+add_task(async function () {
+ const tabOptions = { gBrowser, url: makeTestURL() };
+ await BrowserTestUtils.withNewTab(tabOptions, async function (browser) {
+ const manifest = await ManifestObtainer.browserObtainManifest(browser);
+ let icon = await ManifestIcons.browserFetchIcon(browser, manifest, 25);
+ let color = await SpecialPowers.spawn(browser, [icon], getIconColor);
+ is(color[0], 255, "Fetched red icon");
+
+ icon = await ManifestIcons.browserFetchIcon(browser, manifest, 500);
+ color = await SpecialPowers.spawn(browser, [icon], getIconColor);
+ is(color[2], 255, "Fetched blue icon");
+ });
+});
diff --git a/dom/manifest/test/browser_ManifestObtainer_credentials.js b/dom/manifest/test/browser_ManifestObtainer_credentials.js
new file mode 100644
index 0000000000..826ec24fc0
--- /dev/null
+++ b/dom/manifest/test/browser_ManifestObtainer_credentials.js
@@ -0,0 +1,45 @@
+"use strict";
+
+Services.prefs.setBoolPref("dom.manifest.enabled", true);
+
+const { ManifestObtainer } = ChromeUtils.importESModule(
+ "resource://gre/modules/ManifestObtainer.sys.mjs"
+);
+
+// Don't send cookies
+add_task(async function () {
+ const testPath = "/browser/dom/manifest/test/cookie_setter.html";
+ const tabURL = `https://example.com${testPath}`;
+ const browser = BrowserTestUtils.addTab(gBrowser, tabURL).linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+ const { short_name } = await ManifestObtainer.browserObtainManifest(browser);
+ is(short_name, "no cookie");
+ const tab = gBrowser.getTabForBrowser(browser);
+ gBrowser.removeTab(tab);
+});
+
+// Send cookies
+add_task(async function () {
+ const testPath =
+ "/browser/dom/manifest/test/cookie_setter_with_credentials.html";
+ const tabURL = `https://example.com${testPath}`;
+ const browser = BrowserTestUtils.addTab(gBrowser, tabURL).linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+ const { short_name } = await ManifestObtainer.browserObtainManifest(browser);
+ is(short_name, "🍪");
+ const tab = gBrowser.getTabForBrowser(browser);
+ gBrowser.removeTab(tab);
+});
+
+// Cross origin - we go from example.com to example.org
+add_task(async function () {
+ const testPath =
+ "/browser/dom/manifest/test/cookie_setter_with_credentials_cross_origin.html";
+ const tabURL = `https://example.com${testPath}`;
+ const browser = BrowserTestUtils.addTab(gBrowser, tabURL).linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+ const { short_name } = await ManifestObtainer.browserObtainManifest(browser);
+ is(short_name, "no cookie");
+ const tab = gBrowser.getTabForBrowser(browser);
+ gBrowser.removeTab(tab);
+});
diff --git a/dom/manifest/test/browser_ManifestObtainer_obtain.js b/dom/manifest/test/browser_ManifestObtainer_obtain.js
new file mode 100644
index 0000000000..a6d1dd6f23
--- /dev/null
+++ b/dom/manifest/test/browser_ManifestObtainer_obtain.js
@@ -0,0 +1,268 @@
+"use strict";
+
+Services.prefs.setBoolPref("dom.manifest.enabled", true);
+Services.prefs.setBoolPref("dom.security.https_first", false);
+
+const { ManifestObtainer } = ChromeUtils.importESModule(
+ "resource://gre/modules/ManifestObtainer.sys.mjs"
+);
+const remoteURL =
+ "http://mochi.test:8888/browser/dom/manifest/test/resource.sjs";
+const defaultURL = new URL(
+ "http://example.org/browser/dom/manifest/test/resource.sjs"
+);
+defaultURL.searchParams.set("Content-Type", "text/html; charset=utf-8");
+requestLongerTimeout(4);
+
+const tests = [
+ // Fetch tests.
+ {
+ body: `<!-- no manifest in document -->`,
+ run(manifest) {
+ is(manifest, null, "Manifest without a href yields a null manifest.");
+ },
+ },
+ {
+ body: `<link rel="manifest">`,
+ run(manifest) {
+ is(manifest, null, "Manifest without a href yields a null manifest.");
+ },
+ },
+ {
+ body: `
+ <link rel="manifesto" href='resource.sjs?body={"name":"fail"}'>
+ <link rel="foo bar manifest bar test" href='resource.sjs?body={"name":"pass-1"}'>
+ <link rel="manifest" href='resource.sjs?body={"name":"fail"}'>`,
+ run(manifest) {
+ is(
+ manifest.name,
+ "pass-1",
+ "Manifest is first `link` where @rel contains token manifest."
+ );
+ },
+ },
+ {
+ body: `
+ <link rel="foo bar manifest bar test" href='resource.sjs?body={"name":"pass-2"}'>
+ <link rel="manifest" href='resource.sjs?body={"name":"fail"}'>
+ <link rel="manifest foo bar test" href='resource.sjs?body={"name":"fail"}'>`,
+ run(manifest) {
+ is(
+ manifest.name,
+ "pass-2",
+ "Manifest is first `link` where @rel contains token manifest."
+ );
+ },
+ },
+ {
+ body: `<link rel="manifest" href='${remoteURL}?body={"name":"pass-3"}'>`,
+ run(err) {
+ is(
+ err.name,
+ "TypeError",
+ "By default, manifest cannot load cross-origin."
+ );
+ },
+ },
+ // CORS Tests.
+ {
+ get body() {
+ const body = 'body={"name": "pass-4"}';
+ const CORS = `Access-Control-Allow-Origin=${defaultURL.origin}`;
+ const link = `<link
+ crossorigin=anonymous
+ rel="manifest"
+ href='${remoteURL}?${body}&${CORS}'>`;
+ return link;
+ },
+ run(manifest) {
+ is(manifest.name, "pass-4", "CORS enabled, manifest must be fetched.");
+ },
+ },
+ {
+ get body() {
+ const body = 'body={"name": "fail"}';
+ const CORS = "Access-Control-Allow-Origin=http://not-here";
+ const link = `<link
+ crossorigin
+ rel="manifest"
+ href='${remoteURL}?${body}&${CORS}'>`;
+ return link;
+ },
+ run(err) {
+ is(
+ err.name,
+ "TypeError",
+ "Fetch blocked by CORS - origin does not match."
+ );
+ },
+ },
+ {
+ body: `<link rel="manifest" href='about:whatever'>`,
+ run(err) {
+ is(
+ err.name,
+ "TypeError",
+ "Trying to load from about:whatever is TypeError."
+ );
+ },
+ },
+ {
+ body: `<link rel="manifest" href='file://manifest'>`,
+ run(err) {
+ is(
+ err.name,
+ "TypeError",
+ "Trying to load from file://whatever is a TypeError."
+ );
+ },
+ },
+ // URL parsing tests
+ {
+ body: `<link rel="manifest" href='http://[12.1212.21.21.12.21.12]'>`,
+ run(err) {
+ is(err.name, "TypeError", "Trying to load invalid URL is a TypeError.");
+ },
+ },
+];
+
+function makeTestURL({ body }) {
+ const url = new URL(defaultURL);
+ url.searchParams.set("body", encodeURIComponent(body));
+ return url.href;
+}
+
+add_task(async function () {
+ const promises = tests
+ .map(test => ({
+ gBrowser,
+ testRunner: testObtainingManifest(test),
+ url: makeTestURL(test),
+ }))
+ .reduce((collector, tabOpts) => {
+ const promise = BrowserTestUtils.withNewTab(tabOpts, tabOpts.testRunner);
+ collector.push(promise);
+ return collector;
+ }, []);
+
+ await Promise.all(promises);
+
+ function testObtainingManifest(aTest) {
+ return async function (aBrowser) {
+ try {
+ const manifest = await ManifestObtainer.browserObtainManifest(aBrowser);
+ aTest.run(manifest);
+ } catch (e) {
+ aTest.run(e);
+ }
+ };
+ }
+});
+
+add_task(async () => {
+ // This loads a generic html page.
+ const url = new URL(defaultURL);
+ // The body get injected into the page on the server.
+ const body = `<link rel="manifest" href='resource.sjs?body={"name": "conformance check"}'>`;
+ url.searchParams.set("body", encodeURIComponent(body));
+
+ // Let's open a tab!
+ const tabOpts = {
+ gBrowser,
+ url: url.href,
+ };
+ // Let's do the test
+ await BrowserTestUtils.withNewTab(tabOpts, async aBrowser => {
+ const obtainerOpts = {
+ checkConformance: true, // gives us back "moz_manifest_url" member
+ };
+ const manifest = await ManifestObtainer.browserObtainManifest(
+ aBrowser,
+ obtainerOpts
+ );
+ is(manifest.name, "conformance check");
+ ok("moz_manifest_url" in manifest, "Has a moz_manifest_url member");
+ const testString = defaultURL.origin + defaultURL.pathname;
+ ok(
+ manifest.moz_manifest_url.startsWith(testString),
+ `Expect to start with with the testString, but got ${manifest.moz_manifest_url} instead,`
+ );
+ // Clean up!
+ gBrowser.removeTab(gBrowser.getTabForBrowser(aBrowser));
+ });
+});
+
+/*
+ * e10s race condition tests
+ * Open a bunch of tabs and load manifests
+ * in each tab. They should all return pass.
+ */
+add_task(async function () {
+ const defaultPath = "/browser/dom/manifest/test/manifestLoader.html";
+ const tabURLs = [
+ `http://example.com:80${defaultPath}`,
+ `http://example.org:80${defaultPath}`,
+ `http://example.org:8000${defaultPath}`,
+ `http://mochi.test:8888${defaultPath}`,
+ `http://sub1.test1.example.com:80${defaultPath}`,
+ `http://sub1.test1.example.org:80${defaultPath}`,
+ `http://sub1.test1.example.org:8000${defaultPath}`,
+ `http://sub1.test1.mochi.test:8888${defaultPath}`,
+ `http://sub1.test2.example.com:80${defaultPath}`,
+ `http://sub1.test2.example.org:80${defaultPath}`,
+ `http://sub1.test2.example.org:8000${defaultPath}`,
+ `http://sub2.test1.example.com:80${defaultPath}`,
+ `http://sub2.test1.example.org:80${defaultPath}`,
+ `http://sub2.test1.example.org:8000${defaultPath}`,
+ `http://sub2.test2.example.com:80${defaultPath}`,
+ `http://sub2.test2.example.org:80${defaultPath}`,
+ `http://sub2.test2.example.org:8000${defaultPath}`,
+ `http://sub2.xn--lt-uia.mochi.test:8888${defaultPath}`,
+ `http://test1.example.com:80${defaultPath}`,
+ `http://test1.example.org:80${defaultPath}`,
+ `http://test1.example.org:8000${defaultPath}`,
+ `http://test1.mochi.test:8888${defaultPath}`,
+ `http://test2.example.com:80${defaultPath}`,
+ `http://test2.example.org:80${defaultPath}`,
+ `http://test2.example.org:8000${defaultPath}`,
+ `http://test2.mochi.test:8888${defaultPath}`,
+ `http://test:80${defaultPath}`,
+ `http://www.example.com:80${defaultPath}`,
+ ];
+ // Open tabs an collect corresponding browsers
+ let browsers = tabURLs.map(
+ url => BrowserTestUtils.addTab(gBrowser, url).linkedBrowser
+ );
+
+ // Once all the pages have loaded, run a bunch of tests in "parallel".
+ await Promise.all(
+ (function* () {
+ for (let browser of browsers) {
+ yield BrowserTestUtils.browserLoaded(browser);
+ }
+ })()
+ );
+ // Flood random browsers with requests. Once promises settle, check that
+ // responses all pass.
+ const results = await Promise.all(
+ (function* () {
+ for (let browser of randBrowsers(browsers, 50)) {
+ yield ManifestObtainer.browserObtainManifest(browser);
+ }
+ })()
+ );
+ const pass = results.every(manifest => manifest.name === "pass");
+ ok(pass, "Expect every manifest to have name equal to `pass`.");
+ // cleanup
+ browsers
+ .map(browser => gBrowser.getTabForBrowser(browser))
+ .forEach(tab => gBrowser.removeTab(tab));
+
+ // Helper generator, spits out random browsers
+ function* randBrowsers(aBrowsers, aMax) {
+ for (let i = 0; i < aMax; i++) {
+ const randNum = Math.round(Math.random() * (aBrowsers.length - 1));
+ yield aBrowsers[randNum];
+ }
+ }
+});
diff --git a/dom/manifest/test/browser_Manifest_install.js b/dom/manifest/test/browser_Manifest_install.js
new file mode 100644
index 0000000000..d3b949be19
--- /dev/null
+++ b/dom/manifest/test/browser_Manifest_install.js
@@ -0,0 +1,58 @@
+"use strict";
+
+const { Manifests } = ChromeUtils.importESModule(
+ "resource://gre/modules/Manifest.sys.mjs"
+);
+
+const defaultURL = new URL(
+ "http://example.org/browser/dom/manifest/test/resource.sjs"
+);
+defaultURL.searchParams.set("Content-Type", "application/manifest+json");
+
+const manifestMock = JSON.stringify({
+ short_name: "hello World",
+ scope: "/browser/",
+});
+const manifestUrl = `${defaultURL}&body=${manifestMock}`;
+
+function makeTestURL() {
+ const url = new URL(defaultURL);
+ const body = `<link rel="manifest" href='${manifestUrl}'>`;
+ url.searchParams.set("Content-Type", "text/html; charset=utf-8");
+ url.searchParams.set("body", encodeURIComponent(body));
+ return url.href;
+}
+
+add_task(async function () {
+ const tabOptions = { gBrowser, url: makeTestURL() };
+
+ await BrowserTestUtils.withNewTab(tabOptions, async function (browser) {
+ let manifest = await Manifests.getManifest(browser, manifestUrl);
+ is(manifest.installed, false, "We haven't installed this manifest yet");
+
+ await manifest.install(browser);
+ is(manifest.name, "hello World", "Manifest has correct name");
+ is(manifest.installed, true, "Manifest is installed");
+ is(manifest.url, manifestUrl, "has correct url");
+ is(manifest.browser, browser, "has correct browser");
+
+ manifest = await Manifests.getManifest(browser, manifestUrl);
+ is(manifest.installed, true, "New instances are installed");
+
+ manifest = await Manifests.getManifest(browser);
+ is(manifest.installed, true, "Will find manifest without being given url");
+
+ let foundManifest = Manifests.findManifestUrl(
+ "http://example.org/browser/dom/"
+ );
+ is(foundManifest, manifestUrl, "Finds manifests within scope");
+
+ foundManifest = Manifests.findManifestUrl("http://example.org/");
+ is(foundManifest, null, "Does not find manifests outside scope");
+ });
+ // Get the cached one now
+ await BrowserTestUtils.withNewTab(tabOptions, async browser => {
+ const manifest = await Manifests.getManifest(browser, manifestUrl);
+ is(manifest.browser, browser, "has updated browser object");
+ });
+});
diff --git a/dom/manifest/test/common.js b/dom/manifest/test/common.js
new file mode 100644
index 0000000000..50f27c119a
--- /dev/null
+++ b/dom/manifest/test/common.js
@@ -0,0 +1,139 @@
+/**
+ * Common infrastructure for manifest tests.
+ **/
+"use strict";
+const { ManifestProcessor } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://gre/modules/ManifestProcessor.sys.mjs"
+);
+const processor = ManifestProcessor;
+const manifestURL = new URL(document.location.origin + "/manifest.json");
+const docURL = document.location;
+const seperators =
+ "\u2028\u2029\u0020\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000";
+const lineTerminators = "\u000D\u000A\u2028\u2029";
+const whiteSpace = `${seperators}${lineTerminators}`;
+const typeTests = [1, null, {}, [], false];
+const data = {
+ jsonText: "{}",
+ manifestURL,
+ docURL,
+};
+
+const validThemeColors = [
+ ["maroon", "#800000ff"],
+ ["#f00", "#ff0000ff"],
+ ["#ff0000", "#ff0000ff"],
+ ["rgb(255,0,0)", "#ff0000ff"],
+ ["rgb(255,0,0,1)", "#ff0000ff"],
+ ["rgb(255,0,0,1.0)", "#ff0000ff"],
+ ["rgb(255,0,0,100%)", "#ff0000ff"],
+ ["rgb(255 0 0)", "#ff0000ff"],
+ ["rgb(255 0 0 / 1)", "#ff0000ff"],
+ ["rgb(255 0 0 / 1.0)", "#ff0000ff"],
+ ["rgb(255 0 0 / 100%)", "#ff0000ff"],
+ ["rgb(100%, 0%, 0%)", "#ff0000ff"],
+ ["rgb(100%, 0%, 0%, 1)", "#ff0000ff"],
+ ["rgb(100%, 0%, 0%, 1.0)", "#ff0000ff"],
+ ["rgb(100%, 0%, 0%, 100%)", "#ff0000ff"],
+ ["rgb(100% 0% 0%)", "#ff0000ff"],
+ ["rgb(100% 0% 0% / 1)", "#ff0000ff"],
+ ["rgb(100%, 0%, 0%, 1.0)", "#ff0000ff"],
+ ["rgb(100%, 0%, 0%, 100%)", "#ff0000ff"],
+ ["rgb(300,0,0)", "#ff0000ff"],
+ ["rgb(300 0 0)", "#ff0000ff"],
+ ["rgb(255,-10,0)", "#ff0000ff"],
+ ["rgb(110%, 0%, 0%)", "#ff0000ff"],
+ ["rgba(255,0,0)", "#ff0000ff"],
+ ["rgba(255,0,0,1)", "#ff0000ff"],
+ ["rgba(255 0 0 / 1)", "#ff0000ff"],
+ ["rgba(100%,0%,0%,1)", "#ff0000ff"],
+ ["rgba(0,0,255,0.5)", "#0000ff80"],
+ ["rgba(100%, 50%, 0%, 0.1)", "#ff80001a"],
+ ["hsl(120, 100%, 50%)", "#00ff00ff"],
+ ["hsl(120 100% 50%)", "#00ff00ff"],
+ ["hsl(120, 100%, 50%, 1.0)", "#00ff00ff"],
+ ["hsl(120 100% 50% / 1.0)", "#00ff00ff"],
+ ["hsla(120, 100%, 50%)", "#00ff00ff"],
+ ["hsla(120 100% 50%)", "#00ff00ff"],
+ ["hsla(120, 100%, 50%, 1.0)", "#00ff00ff"],
+ ["hsla(120 100% 50% / 1.0)", "#00ff00ff"],
+ ["hsl(120deg, 100%, 50%)", "#00ff00ff"],
+ ["hsl(133.33333333grad, 100%, 50%)", "#00ff00ff"],
+ ["hsl(2.0943951024rad, 100%, 50%)", "#00ff00ff"],
+ ["hsl(0.3333333333turn, 100%, 50%)", "#00ff00ff"],
+];
+
+function setupManifest(key, value) {
+ const manifest = {};
+ manifest[key] = value;
+ data.jsonText = JSON.stringify(manifest);
+}
+
+function testValidColors(key) {
+ validThemeColors.forEach(item => {
+ const [manifest_color, parsed_color] = item;
+ setupManifest(key, manifest_color);
+ const result = processor.process(data);
+
+ is(
+ result[key],
+ parsed_color,
+ `Expect ${key} to be returned for ${manifest_color}`
+ );
+ });
+
+ // Trim tests
+ validThemeColors.forEach(item => {
+ const [manifest_color, parsed_color] = item;
+ const expandedThemeColor = `${seperators}${lineTerminators}${manifest_color}${lineTerminators}${seperators}`;
+ setupManifest(key, expandedThemeColor);
+ const result = processor.process(data);
+
+ is(
+ result[key],
+ parsed_color,
+ `Expect trimmed ${key} to be returned for ${manifest_color}`
+ );
+ });
+}
+
+const invalidThemeColors = [
+ "marooon",
+ "f000000",
+ "#ff00000",
+ "rgb(100, 0%, 0%)",
+ "rgb(255,0)",
+ "rbg(255,-10,0)",
+ "rgb(110, 0%, 0%)",
+ "(255,0,0) }",
+ "rgba(255)",
+ " rgb(100%,0%,0%) }",
+ "hsl(120, 100%, 50)",
+ "hsl(120, 100%, 50.0)",
+ "hsl 120, 100%, 50%",
+ "hsla{120, 100%, 50%, 1}",
+];
+
+function testInvalidColors(key) {
+ typeTests.forEach(type => {
+ setupManifest(key, type);
+ const result = processor.process(data);
+
+ is(
+ result[key],
+ undefined,
+ `Expect non-string ${key} to be undefined: ${typeof type}.`
+ );
+ });
+
+ invalidThemeColors.forEach(manifest_color => {
+ setupManifest(key, manifest_color);
+ const result = processor.process(data);
+
+ is(
+ result[key],
+ undefined,
+ `Expect ${key} to be undefined: ${manifest_color}.`
+ );
+ });
+}
diff --git a/dom/manifest/test/cookie_checker.sjs b/dom/manifest/test/cookie_checker.sjs
new file mode 100644
index 0000000000..5405a6207b
--- /dev/null
+++ b/dom/manifest/test/cookie_checker.sjs
@@ -0,0 +1,24 @@
+"use strict";
+let { NetUtil } = ChromeUtils.importESModule(
+ "resource://gre/modules/NetUtil.sys.mjs"
+);
+
+function handleRequest(request, response) {
+ response.setStatusLine(request.httpVersion, 200);
+ // avoid confusing cache behaviors
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "application/json", false);
+
+ // CORS stuff
+ const origin = request.hasHeader("Origin")
+ ? request.getHeader("Origin")
+ : null;
+ if (origin) {
+ response.setHeader("Access-Control-Allow-Origin", origin);
+ response.setHeader("Access-Control-Allow-Credentials", "true");
+ }
+ const short_name = request.hasHeader("Cookie")
+ ? request.getHeader("Cookie")
+ : "no cookie";
+ response.write(JSON.stringify({ short_name }));
+}
diff --git a/dom/manifest/test/cookie_setter.html b/dom/manifest/test/cookie_setter.html
new file mode 100644
index 0000000000..d8f412fbcb
--- /dev/null
+++ b/dom/manifest/test/cookie_setter.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script>
+ document.cookie = "🍪";
+</script>
+<link
+ rel="manifest"
+ href="cookie_checker.sjs">
diff --git a/dom/manifest/test/cookie_setter_with_credentials.html b/dom/manifest/test/cookie_setter_with_credentials.html
new file mode 100644
index 0000000000..0d721b55ef
--- /dev/null
+++ b/dom/manifest/test/cookie_setter_with_credentials.html
@@ -0,0 +1,6 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script>
+ document.cookie = "🍪";
+</script>
+<link rel="manifest" href="cookie_checker.sjs" crossorigin="use-credentials"`>
diff --git a/dom/manifest/test/cookie_setter_with_credentials_cross_origin.html b/dom/manifest/test/cookie_setter_with_credentials_cross_origin.html
new file mode 100644
index 0000000000..6d1c4045c3
--- /dev/null
+++ b/dom/manifest/test/cookie_setter_with_credentials_cross_origin.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<h1>Cross origin cookie sender</h1>
+<script>
+ document.cookie = "🍪";
+</script>
+<!--
+ This is loaded from "example.com", which then loads
+ the manifest from "example.org".
+-->
+<link
+ rel="manifest"
+ href="https://example.org/browser/dom/manifest/test/cookie_checker.sjs"
+ crossorigin="use-credentials">
diff --git a/dom/manifest/test/file_testserver.sjs b/dom/manifest/test/file_testserver.sjs
new file mode 100644
index 0000000000..aec29ee144
--- /dev/null
+++ b/dom/manifest/test/file_testserver.sjs
@@ -0,0 +1,56 @@
+"use strict";
+let { NetUtil } = ChromeUtils.importESModule(
+ "resource://gre/modules/NetUtil.sys.mjs"
+);
+
+function loadHTMLFromFile(path) {
+ // Load the HTML to return in the response from file.
+ // Since it's relative to the cwd of the test runner, we start there and
+ // append to get to the actual path of the file.
+ const testHTMLFile = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
+
+ const testHTMLFileStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+
+ path
+ .split("/")
+ .filter(path_1 => path_1)
+ .reduce((file, path_2) => {
+ testHTMLFile.append(path_2);
+ return testHTMLFile;
+ }, testHTMLFile);
+ testHTMLFileStream.init(testHTMLFile, -1, 0, 0);
+ const isAvailable = testHTMLFileStream.available();
+ return NetUtil.readInputStreamToString(testHTMLFileStream, isAvailable);
+}
+
+function handleRequest(request, response) {
+ const query = new URLSearchParams(request.queryString);
+
+ // avoid confusing cache behaviors
+ response.setHeader("Cache-Control", "no-cache", false);
+
+ // Deliver the CSP policy encoded in the URL
+ if (query.has("csp")) {
+ response.setHeader("Content-Security-Policy", query.get("csp"), false);
+ }
+
+ // Deliver the CSPRO policy encoded in the URL
+ if (query.has("cspro")) {
+ response.setHeader(
+ "Content-Security-Policy-Report-Only",
+ query.get("cspro"),
+ false
+ );
+ }
+
+ // Deliver the CORS header in the URL
+ if (query.has("cors")) {
+ response.setHeader("Access-Control-Allow-Origin", query.get("cors"), false);
+ }
+
+ // Send HTML to test allowed/blocked behaviors
+ response.setHeader("Content-Type", "text/html", false);
+ response.write(loadHTMLFromFile(query.get("file")));
+}
diff --git a/dom/manifest/test/icon.png b/dom/manifest/test/icon.png
new file mode 100644
index 0000000000..bb87f783b7
--- /dev/null
+++ b/dom/manifest/test/icon.png
Binary files differ
diff --git a/dom/manifest/test/manifestLoader.html b/dom/manifest/test/manifestLoader.html
new file mode 100644
index 0000000000..e244260902
--- /dev/null
+++ b/dom/manifest/test/manifestLoader.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<meta charset=utf-8>
+<!--
+Uses resource.sjs to load a Web Manifest that can be loaded cross-origin.
+-->
+<link rel="manifest" href='resource.sjs?body={"name":"pass"}&amp;Access-Control-Allow-Origin=*'>
+<h1>Manifest loader</h1>
+<p>Uses resource.sjs to load a Web Manifest that can be loaded cross-origin. The manifest looks like this:</p>
+<pre>
+{
+ "name":"pass"
+}
+</pre>
diff --git a/dom/manifest/test/mochitest.toml b/dom/manifest/test/mochitest.toml
new file mode 100644
index 0000000000..e674c3229e
--- /dev/null
+++ b/dom/manifest/test/mochitest.toml
@@ -0,0 +1,43 @@
+[DEFAULT]
+support-files = [
+ "common.js",
+ "resource.sjs",
+ "manifestLoader.html",
+ "file_testserver.sjs",
+]
+
+["test_ImageObjectProcessor_purpose.html"]
+
+["test_ImageObjectProcessor_sizes.html"]
+
+["test_ImageObjectProcessor_src.html"]
+
+["test_ImageObjectProcessor_type.html"]
+
+["test_ManifestProcessor_JSON.html"]
+
+["test_ManifestProcessor_background_color.html"]
+
+["test_ManifestProcessor_dir.html"]
+
+["test_ManifestProcessor_display.html"]
+
+["test_ManifestProcessor_icons.html"]
+
+["test_ManifestProcessor_id.html"]
+
+["test_ManifestProcessor_lang.html"]
+
+["test_ManifestProcessor_name_and_short_name.html"]
+
+["test_ManifestProcessor_orientation.html"]
+
+["test_ManifestProcessor_scope.html"]
+
+["test_ManifestProcessor_start_url.html"]
+
+["test_ManifestProcessor_theme_color.html"]
+
+["test_ManifestProcessor_warnings.html"]
+
+["test_link_relList_supports_manifest.html"]
diff --git a/dom/manifest/test/red-50.png b/dom/manifest/test/red-50.png
new file mode 100644
index 0000000000..e2ce365c3c
--- /dev/null
+++ b/dom/manifest/test/red-50.png
Binary files differ
diff --git a/dom/manifest/test/resource.sjs b/dom/manifest/test/resource.sjs
new file mode 100644
index 0000000000..eadac95b64
--- /dev/null
+++ b/dom/manifest/test/resource.sjs
@@ -0,0 +1,85 @@
+/* Generic responder that composes a response from
+ * the query string of a request.
+ *
+ * It reserves some special prop names:
+ * - body: get's used as the response body
+ * - statusCode: override the 200 OK response code
+ * (response text is set automatically)
+ *
+ * Any property names it doesn't know about get converted into
+ * HTTP headers.
+ *
+ * For example:
+ * http://test/resource.sjs?Content-Type=text/html&body=<h1>hello</h1>&Hello=hi
+ *
+ * Outputs:
+ * HTTP/1.1 200 OK
+ * Content-Type: text/html
+ * Hello: hi
+ * <h1>hello</h1>
+ */
+//global handleRequest
+"use strict";
+
+const HTTPStatus = new Map([
+ [100, "Continue"],
+ [101, "Switching Protocol"],
+ [200, "OK"],
+ [201, "Created"],
+ [202, "Accepted"],
+ [203, "Non-Authoritative Information"],
+ [204, "No Content"],
+ [205, "Reset Content"],
+ [206, "Partial Content"],
+ [300, "Multiple Choice"],
+ [301, "Moved Permanently"],
+ [302, "Found"],
+ [303, "See Other"],
+ [304, "Not Modified"],
+ [305, "Use Proxy"],
+ [306, "unused"],
+ [307, "Temporary Redirect"],
+ [308, "Permanent Redirect"],
+ [400, "Bad Request"],
+ [401, "Unauthorized"],
+ [402, "Payment Required"],
+ [403, "Forbidden"],
+ [404, "Not Found"],
+ [405, "Method Not Allowed"],
+ [406, "Not Acceptable"],
+ [407, "Proxy Authentication Required"],
+ [408, "Request Timeout"],
+ [409, "Conflict"],
+ [410, "Gone"],
+ [411, "Length Required"],
+ [412, "Precondition Failed"],
+ [413, "Request Entity Too Large"],
+ [414, "Request-URI Too Long"],
+ [415, "Unsupported Media Type"],
+ [416, "Requested Range Not Satisfiable"],
+ [417, "Expectation Failed"],
+ [500, "Internal Server Error"],
+ [501, "Not Implemented"],
+ [502, "Bad Gateway"],
+ [503, "Service Unavailable"],
+ [504, "Gateway Timeout"],
+ [505, "HTTP Version Not Supported"],
+]);
+
+function handleRequest(request, response) {
+ const queryMap = new URLSearchParams(request.queryString);
+ if (queryMap.has("statusCode")) {
+ let statusCode = parseInt(queryMap.get("statusCode"));
+ let statusText = HTTPStatus.get(statusCode);
+ queryMap.delete("statusCode");
+ response.setStatusLine("1.1", statusCode, statusText);
+ }
+ if (queryMap.has("body")) {
+ let body = queryMap.get("body") || "";
+ queryMap.delete("body");
+ response.write(decodeURIComponent(body));
+ }
+ for (let [key, value] of queryMap.entries()) {
+ response.setHeader(key, value);
+ }
+}
diff --git a/dom/manifest/test/test_ImageObjectProcessor_purpose.html b/dom/manifest/test/test_ImageObjectProcessor_purpose.html
new file mode 100644
index 0000000000..386c4566d4
--- /dev/null
+++ b/dom/manifest/test/test_ImageObjectProcessor_purpose.html
@@ -0,0 +1,120 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+ /**
+ * Image object's purpose member
+ * https://w3c.github.io/manifest/#purpose-member
+ **/
+
+ "use strict";
+ const testManifest = {
+ icons: [{
+ src: "test",
+ }],
+ };
+
+ const invalidPurposeTypes = [
+ [],
+ 123,
+ {},
+ null,
+ ]
+
+ invalidPurposeTypes.forEach(invalidType => {
+ const expected = `Invalid types get treated as 'any'.`;
+ testManifest.icons[0].purpose = invalidType;
+ data.jsonText = JSON.stringify(testManifest);
+ const result = processor.process(data);
+ is(result.icons.length, 1, expected);
+ is(result.icons[0].purpose.length, 1, expected);
+ is(result.icons[0].purpose[0], "any", expected);
+ });
+
+ const invalidPurposes = [
+ "not-known-test-purpose",
+ "invalid-purpose invalid-purpose",
+ "no-purpose invalid-purpose some-other-non-valid-purpose",
+ ];
+
+ invalidPurposes.forEach(invalidPurpose => {
+ const expected = `Expect invalid purposes to invalidate the icon.`;
+ testManifest.icons[0].purpose = invalidPurpose;
+ data.jsonText = JSON.stringify(testManifest);
+ const result = processor.process(data);
+ is(result.icons.length, 0, expected);
+ });
+
+ const mixedMaskableAndInvalidPurposes = [
+ "not-known-test-purpose maskable",
+ "maskable invalid-purpose invalid-purpose",
+ "no-purpose invalid-purpose maskable some-other-non-valid-purpose",
+ ];
+
+ mixedMaskableAndInvalidPurposes.forEach(mixedPurpose => {
+ const expected = `Expect on 'maskable' to remain.`;
+ testManifest.icons[0].purpose = mixedPurpose;
+ data.jsonText = JSON.stringify(testManifest);
+ const result = processor.process(data);
+ is(result.icons.length, 1, expected);
+ is(result.icons[0].purpose.join(), "maskable", expected);
+ });
+
+ const mixedMonochromeAndInvalidPurposes = [
+ "not-known-test-purpose monochrome",
+ "monochrome invalid-purpose invalid-purpose",
+ "no-purpose invalid-purpose monochrome some-other-non-valid-purpose",
+ ];
+
+ mixedMonochromeAndInvalidPurposes.forEach(mixedPurpose => {
+ const expected = `Expect on 'monochrome' to remain.`;
+ testManifest.icons[0].purpose = mixedPurpose;
+ data.jsonText = JSON.stringify(testManifest);
+ const result = processor.process(data);
+ is(result.icons.length, 1, expected);
+ is(result.icons[0].purpose.join(), "monochrome", expected);
+ });
+
+ const validPurposes = [
+ "maskable",
+ "monochrome",
+ "any",
+ "any maskable",
+ "maskable any",
+ "any monochrome",
+ "monochrome any",
+ "maskable monochrome any",
+ "monochrome maskable"
+ ];
+
+ validPurposes.forEach(purpose => {
+ testManifest.icons[0].purpose = purpose;
+ data.jsonText = JSON.stringify(testManifest);
+ var manifest = processor.process(data);
+ is(manifest.icons[0].purpose.join(" "), purpose, `Expected "${purpose}" as purpose.`);
+ });
+
+ const validWhiteSpace = [
+ "",
+ whiteSpace, // defined in common.js
+ `${whiteSpace}any`,
+ `any${whiteSpace}`,
+ `${whiteSpace}any${whiteSpace}`,
+ ];
+
+ validWhiteSpace.forEach(purpose => {
+ testManifest.icons[0].purpose = purpose;
+ data.jsonText = JSON.stringify(testManifest);
+ var manifest = processor.process(data);
+ is(manifest.icons[0].purpose.join(), "any");
+ });
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ImageObjectProcessor_sizes.html b/dom/manifest/test/test_ImageObjectProcessor_sizes.html
new file mode 100644
index 0000000000..9418b44118
--- /dev/null
+++ b/dom/manifest/test/test_ImageObjectProcessor_sizes.html
@@ -0,0 +1,88 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1079453
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1079453</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * Image object's sizes member
+ * https://w3c.github.io/manifest/#sizes-member
+ **/
+"use strict";
+var validSizes = [{
+ test: "16x16",
+ expect: ["16x16"],
+}, {
+ test: "hello 16x16 16x16",
+ expect: ["16x16"],
+}, {
+ test: "32x32 16 48x48 12",
+ expect: ["32x32", "48x48"],
+}, {
+ test: `${whiteSpace}128x128${whiteSpace}512x512 8192x8192 32768x32768${whiteSpace}`,
+ expect: ["128x128", "512x512", "8192x8192", "32768x32768"],
+}, {
+ test: "any",
+ expect: ["any"],
+}, {
+ test: "Any",
+ expect: ["Any"],
+}, {
+ test: "16x32",
+ expect: ["16x32"],
+}, {
+ test: "17x33",
+ expect: ["17x33"],
+}, {
+ test: "32x32 32x32",
+ expect: ["32x32"],
+}, {
+ test: "32X32",
+ expect: ["32X32"],
+}, {
+ test: "any 32x32",
+ expect: ["any", "32x32"],
+}];
+
+var testIcon = {
+ icons: [{
+ src: "test",
+ sizes: undefined,
+ }],
+};
+
+validSizes.forEach(({test, expect}) => {
+ testIcon.icons[0].sizes = test;
+ data.jsonText = JSON.stringify(testIcon);
+ var result = processor.process(data);
+ var sizes = result.icons[0].sizes;
+ var expected = `Expect sizes to equal ${expect.join(" ")}`;
+ is(sizes.join(" "), expect.join(" "), expected);
+});
+
+var invalidSizes = ["invalid", "", " ", "16 x 16", "32", "21", "16xx16", "16 x x 6"];
+invalidSizes.forEach((invalidSize) => {
+ var expected = "Expect invalid sizes to return undefined.";
+ testIcon.icons[0].sizes = invalidSize;
+ data.jsonText = JSON.stringify(testIcon);
+ var result = processor.process(data);
+ var sizes = result.icons[0].sizes;
+ is(sizes, undefined, expected);
+});
+
+typeTests.forEach((type) => {
+ var expected = `Expect non-string sizes ${typeof type} to be undefined.`;
+ testIcon.icons[0].sizes = type;
+ data.jsonText = JSON.stringify(testIcon);
+ var result = processor.process(data);
+ var sizes = result.icons[0].sizes;
+ is(sizes, undefined, expected);
+});
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ImageObjectProcessor_src.html b/dom/manifest/test/test_ImageObjectProcessor_src.html
new file mode 100644
index 0000000000..8d17c3048f
--- /dev/null
+++ b/dom/manifest/test/test_ImageObjectProcessor_src.html
@@ -0,0 +1,110 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1079453
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1079453</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * Image object's src member
+ * https://w3c.github.io/manifest/#src-member
+ **/
+"use strict";
+
+var noSrc = {
+ icons: [{}, {
+ src: [],
+ }, {
+ src: {},
+ }, {
+ src: null,
+ }, {
+ type: "image/jpg",
+ }, {
+ sizes: "1x1,2x2",
+ }, {
+ sizes: "any",
+ type: "image/jpg",
+ }],
+};
+
+var expected = `Expect icons without a src prop to be filtered out.`;
+data.jsonText = JSON.stringify(noSrc);
+var result = processor.process(data);
+is(result.icons.length, 0, expected);
+
+var invalidSrc = {
+ icons: [{
+ src: null,
+ }, {
+ src: 1,
+ }, {
+ src: [],
+ }, {
+ src: {},
+ }, {
+ src: true,
+ }, {
+ src: "",
+ }],
+};
+
+expected = `Expect icons with invalid src prop to be filtered out.`;
+data.jsonText = JSON.stringify(invalidSrc);
+result = processor.process(data);
+is(result.icons.length, 0, expected);
+
+expected = `Expect icon's src to be a string.`;
+var withSrc = {
+ icons: [{
+ src: "pass",
+ }],
+};
+data.jsonText = JSON.stringify(withSrc);
+result = processor.process(data);
+is(typeof result.icons[0].src, "string", expected);
+
+
+expected = `Expect only icons with a src prop to be kept.`;
+withSrc = {
+ icons: [{
+ src: "pass",
+ }, {
+ src: "pass",
+ }, {}, {
+ foo: "foo",
+ }],
+};
+data.jsonText = JSON.stringify(withSrc);
+result = processor.process(data);
+is(result.icons.length, 2, expected);
+
+var expectedURL = new URL("pass", manifestURL);
+for (var icon of result.icons) {
+ expected = `Expect src prop to be ${expectedURL.toString()}`;
+ is(icon.src.toString(), expectedURL.toString(), expected);
+}
+
+
+// Resolve URLs relative to manfiest
+var URLs = ["path", "/path", "../../path"];
+
+URLs.forEach((url) => {
+ expected = `Resolve icon src URLs relative to manifest.`;
+ data.jsonText = JSON.stringify({
+ icons: [{
+ src: url,
+ }],
+ });
+ var absURL = new URL(url, manifestURL).toString();
+ result = processor.process(data);
+ is(result.icons[0].src.toString(), absURL, expected);
+});
+
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ImageObjectProcessor_type.html b/dom/manifest/test/test_ImageObjectProcessor_type.html
new file mode 100644
index 0000000000..29627c5e46
--- /dev/null
+++ b/dom/manifest/test/test_ImageObjectProcessor_type.html
@@ -0,0 +1,57 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1079453
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1079453</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * Image object's type property
+ * https://w3c.github.io/manifest/#type-member
+ **/
+
+"use strict";
+var testIcon = {
+ icons: [{
+ src: "test",
+ type: undefined,
+ }],
+};
+
+var invalidMimeTypes = [
+ "application / text",
+ "test;test",
+ ";test?test",
+ "application\\text",
+ "image/jpeg, image/gif",
+];
+invalidMimeTypes.forEach((invalidMime) => {
+ var expected = `Expect invalid mime to be treated like undefined.`;
+ testIcon.icons[0].type = invalidMime;
+ data.jsonText = JSON.stringify(testIcon);
+ var result = processor.process(data);
+ is(result.icons[0].type, undefined, expected);
+});
+
+var validTypes = [
+ "image/jpeg",
+ "IMAGE/jPeG",
+ `${whiteSpace}image/jpeg${whiteSpace}`,
+ "image/JPEG; whatever=something",
+ "image/JPEG;whatever",
+];
+
+validTypes.forEach((validMime) => {
+ var expected = `Expect valid mime to be parsed to : image/jpeg.`;
+ testIcon.icons[0].type = validMime;
+ data.jsonText = JSON.stringify(testIcon);
+ var result = processor.process(data);
+ is(result.icons[0].type, "image/jpeg", expected);
+});
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ManifestProcessor_JSON.html b/dom/manifest/test/test_ManifestProcessor_JSON.html
new file mode 100644
index 0000000000..b072355f20
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_JSON.html
@@ -0,0 +1,48 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1079453
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1079453</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * JSON parsing/processing tests
+ * https://w3c.github.io/manifest/#processing
+ **/
+"use strict";
+var invalidJson = ["", ` \t \n ${whiteSpace} `, "{", "{[[}"];
+invalidJson.forEach((testString) => {
+ var expected = `Expect to recover from invalid JSON: ${testString}`;
+ data.jsonText = testString;
+ data.checkConformance = true;
+ var result = processor.process(data);
+ SimpleTest.is(result.start_url, docURL.href, expected);
+ SimpleTest.ok(
+ [...result.moz_validation].find(x => x.error && x.type === "json"),
+ "A JSON error message is included");
+});
+
+var validButUnhelpful = ["1", 1, "[{}]"];
+validButUnhelpful.forEach((testString) => {
+ var expected = `Expect to recover from valid JSON: ${testString}`;
+ data.jsonText = testString;
+ var result = processor.process(data);
+ SimpleTest.is(result.start_url, docURL.href, expected);
+});
+
+// Bug 1534756 - Allow for null manifests
+var input = {
+ jsonText: "null",
+ manifestURL,
+ docURL,
+};
+var result = processor.process(input);
+SimpleTest.is(result, null, "Expected null when the input is null");
+
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ManifestProcessor_background_color.html b/dom/manifest/test/test_ManifestProcessor_background_color.html
new file mode 100644
index 0000000000..38073b8e4f
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_background_color.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1195018
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1195018</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * background_color member
+ * https://w3c.github.io/manifest/#background_color-member
+ **/
+"use strict";
+
+testValidColors("background_color");
+testInvalidColors("background_color");
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ManifestProcessor_dir.html b/dom/manifest/test/test_ManifestProcessor_dir.html
new file mode 100644
index 0000000000..b5dc84a9e4
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_dir.html
@@ -0,0 +1,57 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1258899
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1258899</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * dir member
+ * https://w3c.github.io/manifest/#dir-member
+ **/
+"use strict";
+// Type checks
+typeTests.forEach((type) => {
+ var expected = `Expect non - string dir to default to "auto".`;
+ data.jsonText = JSON.stringify({
+ dir: type,
+ });
+ var result = processor.process(data);
+ is(result.dir, "auto", expected);
+});
+
+/* Test valid values*/
+var validDirs = ["ltr", "rtl", "auto"];
+validDirs.forEach((dir) => {
+ var expected = `Expect dir value to be ${dir}.`;
+ data.jsonText = JSON.stringify({dir});
+ var result = processor.process(data);
+ is(result.dir, dir, expected);
+});
+
+// trim tests
+validDirs.forEach((dir) => {
+ var expected = `Expect trimmed dir to be returned.`;
+ var expandeddir = seperators + lineTerminators + dir + lineTerminators + seperators;
+ data.jsonText = JSON.stringify({
+ dir: expandeddir,
+ });
+ var result = processor.process(data);
+ is(result.dir, dir, expected);
+});
+
+// Unknown/Invalid directions
+var invalidDirs = ["LTR", "RtL", `fooo${whiteSpace}rtl`, "", "bar baz, some value", "ltr rtl auto", "AuTo"];
+invalidDirs.forEach((dir) => {
+ var expected = `Expect default dir "auto" to be returned: '${dir}'`;
+ data.jsonText = JSON.stringify({dir});
+ var result = processor.process(data);
+ is(result.dir, "auto", expected);
+});
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ManifestProcessor_display.html b/dom/manifest/test/test_ManifestProcessor_display.html
new file mode 100644
index 0000000000..f5ea5fbc4f
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_display.html
@@ -0,0 +1,78 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1079453
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1079453</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * display member
+ * https://w3c.github.io/manifest/#display-member
+ **/
+"use strict";
+// Type checks
+typeTests.forEach((type) => {
+ var expected = `Expect non - string display to default to "browser".`;
+ data.jsonText = JSON.stringify({
+ display: type,
+ });
+ var result = processor.process(data);
+ is(result.display, "browser", expected);
+});
+
+/* Test valid modes - case insensitive*/
+var validModes = [
+ "fullscreen",
+ "standalone",
+ "minimal-ui",
+ "browser",
+ "FullScreen",
+ "standAlone",
+ "minimal-UI",
+ "BROWSER",
+];
+validModes.forEach((mode) => {
+ var expected = `Expect display mode to be ${mode.toLowerCase()}.`;
+ data.jsonText = JSON.stringify({
+ display: mode,
+ });
+ var result = processor.process(data);
+ is(result.display, mode.toLowerCase(), expected);
+});
+
+// trim tests
+validModes.forEach((display) => {
+ var expected = `Expect trimmed display mode to be returned.`;
+ var expandedDisplay = seperators + lineTerminators + display + lineTerminators + seperators;
+ data.jsonText = JSON.stringify({
+ display: expandedDisplay,
+ });
+ var result = processor.process(data);
+ is(result.display, display.toLowerCase(), expected);
+});
+
+// Unknown modes
+var invalidModes = [
+ "foo",
+ `fooo${whiteSpace}`,
+ "",
+ "fullscreen,standalone",
+ "standalone fullscreen",
+ "FULLSCreENS",
+];
+
+invalidModes.forEach((invalidMode) => {
+ var expected = `Expect default display mode "browser" to be returned: '${invalidMode}'`;
+ data.jsonText = JSON.stringify({
+ display: invalidMode,
+ });
+ var result = processor.process(data);
+ is(result.display, "browser", expected);
+});
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ManifestProcessor_icons.html b/dom/manifest/test/test_ManifestProcessor_icons.html
new file mode 100644
index 0000000000..578f49f5dc
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_icons.html
@@ -0,0 +1,30 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1079453
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1079453</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * Manifest icons member
+ * https://w3c.github.io/manifest/#icons-member
+ **/
+
+"use strict";
+
+typeTests.forEach((type) => {
+ var expected = `Expect non-array icons to be empty array: ${typeof type}.`;
+ data.jsonText = JSON.stringify({
+ icons: type,
+ });
+ var result = processor.process(data);
+ SpecialPowers.unwrap(result.icons);
+ is(result.icons.length, 0, expected);
+});
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ManifestProcessor_id.html b/dom/manifest/test/test_ManifestProcessor_id.html
new file mode 100644
index 0000000000..336d1d3a77
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_id.html
@@ -0,0 +1,123 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1731940
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1731940 - implement id member</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+ /**
+ * Manifest id member
+ * https://w3c.github.io/manifest/#id-member
+ **/
+ for (const type of typeTests) {
+ data.jsonText = JSON.stringify({
+ id: type,
+ });
+ const result = processor.process(data);
+ is(
+ result.id.toString(),
+ result.start_url.toString(),
+ `Expect non-string id to fall back to start_url: ${typeof type}.`
+ );
+ }
+
+ // Invalid URLs
+ const invalidURLs = [
+ "https://foo:65536",
+ "https://foo\u0000/",
+ "//invalid:65555",
+ "file:///passwords",
+ "about:blank",
+ "data:text/html,<html><script>alert('lol')<\/script></html>",
+ ];
+
+ for (const url of invalidURLs) {
+ data.jsonText = JSON.stringify({
+ id: url,
+ });
+ const result = processor.process(data);
+ is(
+ result.id.toString(),
+ result.start_url.toString(),
+ "Expect invalid id URL to fall back to start_url."
+ );
+ }
+
+ // Not same origin
+ data.jsonText = JSON.stringify({
+ id: "http://not-same-origin",
+ });
+ var result = processor.process(data);
+ is(
+ result.id.toString(),
+ result.start_url,
+ "Expect different origin id to fall back to start_url."
+ );
+
+ // Empty string test
+ data.jsonText = JSON.stringify({
+ id: "",
+ });
+ result = processor.process(data);
+ is(
+ result.id.toString(),
+ result.start_url.toString(),
+ `Expect empty string for id to use start_url.`
+ );
+
+ // Resolve URLs relative to the start_url's origin
+ const URLs = [
+ "path",
+ "/path",
+ "../../path",
+ "./path",
+ `${whiteSpace}path${whiteSpace}`,
+ `${whiteSpace}/path`,
+ `${whiteSpace}../../path`,
+ `${whiteSpace}./path`,
+ ];
+
+ for (const url of URLs) {
+ data.jsonText = JSON.stringify({
+ id: url,
+ start_url: "/path/some.html",
+ });
+ result = processor.process(data);
+ const baseOrigin = new URL(result.start_url.toString()).origin;
+ const expectedUrl = new URL(url, baseOrigin).toString();
+ is(
+ result.id.toString(),
+ expectedUrl,
+ "Expected id to be resolved relative to start_url's origin."
+ );
+ }
+
+ // Handles unicode encoded URLs
+ const specialCases = [
+ ["😀", "%F0%9F%98%80"],
+ [
+ "this/is/ok?query_is_ok=😀#keep_hash",
+ "this/is/ok?query_is_ok=%F0%9F%98%80#keep_hash",
+ ],
+ ];
+ for (const [id, expected] of specialCases) {
+ data.jsonText = JSON.stringify({
+ id,
+ start_url: "/my-app/",
+ });
+ result = processor.process(data);
+ const baseOrigin = new URL(result.start_url.toString()).origin;
+ const expectedUrl = new URL(expected, baseOrigin).toString();
+ is(
+ result.id.toString(),
+ expectedUrl,
+ `Expect id to be encoded/decoded per URL spec.`
+ );
+ }
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ManifestProcessor_lang.html b/dom/manifest/test/test_ManifestProcessor_lang.html
new file mode 100644
index 0000000000..568950c2ba
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_lang.html
@@ -0,0 +1,139 @@
+<!DOCTYPE HTML>
+<meta charset="utf-8">
+<!--
+Bug 1143879 - Implement lang member of Web manifest
+https://bugzilla.mozilla.org/show_bug.cgi?id=1143879
+-->
+<meta charset="utf-8">
+<title>Test for Bug 1143879 - Implement lang member of Web manifest</title>
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+<script src="common.js"></script>
+<script>
+/**
+ * lang member
+ * https://w3c.github.io/manifest/#lang-member
+ **/
+/* globals is, typeTests, data, processor, seperators, lineTerminators, todo_is*/
+"use strict";
+// Type checks: checks that only strings are accepted.
+
+for (const type of typeTests) {
+ const expected = `Expect non-string to be undefined.`;
+ data.jsonText = JSON.stringify({
+ lang: type,
+ });
+ const result = processor.process(data);
+ is(result.lang, undefined, expected);
+}
+
+// Test valid language tags - derived from IANA and BCP-47 spec
+// and our Intl.js implementation.
+var validTags = [
+ "aa", "ab", "ae", "af", "ak", "am", "an", "ar", "as", "av", "ay", "az",
+ "ba", "be", "bg", "bi", "bm", "bn", "bo", "br", "bs", "ca", "ce",
+ "ch", "co", "cr", "cs", "cu", "cv", "cy", "da", "de", "dv", "dz", "ee",
+ "el", "en", "eo", "es", "et", "eu", "fa", "ff", "fi", "fj", "fo", "fr",
+ "fy", "ga", "gd", "gl", "gn", "gu", "gv", "ha", "he", "hi", "ho", "hr",
+ "ht", "hu", "hy", "hz", "ia", "id", "ie", "ig", "ik", "io",
+ "is", "it", "iu", "ja", "jv", "ka", "kg", "ki", "kj",
+ "kk", "kl", "km", "kn", "ko", "kr", "ks", "ku", "kv", "kw", "ky", "la",
+ "lb", "lg", "li", "ln", "lo", "lt", "lu", "lv", "mg", "mh", "mi", "mk",
+ "ml", "mn", "mr", "ms", "mt", "my", "na", "nb", "nd", "ne", "ng",
+ "nl", "nn", "no", "nr", "nv", "ny", "oc", "oj", "om", "or", "os", "pa",
+ "pi", "pl", "ps", "pt", "qu", "rm", "rn", "ro", "ru", "rw", "sa", "sc",
+ "sd", "se", "sg", "si", "sk", "sl", "sm", "sn", "so", "sq", "sr",
+ "ss", "st", "su", "sv", "sw", "ta", "te", "tg", "th", "ti", "tk",
+ "tn", "to", "tr", "ts", "tt", "ty", "ug", "uk", "ur", "uz", "ve",
+ "vi", "vo", "wa", "wo", "xh", "yi", "yo", "za", "zh", "zu", "en-US",
+ "jp-JS", "pt-PT", "pt-BR", "de-CH", "de-DE-1901", "es-419", "sl-IT-nedis",
+ "en-US-boont", "mn-Cyrl-MN", "sr-Cyrl", "sr-Latn",
+ "zh-TW", "en-GB-boont-posix-r-extended-sequence-x-private",
+ "yue-HK", "de-CH-x-phonebk", "az-Arab-x-aze-derbend",
+ "qaa-Qaaa-QM-x-southern",
+];
+
+
+for (var tag of validTags) {
+ const expected = `Expect lang to be "${tag}"`;
+ data.jsonText = JSON.stringify({
+ lang: tag,
+ });
+ const result = processor.process(data);
+ is(result.lang, tag, expected);
+}
+
+// Canonical form conversion... old names become new names.
+const granfatheredTags = [
+ ["bh", "bho"],
+ ["in", "id"],
+ ["iw", "he"],
+ ["ji", "yi"],
+ ["jw", "jv"],
+ ["mo", "ro"],
+ ["sh", "sr-Latn"],
+ ["tl", "fil"],
+ ["tw", "ak"],
+ ["nan-Hans-MM-variant2-variant1-t-zh-latn-u-ca-chinese-x-private",
+ "nan-Hans-MM-variant1-variant2-t-zh-latn-u-ca-chinese-x-private"],
+ ["cmn-Hans-CN", "zh-Hans-CN"],
+];
+
+for (const [oldTag, newTag] of granfatheredTags) {
+ const expected = `Expect lang to be "${newTag}"`;
+ data.jsonText = JSON.stringify({
+ lang: oldTag,
+ });
+ const result = processor.process(data);
+ is(result.lang, newTag, expected);
+}
+
+// trim tests - check that language tags get trimmed properly.
+for (tag of validTags) {
+ const expected = `Expect trimmed tag to be returned.`;
+ let expandedtag = seperators + lineTerminators + tag;
+ expandedtag += lineTerminators + seperators;
+ data.jsonText = JSON.stringify({
+ lang: expandedtag,
+ });
+ const result = processor.process(data);
+ is(result.lang, tag, expected);
+}
+
+// Invalid language tags, derived from BCP-47 and made up.
+var invalidTags = [
+"de-419-DE", " a-DE ", "ar-a-aaa-b-bbb-a-ccc", "sdafsdfaadsfdsf", "i",
+"i-phone", "en US", "EN-*-US-JP", "JA-INVALID-TAG", "123123123",
+];
+
+
+for (var item of invalidTags) {
+ const expected = `Expect invalid tag (${item}) to be treated as undefined.`;
+ data.jsonText = JSON.stringify({
+ lang: item,
+ });
+ const result = processor.process(data);
+ is(result.lang, undefined, expected);
+}
+
+// Canonical form conversion tests. We convert the following tags, which are in
+// canonical form, to upper case and expect the processor to return them
+// in canonical form.
+var canonicalTags = [
+ "jp-JS", "pt-PT", "pt-BR", "de-CH", "de-DE-1901", "es-419", "sl-IT-nedis",
+ "en-US-boont", "mn-Cyrl-MN", "sr-Cyrl", "sr-Latn",
+ "hy-Latn-IT", "zh-TW", "en-GB-boont-r-extended-sequence-x-private",
+ "yue-HK", "de-CH-x-phonebk", "az-Arab-x-aze-derbend",
+ "qaa-Qaaa-QM-x-southern",
+];
+
+for (tag of canonicalTags) {
+ var uppedTag = tag.toUpperCase();
+ const expected = `Expect tag (${uppedTag}) to be in canonical form (${tag}).`;
+ data.jsonText = JSON.stringify({
+ lang: uppedTag,
+ });
+ const result = processor.process(data);
+ is(result.lang, tag, expected);
+}
+</script>
diff --git a/dom/manifest/test/test_ManifestProcessor_name_and_short_name.html b/dom/manifest/test/test_ManifestProcessor_name_and_short_name.html
new file mode 100644
index 0000000000..e843b583e4
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_name_and_short_name.html
@@ -0,0 +1,79 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1079453
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1079453</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * name and short_name members
+ * https://w3c.github.io/manifest/#name-member
+ * https://w3c.github.io/manifest/#short_name-member
+ **/
+
+"use strict";
+
+var trimNamesTests = [
+ `${seperators}pass${seperators}`,
+ `${lineTerminators}pass${lineTerminators}`,
+ `${whiteSpace}pass${whiteSpace}`,
+ // BOM
+ `\uFEFFpass\uFEFF`,
+];
+var props = ["name", "short_name"];
+
+props.forEach((prop) => {
+ trimNamesTests.forEach((trimmableString) => {
+ var assetion = `Expecting ${prop} to be trimmed.`;
+ var obj = {};
+ obj[prop] = trimmableString;
+ data.jsonText = JSON.stringify(obj);
+ var result = processor.process(data);
+ is(result[prop], "pass", assetion);
+ });
+});
+
+/*
+ * If the object is not a string, it becomes undefined
+ */
+props.forEach((prop) => {
+ typeTests.forEach((type) => {
+ var expected = `Expect non - string ${prop} to be undefined: ${typeof type}`;
+ var obj = {};
+ obj[prop] = type;
+ data.jsonText = JSON.stringify(obj);
+ var result = processor.process(data);
+ SimpleTest.ok(result[prop] === undefined, expected);
+ });
+});
+
+/**
+ * acceptable names - including long names
+ */
+var acceptableNames = [
+ "pass",
+ `pass pass pass pass pass pass pass pass pass pass pass pass pass pass
+ pass pass pass pass pass pass pass pass pass pass pass pass pass pass
+ pass pass pass pass pass pass pass pass pass pass pass pass pass pass
+ pass pass pass pass pass pass pass pass pass pass pass pass`,
+ "これは許容できる名前です",
+ "ນີ້ແມ່ນຊື່ທີ່ຍອມຮັບໄດ້",
+];
+
+props.forEach((prop) => {
+ acceptableNames.forEach((name) => {
+ var expected = `Expecting name to be acceptable : ${name}`;
+ var obj = {};
+ obj[prop] = name;
+ data.jsonText = JSON.stringify(obj);
+ var result = processor.process(data);
+ is(result[prop], name, expected);
+ });
+});
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ManifestProcessor_orientation.html b/dom/manifest/test/test_ManifestProcessor_orientation.html
new file mode 100644
index 0000000000..9333eecbe3
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_orientation.html
@@ -0,0 +1,86 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1079453
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1079453</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * orientation member
+ * https://w3c.github.io/manifest/#orientation-member
+ **/
+"use strict";
+
+typeTests.forEach((type) => {
+ var expected = `Expect non-string orientation to be empty string : ${typeof type}.`;
+ data.jsonText = JSON.stringify({
+ orientation: type,
+ });
+ var result = processor.process(data);
+ is(result.orientation, undefined, expected);
+});
+
+var validOrientations = [
+ "any",
+ "natural",
+ "landscape",
+ "portrait",
+ "portrait-primary",
+ "portrait-secondary",
+ "landscape-primary",
+ "landscape-secondary",
+ "aNy",
+ "NaTuRal",
+ "LANDsCAPE",
+ "PORTRAIT",
+ "portrait-PRIMARY",
+ "portrait-SECONDARY",
+ "LANDSCAPE-primary",
+ "LANDSCAPE-secondary",
+];
+
+validOrientations.forEach((orientation) => {
+ var expected = `Expect orientation to be returned: ${orientation}.`;
+ data.jsonText = JSON.stringify({ orientation });
+ var result = processor.process(data);
+ is(result.orientation, orientation.toLowerCase(), expected);
+});
+
+var invalidOrientations = [
+ "all",
+ "ANYMany",
+ "NaTuRalle",
+ "portrait-primary portrait-secondary",
+ "portrait-primary,portrait-secondary",
+ "any-natural",
+ "portrait-landscape",
+ "primary-portrait",
+ "secondary-portrait",
+ "landscape-landscape",
+ "secondary-primary",
+];
+
+invalidOrientations.forEach((orientation) => {
+ var expected = `Expect orientation to be empty string: ${orientation}.`;
+ data.jsonText = JSON.stringify({ orientation });
+ var result = processor.process(data);
+ is(result.orientation, undefined, expected);
+});
+
+// Trim tests
+validOrientations.forEach((orientation) => {
+ var expected = `Expect trimmed orientation to be returned.`;
+ var expandedOrientation = `${seperators}${lineTerminators}${orientation}${lineTerminators}${seperators}`;
+ data.jsonText = JSON.stringify({
+ orientation: expandedOrientation,
+ });
+ var result = processor.process(data);
+ is(result.orientation, orientation.toLowerCase(), expected);
+});
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ManifestProcessor_scope.html b/dom/manifest/test/test_ManifestProcessor_scope.html
new file mode 100644
index 0000000000..590d76a251
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_scope.html
@@ -0,0 +1,113 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1079453
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1079453</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+
+/**
+ * Manifest scope
+ * https://w3c.github.io/manifest/#scope-member
+ **/
+"use strict";
+
+var expected = "Expect non-string scope to be the default";
+typeTests.forEach((type) => {
+ data.jsonText = JSON.stringify({
+ scope: type,
+ });
+ var result = processor.process(data);
+ is(result.scope, new URL(".", docURL).href, expected);
+});
+
+expected = "Expect different origin to be the default";
+data.jsonText = JSON.stringify({
+ scope: "http://not-same-origin",
+});
+var result = processor.process(data);
+is(result.scope, new URL(".", docURL).href, expected);
+
+expected = "Expect the empty string to be the default";
+data.jsonText = JSON.stringify({
+ scope: "",
+});
+result = processor.process(data);
+is(result.scope, new URL(".", docURL).href, expected);
+
+expected = "Resolve URLs relative to manifest.";
+var URLs = ["path", "/path", "../../path"];
+URLs.forEach((url) => {
+ data.jsonText = JSON.stringify({
+ scope: url,
+ start_url: "/path",
+ });
+ var absURL = new URL(url, manifestURL).toString();
+ result = processor.process(data);
+ is(result.scope, absURL, expected);
+});
+
+expected = "If start URL is not in scope, return the default.";
+data.jsonText = JSON.stringify({
+ scope: "foo",
+ start_url: "bar",
+});
+result = processor.process(data);
+let expected_start = new URL("bar", docURL);
+is(result.scope, new URL(document.location.origin).href, expected);
+
+expected = "If start URL is in scope, use the scope.";
+data.jsonText = JSON.stringify({
+ start_url: "foobar",
+ scope: "foo",
+});
+result = processor.process(data);
+is(result.scope.toString(), new URL("foo", manifestURL).toString(), expected);
+
+expected = "Expect start_url to be " + new URL("foobar", manifestURL).toString();
+is(result.start_url.toString(), new URL("foobar", manifestURL).toString(), expected);
+
+expected = "If start URL is in scope, use the scope.";
+data.jsonText = JSON.stringify({
+ start_url: "/foo/",
+ scope: "/foo/",
+});
+result = processor.process(data);
+is(result.scope.toString(), new URL("/foo/", manifestURL).toString(), expected);
+
+expected = "If start URL is in scope, use the scope.";
+data.jsonText = JSON.stringify({
+ start_url: ".././foo/",
+ scope: "../foo/",
+});
+result = processor.process(data);
+is(result.scope.toString(), new URL("/foo/", manifestURL).toString(), expected);
+
+expected = "scope member has the URL's query removed.";
+data.jsonText = JSON.stringify({
+ scope: "./test/?a=b&a=b&b=c&c=d&e",
+});
+result = processor.process(data);
+is(new URL(result.scope).search, "", expected);
+
+expected = "scope member has the URL's fragment removed.";
+data.jsonText = JSON.stringify({
+ scope: "./test/#fragment"
+});
+result = processor.process(data);
+is(new URL(result.scope).hash, "", expected);
+
+expected = "scope member has the URL's query and fragment removed.";
+data.jsonText = JSON.stringify({
+ scope: "./test/?a=b&a=b&b=c&c=d&e#fragment"
+});
+result = processor.process(data);
+is(new URL(result.scope).search, "", expected);
+is(new URL(result.scope).hash, "", expected);
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ManifestProcessor_start_url.html b/dom/manifest/test/test_ManifestProcessor_start_url.html
new file mode 100644
index 0000000000..1d172df555
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_start_url.html
@@ -0,0 +1,83 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1079453
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1079453</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * Manifest start_url
+ * https://w3c.github.io/manifest/#start_url-member
+ **/
+"use strict";
+typeTests.forEach((type) => {
+ var expected = `Expect non - string start_url to be doc's url: ${typeof type}.`;
+ data.jsonText = JSON.stringify({
+ start_url: type,
+ });
+ var result = processor.process(data);
+ is(result.start_url.toString(), docURL.toString(), expected);
+});
+
+// Not same origin
+var expected = `Expect different origin URLs to become document's URL.`;
+data.jsonText = JSON.stringify({
+ start_url: "http://not-same-origin",
+});
+var result = processor.process(data);
+is(result.start_url.toString(), docURL.toString(), expected);
+
+// Empty string test
+expected = `Expect empty string for start_url to become document's URL.`;
+data.jsonText = JSON.stringify({
+ start_url: "",
+});
+result = processor.process(data);
+is(result.start_url.toString(), docURL.toString(), expected);
+
+// Resolve URLs relative to manifest
+var URLs = [
+ "path",
+ "/path",
+ "../../path",
+ `${whiteSpace}path${whiteSpace}`,
+ `${whiteSpace}/path`,
+ `${whiteSpace}../../path`,
+];
+
+URLs.forEach((url) => {
+ expected = `Resolve URLs relative to manifest.`;
+ data.jsonText = JSON.stringify({
+ start_url: url,
+ });
+ var absURL = new URL(url, manifestURL).toString();
+ result = processor.process(data);
+ is(result.start_url.toString(), absURL, expected);
+});
+
+// It retains the fragment
+var startURL = "./path?query=123#fragment";
+data.jsonText = JSON.stringify({
+ start_url: startURL,
+});
+var absURL = new URL(startURL, manifestURL).href;
+result = processor.process(data);
+is(result.start_url.toString(), absURL, "Retains fragment");
+
+// It retains the fragment on the document's location too.
+window.location = "#here";
+data.jsonText = JSON.stringify({});
+result = processor.process(data);
+is(
+ window.location.href,
+ result.start_url.toString(),
+ `Retains the fragment of document's location`
+);
+</script>
+ </head>
+</html>
diff --git a/dom/manifest/test/test_ManifestProcessor_theme_color.html b/dom/manifest/test/test_ManifestProcessor_theme_color.html
new file mode 100644
index 0000000000..c73832748d
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_theme_color.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1195018
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1195018</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+/**
+ * theme_color member
+ * https://w3c.github.io/manifest/#theme_color-member
+ **/
+"use strict";
+
+testValidColors("theme_color");
+testInvalidColors("theme_color");
+ </script>
+</head>
diff --git a/dom/manifest/test/test_ManifestProcessor_warnings.html b/dom/manifest/test/test_ManifestProcessor_warnings.html
new file mode 100644
index 0000000000..f2092c1dcf
--- /dev/null
+++ b/dom/manifest/test/test_ManifestProcessor_warnings.html
@@ -0,0 +1,149 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1086997
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1086997</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="common.js"></script>
+ <script>
+"use strict";
+const options = {...data, checkConformance: true } ;
+[
+ {
+ func: () => options.jsonText = JSON.stringify(1),
+ warn: "Manifest should be an object.",
+ },
+ {
+ func: () => options.jsonText = JSON.stringify("a string"),
+ warn: "Manifest should be an object.",
+ },
+ {
+ func: () => options.jsonText = JSON.stringify({
+ scope: "https://www.mozilla.org",
+ }),
+ warn: "The scope URL must be same origin as document.",
+ },
+ {
+ func: () => options.jsonText = JSON.stringify({
+ scope: "foo",
+ start_url: "bar",
+ }),
+ warn: "The start URL is outside the scope, so the scope is invalid.",
+ },
+ {
+ func: () => options.jsonText = JSON.stringify({
+ start_url: "https://www.mozilla.org",
+ }),
+ warn: "The start URL must be same origin as document.",
+ },
+ {
+ func: () => options.jsonText = JSON.stringify({
+ start_url: 42,
+ }),
+ warn: "Expected the manifest\u2019s start_url member to be a string.",
+ },
+ {
+ func: () => options.jsonText = JSON.stringify({
+ theme_color: "42",
+ }),
+ warn: "theme_color: 42 is not a valid CSS color.",
+ },
+ {
+ func: () => options.jsonText = JSON.stringify({
+ background_color: "42",
+ }),
+ warn: "background_color: 42 is not a valid CSS color.",
+ },
+ {
+ func: () => options.jsonText = JSON.stringify({
+ icons: [
+ { "src": "http://example.com", "sizes": "48x48"},
+ { "src": "http://:Invalid", "sizes": "48x48"},
+ ],
+ }),
+ warn: "icons item at index 1 is invalid. The src member is an invalid URL http://:Invalid",
+ },
+ // testing dom.properties: ManifestImageUnusable
+ {
+ func() {
+ return (options.jsonText = JSON.stringify({
+ icons: [
+ { src: "http://example.com", purpose: "any" }, // valid
+ { src: "http://example.com", purpose: "banana" }, // generates error
+ ],
+ }));
+ },
+ get warn() {
+ // Returns 2 warnings... array here is just to keep them organized
+ return [
+ "icons item at index 1 includes unsupported purpose(s): banana.",
+ "icons item at index 1 lacks a usable purpose. It will be ignored.",
+ ].join(" ");
+ },
+ },
+ // testing dom.properties: ManifestImageUnsupportedPurposes
+ {
+ func() {
+ return (options.jsonText = JSON.stringify({
+ icons: [
+ { src: "http://example.com", purpose: "any" }, // valid
+ { src: "http://example.com", purpose: "any foo bar baz bar bar baz" }, // generates error
+ ],
+ }));
+ },
+ warn: "icons item at index 1 includes unsupported purpose(s): foo bar baz.",
+ },
+ // testing dom.properties: ManifestImageRepeatedPurposes
+ {
+ func() {
+ return (options.jsonText = JSON.stringify({
+ icons: [
+ { src: "http://example.com", purpose: "any" }, // valid
+ {
+ src: "http://example.com",
+ purpose: "any maskable any maskable maskable", // generates error
+ },
+ ],
+ }));
+ },
+ warn: "icons item at index 1 includes repeated purpose(s): any maskable.",
+ },
+ // testing dom.properties: ManifestIdIsInvalid
+ {
+ func() {
+ return (options.jsonText = JSON.stringify({
+ id: "http://test:65536/foo",
+ }));
+ },
+ warn: "The id member did not resolve to a valid URL.",
+ },
+ // testing dom.properties ManifestIdNotSameOrigin
+ {
+ func() {
+ return (options.jsonText = JSON.stringify({
+ id: "https://other.com",
+ start_url: "/this/place"
+ }));
+ },
+ warn: "The id member must have the same origin as the start_url member.",
+ }
+].forEach((test, index) => {
+ test.func();
+ const result = processor.process(options);
+ let messages = [];
+ // Poking directly at "warn" triggers xray security wrapper.
+ for (const validationError of result.moz_validation) {
+ const { warn } = validationError;
+ messages.push(warn);
+ }
+ is(messages.join(" "), test.warn, "Check warning.");
+ options.manifestURL = manifestURL;
+ options.docURL = docURL;
+});
+
+ </script>
+</head>
diff --git a/dom/manifest/test/test_link_relList_supports_manifest.html b/dom/manifest/test/test_link_relList_supports_manifest.html
new file mode 100644
index 0000000000..2a5c0e1ee1
--- /dev/null
+++ b/dom/manifest/test/test_link_relList_supports_manifest.html
@@ -0,0 +1,47 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1596040
+-->
+
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1596040 - Link relList support returns false for manifest</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+ <script>
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ async function run() {
+ const prefSetting = [
+ { manifest: true, modulepreload: true },
+ { manifest: true, modulepreload: false },
+ { manifest: false, modulepreload: true },
+ { manifest: false, modulepreload: false },
+ ];
+ for (const { manifest, modulepreload } of prefSetting) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.manifest.enabled", manifest],
+ ["network.modulepreload", modulepreload],
+ ],
+ });
+ const { relList } = document.createElement("link");
+ is(
+ relList.supports("manifest"),
+ manifest,
+ `Expected manifest to be ${manifest}`
+ );
+ is(
+ relList.supports("modulepreload"),
+ modulepreload,
+ `Expected preload to be ${modulepreload}`
+ );
+ }
+ }
+ run()
+ .catch(console.error)
+ .finally(() => SimpleTest.finish());
+ </script>
+</head>