diff options
Diffstat (limited to 'dom/manifest')
44 files changed, 3620 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..c350a6aefb --- /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.ini"] +BROWSER_CHROME_MANIFESTS += ["test/browser.ini"] diff --git a/dom/manifest/test/blue-150.png b/dom/manifest/test/blue-150.png Binary files differnew file mode 100644 index 0000000000..f4a62faddf --- /dev/null +++ b/dom/manifest/test/blue-150.png diff --git a/dom/manifest/test/browser.ini b/dom/manifest/test/browser.ini new file mode 100644 index 0000000000..8ea569b261 --- /dev/null +++ b/dom/manifest/test/browser.ini @@ -0,0 +1,18 @@ +[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_Manifest_install.js] +skip-if = verify +[browser_ManifestFinder_browserHasManifestLink.js] +[browser_ManifestIcons_browserFetchIcon.js] +[browser_ManifestObtainer_credentials.js] +[browser_ManifestObtainer_obtain.js] 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..fa5df00d44 --- /dev/null +++ b/dom/manifest/test/cookie_checker.sjs @@ -0,0 +1,22 @@ +"use strict"; +let { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); + +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..bcceb15504 --- /dev/null +++ b/dom/manifest/test/file_testserver.sjs @@ -0,0 +1,55 @@ +"use strict"; +let { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); +Cu.importGlobalProperties(["URLSearchParams"]); + +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 Binary files differnew file mode 100644 index 0000000000..bb87f783b7 --- /dev/null +++ b/dom/manifest/test/icon.png 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"}&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.ini b/dom/manifest/test/mochitest.ini new file mode 100644 index 0000000000..73d7a65787 --- /dev/null +++ b/dom/manifest/test/mochitest.ini @@ -0,0 +1,24 @@ +[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_link_relList_supports_manifest.html] +[test_ManifestProcessor_background_color.html] +[test_ManifestProcessor_dir.html] +[test_ManifestProcessor_display.html] +[test_ManifestProcessor_icons.html] +[test_ManifestProcessor_id.html] +[test_ManifestProcessor_JSON.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] diff --git a/dom/manifest/test/red-50.png b/dom/manifest/test/red-50.png Binary files differnew file mode 100644 index 0000000000..e2ce365c3c --- /dev/null +++ b/dom/manifest/test/red-50.png diff --git a/dom/manifest/test/resource.sjs b/dom/manifest/test/resource.sjs new file mode 100644 index 0000000000..56deaa61d7 --- /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"; +Cu.importGlobalProperties(["URLSearchParams"]); +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..af678ddb5f --- /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, preload: true }, + { manifest: true, preload: false }, + { manifest: false, preload: true }, + { manifest: false, preload: false }, + ]; + for (const { manifest, preload } of prefSetting) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.manifest.enabled", manifest], + ["network.preload", preload], + ], + }); + const { relList } = document.createElement("link"); + is( + relList.supports("manifest"), + manifest, + `Expected manifest to be ${manifest}` + ); + is( + relList.supports("preload"), + preload, + `Expected preload to be ${preload}` + ); + } + } + run() + .catch(console.error) + .finally(() => SimpleTest.finish()); + </script> +</head> |