diff options
Diffstat (limited to 'browser/components/newtab')
33 files changed, 3315 insertions, 0 deletions
diff --git a/browser/components/newtab/NewTabComponents.manifest b/browser/components/newtab/NewTabComponents.manifest new file mode 100644 index 000000000..42db65acd --- /dev/null +++ b/browser/components/newtab/NewTabComponents.manifest @@ -0,0 +1,2 @@ +component {dfcd2adc-7867-4d3a-ba70-17501f208142} aboutNewTabService.js +contract @mozilla.org/browser/aboutnewtab-service;1 {dfcd2adc-7867-4d3a-ba70-17501f208142} diff --git a/browser/components/newtab/NewTabMessages.jsm b/browser/components/newtab/NewTabMessages.jsm new file mode 100644 index 000000000..0816ed65a --- /dev/null +++ b/browser/components/newtab/NewTabMessages.jsm @@ -0,0 +1,242 @@ +/* global + NewTabWebChannel, + NewTabPrefsProvider, + PlacesProvider, + PreviewProvider, + NewTabSearchProvider, + Preferences, + XPCOMUtils, + Task +*/ + +/* exported NewTabMessages */ + +"use strict"; + +const {utils: Cu} = Components; +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesProvider", + "resource:///modules/PlacesProvider.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PreviewProvider", + "resource:///modules/PreviewProvider.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabPrefsProvider", + "resource:///modules/NewTabPrefsProvider.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabSearchProvider", + "resource:///modules/NewTabSearchProvider.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabWebChannel", + "resource:///modules/NewTabWebChannel.jsm"); + +this.EXPORTED_SYMBOLS = ["NewTabMessages"]; + +const PREF_ENABLED = "browser.newtabpage.remote"; +const CURRENT_ENGINE = "browser-search-engine-modified"; + +// Action names are from the content's perspective. in from chrome == out from content +// Maybe replace the ACTION objects by a bi-directional Map a bit later? +const ACTIONS = { + inboundActions: [ + "REQUEST_PREFS", + "REQUEST_THUMB", + "REQUEST_FRECENT", + "REQUEST_UISTRINGS", + "REQUEST_SEARCH_SUGGESTIONS", + "REQUEST_MANAGE_ENGINES", + "REQUEST_SEARCH_STATE", + "REQUEST_REMOVE_FORM_HISTORY", + "REQUEST_PERFORM_SEARCH", + "REQUEST_CYCLE_ENGINE", + ], + prefs: { + inPrefs: "REQUEST_PREFS", + outPrefs: "RECEIVE_PREFS", + }, + preview: { + inThumb: "REQUEST_THUMB", + outThumb: "RECEIVE_THUMB", + }, + links: { + inFrecent: "REQUEST_FRECENT", + outFrecent: "RECEIVE_FRECENT", + outPlacesChange: "RECEIVE_PLACES_CHANGE", + }, + search: { + inSearch: { + UIStrings: "REQUEST_UISTRINGS", + suggestions: "REQUEST_SEARCH_SUGGESTIONS", + manageEngines: "REQUEST_MANAGE_ENGINES", + state: "REQUEST_SEARCH_STATE", + removeFormHistory: "REQUEST_REMOVE_FORM_HISTORY", + performSearch: "REQUEST_PERFORM_SEARCH", + cycleEngine: "REQUEST_CYCLE_ENGINE" + }, + outSearch: { + UIStrings: "RECEIVE_UISTRINGS", + suggestions: "RECEIVE_SEARCH_SUGGESTIONS", + state: "RECEIVE_SEARCH_STATE", + currentEngine: "RECEIVE_CURRENT_ENGINE" + }, + } +}; + +let NewTabMessages = { + + _prefs: {}, + + /** NEWTAB EVENT HANDLERS **/ + + handleContentRequest(actionName, {data, target}) { + switch (actionName) { + case ACTIONS.prefs.inPrefs: + // Return to the originator all newtabpage prefs + let results = NewTabPrefsProvider.prefs.newtabPagePrefs; + NewTabWebChannel.send(ACTIONS.prefs.outPrefs, results, target); + break; + case ACTIONS.preview.inThumb: + // Return to the originator a preview URL + PreviewProvider.getThumbnail(data).then(imgData => { + NewTabWebChannel.send(ACTIONS.preview.outThumb, {url: data, imgData}, target); + }); + break; + case ACTIONS.links.inFrecent: + // Return to the originator the top frecent links + PlacesProvider.links.getLinks().then(links => { + NewTabWebChannel.send(ACTIONS.links.outFrecent, links, target); + }); + break; + case ACTIONS.search.inSearch.UIStrings: + // Return to the originator all search strings to display + let strings = NewTabSearchProvider.search.searchSuggestionUIStrings; + NewTabWebChannel.send(ACTIONS.search.outSearch.UIStrings, strings, target); + break; + case ACTIONS.search.inSearch.suggestions: + // Return to the originator all search suggestions + Task.spawn(function*() { + try { + let {engineName, searchString} = data; + let suggestions = yield NewTabSearchProvider.search.asyncGetSuggestions(engineName, searchString, target); + NewTabWebChannel.send(ACTIONS.search.outSearch.suggestions, suggestions, target); + } catch (e) { + Cu.reportError(e); + } + }); + break; + case ACTIONS.search.inSearch.manageEngines: + // Open about:preferences to manage search state + NewTabSearchProvider.search.manageEngines(target.browser); + break; + case ACTIONS.search.inSearch.state: + // Return the state of the search component (i.e current engine and visible engine details) + Task.spawn(function*() { + try { + let state = yield NewTabSearchProvider.search.asyncGetState(); + NewTabWebChannel.broadcast(ACTIONS.search.outSearch.state, state); + } catch (e) { + Cu.reportError(e); + } + }); + break; + case ACTIONS.search.inSearch.removeFormHistory: + // Remove a form history entry from the search component + let suggestion = data; + NewTabSearchProvider.search.removeFormHistory(target, suggestion); + break; + case ACTIONS.search.inSearch.performSearch: + // Perform a search + NewTabSearchProvider.search.asyncPerformSearch(target, data).catch(Cu.reportError); + break; + case ACTIONS.search.inSearch.cycleEngine: + // Set the new current engine + NewTabSearchProvider.search.asyncCycleEngine(data).catch(Cu.reportError); + break; + } + }, + + /* + * Broadcast places change to all open newtab pages + */ + handlePlacesChange(type, data) { + NewTabWebChannel.broadcast(ACTIONS.links.outPlacesChange, {type, data}); + }, + + /* + * Broadcast current engine has changed to all open newtab pages + */ + _handleCurrentEngineChange(name, value) { // jshint unused: false + let engine = value; + NewTabWebChannel.broadcast(ACTIONS.search.outSearch.currentEngine, engine); + }, + + /* + * Broadcast preference changes to all open newtab pages + */ + handlePrefChange(actionName, value) { + let prefChange = {}; + prefChange[actionName] = value; + NewTabWebChannel.broadcast(ACTIONS.prefs.outPrefs, prefChange); + }, + + _handleEnabledChange(prefName, value) { + if (prefName === PREF_ENABLED) { + if (this._prefs.enabled && !value) { + this.uninit(); + } else if (!this._prefs.enabled && value) { + this.init(); + } + } + }, + + init() { + this.handleContentRequest = this.handleContentRequest.bind(this); + this._handleEnabledChange = this._handleEnabledChange.bind(this); + this._handleCurrentEngineChange = this._handleCurrentEngineChange.bind(this); + + PlacesProvider.links.init(); + NewTabPrefsProvider.prefs.init(); + NewTabSearchProvider.search.init(); + NewTabWebChannel.init(); + + this._prefs.enabled = Preferences.get(PREF_ENABLED, false); + + if (this._prefs.enabled) { + for (let action of ACTIONS.inboundActions) { + NewTabWebChannel.on(action, this.handleContentRequest); + } + + NewTabPrefsProvider.prefs.on(PREF_ENABLED, this._handleEnabledChange); + NewTabSearchProvider.search.on(CURRENT_ENGINE, this._handleCurrentEngineChange); + + for (let pref of NewTabPrefsProvider.newtabPagePrefSet) { + NewTabPrefsProvider.prefs.on(pref, this.handlePrefChange); + } + + PlacesProvider.links.on("deleteURI", this.handlePlacesChange); + PlacesProvider.links.on("clearHistory", this.handlePlacesChange); + PlacesProvider.links.on("linkChanged", this.handlePlacesChange); + PlacesProvider.links.on("manyLinksChanged", this.handlePlacesChange); + } + }, + + uninit() { + this._prefs.enabled = Preferences.get(PREF_ENABLED, false); + + if (this._prefs.enabled) { + NewTabPrefsProvider.prefs.off(PREF_ENABLED, this._handleEnabledChange); + NewTabSearchProvider.search.off(CURRENT_ENGINE, this._handleCurrentEngineChange); + + for (let action of ACTIONS.inboundActions) { + NewTabWebChannel.off(action, this.handleContentRequest); + } + + for (let pref of NewTabPrefsProvider.newtabPagePrefSet) { + NewTabPrefsProvider.prefs.off(pref, this.handlePrefChange); + } + } + + NewTabPrefsProvider.prefs.uninit(); + NewTabSearchProvider.search.uninit(); + NewTabWebChannel.uninit(); + } +}; diff --git a/browser/components/newtab/NewTabPrefsProvider.jsm b/browser/components/newtab/NewTabPrefsProvider.jsm new file mode 100644 index 000000000..c1d8b4149 --- /dev/null +++ b/browser/components/newtab/NewTabPrefsProvider.jsm @@ -0,0 +1,114 @@ +/* global Services, Preferences, EventEmitter, XPCOMUtils */ +/* exported NewTabPrefsProvider */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["NewTabPrefsProvider"]; + +const {interfaces: Ci, utils: Cu} = Components; +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "EventEmitter", function() { + const {EventEmitter} = Cu.import("resource://devtools/shared/event-emitter.js", {}); + return EventEmitter; +}); + +// Supported prefs and data type +const gPrefsMap = new Map([ + ["browser.newtabpage.remote", "bool"], + ["browser.newtabpage.remote.mode", "str"], + ["browser.newtabpage.remote.version", "str"], + ["browser.newtabpage.enabled", "bool"], + ["browser.newtabpage.enhanced", "bool"], + ["browser.newtabpage.introShown", "bool"], + ["browser.newtabpage.updateIntroShown", "bool"], + ["browser.newtabpage.pinned", "str"], + ["browser.newtabpage.blocked", "str"], + ["intl.locale.matchOS", "bool"], + ["general.useragent.locale", "localized"], + ["browser.search.hiddenOneOffs", "str"], +]); + +// prefs that are important for the newtab page +const gNewtabPagePrefs = new Set([ + "browser.newtabpage.enabled", + "browser.newtabpage.enhanced", + "browser.newtabpage.pinned", + "browser.newtabpage.blocked", + "browser.newtabpage.introShown", + "browser.newtabpage.updateIntroShown", + "browser.search.hiddenOneOffs", +]); + +let PrefsProvider = function PrefsProvider() { + EventEmitter.decorate(this); +}; + +PrefsProvider.prototype = { + + observe(subject, topic, data) { // jshint ignore:line + if (topic === "nsPref:changed") { + if (gPrefsMap.has(data)) { + switch (gPrefsMap.get(data)) { + case "bool": + this.emit(data, Preferences.get(data, false)); + break; + case "str": + this.emit(data, Preferences.get(data, "")); + break; + case "localized": + try { + this.emit(data, Preferences.get(data, "", Ci.nsIPrefLocalizedString)); + } catch (e) { + this.emit(data, Preferences.get(data, "")); + } + break; + default: + this.emit(data); + break; + } + } + } else { + Cu.reportError(new Error("NewTabPrefsProvider observing unknown topic")); + } + }, + + /* + * Return the preferences that are important to the newtab page + */ + get newtabPagePrefs() { + let results = {}; + for (let pref of gNewtabPagePrefs) { + results[pref] = Preferences.get(pref, null); + } + return results; + }, + + get prefsMap() { + return gPrefsMap; + }, + + init() { + for (let pref of gPrefsMap.keys()) { + Services.prefs.addObserver(pref, this, false); + } + }, + + uninit() { + for (let pref of gPrefsMap.keys()) { + Services.prefs.removeObserver(pref, this, false); + } + } +}; + +/** + * Singleton that serves as the default new tab pref provider for the grid. + */ +const gPrefs = new PrefsProvider(); + +let NewTabPrefsProvider = { + prefs: gPrefs, + newtabPagePrefSet: gNewtabPagePrefs, +}; diff --git a/browser/components/newtab/NewTabRemoteResources.jsm b/browser/components/newtab/NewTabRemoteResources.jsm new file mode 100644 index 000000000..57351b15c --- /dev/null +++ b/browser/components/newtab/NewTabRemoteResources.jsm @@ -0,0 +1,15 @@ +/* exported NewTabRemoteResources */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["NewTabRemoteResources"]; + +const NewTabRemoteResources = { + MODE_CHANNEL_MAP: { + production: {origin: "https://content.cdn.mozilla.net"}, + staging: {origin: "https://s3_proxy_tiles.stage.mozaws.net"}, + test: {origin: "https://example.com"}, + test2: {origin: "http://mochi.test:8888"}, + dev: {origin: "http://localhost:8888"} + } +}; diff --git a/browser/components/newtab/NewTabSearchProvider.jsm b/browser/components/newtab/NewTabSearchProvider.jsm new file mode 100644 index 000000000..a50d8c706 --- /dev/null +++ b/browser/components/newtab/NewTabSearchProvider.jsm @@ -0,0 +1,103 @@ +/* global XPCOMUtils, ContentSearch, Task, Services, EventEmitter */ +/* exported NewTabSearchProvider */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["NewTabSearchProvider"]; + +const {utils: Cu, interfaces: Ci} = Components; +const CURRENT_ENGINE = "browser-search-engine-modified"; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ContentSearch", + "resource:///modules/ContentSearch.jsm"); + +XPCOMUtils.defineLazyGetter(this, "EventEmitter", function() { + const {EventEmitter} = Cu.import("resource://devtools/shared/event-emitter.js", {}); + return EventEmitter; +}); + +function SearchProvider() { + EventEmitter.decorate(this); +} + +SearchProvider.prototype = { + + observe(subject, topic, data) { // jshint unused:false + // all other topics are not relevant to content searches and can be + // ignored by NewTabSearchProvider + if (data === "engine-current" && topic === CURRENT_ENGINE) { + Task.spawn(function* () { + try { + let state = yield ContentSearch.currentStateObj(true); + let engine = state.currentEngine; + this.emit(CURRENT_ENGINE, engine); + } catch (e) { + Cu.reportError(e); + } + }.bind(this)); + } + }, + + init() { + try { + Services.obs.addObserver(this, CURRENT_ENGINE, true); + } catch (e) { + Cu.reportError(e); + } + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, + Ci.nsISupportsWeakReference + ]), + + uninit() { + try { + Services.obs.removeObserver(this, CURRENT_ENGINE, true); + } catch (e) { + Cu.reportError(e); + } + }, + + get searchSuggestionUIStrings() { + return ContentSearch.searchSuggestionUIStrings; + }, + + removeFormHistory({browser}, suggestion) { + ContentSearch.removeFormHistoryEntry({target: browser}, suggestion); + }, + + manageEngines(browser) { + const browserWin = browser.ownerGlobal; + browserWin.openPreferences("paneSearch"); + }, + + asyncGetState: Task.async(function*() { + let state = yield ContentSearch.currentStateObj(true); + return state; + }), + + asyncPerformSearch: Task.async(function*({browser}, searchData) { + ContentSearch.performSearch({target: browser}, searchData); + yield ContentSearch.addFormHistoryEntry({target: browser}, searchData.searchString); + }), + + asyncCycleEngine: Task.async(function*(engineName) { + Services.search.currentEngine = Services.search.getEngineByName(engineName); + let state = yield ContentSearch.currentStateObj(true); + let newEngine = state.currentEngine; + this.emit(CURRENT_ENGINE, newEngine); + }), + + asyncGetSuggestions: Task.async(function*(engineName, searchString, target) { + let suggestions = ContentSearch.getSuggestions(engineName, searchString, target.browser); + return suggestions; + }), +}; + +const NewTabSearchProvider = { + search: new SearchProvider(), +}; diff --git a/browser/components/newtab/NewTabURL.jsm b/browser/components/newtab/NewTabURL.jsm new file mode 100644 index 000000000..5000eae2e --- /dev/null +++ b/browser/components/newtab/NewTabURL.jsm @@ -0,0 +1,36 @@ +/* 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/. */ + +/* globals XPCOMUtils, aboutNewTabService*/ +/* exported NewTabURL */ + +"use strict"; + +const {utils: Cu} = Components; + +this.EXPORTED_SYMBOLS = ["NewTabURL"]; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService", + "@mozilla.org/browser/aboutnewtab-service;1", + "nsIAboutNewTabService"); + +this.NewTabURL = { + + get: function() { + return aboutNewTabService.newTabURL; + }, + + get overridden() { + return aboutNewTabService.overridden; + }, + + override: function(newURL) { + aboutNewTabService.newTabURL = newURL; + }, + + reset: function() { + aboutNewTabService.resetNewTabURL(); + } +}; diff --git a/browser/components/newtab/NewTabWebChannel.jsm b/browser/components/newtab/NewTabWebChannel.jsm new file mode 100644 index 000000000..40ee73684 --- /dev/null +++ b/browser/components/newtab/NewTabWebChannel.jsm @@ -0,0 +1,299 @@ +/* global + NewTabPrefsProvider, + Services, + EventEmitter, + Preferences, + XPCOMUtils, + WebChannel, + NewTabRemoteResources +*/ +/* exported NewTabWebChannel */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["NewTabWebChannel"]; + +const {utils: Cu} = Components; +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NewTabPrefsProvider", + "resource:///modules/NewTabPrefsProvider.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabRemoteResources", + "resource:///modules/NewTabRemoteResources.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WebChannel", + "resource://gre/modules/WebChannel.jsm"); +XPCOMUtils.defineLazyGetter(this, "EventEmitter", function() { + const {EventEmitter} = Cu.import("resource://devtools/shared/event-emitter.js", {}); + return EventEmitter; +}); + +const CHAN_ID = "newtab"; +const PREF_ENABLED = "browser.newtabpage.remote"; +const PREF_MODE = "browser.newtabpage.remote.mode"; + +/** + * NewTabWebChannel is the conduit for all communication with unprivileged newtab instances. + * + * It allows for the ability to broadcast to all newtab browsers. + * If the browser.newtab.remote pref is false, the object will be in an uninitialized state. + * + * Mode choices: + * 'production': pages from our production CDN + * 'staging': pages from our staging CDN + * 'test': intended for tests + * 'test2': intended for tests + * 'dev': intended for development + * + * An unknown mode will result in 'production' mode, which is the default + * + * Incoming messages are expected to be JSON-serialized and in the format: + * + * { + * type: "REQUEST_SCREENSHOT", + * data: { + * url: "https://example.com" + * } + * } + * + * Or: + * + * { + * type: "REQUEST_SCREENSHOT", + * } + * + * Outgoing messages are expected to be objects serializable by structured cloning, in a similar format: + * { + * type: "RECEIVE_SCREENSHOT", + * data: { + * "url": "https://example.com", + * "image": "dataURi:....." + * } + * } + */ +let NewTabWebChannelImpl = function NewTabWebChannelImpl() { + EventEmitter.decorate(this); + this._handlePrefChange = this._handlePrefChange.bind(this); + this._incomingMessage = this._incomingMessage.bind(this); +}; + +NewTabWebChannelImpl.prototype = { + _prefs: {}, + _channel: null, + + // a WeakMap containing browsers as keys and a weak ref to their principal + // as value + _principals: null, + + // a Set containing weak refs to browsers + _browsers: null, + + /* + * Returns current channel's ID + */ + get chanId() { + return CHAN_ID; + }, + + /* + * Returns the number of browsers currently tracking + */ + get numBrowsers() { + return this._getBrowserRefs().length; + }, + + /* + * Returns current channel's origin + */ + get origin() { + if (!(this._prefs.mode in NewTabRemoteResources.MODE_CHANNEL_MAP)) { + this._prefs.mode = "production"; + } + return NewTabRemoteResources.MODE_CHANNEL_MAP[this._prefs.mode].origin; + }, + + /* + * Unloads all browsers and principals + */ + _unloadAll() { + if (this._principals != null) { + this._principals = new WeakMap(); + } + this._browsers = new Set(); + this.emit("targetUnloadAll"); + }, + + /* + * Checks if a browser is known + * + * This will cause an iteration through all known browsers. + * That's ok, we don't expect a lot of browsers + */ + _isBrowserKnown(browser) { + for (let bRef of this._getBrowserRefs()) { + let b = bRef.get(); + if (b && b.permanentKey === browser.permanentKey) { + return true; + } + } + + return false; + }, + + /* + * Obtains all known browser refs + */ + _getBrowserRefs() { + // Some code may try to emit messages after teardown. + if (!this._browsers) { + return []; + } + let refs = []; + for (let bRef of this._browsers) { + /* + * even though we hold a weak ref to browser, it seems that browser + * objects aren't gc'd immediately after a tab closes. They stick around + * in memory, but thankfully they don't have a documentURI in that case + */ + let browser = bRef.get(); + if (browser && browser.documentURI) { + refs.push(bRef); + } else { + // need to clean up principals because the browser object is not gc'ed + // immediately + this._principals.delete(browser); + this._browsers.delete(bRef); + this.emit("targetUnload"); + } + } + return refs; + }, + + /* + * Receives a message from content. + * + * Keeps track of browsers for broadcast, relays messages to listeners. + */ + _incomingMessage(id, message, target) { + if (this.chanId !== id) { + Cu.reportError(new Error("NewTabWebChannel unexpected message destination")); + } + + /* + * need to differentiate by browser, because event targets are created each + * time a message is sent. + */ + if (!this._isBrowserKnown(target.browser)) { + this._browsers.add(Cu.getWeakReference(target.browser)); + this._principals.set(target.browser, Cu.getWeakReference(target.principal)); + this.emit("targetAdd"); + } + + try { + let msg = JSON.parse(message); + this.emit(msg.type, {data: msg.data, target: target}); + } catch (err) { + Cu.reportError(err); + } + }, + + /* + * Sends a message to all known browsers + */ + broadcast(actionType, message) { + for (let bRef of this._getBrowserRefs()) { + let browser = bRef.get(); + try { + let principal = this._principals.get(browser).get(); + if (principal && browser && browser.documentURI) { + this._channel.send({type: actionType, data: message}, {browser, principal}); + } + } catch (e) { + Cu.reportError(new Error("NewTabWebChannel WeakRef is dead")); + this._principals.delete(browser); + } + } + }, + + /* + * Sends a message to a specific target + */ + send(actionType, message, target) { + try { + this._channel.send({type: actionType, data: message}, target); + } catch (e) { + // Web Channel might be dead + Cu.reportError(e); + } + }, + + /* + * Pref change observer callback + */ + _handlePrefChange(prefName, newState, forceState) { // eslint-disable-line no-unused-vars + switch (prefName) { + case PREF_ENABLED: + if (!this._prefs.enabled && newState) { + // changing state from disabled to enabled + this.setupState(); + } else if (this._prefs.enabled && !newState) { + // changing state from enabled to disabled + this.tearDownState(); + } + break; + case PREF_MODE: + if (this._prefs.mode !== newState) { + // changing modes + this.tearDownState(); + this.setupState(); + } + break; + } + }, + + /* + * Sets up the internal state + */ + setupState() { + this._prefs.enabled = Preferences.get(PREF_ENABLED, false); + + let mode = Preferences.get(PREF_MODE, "production"); + if (!(mode in NewTabRemoteResources.MODE_CHANNEL_MAP)) { + mode = "production"; + } + this._prefs.mode = mode; + this._principals = new WeakMap(); + this._browsers = new Set(); + + if (this._prefs.enabled) { + this._channel = new WebChannel(this.chanId, Services.io.newURI(this.origin, null, null)); + this._channel.listen(this._incomingMessage); + } + }, + + tearDownState() { + if (this._channel) { + this._channel.stopListening(); + } + this._prefs = {}; + this._unloadAll(); + this._channel = null; + this._principals = null; + this._browsers = null; + }, + + init() { + this.setupState(); + NewTabPrefsProvider.prefs.on(PREF_ENABLED, this._handlePrefChange); + NewTabPrefsProvider.prefs.on(PREF_MODE, this._handlePrefChange); + }, + + uninit() { + this.tearDownState(); + NewTabPrefsProvider.prefs.off(PREF_ENABLED, this._handlePrefChange); + NewTabPrefsProvider.prefs.off(PREF_MODE, this._handlePrefChange); + } +}; + +let NewTabWebChannel = new NewTabWebChannelImpl(); diff --git a/browser/components/newtab/PlacesProvider.jsm b/browser/components/newtab/PlacesProvider.jsm new file mode 100644 index 000000000..f478b5c5c --- /dev/null +++ b/browser/components/newtab/PlacesProvider.jsm @@ -0,0 +1,211 @@ +/* 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/. */ + +/* global XPCOMUtils, Services, PlacesUtils, EventEmitter */ +/* global gLinks */ +/* exported PlacesProvider */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["PlacesProvider"]; + +const {interfaces: Ci, utils: Cu} = Components; +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "EventEmitter", function() { + const {EventEmitter} = Cu.import("resource://devtools/shared/event-emitter.js", {}); + return EventEmitter; +}); + +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils", + "resource://gre/modules/NewTabUtils.jsm"); + +// The maximum number of results PlacesProvider retrieves from history. +const HISTORY_RESULTS_LIMIT = 100; + +/* Queries history to retrieve the most visited sites. Emits events when the + * history changes. + * Implements the EventEmitter interface. + */ +let Links = function Links() { + EventEmitter.decorate(this); +}; + +Links.prototype = { + /** + * Set this to change the maximum number of links the provider will provide. + */ + get maxNumLinks() { + // getter, so it can't be replaced dynamically + return HISTORY_RESULTS_LIMIT; + }, + + /** + * A set of functions called by @mozilla.org/browser/nav-historyservice + * All history events are emitted from this object. + */ + historyObserver: { + onDeleteURI: function historyObserver_onDeleteURI(aURI) { + // let observers remove sensetive data associated with deleted visit + gLinks.emit("deleteURI", { + url: aURI.spec, + }); + }, + + onClearHistory: function historyObserver_onClearHistory() { + gLinks.emit("clearHistory"); + }, + + onFrecencyChanged: function historyObserver_onFrecencyChanged(aURI, + aNewFrecency, aGUID, aHidden, aLastVisitDate) { // jshint ignore:line + // The implementation of the query in getLinks excludes hidden and + // unvisited pages, so it's important to exclude them here, too. + if (!aHidden && aLastVisitDate && + NewTabUtils.linkChecker.checkLoadURI(aURI.spec)) { + gLinks.emit("linkChanged", { + url: aURI.spec, + frecency: aNewFrecency, + lastVisitDate: aLastVisitDate, + type: "history", + }); + } + }, + + onManyFrecenciesChanged: function historyObserver_onManyFrecenciesChanged() { + // Called when frecencies are invalidated and also when clearHistory is called + // See toolkit/components/places/tests/unit/test_frecency_observers.js + gLinks.emit("manyLinksChanged"); + }, + + onTitleChanged: function historyObserver_onTitleChanged(aURI, aNewTitle) { + if (NewTabUtils.linkChecker.checkLoadURI(aURI.spec)) { + gLinks.emit("linkChanged", { + url: aURI.spec, + title: aNewTitle + }); + } + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver, + Ci.nsISupportsWeakReference]) + }, + + /** + * Must be called before the provider is used. + * Makes it easy to disable under pref + */ + init: function PlacesProvider_init() { + try { + PlacesUtils.history.addObserver(this.historyObserver, true); + } catch (e) { + Cu.reportError(e); + } + }, + + /** + * Gets the current set of links delivered by this provider. + * + * @returns {Promise} Returns a promise with the array of links as payload. + */ + getLinks: Task.async(function*() { + // Select a single page per host with highest frecency, highest recency. + // Choose N top such pages. Note +rev_host, to turn off optimizer per :mak + // suggestion. + let sqlQuery = `SELECT url, title, frecency, + last_visit_date as lastVisitDate, + "history" as type + FROM moz_places + WHERE frecency in ( + SELECT MAX(frecency) as frecency + FROM moz_places + WHERE hidden = 0 AND last_visit_date NOTNULL + GROUP BY +rev_host + ORDER BY frecency DESC + LIMIT :limit + ) + GROUP BY rev_host HAVING MAX(lastVisitDate) + ORDER BY frecency DESC, lastVisitDate DESC, url`; + + let links = yield this.executePlacesQuery(sqlQuery, { + columns: ["url", "title", "lastVisitDate", "frecency", "type"], + params: {limit: this.maxNumLinks} + }); + + return links.filter(link => NewTabUtils.linkChecker.checkLoadURI(link.url)); + }), + + /** + * Executes arbitrary query against places database + * + * @param {String} aSql + * SQL query to execute + * @param {Object} [optional] aOptions + * aOptions.columns - an array of column names. if supplied the returned + * items will consist of objects keyed on column names. Otherwise + * an array of raw values is returned in the select order + * aOptions.param - an object of SQL binding parameters + * aOptions.callback - a callback to handle query rows + * + * @returns {Promise} Returns a promise with the array of retrieved items + */ + executePlacesQuery: Task.async(function*(aSql, aOptions={}) { + let {columns, params, callback} = aOptions; + let items = []; + let queryError = null; + let conn = yield PlacesUtils.promiseDBConnection(); + yield conn.executeCached(aSql, params, aRow => { + try { + // check if caller wants to handle query raws + if (callback) { + callback(aRow); + } + // otherwise fill in the item and add items array + else { + let item = null; + // if columns array is given construct an object + if (columns && Array.isArray(columns)) { + item = {}; + columns.forEach(column => { + item[column] = aRow.getResultByName(column); + }); + } else { + // if no columns - make an array of raw values + item = []; + for (let i = 0; i < aRow.numEntries; i++) { + item.push(aRow.getResultByIndex(i)); + } + } + items.push(item); + } + } catch (e) { + queryError = e; + throw StopIteration; + } + }); + if (queryError) { + throw new Error(queryError); + } + return items; + }), +}; + +/** + * Singleton that serves as the default link provider for the grid. + */ +const gLinks = new Links(); // jshint ignore:line + +let PlacesProvider = { + links: gLinks, +}; + +// Kept only for backwards-compatibility +XPCOMUtils.defineLazyGetter(PlacesProvider, "LinkChecker", + () => NewTabUtils.linkChecker); + diff --git a/browser/components/newtab/PreviewProvider.jsm b/browser/components/newtab/PreviewProvider.jsm new file mode 100644 index 000000000..8624b8544 --- /dev/null +++ b/browser/components/newtab/PreviewProvider.jsm @@ -0,0 +1,49 @@ +/* global XPCOMUtils, BackgroundPageThumbs, FileUtils, PageThumbsStorage, Task, MIMEService */ +/* exported PreviewProvider */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["PreviewProvider"]; + +const {utils: Cu} = Components; +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/PageThumbs.jsm"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +const {OS} = Cu.import("resource://gre/modules/osfile.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "BackgroundPageThumbs", + "resource://gre/modules/BackgroundPageThumbs.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "MIMEService", + "@mozilla.org/mime;1", "nsIMIMEService"); + +let PreviewProvider = { + /** + * Returns a thumbnail as a data URI for a url, creating it if necessary + * + * @param {String} url + * a url to obtain a thumbnail for + * @return {Promise} A Promise that resolves with a base64 encoded thumbnail + */ + getThumbnail: Task.async(function* PreviewProvider_getThumbnail(url) { + try { + yield BackgroundPageThumbs.captureIfMissing(url); + let imgPath = PageThumbsStorage.getFilePathForURL(url); + + // OS.File object used to easily read off-thread + let file = yield OS.File.open(imgPath, {read: true, existing: true}); + + // nsIFile object needed for MIMEService + let nsFile = FileUtils.File(imgPath); + + let contentType = MIMEService.getTypeFromFile(nsFile); + let bytes = yield file.read(); + let encodedData = btoa(String.fromCharCode.apply(null, bytes)); + file.close(); + return `data:${contentType};base64,${encodedData}`; + } catch (err) { + Cu.reportError(`PreviewProvider_getThumbnail error: ${err}`); + throw err; + } + }) +}; diff --git a/browser/components/newtab/aboutNewTabService.js b/browser/components/newtab/aboutNewTabService.js new file mode 100644 index 000000000..54c3749e8 --- /dev/null +++ b/browser/components/newtab/aboutNewTabService.js @@ -0,0 +1,289 @@ +/* + * 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/. +*/ + +/* globals XPCOMUtils, NewTabPrefsProvider, Services, + Locale, UpdateUtils, NewTabRemoteResources +*/ +"use strict"; + +const {utils: Cu, interfaces: Ci} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils", + "resource://gre/modules/UpdateUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabPrefsProvider", + "resource:///modules/NewTabPrefsProvider.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Locale", + "resource://gre/modules/Locale.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabRemoteResources", + "resource:///modules/NewTabRemoteResources.jsm"); + +const LOCAL_NEWTAB_URL = "chrome://browser/content/newtab/newTab.xhtml"; + +const REMOTE_NEWTAB_PATH = "/newtab/v%VERSION%/%CHANNEL%/%LOCALE%/index.html"; + +const ABOUT_URL = "about:newtab"; + +// Pref that tells if remote newtab is enabled +const PREF_REMOTE_ENABLED = "browser.newtabpage.remote"; + +// Pref branch necesssary for testing +const PREF_REMOTE_CS_TEST = "browser.newtabpage.remote.content-signing-test"; + +// The preference that tells whether to match the OS locale +const PREF_MATCH_OS_LOCALE = "intl.locale.matchOS"; + +// The preference that tells what locale the user selected +const PREF_SELECTED_LOCALE = "general.useragent.locale"; + +// The preference that tells what remote mode is enabled. +const PREF_REMOTE_MODE = "browser.newtabpage.remote.mode"; + +// The preference that tells which remote version is expected. +const PREF_REMOTE_VERSION = "browser.newtabpage.remote.version"; + +const VALID_CHANNELS = new Set(["esr", "release", "beta", "aurora", "nightly"]); + +function AboutNewTabService() { + NewTabPrefsProvider.prefs.on(PREF_REMOTE_ENABLED, this._handleToggleEvent.bind(this)); + + this._updateRemoteMaybe = this._updateRemoteMaybe.bind(this); + + // trigger remote change if needed, according to pref + this.toggleRemote(Services.prefs.getBoolPref(PREF_REMOTE_ENABLED)); +} + +/* + * A service that allows for the overriding, at runtime, of the newtab page's url. + * Additionally, the service manages pref state between a remote and local newtab page. + * + * There is tight coupling with browser/about/AboutRedirector.cpp. + * + * 1. Browser chrome access: + * + * When the user issues a command to open a new tab page, usually clicking a button + * in the browser chrome or using shortcut keys, the browser chrome code invokes the + * service to obtain the newtab URL. It then loads that URL in a new tab. + * + * When not overridden, the default URL emitted by the service is "about:newtab". + * When overridden, it returns the overriden URL. + * + * 2. Redirector Access: + * + * When the URL loaded is about:newtab, the default behavior, or when entered in the + * URL bar, the redirector is hit. The service is then called to return either of + * two URLs, a chrome or remote one, based on the browser.newtabpage.remote pref. + * + * NOTE: "about:newtab" will always result in a default newtab page, and never an overridden URL. + * + * Access patterns: + * + * The behavior is different when accessing the service via browser chrome or via redirector + * largely to maintain compatibility with expectations of add-on developers. + * + * Loading a chrome resource, or an about: URL in the redirector with either the + * LOAD_NORMAL or LOAD_REPLACE flags yield unexpected behaviors, so a roundtrip + * to the redirector from browser chrome is avoided. + */ +AboutNewTabService.prototype = { + + _newTabURL: ABOUT_URL, + _remoteEnabled: false, + _remoteURL: null, + _overridden: false, + + classID: Components.ID("{dfcd2adc-7867-4d3a-ba70-17501f208142}"), + QueryInterface: XPCOMUtils.generateQI([Ci.nsIAboutNewTabService]), + _xpcom_categories: [{ + service: true + }], + + _handleToggleEvent(prefName, stateEnabled, forceState) { // jshint unused:false + if (this.toggleRemote(stateEnabled, forceState)) { + Services.obs.notifyObservers(null, "newtab-url-changed", ABOUT_URL); + } + }, + + /** + * React to changes to the remote newtab pref. + * + * If browser.newtabpage.remote is true, this will change the default URL to the + * remote newtab page URL. If browser.newtabpage.remote is false, the default URL + * will be a local chrome URL. + * + * This will only act if there is a change of state and if not overridden. + * + * @returns {Boolean} Returns if there has been a state change + * + * @param {Boolean} stateEnabled remote state to set to + * @param {Boolean} forceState force state change + */ + toggleRemote(stateEnabled, forceState) { + + if (!forceState && (this._overriden || stateEnabled === this._remoteEnabled)) { + // exit there is no change of state + return false; + } + + let csTest = Services.prefs.getBoolPref(PREF_REMOTE_CS_TEST); + if (stateEnabled) { + if (!csTest) { + this._remoteURL = this.generateRemoteURL(); + } else { + this._remoteURL = this._newTabURL; + } + NewTabPrefsProvider.prefs.on( + PREF_SELECTED_LOCALE, + this._updateRemoteMaybe); + NewTabPrefsProvider.prefs.on( + PREF_MATCH_OS_LOCALE, + this._updateRemoteMaybe); + NewTabPrefsProvider.prefs.on( + PREF_REMOTE_MODE, + this._updateRemoteMaybe); + NewTabPrefsProvider.prefs.on( + PREF_REMOTE_VERSION, + this._updateRemoteMaybe); + this._remoteEnabled = true; + } else { + NewTabPrefsProvider.prefs.off(PREF_SELECTED_LOCALE, this._updateRemoteMaybe); + NewTabPrefsProvider.prefs.off(PREF_MATCH_OS_LOCALE, this._updateRemoteMaybe); + NewTabPrefsProvider.prefs.off(PREF_REMOTE_MODE, this._updateRemoteMaybe); + NewTabPrefsProvider.prefs.off(PREF_REMOTE_VERSION, this._updateRemoteMaybe); + this._remoteEnabled = false; + } + if (!csTest) { + this._newTabURL = ABOUT_URL; + } + return true; + }, + + /* + * Generate a default url based on remote mode, version, locale and update channel + */ + generateRemoteURL() { + let releaseName = this.releaseFromUpdateChannel(UpdateUtils.UpdateChannel); + let path = REMOTE_NEWTAB_PATH + .replace("%VERSION%", this.remoteVersion) + .replace("%LOCALE%", Locale.getLocale()) + .replace("%CHANNEL%", releaseName); + let mode = Services.prefs.getCharPref(PREF_REMOTE_MODE, "production"); + if (!(mode in NewTabRemoteResources.MODE_CHANNEL_MAP)) { + mode = "production"; + } + return NewTabRemoteResources.MODE_CHANNEL_MAP[mode].origin + path; + }, + + /* + * Returns the default URL. + * + * This URL only depends on the browser.newtabpage.remote pref. Overriding + * the newtab page has no effect on the result of this function. + * + * The result is also the remote URL if this is in a test (PREF_REMOTE_CS_TEST) + * + * @returns {String} the default newtab URL, remote or local depending on browser.newtabpage.remote + */ + get defaultURL() { + let csTest = Services.prefs.getBoolPref(PREF_REMOTE_CS_TEST); + if (this._remoteEnabled || csTest) { + return this._remoteURL; + } + return LOCAL_NEWTAB_URL; + }, + + /* + * Updates the remote location when the page is not overriden. + * + * Useful when there is a dependent pref change + */ + _updateRemoteMaybe() { + if (!this._remoteEnabled || this._overridden) { + return; + } + + let url = this.generateRemoteURL(); + if (url !== this._remoteURL) { + this._remoteURL = url; + Services.obs.notifyObservers(null, "newtab-url-changed", + this._remoteURL); + } + }, + + /** + * Returns the release name from an Update Channel name + * + * @returns {String} a release name based on the update channel. Defaults to nightly + */ + releaseFromUpdateChannel(channelName) { + return VALID_CHANNELS.has(channelName) ? channelName : "nightly"; + }, + + get newTabURL() { + return this._newTabURL; + }, + + get remoteVersion() { + return Services.prefs.getCharPref(PREF_REMOTE_VERSION, "1"); + }, + + get remoteReleaseName() { + return this.releaseFromUpdateChannel(UpdateUtils.UpdateChannel); + }, + + set newTabURL(aNewTabURL) { + let csTest = Services.prefs.getBoolPref(PREF_REMOTE_CS_TEST); + aNewTabURL = aNewTabURL.trim(); + if (aNewTabURL === ABOUT_URL) { + // avoid infinite redirects in case one sets the URL to about:newtab + this.resetNewTabURL(); + return; + } else if (aNewTabURL === "") { + aNewTabURL = "about:blank"; + } + let remoteURL = this.generateRemoteURL(); + let prefRemoteEnabled = Services.prefs.getBoolPref(PREF_REMOTE_ENABLED); + let isResetLocal = !prefRemoteEnabled && aNewTabURL === LOCAL_NEWTAB_URL; + let isResetRemote = prefRemoteEnabled && aNewTabURL === remoteURL; + + if (isResetLocal || isResetRemote) { + if (this._overriden && !csTest) { + // only trigger a reset if previously overridden and this is no test + this.resetNewTabURL(); + } + return; + } + // turn off remote state if needed + if (!csTest) { + this.toggleRemote(false); + } else { + // if this is a test, we want the remoteURL to be set + this._remoteURL = aNewTabURL; + } + this._newTabURL = aNewTabURL; + this._overridden = true; + Services.obs.notifyObservers(null, "newtab-url-changed", this._newTabURL); + }, + + get overridden() { + return this._overridden; + }, + + get remoteEnabled() { + return this._remoteEnabled; + }, + + resetNewTabURL() { + this._overridden = false; + this._newTabURL = ABOUT_URL; + this.toggleRemote(Services.prefs.getBoolPref(PREF_REMOTE_ENABLED), true); + Services.obs.notifyObservers(null, "newtab-url-changed", this._newTabURL); + } +}; + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([AboutNewTabService]); diff --git a/browser/components/newtab/moz.build b/browser/components/newtab/moz.build new file mode 100644 index 000000000..37c9983f7 --- /dev/null +++ b/browser/components/newtab/moz.build @@ -0,0 +1,33 @@ +# -*- 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/. + +BROWSER_CHROME_MANIFESTS += ['tests/browser/browser.ini'] + +XPCSHELL_TESTS_MANIFESTS += [ + 'tests/xpcshell/xpcshell.ini', +] + +EXTRA_JS_MODULES += [ + 'NewTabMessages.jsm', + 'NewTabPrefsProvider.jsm', + 'NewTabRemoteResources.jsm', + 'NewTabSearchProvider.jsm', + 'NewTabURL.jsm', + 'NewTabWebChannel.jsm', + 'PlacesProvider.jsm', + 'PreviewProvider.jsm' +] + +XPIDL_SOURCES += [ + 'nsIAboutNewTabService.idl', +] + +XPIDL_MODULE = 'browser-newtab' + +EXTRA_COMPONENTS += [ + 'aboutNewTabService.js', + 'NewTabComponents.manifest', +] diff --git a/browser/components/newtab/nsIAboutNewTabService.idl b/browser/components/newtab/nsIAboutNewTabService.idl new file mode 100644 index 000000000..bc25c7492 --- /dev/null +++ b/browser/components/newtab/nsIAboutNewTabService.idl @@ -0,0 +1,63 @@ +/* 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/. */ + +#include "nsISupports.idl" + +/** + * Allows to override about:newtab to point to a different location + * than the one specified within AboutRedirector.cpp + */ + +[scriptable, uuid(dfcd2adc-7867-4d3a-ba70-17501f208142)] +interface nsIAboutNewTabService : nsISupports +{ + /** + * Returns the url of the resource for the newtab page if not overridden, + * otherwise a string represenation of the new URL. + */ + attribute ACString newTabURL; + + /** + * Returns the default URL (remote or local depending on pref) + */ + attribute ACString defaultURL; + + /** + * Returns true if the default resource got overridden. + */ + readonly attribute bool overridden; + + /** + * Returns true if the default resource is remotely hosted and isn't + * overridden + */ + readonly attribute bool remoteEnabled; + + + /** + * Returns the version of the remote newtab page expected + */ + readonly attribute ACString remoteVersion; + + /** + * Returns the expected channel for the remote the newtab page + */ + readonly attribute ACString remoteReleaseName; + + /** + * Generates and returns the remote newtab page url + */ + ACString generateRemoteURL(); + + /** + * Returns a remote new tab release name given an update channel name + */ + ACString releaseFromUpdateChannel(in ACString channelName); + + /** + * Resets to the default resource and also resets the + * overridden attribute to false. + */ + void resetNewTabURL(); +}; diff --git a/browser/components/newtab/tests/browser/.eslintrc.js b/browser/components/newtab/tests/browser/.eslintrc.js new file mode 100644 index 000000000..7c8021192 --- /dev/null +++ b/browser/components/newtab/tests/browser/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/mochitest/browser.eslintrc.js" + ] +}; diff --git a/browser/components/newtab/tests/browser/blue_page.html b/browser/components/newtab/tests/browser/blue_page.html new file mode 100644 index 000000000..a7f000bfd --- /dev/null +++ b/browser/components/newtab/tests/browser/blue_page.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> + +<html> +<head> + <meta charset="utf-8"> +</head> +<body style="background-color: blue"> +</body> +</html> diff --git a/browser/components/newtab/tests/browser/browser.ini b/browser/components/newtab/tests/browser/browser.ini new file mode 100644 index 000000000..fa740be9e --- /dev/null +++ b/browser/components/newtab/tests/browser/browser.ini @@ -0,0 +1,16 @@ +[DEFAULT] +support-files = + blue_page.html + dummy_page.html + newtabwebchannel_basic.html + newtabmessages_places.html + newtabmessages_prefs.html + newtabmessages_preview.html + newtabmessages_search.html + +[browser_PreviewProvider.js] +[browser_remotenewtab_pageloads.js] +[browser_newtab_overrides.js] +[browser_newtabmessages.js] +skip-if = true # Bug 1271177, bug 1262719 +[browser_newtabwebchannel.js] diff --git a/browser/components/newtab/tests/browser/browser_PreviewProvider.js b/browser/components/newtab/tests/browser/browser_PreviewProvider.js new file mode 100644 index 000000000..b1e3eda9f --- /dev/null +++ b/browser/components/newtab/tests/browser/browser_PreviewProvider.js @@ -0,0 +1,90 @@ +/* globals XPCOMUtils, Services, PreviewProvider, registerCleanupFunction */ +"use strict"; + +let Cu = Components.utils; +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PreviewProvider", + "resource:///modules/PreviewProvider.jsm"); + +var oldEnabledPref = Services.prefs.getBoolPref("browser.pagethumbnails.capturing_disabled"); +Services.prefs.setBoolPref("browser.pagethumbnails.capturing_disabled", false); + +registerCleanupFunction(function() { + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.tabs[1]); + } + Services.prefs.setBoolPref("browser.pagethumbnails.capturing_disabled", oldEnabledPref); +}); + +const TEST_URL = "https://example.com/browser/browser/components/newtab/tests/browser/blue_page.html"; + +function pixelsForDataURI(dataURI, options) { + return new Promise(resolve => { + if (!options) { + options = {}; + } + let {width, height} = options; + if (!width) { + width = 100; + } + if (!height) { + height = 100; + } + + let htmlns = "http://www.w3.org/1999/xhtml"; + let img = document.createElementNS(htmlns, "img"); + img.setAttribute("src", dataURI); + + img.addEventListener("load", function onLoad() { + img.removeEventListener("load", onLoad, true); + let canvas = document.createElementNS(htmlns, "canvas"); + canvas.setAttribute("width", width); + canvas.setAttribute("height", height); + let ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, width, height); + let result = ctx.getImageData(0, 0, width, height).data; + resolve(result); + }); + }); +} + +function* chunk_four(listData) { + let index = 0; + while (index < listData.length) { + yield listData.slice(index, index + 5); + index += 4; + } +} + +add_task(function* open_page() { + let dataURI = yield PreviewProvider.getThumbnail(TEST_URL); + let pixels = yield pixelsForDataURI(dataURI, {width: 10, height: 10}); + let rgbCount = {r: 0, g: 0, b: 0, a: 0}; + for (let [r, g, b, a] of chunk_four(pixels)) { + if (r === 255) { + rgbCount.r += 1; + } + if (g === 255) { + rgbCount.g += 1; + } + if (b === 255) { + rgbCount.b += 1; + } + if (a === 255) { + rgbCount.a += 1; + } + } + Assert.equal(`${rgbCount.r},${rgbCount.g},${rgbCount.b},${rgbCount.a}`, + "0,0,100,100", "there should be 100 blue-only pixels at full opacity"); +}); + +add_task(function* invalid_url() { + try { + yield PreviewProvider.getThumbnail("invalid:URL"); + } catch (err) { + Assert.ok(true, "URL Failed"); + } +}); diff --git a/browser/components/newtab/tests/browser/browser_newtab_overrides.js b/browser/components/newtab/tests/browser/browser_newtab_overrides.js new file mode 100644 index 000000000..eab9092a0 --- /dev/null +++ b/browser/components/newtab/tests/browser/browser_newtab_overrides.js @@ -0,0 +1,139 @@ +/* globals + XPCOMUtils, + aboutNewTabService, + Services, + ContentTask, + TestUtils, + BrowserOpenTab, + registerCleanupFunction, + is, + content +*/ + +"use strict"; + +let Cu = Components.utils; +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService", + "@mozilla.org/browser/aboutnewtab-service;1", + "nsIAboutNewTabService"); + +registerCleanupFunction(function() { + Services.prefs.setBoolPref("browser.newtabpage.remote", false); + aboutNewTabService.resetNewTabURL(); +}); + +/* + * Tests that the default newtab page is always returned when one types "about:newtab" in the URL bar, + * even when overridden. + */ +add_task(function* redirector_ignores_override() { + let overrides = [ + "chrome://browser/content/downloads/contentAreaDownloadsView.xul", + "about:home", + ]; + + for (let overrideURL of overrides) { + let notificationPromise = nextChangeNotificationPromise(overrideURL, `newtab page now points to ${overrideURL}`); + aboutNewTabService.newTabURL = overrideURL; + + yield notificationPromise; + Assert.ok(aboutNewTabService.overridden, "url has been overridden"); + + let tabOptions = { + gBrowser, + url: "about:newtab", + }; + + /* + * Simulate typing "about:newtab" in the url bar. + * + * Bug 1240169 - We expect the redirector to lead the user to "about:newtab", the default URL, + * due to invoking AboutRedirector. A user interacting with the chrome otherwise would lead + * to the overriding URLs. + */ + yield BrowserTestUtils.withNewTab(tabOptions, function*(browser) { + yield ContentTask.spawn(browser, {}, function*() { + Assert.equal(content.location.href, "about:newtab", "Got right URL"); + Assert.equal(content.document.location.href, "about:newtab", "Got right URL"); + Assert.equal(content.document.nodePrincipal, + Services.scriptSecurityManager.getSystemPrincipal(), + "nodePrincipal should match systemPrincipal"); + }); + }); // jshint ignore:line + } +}); + +/* + * Tests loading an overridden newtab page by simulating opening a newtab page from chrome + */ +add_task(function* override_loads_in_browser() { + let overrides = [ + "chrome://browser/content/downloads/contentAreaDownloadsView.xul", + "about:home", + " about:home", + ]; + + for (let overrideURL of overrides) { + let notificationPromise = nextChangeNotificationPromise(overrideURL.trim(), `newtab page now points to ${overrideURL}`); + aboutNewTabService.newTabURL = overrideURL; + + yield notificationPromise; + Assert.ok(aboutNewTabService.overridden, "url has been overridden"); + + // simulate a newtab open as a user would + BrowserOpenTab(); // jshint ignore:line + + let browser = gBrowser.selectedBrowser; + yield BrowserTestUtils.browserLoaded(browser); + + yield ContentTask.spawn(browser, {url: overrideURL}, function*(args) { + Assert.equal(content.location.href, args.url.trim(), "Got right URL"); + Assert.equal(content.document.location.href, args.url.trim(), "Got right URL"); + }); // jshint ignore:line + yield BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); + +/* + * Tests edge cases when someone overrides the newtabpage with whitespace + */ +add_task(function* override_blank_loads_in_browser() { + let overrides = [ + "", + " ", + "\n\t", + " about:blank", + ]; + + for (let overrideURL of overrides) { + let notificationPromise = nextChangeNotificationPromise("about:blank", "newtab page now points to about:blank"); + aboutNewTabService.newTabURL = overrideURL; + + yield notificationPromise; + Assert.ok(aboutNewTabService.overridden, "url has been overridden"); + + // simulate a newtab open as a user would + BrowserOpenTab(); // jshint ignore:line + + let browser = gBrowser.selectedBrowser; + yield BrowserTestUtils.browserLoaded(browser); + + yield ContentTask.spawn(browser, {}, function*() { + Assert.equal(content.location.href, "about:blank", "Got right URL"); + Assert.equal(content.document.location.href, "about:blank", "Got right URL"); + }); // jshint ignore:line + yield BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); + +function nextChangeNotificationPromise(aNewURL, testMessage) { + return TestUtils.topicObserved("newtab-url-changed", function observer(aSubject, aData) { // jshint unused:false + Assert.equal(aData, aNewURL, testMessage); + return true; + }.bind(this)); +} diff --git a/browser/components/newtab/tests/browser/browser_newtabmessages.js b/browser/components/newtab/tests/browser/browser_newtabmessages.js new file mode 100644 index 000000000..319ca1c34 --- /dev/null +++ b/browser/components/newtab/tests/browser/browser_newtabmessages.js @@ -0,0 +1,222 @@ +/* globals Cu, XPCOMUtils, Preferences, is, registerCleanupFunction, NewTabWebChannel, +PlacesTestUtils, NewTabMessages, ok, Services, PlacesUtils, NetUtil, Task */ + +"use strict"; + +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabWebChannel", + "resource:///modules/NewTabWebChannel.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabMessages", + "resource:///modules/NewTabMessages.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils", + "resource://testing-common/PlacesTestUtils.jsm"); + +let setup = Task.async(function*() { + Preferences.set("browser.newtabpage.enhanced", true); + Preferences.set("browser.newtabpage.remote.mode", "test"); + Preferences.set("browser.newtabpage.remote", true); + NewTabMessages.init(); + yield PlacesTestUtils.clearHistory(); +}); + +let cleanup = Task.async(function*() { + NewTabMessages.uninit(); + Preferences.set("browser.newtabpage.remote", false); + Preferences.set("browser.newtabpage.remote.mode", "production"); +}); +registerCleanupFunction(cleanup); + +/* + * Sanity tests for pref messages + */ +add_task(function* prefMessages_request() { + yield setup(); + + let testURL = "https://example.com/browser/browser/components/newtab/tests/browser/newtabmessages_prefs.html"; + + let tabOptions = { + gBrowser, + url: testURL + }; + + let prefResponseAck = new Promise(resolve => { + NewTabWebChannel.once("responseAck", () => { + ok(true, "a request response has been received"); + resolve(); + }); + }); + + yield BrowserTestUtils.withNewTab(tabOptions, function*() { + yield prefResponseAck; + let prefChangeAck = new Promise(resolve => { + NewTabWebChannel.once("responseAck", () => { + ok(true, "a change response has been received"); + resolve(); + }); + }); + Preferences.set("browser.newtabpage.enhanced", false); + yield prefChangeAck; + }); + yield cleanup(); +}); + +/* + * Sanity tests for preview messages + */ +add_task(function* previewMessages_request() { + yield setup(); + var oldEnabledPref = Services.prefs.getBoolPref("browser.pagethumbnails.capturing_disabled"); + Services.prefs.setBoolPref("browser.pagethumbnails.capturing_disabled", false); + + let testURL = "https://example.com/browser/browser/components/newtab/tests/browser/newtabmessages_preview.html"; + + let tabOptions = { + gBrowser, + url: testURL + }; + + let previewResponseAck = new Promise(resolve => { + NewTabWebChannel.once("responseAck", () => { + ok(true, "a request response has been received"); + resolve(); + }); + }); + + yield BrowserTestUtils.withNewTab(tabOptions, function*() { + yield previewResponseAck; + }); + yield cleanup(); + Services.prefs.setBoolPref("browser.pagethumbnails.capturing_disabled", oldEnabledPref); +}); + +/* + * Sanity tests for places messages + */ +add_task(function* placesMessages_request() { + yield setup(); + let testURL = "https://example.com/browser/browser/components/newtab/tests/browser/newtabmessages_places.html"; + + // url prefix for test history population + const TEST_URL = "https://mozilla.com/"; + // time when the test starts execution + const TIME_NOW = (new Date()).getTime(); + + // utility function to compute past timestamp + function timeDaysAgo(numDays) { + return TIME_NOW - (numDays * 24 * 60 * 60 * 1000); + } + + // utility function to make a visit for insertion into places db + function makeVisit(index, daysAgo, isTyped, domain=TEST_URL) { + let { + TRANSITION_TYPED, + TRANSITION_LINK + } = PlacesUtils.history; + + return { + uri: NetUtil.newURI(`${domain}${index}`), + visitDate: timeDaysAgo(daysAgo), + transition: (isTyped) ? TRANSITION_TYPED : TRANSITION_LINK, + }; + } + + yield PlacesTestUtils.clearHistory(); + + // all four visits must come from different domains to avoid deduplication + let visits = [ + makeVisit(0, 0, true, "http://bar.com/"), // frecency 200, today + makeVisit(1, 0, true, "http://foo.com/"), // frecency 200, today + makeVisit(2, 2, true, "http://buz.com/"), // frecency 200, 2 days ago + makeVisit(3, 2, false, "http://aaa.com/"), // frecency 10, 2 days ago, transition + ]; + + yield PlacesTestUtils.addVisits(visits); + + /** Test Begins **/ + + let tabOptions = { + gBrowser, + url: testURL + }; + + let placesResponseAck = new Promise(resolve => { + NewTabWebChannel.once("numItemsAck", (_, msg) => { + ok(true, "a request response has been received"); + is(msg.data, visits.length + 1, "received an expected number of history items"); + resolve(); + }); + }); + + yield BrowserTestUtils.withNewTab(tabOptions, function*() { + yield placesResponseAck; + ok(true, "a change response has been received"); + let placesChangeAck = new Promise(resolve => { + NewTabWebChannel.once("clearHistoryAck", (_, msg) => { + is(msg.data, "clearHistory", "a clear history message has been received"); + resolve(); + }); + }); + yield PlacesTestUtils.clearHistory(); + yield placesChangeAck; + }); + yield cleanup(); +}); + +/* + * Sanity tests for search messages + */ +add_task(function* searchMessages_request() { + yield setup(); + let testURL = "https://example.com/browser/browser/components/newtab/tests/browser/newtabmessages_search.html"; + + // create dummy test engines + Services.search.addEngineWithDetails("Engine1", "", "", "", "GET", + "http://example.com/?q={searchTerms}"); + Services.search.addEngineWithDetails("Engine2", "", "", "", "GET", + "http://example.com/?q={searchTerms}"); + + let tabOptions = { + gBrowser, + url: testURL + }; + + let UIStringsResponseAck = new Promise(resolve => { + NewTabWebChannel.once("UIStringsAck", (_, msg) => { + ok(true, "a search request response for UI string has been received"); + ok(msg.data, "received the UI Strings"); + resolve(); + }); + }); + let suggestionsResponseAck = new Promise(resolve => { + NewTabWebChannel.once("suggestionsAck", (_, msg) => { + ok(true, "a search request response for suggestions has been received"); + ok(msg.data, "received the suggestions"); + resolve(); + }); + }); + let stateResponseAck = new Promise(resolve => { + NewTabWebChannel.once("stateAck", (_, msg) => { + ok(true, "a search request response for state has been received"); + ok(msg.data, "received a state object"); + resolve(); + }); + }); + let currentEngineResponseAck = new Promise(resolve => { + NewTabWebChannel.once("currentEngineAck", (_, msg) => { + ok(true, "a search request response for current engine has been received"); + ok(msg.data, "received a current engine"); + resolve(); + }); + }); + + yield BrowserTestUtils.withNewTab(tabOptions, function*() { + yield UIStringsResponseAck; + yield suggestionsResponseAck; + yield stateResponseAck; + yield currentEngineResponseAck; + }); + + cleanup(); +}); diff --git a/browser/components/newtab/tests/browser/browser_newtabwebchannel.js b/browser/components/newtab/tests/browser/browser_newtabwebchannel.js new file mode 100644 index 000000000..f003b105b --- /dev/null +++ b/browser/components/newtab/tests/browser/browser_newtabwebchannel.js @@ -0,0 +1,251 @@ +/* globals XPCOMUtils, Cu, Preferences, NewTabWebChannel, is, registerCleanupFunction */ + +"use strict"; + +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NewTabWebChannel", + "resource:///modules/NewTabWebChannel.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabMessages", + "resource:///modules/NewTabMessages.jsm"); + +const TEST_URL = "https://example.com/browser/browser/components/newtab/tests/browser/newtabwebchannel_basic.html"; +const TEST_URL_2 = "http://mochi.test:8888/browser/browser/components/newtab/tests/browser/newtabwebchannel_basic.html"; + +function setup(mode = "test") { + Preferences.set("browser.newtabpage.remote.mode", mode); + Preferences.set("browser.newtabpage.remote", true); + NewTabWebChannel.init(); + NewTabMessages.init(); +} + +function cleanup() { + NewTabMessages.uninit(); + NewTabWebChannel.uninit(); + Preferences.set("browser.newtabpage.remote", false); + Preferences.set("browser.newtabpage.remote.mode", "production"); +} +registerCleanupFunction(cleanup); + +/* + * Tests flow of messages from newtab to chrome and chrome to newtab + */ +add_task(function* open_webchannel_basic() { + setup(); + + let tabOptions = { + gBrowser, + url: TEST_URL + }; + + let messagePromise = new Promise(resolve => { + NewTabWebChannel.once("foo", function(name, msg) { + is(name, "foo", "Correct message type sent: foo"); + is(msg.data, "bar", "Correct data sent: bar"); + resolve(msg.target); + }); + }); + + let replyPromise = new Promise(resolve => { + NewTabWebChannel.once("reply", function(name, msg) { + is(name, "reply", "Correct message type sent: reply"); + is(msg.data, "quuz", "Correct data sent: quuz"); + resolve(msg.target); + }); + }); + + let unloadPromise = new Promise(resolve => { + NewTabWebChannel.once("targetUnload", function(name) { + is(name, "targetUnload", "Correct message type sent: targetUnload"); + resolve(); + }); + }); + + is(NewTabWebChannel.numBrowsers, 0, "Sanity check"); + yield BrowserTestUtils.withNewTab(tabOptions, function*(browser) { + let target = yield messagePromise; + is(NewTabWebChannel.numBrowsers, 1, "One target expected"); + is(target.browser, browser, "Same browser"); + NewTabWebChannel.send("respond", null, target); + yield replyPromise; + }); + + Cu.forceGC(); + is(NewTabWebChannel.numBrowsers, 0, "Sanity check"); + yield unloadPromise; + cleanup(); +}); + +/* + * Tests message broadcast reaches all open newtab pages + */ +add_task(function* webchannel_broadcast() { + setup(); + + let countingMessagePromise = new Promise(resolve => { + let count = 0; + NewTabWebChannel.on("foo", function test_message(name, msg) { + count += 1; + if (count === 2) { + NewTabWebChannel.off("foo", test_message); + resolve(msg.target); + } + }.bind(this)); + }); + + let countingReplyPromise = new Promise(resolve => { + let count = 0; + NewTabWebChannel.on("reply", function test_message(name, msg) { + count += 1; + if (count === 2) { + NewTabWebChannel.off("reply", test_message); + resolve(msg.target); + } + }.bind(this)); + }); + + let countingUnloadPromise = new Promise(resolve => { + let count = 0; + NewTabWebChannel.on("targetUnload", function test_message() { + count += 1; + if (count === 2) { + NewTabWebChannel.off("targetUnload", test_message); + resolve(); + } + }); + }); + + let tabs = []; + is(NewTabWebChannel.numBrowsers, 0, "Sanity check"); + tabs.push(yield BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL)); + tabs.push(yield BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL)); + + yield countingMessagePromise; + is(NewTabWebChannel.numBrowsers, 2, "Two targets expected"); + + NewTabWebChannel.broadcast("respond", null); + yield countingReplyPromise; + + for (let tab of tabs) { + yield BrowserTestUtils.removeTab(tab); + } + Cu.forceGC(); + + is(NewTabWebChannel.numBrowsers, 0, "Sanity check"); + yield countingUnloadPromise; + cleanup(); +}); + +/* + * Tests switching modes + */ +add_task(function* webchannel_switch() { + setup(); + + function newMessagePromise() { + return new Promise(resolve => { + NewTabWebChannel.once("foo", function(name, msg) { + resolve(msg.target); + }.bind(this)); + }); + } + + let replyCount = 0; + function newReplyPromise() { + return new Promise(resolve => { + NewTabWebChannel.on("reply", function() { + replyCount += 1; + resolve(); + }); + }); + } + + let unloadPromise = new Promise(resolve => { + NewTabWebChannel.once("targetUnload", function() { + resolve(); + }); + }); + + let unloadAllPromise = new Promise(resolve => { + NewTabWebChannel.once("targetUnloadAll", function() { + resolve(); + }); + }); + + let tabs = []; + let messagePromise; + is(NewTabWebChannel.numBrowsers, 0, "Sanity check"); + + messagePromise = newMessagePromise(); + tabs.push(yield BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL)); + yield messagePromise; + is(NewTabWebChannel.numBrowsers, 1, "Correct number of targets"); + + messagePromise = newMessagePromise(); + Preferences.set("browser.newtabpage.remote.mode", "test2"); + tabs.push(yield BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL_2)); + yield unloadAllPromise; + yield messagePromise; + is(NewTabWebChannel.numBrowsers, 1, "Correct number of targets"); + + NewTabWebChannel.broadcast("respond", null); + yield newReplyPromise(); + is(replyCount, 1, "only current channel is listened to for replies"); + + const webchannelWhitelistPref = "webchannel.allowObject.urlWhitelist"; + let origWhitelist = Services.prefs.getCharPref(webchannelWhitelistPref); + let newWhitelist = origWhitelist + " http://mochi.test:8888"; + Services.prefs.setCharPref(webchannelWhitelistPref, newWhitelist); + try { + NewTabWebChannel.broadcast("respond_object", null); + yield newReplyPromise(); + } finally { + Services.prefs.clearUserPref(webchannelWhitelistPref); + } + + for (let tab of tabs) { + yield BrowserTestUtils.removeTab(tab); + } + + Cu.forceGC(); + is(NewTabWebChannel.numBrowsers, 0, "Sanity check"); + yield unloadPromise; + cleanup(); +}); + +add_task(function* open_webchannel_reload() { + setup(); + + let tabOptions = { + gBrowser, + url: TEST_URL + }; + + let messagePromise = new Promise(resolve => { + NewTabWebChannel.once("foo", function(name, msg) { + is(name, "foo", "Correct message type sent: foo"); + is(msg.data, "bar", "Correct data sent: bar"); + resolve(msg.target); + }); + }); + let unloadPromise = new Promise(resolve => { + NewTabWebChannel.once("targetUnload", function() { + resolve(); + }); + }); + + is(NewTabWebChannel.numBrowsers, 0, "Sanity check"); + yield BrowserTestUtils.withNewTab(tabOptions, function*(browser) { + let target = yield messagePromise; + is(NewTabWebChannel.numBrowsers, 1, "One target expected"); + is(target.browser, browser, "Same browser"); + + browser.reload(); + }); + + Cu.forceGC(); + is(NewTabWebChannel.numBrowsers, 0, "Sanity check"); + yield unloadPromise; + cleanup(); +}); diff --git a/browser/components/newtab/tests/browser/browser_remotenewtab_pageloads.js b/browser/components/newtab/tests/browser/browser_remotenewtab_pageloads.js new file mode 100644 index 000000000..e99aeffc2 --- /dev/null +++ b/browser/components/newtab/tests/browser/browser_remotenewtab_pageloads.js @@ -0,0 +1,52 @@ +/* globals Cu, XPCOMUtils, TestUtils, aboutNewTabService, ContentTask, content, is */ +"use strict"; + +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService", + "@mozilla.org/browser/aboutnewtab-service;1", + "nsIAboutNewTabService"); + +const TEST_URL = "https://example.com/browser/browser/components/newtab/tests/browser/dummy_page.html"; + +/* + * Tests opening a newtab page with a remote URL. Simulates a newtab open from chrome + */ +add_task(function* open_newtab() { + let notificationPromise = nextChangeNotificationPromise(TEST_URL, "newtab page now points to test url"); + aboutNewTabService.newTabURL = TEST_URL; + + yield notificationPromise; + Assert.ok(aboutNewTabService.overridden, "url has been overridden"); + + /* + * Simulate a newtab open as a user would. + * + * Bug 1240169 - We cannot set the URL to about:newtab because that would invoke the redirector. + * The redirector always yields the loading of a default newtab URL. We expect the user to use + * the browser UI to access overriding URLs, for istance by click on the "+" button in the tab + * bar, or by using the new tab shortcut key. + */ + BrowserOpenTab(); // jshint ignore:line + + let browser = gBrowser.selectedBrowser; + yield BrowserTestUtils.browserLoaded(browser); + + yield ContentTask.spawn(browser, {url: TEST_URL}, function*(args) { + Assert.equal(content.document.location.href, args.url, + "document.location should match the external resource"); + Assert.equal(content.document.documentURI, args.url, + "document.documentURI should match the external resource"); + Assert.equal(content.document.nodePrincipal.URI.spec, args.url, + "nodePrincipal should match the external resource"); + }); + yield BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +function nextChangeNotificationPromise(aNewURL, testMessage) { + return TestUtils.topicObserved("newtab-url-changed", function observer(aSubject, aData) { // jshint unused:false + Assert.equal(aData, aNewURL, testMessage); + return true; + }.bind(this)); +} diff --git a/browser/components/newtab/tests/browser/dummy_page.html b/browser/components/newtab/tests/browser/dummy_page.html new file mode 100644 index 000000000..4b0689bde --- /dev/null +++ b/browser/components/newtab/tests/browser/dummy_page.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> + +<html> +<head> + <meta charset="utf-8"> +</head> +<body> +<p>Dummy Page</p> +</body> +</html> diff --git a/browser/components/newtab/tests/browser/newtabmessages_places.html b/browser/components/newtab/tests/browser/newtabmessages_places.html new file mode 100644 index 000000000..e89bc4a22 --- /dev/null +++ b/browser/components/newtab/tests/browser/newtabmessages_places.html @@ -0,0 +1,49 @@ +<html> + <head> + <meta charset="utf8"> + <title>Newtab WebChannel test</title> + </head> + <body> + <script> + window.addEventListener("WebChannelMessageToContent", function(e) { + if (e.detail.message) { + let reply; + switch (e.detail.message.type) { + case "RECEIVE_FRECENT": + reply = new window.CustomEvent("WebChannelMessageToChrome", { + detail: JSON.stringify({ + id: "newtab", + message: JSON.stringify({type: "numItemsAck", data: e.detail.message.data.length}), + }) + }); + window.dispatchEvent(reply); + break; + case "RECEIVE_PLACES_CHANGE": + if (e.detail.message.data.type === "clearHistory") { + reply = new window.CustomEvent("WebChannelMessageToChrome", { + detail: JSON.stringify({ + id: "newtab", + message: JSON.stringify({type: "clearHistoryAck", data: e.detail.message.data.type}), + }) + }); + window.dispatchEvent(reply); + } + break; + } + } + }, true); + + document.onreadystatechange = function () { + if (document.readyState === "complete") { + let msg = new window.CustomEvent("WebChannelMessageToChrome", { + detail: JSON.stringify({ + id: "newtab", + message: JSON.stringify({type: "REQUEST_FRECENT"}), + }) + }); + window.dispatchEvent(msg); + } + } + </script> + </body> +</html> diff --git a/browser/components/newtab/tests/browser/newtabmessages_prefs.html b/browser/components/newtab/tests/browser/newtabmessages_prefs.html new file mode 100644 index 000000000..9b38af4b9 --- /dev/null +++ b/browser/components/newtab/tests/browser/newtabmessages_prefs.html @@ -0,0 +1,32 @@ +<html> + <head> + <meta charset="utf8"> + <title>Newtab WebChannel test</title> + </head> + <body> + <script> + window.addEventListener("WebChannelMessageToContent", function(e) { + if (e.detail.message && e.detail.message.type === "RECEIVE_PREFS") { + let reply = new window.CustomEvent("WebChannelMessageToChrome", { + detail: JSON.stringify({ + id: "newtab", + message: JSON.stringify({type: "responseAck"}), + }) + }); + window.dispatchEvent(reply); + } + }, true); + + document.onreadystatechange = function () { + let msg = new window.CustomEvent("WebChannelMessageToChrome", { + detail: JSON.stringify({ + id: "newtab", + message: JSON.stringify({type: "REQUEST_PREFS"}), + }) + }); + window.dispatchEvent(msg); + }; + + </script> + </body> +</html> diff --git a/browser/components/newtab/tests/browser/newtabmessages_preview.html b/browser/components/newtab/tests/browser/newtabmessages_preview.html new file mode 100644 index 000000000..4fe55132d --- /dev/null +++ b/browser/components/newtab/tests/browser/newtabmessages_preview.html @@ -0,0 +1,37 @@ +<html> + <head> + <meta charset="utf8"> + <title>Newtab WebChannel test</title> + </head> + <body> + <script> + let thumbURL = "https://example.com/browser/browser/components/newtab/tests/browser/blue_page.html"; + + window.addEventListener("WebChannelMessageToContent", function(e) { + if (e.detail.message && e.detail.message.type === "RECEIVE_THUMB") { + if (e.detail.message.data.imgData && e.detail.message.data.url === thumbURL) { + let reply = new window.CustomEvent("WebChannelMessageToChrome", { + detail: JSON.stringify({ + id: "newtab", + message: JSON.stringify({type: "responseAck"}), + }) + }); + window.dispatchEvent(reply); + } + } + }, true); + + document.onreadystatechange = function () { + if (document.readyState === "complete") { + let msg = new window.CustomEvent("WebChannelMessageToChrome", { + detail: JSON.stringify({ + id: "newtab", + message: JSON.stringify({type: "REQUEST_THUMB", data: thumbURL}), + }) + }); + window.dispatchEvent(msg); + } + }; + </script> + </body> +</html> diff --git a/browser/components/newtab/tests/browser/newtabmessages_search.html b/browser/components/newtab/tests/browser/newtabmessages_search.html new file mode 100644 index 000000000..b8b21c42a --- /dev/null +++ b/browser/components/newtab/tests/browser/newtabmessages_search.html @@ -0,0 +1,113 @@ +<html> + <head> + <meta charset="utf8"> + <title>Newtab WebChannel test</title> + </head> + <body> + <script> + let suggestionsData = { + engineName: "Engine1", + searchString: "test", + }; + let removeFormHistoryData = "test"; + let performSearchData = { + engineName: "Engine1", + healthReportKey: "1", + searchPurpose: "d", + searchString: "test", + }; + let cycleEngineData = "Engine2"; + + window.addEventListener("WebChannelMessageToContent", function(e) { + if (e.detail.message) { + let reply; + switch (e.detail.message.type) { + case "RECEIVE_UISTRINGS": + reply = new window.CustomEvent("WebChannelMessageToChrome", { + detail: { + id: "newtab", + message: JSON.stringify({type: "UIStringsAck", data: e.detail.message.data}), + } + }); + window.dispatchEvent(reply); + break; + case "RECEIVE_SEARCH_SUGGESTIONS": + reply = new window.CustomEvent("WebChannelMessageToChrome", { + detail: { + id: "newtab", + message: JSON.stringify({type: "suggestionsAck", data: e.detail.message.data}), + } + }); + window.dispatchEvent(reply); + break; + case "RECEIVE_SEARCH_STATE": + reply = new window.CustomEvent("WebChannelMessageToChrome", { + detail: { + id: "newtab", + message: JSON.stringify({type: "stateAck", data: e.detail.message.data}), + } + }); + window.dispatchEvent(reply); + break; + case "RECEIVE_CURRENT_ENGINE": + reply = new window.CustomEvent("WebChannelMessageToChrome", { + detail: { + id: "newtab", + message: JSON.stringify({type: "currentEngineAck", data: e.detail.message.data}), + } + }); + window.dispatchEvent(reply); + break; + } + } + }, true); + + document.onreadystatechange = function () { + if (document.readyState === "complete") { + let msg = new window.CustomEvent("WebChannelMessageToChrome", { + detail: { + id: "newtab", + message: JSON.stringify({type: "REQUEST_UISTRINGS"}), + } + }); + window.dispatchEvent(msg); + msg = new window.CustomEvent("WebChannelMessageToChrome", { + detail: { + id: "newtab", + message: JSON.stringify({type: "REQUEST_SEARCH_SUGGESTIONS", data: suggestionsData}), + } + }); + window.dispatchEvent(msg); + msg = new window.CustomEvent("WebChannelMessageToChrome", { + detail: { + id: "newtab", + message: JSON.stringify({type: "REQUEST_SEARCH_STATE"}), + } + }); + window.dispatchEvent(msg); + msg = new window.CustomEvent("WebChannelMessageToChrome", { + detail: { + id: "newtab", + message: JSON.stringify({type: "REQUEST_REMOVE_FORM_HISTORY", data: removeFormHistoryData}), + } + }); + window.dispatchEvent(msg); + msg = new window.CustomEvent("WebChannelMessageToChrome", { + detail: { + id: "newtab", + message: JSON.stringify({type: "REQUEST_PERFORM_SEARCH", data: performSearchData}), + } + }); + window.dispatchEvent(msg); + msg = new window.CustomEvent("WebChannelMessageToChrome", { + detail: { + id: "newtab", + message: JSON.stringify({type: "REQUEST_CYCLE_ENGINE", data: cycleEngineData}), + } + }); + window.dispatchEvent(msg); + } + } + </script> + </body> +</html> diff --git a/browser/components/newtab/tests/browser/newtabwebchannel_basic.html b/browser/components/newtab/tests/browser/newtabwebchannel_basic.html new file mode 100644 index 000000000..7f3c79920 --- /dev/null +++ b/browser/components/newtab/tests/browser/newtabwebchannel_basic.html @@ -0,0 +1,36 @@ +<html> + <head> + <meta charset="utf8"> + <title>Newtab WebChannel test</title> + </head> + <body> + <script> + document.onreadystatechange = function () { + let msg = new window.CustomEvent("WebChannelMessageToChrome", { + detail: JSON.stringify({ + id: "newtab", + message: JSON.stringify({type: "foo", data: "bar"}), + }) + }); + window.dispatchEvent(msg); + }; + + window.addEventListener("WebChannelMessageToContent", function(e) { + if (e.detail.message && e.detail.message.type.startsWith("respond")) { + var detail = { + id: "newtab", + message: JSON.stringify({type: "reply", data: "quuz"}), + }; + if (e.detail.message.type !== "respond_object") { + detail = JSON.stringify(detail); + } + let reply = new window.CustomEvent("WebChannelMessageToChrome", { + detail: detail + }); + window.dispatchEvent(reply); + } + }, true); + + </script> + </body> +</html> diff --git a/browser/components/newtab/tests/xpcshell/.eslintrc.js b/browser/components/newtab/tests/xpcshell/.eslintrc.js new file mode 100644 index 000000000..d35787cd2 --- /dev/null +++ b/browser/components/newtab/tests/xpcshell/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/xpcshell/xpcshell.eslintrc.js" + ] +}; diff --git a/browser/components/newtab/tests/xpcshell/test_AboutNewTabService.js b/browser/components/newtab/tests/xpcshell/test_AboutNewTabService.js new file mode 100644 index 000000000..21f68ab70 --- /dev/null +++ b/browser/components/newtab/tests/xpcshell/test_AboutNewTabService.js @@ -0,0 +1,236 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/* globals Services, XPCOMUtils, NewTabPrefsProvider, Preferences, aboutNewTabService, do_register_cleanup */ + +"use strict"; + +const {utils: Cu} = Components; +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NewTabPrefsProvider", + "resource:///modules/NewTabPrefsProvider.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService", + "@mozilla.org/browser/aboutnewtab-service;1", + "nsIAboutNewTabService"); + +XPCOMUtils.defineLazyModuleGetter(this, "Locale", + "resource://gre/modules/Locale.jsm"); + +const DEFAULT_HREF = aboutNewTabService.generateRemoteURL(); +const DEFAULT_CHROME_URL = "chrome://browser/content/newtab/newTab.xhtml"; +const DOWNLOADS_URL = "chrome://browser/content/downloads/contentAreaDownloadsView.xul"; +const DEFAULT_VERSION = aboutNewTabService.remoteVersion; + +function cleanup() { + Services.prefs.setBoolPref("browser.newtabpage.remote", false); + Services.prefs.setCharPref("browser.newtabpage.remote.version", DEFAULT_VERSION); + aboutNewTabService.resetNewTabURL(); + NewTabPrefsProvider.prefs.uninit(); +} + +do_register_cleanup(cleanup); + +/** + * Test the overriding of the default URL + */ +add_task(function* test_override_remote_disabled() { + NewTabPrefsProvider.prefs.init(); + let notificationPromise; + Services.prefs.setBoolPref("browser.newtabpage.remote", false); + + // tests default is the local newtab resource + Assert.equal(aboutNewTabService.defaultURL, DEFAULT_CHROME_URL, + `Default newtab URL should be ${DEFAULT_CHROME_URL}`); + + // override with some remote URL + let url = "http://example.com/"; + notificationPromise = nextChangeNotificationPromise(url); + aboutNewTabService.newTabURL = url; + yield notificationPromise; + Assert.ok(aboutNewTabService.overridden, "Newtab URL should be overridden"); + Assert.ok(!aboutNewTabService.remoteEnabled, "Newtab remote should not be enabled"); + Assert.equal(aboutNewTabService.newTabURL, url, "Newtab URL should be the custom URL"); + + // test reset with remote disabled + notificationPromise = nextChangeNotificationPromise("about:newtab"); + aboutNewTabService.resetNewTabURL(); + yield notificationPromise; + Assert.ok(!aboutNewTabService.overridden, "Newtab URL should not be overridden"); + Assert.equal(aboutNewTabService.newTabURL, "about:newtab", "Newtab URL should be the default"); + + // test override to a chrome URL + notificationPromise = nextChangeNotificationPromise(DOWNLOADS_URL); + aboutNewTabService.newTabURL = DOWNLOADS_URL; + yield notificationPromise; + Assert.ok(aboutNewTabService.overridden, "Newtab URL should be overridden"); + Assert.equal(aboutNewTabService.newTabURL, DOWNLOADS_URL, "Newtab URL should be the custom URL"); + + cleanup(); +}); + +add_task(function* test_override_remote_enabled() { + NewTabPrefsProvider.prefs.init(); + let notificationPromise; + // change newtab page to remote + notificationPromise = nextChangeNotificationPromise("about:newtab"); + Services.prefs.setBoolPref("browser.newtabpage.remote", true); + yield notificationPromise; + let remoteHref = aboutNewTabService.generateRemoteURL(); + Assert.equal(aboutNewTabService.defaultURL, remoteHref, "Newtab URL should be the default remote URL"); + Assert.ok(!aboutNewTabService.overridden, "Newtab URL should not be overridden"); + Assert.ok(aboutNewTabService.remoteEnabled, "Newtab remote should be enabled"); + + // change to local newtab page while remote is enabled + notificationPromise = nextChangeNotificationPromise(DEFAULT_CHROME_URL); + aboutNewTabService.newTabURL = DEFAULT_CHROME_URL; + yield notificationPromise; + Assert.equal(aboutNewTabService.newTabURL, DEFAULT_CHROME_URL, + "Newtab URL set to chrome url"); + Assert.equal(aboutNewTabService.defaultURL, DEFAULT_CHROME_URL, + "Newtab URL defaultURL set to the default chrome URL"); + Assert.ok(aboutNewTabService.overridden, "Newtab URL should be overridden"); + Assert.ok(!aboutNewTabService.remoteEnabled, "Newtab remote should not be enabled"); + + cleanup(); +}); + +/** + * Tests reponse to updates to prefs + */ +add_task(function* test_updates() { + /* + * Simulates a "cold-boot" situation, with some pref already set before testing a series + * of changes. + */ + Preferences.set("browser.newtabpage.remote", true); + aboutNewTabService.resetNewTabURL(); // need to set manually because pref notifs are off + let notificationPromise; + let productionModeBaseUrl = "https://content.cdn.mozilla.net"; + let testModeBaseUrl = "https://example.com"; + let expectedPath = `/newtab` + + `/v${aboutNewTabService.remoteVersion}` + + `/${aboutNewTabService.remoteReleaseName}` + + "/en-GB" + + "/index.html"; + let expectedHref = productionModeBaseUrl + expectedPath; + Preferences.set("intl.locale.matchOS", true); + Preferences.set("general.useragent.locale", "en-GB"); + Preferences.set("browser.newtabpage.remote.mode", "production"); + NewTabPrefsProvider.prefs.init(); + + // test update checks for prefs + notificationPromise = nextChangeNotificationPromise( + expectedHref, "Remote href should be updated"); + Preferences.set("intl.locale.matchOS", false); + yield notificationPromise; + + notificationPromise = nextChangeNotificationPromise( + DEFAULT_HREF, "Remote href changes back to default"); + Preferences.set("general.useragent.locale", "en-US"); + yield notificationPromise; + + // test update fires when mode is changed + expectedPath = expectedPath.replace("/en-GB/", "/en-US/"); + notificationPromise = nextChangeNotificationPromise( + testModeBaseUrl + expectedPath, "Remote href changes back to origin of test mode"); + Preferences.set("browser.newtabpage.remote.mode", "test"); + yield notificationPromise; + + // test invalid mode ends up pointing to production url + notificationPromise = nextChangeNotificationPromise( + DEFAULT_HREF, "Remote href changes back to production default"); + Preferences.set("browser.newtabpage.remote.mode", "invalid"); + yield notificationPromise; + + // test update fires on override and reset + let testURL = "https://example.com/"; + notificationPromise = nextChangeNotificationPromise( + testURL, "a notification occurs on override"); + aboutNewTabService.newTabURL = testURL; + yield notificationPromise; + + // from overridden to default + notificationPromise = nextChangeNotificationPromise( + "about:newtab", "a notification occurs on reset"); + aboutNewTabService.resetNewTabURL(); + Assert.ok(aboutNewTabService.remoteEnabled, "Newtab remote should be enabled"); + Assert.equal(aboutNewTabService.defaultURL, DEFAULT_HREF, "Default URL should be the remote page"); + yield notificationPromise; + + // override to default URL from default URL + notificationPromise = nextChangeNotificationPromise( + testURL, "a notification only occurs for a change in overridden urls"); + aboutNewTabService.newTabURL = aboutNewTabService.generateRemoteURL(); + Assert.ok(aboutNewTabService.remoteEnabled, "Newtab remote should be enabled"); + aboutNewTabService.newTabURL = testURL; + yield notificationPromise; + Assert.ok(!aboutNewTabService.remoteEnabled, "Newtab remote should not be enabled"); + + // reset twice, only one notification for default URL + notificationPromise = nextChangeNotificationPromise( + "about:newtab", "reset occurs"); + aboutNewTabService.resetNewTabURL(); + yield notificationPromise; + + cleanup(); +}); + +/** + * Verifies that releaseFromUpdateChannel + * Returns the correct release names + */ +add_task(function* test_release_names() { + let valid_channels = ["esr", "release", "beta", "aurora", "nightly"]; + let invalid_channels = new Set(["default", "invalid"]); + + for (let channel of valid_channels) { + Assert.equal(channel, aboutNewTabService.releaseFromUpdateChannel(channel), + "release == channel name when valid"); + } + + for (let channel of invalid_channels) { + Assert.equal("nightly", aboutNewTabService.releaseFromUpdateChannel(channel), + "release == nightly when invalid"); + } +}); + +/** + * Verifies that remote version updates changes the remote newtab url + */ +add_task(function* test_version_update() { + NewTabPrefsProvider.prefs.init(); + + Services.prefs.setBoolPref("browser.newtabpage.remote", true); + Assert.ok(aboutNewTabService.remoteEnabled, "remote mode enabled"); + + let productionModeBaseUrl = "https://content.cdn.mozilla.net"; + let version_incr = String(parseInt(DEFAULT_VERSION) + 1); + let expectedPath = `/newtab` + + `/v${version_incr}` + + `/${aboutNewTabService.remoteReleaseName}` + + `/${Locale.getLocale()}` + + `/index.html`; + let expectedHref = productionModeBaseUrl + expectedPath; + + let notificationPromise; + notificationPromise = nextChangeNotificationPromise(expectedHref); + Preferences.set("browser.newtabpage.remote.version", version_incr); + yield notificationPromise; + + cleanup(); +}); + +function nextChangeNotificationPromise(aNewURL, testMessage) { + return new Promise(resolve => { + Services.obs.addObserver(function observer(aSubject, aTopic, aData) { // jshint unused:false + Services.obs.removeObserver(observer, aTopic); + Assert.equal(aData, aNewURL, testMessage); + resolve(); + }, "newtab-url-changed", false); + }); +} diff --git a/browser/components/newtab/tests/xpcshell/test_NewTabPrefsProvider.js b/browser/components/newtab/tests/xpcshell/test_NewTabPrefsProvider.js new file mode 100644 index 000000000..f364d0300 --- /dev/null +++ b/browser/components/newtab/tests/xpcshell/test_NewTabPrefsProvider.js @@ -0,0 +1,50 @@ +"use strict"; + +/* global XPCOMUtils, equal, Preferences, NewTabPrefsProvider, run_next_test */ +/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */ + +const Cu = Components.utils; +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NewTabPrefsProvider", + "resource:///modules/NewTabPrefsProvider.jsm"); + +function run_test() { + run_next_test(); +} + +add_task(function* test_observe() { + let prefsMap = NewTabPrefsProvider.prefs.prefsMap; + for (let prefName of prefsMap.keys()) { + let prefValueType = prefsMap.get(prefName); + + let beforeVal; + let afterVal; + + switch (prefValueType) { + case "bool": + beforeVal = false; + afterVal = true; + Preferences.set(prefName, beforeVal); + break; + case "localized": + case "str": + beforeVal = ""; + afterVal = "someStr"; + Preferences.set(prefName, beforeVal); + break; + } + NewTabPrefsProvider.prefs.init(); + let promise = new Promise(resolve => { + NewTabPrefsProvider.prefs.once(prefName, (name, data) => { // jshint ignore:line + resolve([name, data]); + }); + }); + Preferences.set(prefName, afterVal); + let [actualName, actualData] = yield promise; + equal(prefName, actualName, `emitter sent the correct pref: ${prefName}`); + equal(afterVal, actualData, `emitter collected correct pref data for ${prefName}`); + NewTabPrefsProvider.prefs.uninit(); + } +}); diff --git a/browser/components/newtab/tests/xpcshell/test_NewTabSearchProvider.js b/browser/components/newtab/tests/xpcshell/test_NewTabSearchProvider.js new file mode 100644 index 000000000..3e60b282a --- /dev/null +++ b/browser/components/newtab/tests/xpcshell/test_NewTabSearchProvider.js @@ -0,0 +1,82 @@ +"use strict"; + +/* global XPCOMUtils, NewTabSearchProvider, run_next_test, ok, equal, do_check_true, do_get_profile, Services */ +/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */ + +const Cu = Components.utils; +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NewTabSearchProvider", + "resource:///modules/NewTabSearchProvider.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ContentSearch", + "resource:///modules/ContentSearch.jsm"); + +// ensure a profile exists +do_get_profile(); + +function run_test() { + run_next_test(); +} + +function hasProp(obj) { + return function(aProp) { + ok(obj.hasOwnProperty(aProp), `expect to have property ${aProp}`); + }; +} + +add_task(function* test_search() { + ContentSearch.init(); + let observerPromise = new Promise(resolve => { + Services.obs.addObserver(function observer(aSubject, aTopic, aData) { + if (aData === "init-complete" && aTopic === "browser-search-service") { + Services.obs.removeObserver(observer, "browser-search-service"); + resolve(); + } + }, "browser-search-service", false); + }); + Services.search.init(); + yield observerPromise; + do_check_true(Services.search.isInitialized); + + // get initial state of search and check it has correct properties + let state = yield NewTabSearchProvider.search.asyncGetState(); + let stateProps = hasProp(state); + ["engines", "currentEngine"].forEach(stateProps); + + // check that the current engine is correct and has correct properties + let {currentEngine} = state; + equal(currentEngine.name, Services.search.currentEngine.name, "Current engine has been correctly set"); + var engineProps = hasProp(currentEngine); + ["name", "placeholder", "iconBuffer"].forEach(engineProps); + + // create dummy test engines to test observer + Services.search.addEngineWithDetails("TestSearch1", "", "", "", "GET", + "http://example.com/?q={searchTerms}"); + Services.search.addEngineWithDetails("TestSearch2", "", "", "", "GET", + "http://example.com/?q={searchTerms}"); + + // set one of the dummy test engines to the default engine + Services.search.defaultEngine = Services.search.getEngineByName("TestSearch1"); + + // test that the event emitter is working by setting a new current engine "TestSearch2" + let engineName = "TestSearch2"; + NewTabSearchProvider.search.init(); + + // event emitter will fire when current engine is changed + let promise = new Promise(resolve => { + NewTabSearchProvider.search.once("browser-search-engine-modified", (name, data) => { // jshint ignore:line + resolve([name, data.name]); + }); + }); + + // set a new current engine + Services.search.currentEngine = Services.search.getEngineByName(engineName); + let expectedEngineName = Services.search.currentEngine.name; + + // emitter should fire and return the new engine + let [eventName, actualEngineName] = yield promise; + equal(eventName, "browser-search-engine-modified", `emitter sent the correct event ${eventName}`); + equal(expectedEngineName, actualEngineName, `emitter set the correct engine ${expectedEngineName}`); + NewTabSearchProvider.search.uninit(); +}); diff --git a/browser/components/newtab/tests/xpcshell/test_NewTabURL.js b/browser/components/newtab/tests/xpcshell/test_NewTabURL.js new file mode 100644 index 000000000..1505e638c --- /dev/null +++ b/browser/components/newtab/tests/xpcshell/test_NewTabURL.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/* globals Services, NewTabURL, XPCOMUtils, aboutNewTabService, NewTabPrefsProvider */ +"use strict"; + +const {utils: Cu} = Components; +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource:///modules/NewTabURL.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabPrefsProvider", + "resource:///modules/NewTabPrefsProvider.jsm"); +XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService", + "@mozilla.org/browser/aboutnewtab-service;1", + "nsIAboutNewTabService"); + +add_task(function*() { + let defaultURL = aboutNewTabService.newTabURL; + Services.prefs.setBoolPref("browser.newtabpage.remote", false); + + Assert.equal(NewTabURL.get(), defaultURL, `Default newtab URL should be ${defaultURL}`); + let url = "http://example.com/"; + let notificationPromise = promiseNewtabURLNotification(url); + NewTabURL.override(url); + yield notificationPromise; + Assert.ok(NewTabURL.overridden, "Newtab URL should be overridden"); + Assert.equal(NewTabURL.get(), url, "Newtab URL should be the custom URL"); + + notificationPromise = promiseNewtabURLNotification(defaultURL); + NewTabURL.reset(); + yield notificationPromise; + Assert.ok(!NewTabURL.overridden, "Newtab URL should not be overridden"); + Assert.equal(NewTabURL.get(), defaultURL, "Newtab URL should be the default"); + + // change newtab page to remote + NewTabPrefsProvider.prefs.init(); + Services.prefs.setBoolPref("browser.newtabpage.remote", true); + Assert.equal(NewTabURL.get(), "about:newtab", `Newtab URL should be about:newtab`); + Assert.ok(!NewTabURL.overridden, "Newtab URL should not be overridden"); + NewTabPrefsProvider.prefs.uninit(); +}); + +function promiseNewtabURLNotification(aNewURL) { + return new Promise(resolve => { + Services.obs.addObserver(function observer(aSubject, aTopic, aData) { // jshint ignore:line + Services.obs.removeObserver(observer, aTopic); + Assert.equal(aData, aNewURL, "Data for newtab-url-changed notification should be new URL."); + resolve(); + }, "newtab-url-changed", false); + }); +} diff --git a/browser/components/newtab/tests/xpcshell/test_PlacesProvider.js b/browser/components/newtab/tests/xpcshell/test_PlacesProvider.js new file mode 100644 index 000000000..22815741b --- /dev/null +++ b/browser/components/newtab/tests/xpcshell/test_PlacesProvider.js @@ -0,0 +1,358 @@ +"use strict"; + +/* global XPCOMUtils, PlacesUtils, PlacesTestUtils, PlacesProvider, NetUtil */ +/* global do_get_profile, run_next_test, add_task */ +/* global equal, ok */ + +const { + utils: Cu, + interfaces: Ci, +} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesProvider", + "resource:///modules/PlacesProvider.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils", + "resource://testing-common/PlacesTestUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); + +// ensure a profile exists +do_get_profile(); + +function run_test() { + PlacesProvider.links.init(); + run_next_test(); +} + +// url prefix for test history population +const TEST_URL = "https://mozilla.com/"; +// time when the test starts execution +const TIME_NOW = (new Date()).getTime(); + +// utility function to compute past timestap +function timeDaysAgo(numDays) { + return TIME_NOW - (numDays * 24 * 60 * 60 * 1000); +} + +// utility function to make a visit for insetion into places db +function makeVisit(index, daysAgo, isTyped, domain=TEST_URL) { + let { + TRANSITION_TYPED, + TRANSITION_LINK + } = PlacesUtils.history; + + return { + uri: NetUtil.newURI(`${domain}${index}`), + visitDate: timeDaysAgo(daysAgo), + transition: (isTyped) ? TRANSITION_TYPED : TRANSITION_LINK, + }; +} + +/** Test LinkChecker **/ + +add_task(function test_LinkChecker_securityCheck() { + + let urls = [ + {url: "javascript:alert('hello')", expected: false}, // jshint ignore:line + {url: "data:image/png;base64,XXX", expected: false}, + {url: "about:newtab", expected: true}, + {url: "https://example.com", expected: true}, + {url: "ftp://example.com", expected: true}, + {url: "file://home/file/image.png", expected: true}, + {url: "resource:///modules/PlacesProvider.jsm", expected: true}, + ]; + for (let {url, expected} of urls) { + let observed = PlacesProvider.LinkChecker.checkLoadURI(url); + equal(observed, expected, `can load "${url}"?`); + } +}); + +/** Test Provider **/ + +add_task(function* test_Links_getLinks() { + yield PlacesTestUtils.clearHistory(); + let provider = PlacesProvider.links; + + let links = yield provider.getLinks(); + equal(links.length, 0, "empty history yields empty links"); + + // add a visit + let testURI = NetUtil.newURI("http://mozilla.com"); + yield PlacesTestUtils.addVisits(testURI); + + links = yield provider.getLinks(); + equal(links.length, 1, "adding a visit yields a link"); + equal(links[0].url, testURI.spec, "added visit corresponds to added url"); +}); + +add_task(function* test_Links_getLinks_Order() { + yield PlacesTestUtils.clearHistory(); + let provider = PlacesProvider.links; + + // all four visits must come from different domains to avoid deduplication + let visits = [ + makeVisit(0, 0, true, "http://bar.com/"), // frecency 200, today + makeVisit(1, 0, true, "http://foo.com/"), // frecency 200, today + makeVisit(2, 2, true, "http://buz.com/"), // frecency 200, 2 days ago + makeVisit(3, 2, false, "http://aaa.com/"), // frecency 10, 2 days ago, transition + ]; + + let links = yield provider.getLinks(); + equal(links.length, 0, "empty history yields empty links"); + yield PlacesTestUtils.addVisits(visits); + + links = yield provider.getLinks(); + equal(links.length, visits.length, "number of links added is the same as obtain by getLinks"); + for (let i = 0; i < links.length; i++) { + equal(links[i].url, visits[i].uri.spec, "links are obtained in the expected order"); + } +}); + +add_task(function* test_Links_getLinks_Deduplication() { + yield PlacesTestUtils.clearHistory(); + let provider = PlacesProvider.links; + + // all for visits must come from different domains to avoid deduplication + let visits = [ + makeVisit(0, 2, true, "http://bar.com/"), // frecency 200, 2 days ago + makeVisit(1, 0, true, "http://bar.com/"), // frecency 200, today + makeVisit(2, 0, false, "http://foo.com/"), // frecency 10, today + makeVisit(3, 0, true, "http://foo.com/"), // frecency 200, today + ]; + + let links = yield provider.getLinks(); + equal(links.length, 0, "empty history yields empty links"); + yield PlacesTestUtils.addVisits(visits); + + links = yield provider.getLinks(); + equal(links.length, 2, "only two links must be left after deduplication"); + equal(links[0].url, visits[1].uri.spec, "earliest link is present"); + equal(links[1].url, visits[3].uri.spec, "most fresent link is present"); +}); + +add_task(function* test_Links_onLinkChanged() { + let provider = PlacesProvider.links; + + let url = "https://example.com/onFrecencyChanged1"; + let linkChangedMsgCount = 0; + + let linkChangedPromise = new Promise(resolve => { + let handler = (_, link) => { // jshint ignore:line + /* There are 3 linkChanged events: + * 1. visit insertion (-1 frecency by default) + * 2. frecency score update (after transition type calculation etc) + * 3. title change + */ + if (link.url === url) { + equal(link.url, url, `expected url on linkChanged event`); + linkChangedMsgCount += 1; + if (linkChangedMsgCount === 3) { + ok(true, `all linkChanged events captured`); + provider.off("linkChanged", this); + resolve(); + } + } + }; + provider.on("linkChanged", handler); + }); + + // add a visit + let testURI = NetUtil.newURI(url); + yield PlacesTestUtils.addVisits(testURI); + yield linkChangedPromise; + + yield PlacesTestUtils.clearHistory(); +}); + +add_task(function* test_Links_onClearHistory() { + let provider = PlacesProvider.links; + + let clearHistoryPromise = new Promise(resolve => { + let handler = () => { + ok(true, `clearHistory event captured`); + provider.off("clearHistory", handler); + resolve(); + }; + provider.on("clearHistory", handler); + }); + + // add visits + for (let i = 0; i <= 10; i++) { + let url = `https://example.com/onClearHistory${i}`; + let testURI = NetUtil.newURI(url); + yield PlacesTestUtils.addVisits(testURI); + } + yield PlacesTestUtils.clearHistory(); + yield clearHistoryPromise; +}); + +add_task(function* test_Links_onDeleteURI() { + let provider = PlacesProvider.links; + + let testURL = "https://example.com/toDelete"; + + let deleteURIPromise = new Promise(resolve => { + let handler = (_, {url}) => { // jshint ignore:line + equal(testURL, url, "deleted url and expected url are the same"); + provider.off("deleteURI", handler); + resolve(); + }; + + provider.on("deleteURI", handler); + }); + + let testURI = NetUtil.newURI(testURL); + yield PlacesTestUtils.addVisits(testURI); + yield PlacesUtils.history.remove(testURL); + yield deleteURIPromise; +}); + +add_task(function* test_Links_onManyLinksChanged() { + let provider = PlacesProvider.links; + + let promise = new Promise(resolve => { + let handler = () => { + ok(true); + provider.off("manyLinksChanged", handler); + resolve(); + }; + + provider.on("manyLinksChanged", handler); + }); + + let testURL = "https://example.com/toDelete"; + let testURI = NetUtil.newURI(testURL); + yield PlacesTestUtils.addVisits(testURI); + + // trigger DecayFrecency + PlacesUtils.history.QueryInterface(Ci.nsIObserver). + observe(null, "idle-daily", ""); + + yield promise; +}); + +add_task(function* test_Links_execute_query() { + yield PlacesTestUtils.clearHistory(); + let provider = PlacesProvider.links; + + let visits = [ + makeVisit(0, 0, true), // frecency 200, today + makeVisit(1, 0, true), // frecency 200, today + makeVisit(2, 2, true), // frecency 200, 2 days ago + makeVisit(3, 2, false), // frecency 10, 2 days ago, transition + ]; + + yield PlacesTestUtils.addVisits(visits); + + function testItemValue(results, index, value) { + equal(results[index][0], `${TEST_URL}${value}`, "raw url"); + equal(results[index][1], `test visit for ${TEST_URL}${value}`, "raw title"); + } + + function testItemObject(results, index, columnValues) { + Object.keys(columnValues).forEach(name => { + equal(results[index][name], columnValues[name], "object name " + name); + }); + } + + // select all 4 records + let results = yield provider.executePlacesQuery("select url, title from moz_places"); + equal(results.length, 4, "expect 4 items"); + // check for insert order sequence + for (let i = 0; i < results.length; i++) { + testItemValue(results, i, i); + } + + // test parameter passing + results = yield provider.executePlacesQuery( + "select url, title from moz_places limit :limit", + {params: {limit: 2}} + ); + equal(results.length, 2, "expect 2 items"); + for (let i = 0; i < results.length; i++) { + testItemValue(results, i, i); + } + + // test extracting items by name + results = yield provider.executePlacesQuery( + "select url, title from moz_places limit :limit", + {columns: ["url", "title"], params: {limit: 4}} + ); + equal(results.length, 4, "expect 4 items"); + for (let i = 0; i < results.length; i++) { + testItemObject(results, i, { + "url": `${TEST_URL}${i}`, + "title": `test visit for ${TEST_URL}${i}`, + }); + } + + // test ordering + results = yield provider.executePlacesQuery( + "select url, title, last_visit_date, frecency from moz_places " + + "order by frecency DESC, last_visit_date DESC, url DESC limit :limit", + {columns: ["url", "title", "last_visit_date", "frecency"], params: {limit: 4}} + ); + equal(results.length, 4, "expect 4 items"); + testItemObject(results, 0, {url: `${TEST_URL}1`}); + testItemObject(results, 1, {url: `${TEST_URL}0`}); + testItemObject(results, 2, {url: `${TEST_URL}2`}); + testItemObject(results, 3, {url: `${TEST_URL}3`}); + + // test callback passing + results = []; + function handleRow(aRow) { + results.push({ + url: aRow.getResultByName("url"), + title: aRow.getResultByName("title"), + last_visit_date: aRow.getResultByName("last_visit_date"), + frecency: aRow.getResultByName("frecency") + }); + } + yield provider.executePlacesQuery( + "select url, title, last_visit_date, frecency from moz_places " + + "order by frecency DESC, last_visit_date DESC, url DESC", + {callback: handleRow} + ); + equal(results.length, 4, "expect 4 items"); + testItemObject(results, 0, {url: `${TEST_URL}1`}); + testItemObject(results, 1, {url: `${TEST_URL}0`}); + testItemObject(results, 2, {url: `${TEST_URL}2`}); + testItemObject(results, 3, {url: `${TEST_URL}3`}); + + // negative test cases + // bad sql + try { + yield provider.executePlacesQuery("select from moz"); + do_throw("bad sql should've thrown"); + } + catch (e) { + do_check_true("expected failure - bad sql"); + } + // missing bindings + try { + yield provider.executePlacesQuery("select * from moz_places limit :limit"); + do_throw("bad sql should've thrown"); + } + catch (e) { + do_check_true("expected failure - missing bidning"); + } + // non-existent column name + try { + yield provider.executePlacesQuery("select * from moz_places limit :limit", + {columns: ["no-such-column"], params: {limit: 4}}); + do_throw("bad sql should've thrown"); + } + catch (e) { + do_check_true("expected failure - wrong column name"); + } + + // cleanup + yield PlacesTestUtils.clearHistory(); +}); diff --git a/browser/components/newtab/tests/xpcshell/xpcshell.ini b/browser/components/newtab/tests/xpcshell/xpcshell.ini new file mode 100644 index 000000000..c249ee3e2 --- /dev/null +++ b/browser/components/newtab/tests/xpcshell/xpcshell.ini @@ -0,0 +1,11 @@ +[DEFAULT] +head = +tail = +firefox-appdir = browser +skip-if = toolkit == 'android' + +[test_AboutNewTabService.js] +[test_NewTabPrefsProvider.js] +[test_NewTabSearchProvider.js] +[test_NewTabURL.js] +[test_PlacesProvider.js] |